Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,20 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableConversationSummaries: value })}
/>
</SettingsSection>
<SettingsSection title="Skills">
<SettingsSwitchRow
icon="square.grid.2x2"
label="pstack"
subtitle={
projectSelected
? "Environment-wide setting. Select All projects to change it."
: "Offer Lauren Tan's pstack workflows as skills in every provider. Run $pstack:setup-pstack once to choose models."
}
value={uniform("pstackEnabled")}
disabled={disabledFor("pstackEnabled")}
onValueChange={(value) => write({ pstackEnabled: value })}
/>
</SettingsSection>
</>
) : null}

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@
const animatedBorderRadius = useSharedValue(targetBorderRadius);
const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
useLayoutEffect(() => {
animatedBorderRadius.value = shouldAnimate

Check warning on line 390 in apps/mobile/src/features/threads/ThreadComposer.tsx

View workflow job for this annotation

GitHub Actions / Checks and build

react(immutability)

apps/mobile/src/features/threads/ThreadComposer.tsx:390:5: This value cannot be modified
? withTiming(targetBorderRadius, {
duration: COMPOSER_TRANSITION_DURATION_MS,
reduceMotion: ReduceMotion.System,
Expand Down Expand Up @@ -573,6 +573,7 @@
pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,
threadPullRequests: props.selectedThread.pullRequests,
selectedProviderStatus,
pstackSkills: props.serverConfig?.pstackSkills,
hasThread: !props.isDraft,
hasCompactableConversation: props.hasCompactableConversation,
onChangeDraftMessage: props.onChangeDraftMessage,
Expand Down Expand Up @@ -772,7 +773,7 @@

useEffect(() => {
if (settingsSheetPresentation.isActive) {
settingsRoutePresentation.present(settingsRouteSession);

Check warning on line 776 in apps/mobile/src/features/threads/ThreadComposer.tsx

View workflow job for this annotation

GitHub Actions / Checks and build

react(exhaustive-effect-dependencies)

apps/mobile/src/features/threads/ThreadComposer.tsx:776:7: Found missing/extra effect dependencies
}
}, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]);

Expand Down
13 changes: 5 additions & 8 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@
};
}, []);
useEffect(() => {
setKeyboardStateSuspect(false);

Check warning on line 482 in apps/mobile/src/features/threads/ThreadDetailScreen.tsx

View workflow job for this annotation

GitHub Actions / Checks and build

react(set-state-in-effect)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx:482:5: Calling setState synchronously within an effect can trigger cascading renders
}, [isKeyboardVisible, liveKeyboardHeight]);
const handleOwnedInputFocusChange = (focused: boolean) => {
ownedInputFocusedRef.current = focused;
Expand Down Expand Up @@ -1123,14 +1123,11 @@
);
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = (() => {
const provider = props.serverConfig?.providers.find(
(candidate) => candidate.instanceId === selectedInstanceId,
);
return provider
? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot)
: [];
})();
const selectedProviderSkills = resolveProviderSkillsForCwd(
props.serverConfig?.providers.find((candidate) => candidate.instanceId === selectedInstanceId),
props.threadCwd ?? props.projectWorkspaceRoot,
props.serverConfig?.pstackSkills,
);

useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
EnvironmentId,
ProjectId,
ProviderInteractionMode,
ServerConfig,
ServerProvider,
ThreadId,
ThreadPullRequestLink,
Expand Down Expand Up @@ -194,6 +195,7 @@ export function useComposerCommandMenu({
pullRequestRepository = null,
threadPullRequests,
selectedProviderStatus,
pstackSkills,
hasThread,
hasCompactableConversation,
offersUsageLimits = false,
Expand All @@ -213,6 +215,7 @@ export function useComposerCommandMenu({
readonly pullRequestRepository?: string | null;
readonly threadPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
readonly selectedProviderStatus: ServerProvider | null;
readonly pstackSkills: ServerConfig["pstackSkills"];
readonly hasThread: boolean;
readonly hasCompactableConversation: boolean;
readonly offersUsageLimits?: boolean;
Expand Down Expand Up @@ -252,9 +255,7 @@ export function useComposerCommandMenu({
setSelection(composerSelectionAtEnd(draftMessage));
}, [draftMessage, ownerKey]);

const skills = selectedProviderStatus
? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd)
: [];
const skills = resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd, pstackSkills);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@t3tools/provider-muse": "workspace:*",
"@t3tools/provider-opencode": "workspace:*",
"@t3tools/provider-pi": "workspace:*",
"@t3tools/pstack": "workspace:*",
"@t3tools/source-control-azure-devops": "workspace:*",
"@t3tools/source-control-bitbucket": "workspace:*",
"@t3tools/source-control-core": "workspace:*",
Expand Down
8 changes: 8 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ const buildCmd = Command.make(
outputDir: path.join(serverDir, "dist/project-memory"),
});

const pstackSource = path.join(repoRoot, "packages/pstack");
const pstackTarget = path.join(serverDir, "dist/pstack");
yield* fs.makeDirectory(pstackTarget, { recursive: true });
for (const entry of ["upstream", "t3-code.md", "UPSTREAM.json"]) {
yield* fs.copy(path.join(pstackSource, entry), path.join(pstackTarget, entry));
}
yield* Effect.log("[cli] Bundled pstack into dist/pstack");

const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");

Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,13 @@ export const resolveStaticDir = Effect.fn(function* () {
}
return undefined;
});

export const resolvePstackSourceRoot = Effect.fn("ServerConfig.resolvePstackSourceRoot")(
function* () {
const { join, resolve } = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const bundled = resolve(join(import.meta.dirname, "pstack"));
if (yield* fs.exists(join(bundled, "UPSTREAM.json"))) return bundled;
return resolve(join(import.meta.dirname, "../../../packages/pstack"));
},
);
153 changes: 153 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { BUNDLED_CLAUDE_MODEL_CATALOG } from "../provider/ClaudeModelCatalog.ts";
import { parseModelsFile, resolveRoles } from "../pstack/pstackModels.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import {
Expand Down Expand Up @@ -2709,6 +2711,157 @@ describe("OrchestratorMcpService provider resolution", () => {
}),
);

it.effect(
"pstack Opus and Astra targets dispatch identically under Claude and Codex parents",
() =>
Effect.gen(function* () {
const claudeId = ProviderInstanceId.make("claudeAgent");
const providers: ServerProvider[] = [
{
...providerSnapshot({
instanceId: claudeId,
driver: ProviderDriverKind.make("claudeAgent"),
}),
models: BUNDLED_CLAUDE_MODEL_CATALOG.models.map(({ model }) => model),
},
{
...providerSnapshot({
instanceId: codexInstanceId,
driver: ProviderDriverKind.make("codex"),
}),
models: [
{
slug: "gpt-6-astra",
name: "Astra",
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "reasoningEffort",
label: "Effort",
type: "select",
currentValue: "medium",
options: ["low", "medium", "high", "xhigh"].map((id) => ({ id, label: id })),
},
{
id: "serviceTier",
label: "Tier",
type: "select",
currentValue: "default",
options: ["default", "priority"].map((id) => ({ id, label: id })),
},
],
},
},
],
},
];
const resolved = resolveRoles({
file: parseModelsFile(
"architect runners: claudeAgent/claude-opus-5-5 effort=xhigh, codex/gpt-6-astra reasoningEffort=high",
),
defaults: [{ label: "architect runners", defaultValue: "auto" }],
providers,
capableInstanceIds: new Set(providers.map((provider) => provider.instanceId)),
});
const results: unknown[][] = [];
for (const inherited of [
{
instanceId: claudeId,
model: "claude-opus-5-5",
options: [
{ id: "effort", value: "low" },
{ id: "fastMode", value: true },
],
},
{
instanceId: codexInstanceId,
model: "gpt-6-astra",
options: [
{ id: "reasoningEffort", value: "xhigh" },
{ id: "serviceTier", value: "priority" },
],
},
]) {
const selections: unknown[] = [];
for (const seat of resolved.roles[0]!.seats) {
assert.equal(seat.kind, "target");
if (seat.kind !== "target") continue;
const target = seat.target;
const commands: unknown[] = [];
const task = {
id: taskId,
threadId: parentThreadId,
origin: "app_owned",
childThreadId,
providerInstanceId: target.providerInstanceId,
model: target.model,
status: "running",
result: null,
};
const dependencies = Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService.ThreadManagementService)({
hasReceipt: () => Effect.succeed(false),
assertProviderInstanceEnabled: () => Effect.void,
getThreadRecords: (threadId) =>
Effect.succeed(
threadId === parentThreadId
? parentProjection(commands.length ? [task] : [], inherited)
: childProjection,
),
dispatch: (command) =>
Effect.sync(() => {
commands.push(command);
return {
sequence: 1,
storedEvents: [{ event: { type: "subagent.updated", payload: task } }],
} as never;
}),
}),
ServerSettings.layerTest(),
providerRegistryLayer(providers),
adapterRegistryLayer(providers.map((provider) => provider.instanceId)),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
);
yield* Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
yield* service.delegateTask(scope, {
task: "Design the change.",
target: yield* decodeTarget(target),
mode: "async",
clientRequestId: `pstack-${inherited.instanceId}-${target.providerInstanceId}`,
});
}).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies))));
selections.push((commands[0] as { modelSelection: unknown }).modelSelection);
}
results.push(selections);
}
assert.deepEqual(results[0], results[1]);
assert.deepEqual(results[0], [
{
instanceId: claudeId,
model: "claude-opus-5-5",
options: [
{ id: "effort", value: "xhigh" },
{ id: "fastMode", value: false },
],
},
{
instanceId: codexInstanceId,
model: "gpt-6-astra",
options: [
{ id: "reasoningEffort", value: "high" },
{ id: "serviceTier", value: "default" },
],
},
]);
}),
);

describe("resolveRuntimeMode", () => {
const muse = {
modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" },
Expand Down
85 changes: 9 additions & 76 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {
providerConstraints,
invalidOptionSelections,
readOrchestrationProviders,
} from "../provider/orchestrationTargets.ts";
import {
CommandId,
type RunId,
isProviderAvailable,
MessageId,
type ModelSelection,
NodeId,
Expand Down Expand Up @@ -52,9 +56,7 @@ import {
type OrchestratorMcpThreadWaitInput,
type OrchestratorMcpThreadWaitResult,
type ProviderInteractionMode,
type ProviderInstanceId,
type ProviderOptionDescriptor,
type ProviderOptionSelection,
ProviderRequestKind,
type RuntimeMode,
type ScheduledTask,
Expand Down Expand Up @@ -333,68 +335,6 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat
};
}

function providerConstraints(
provider: ServerProvider | undefined,
supportsOrchestrationV2: boolean,
): ReadonlyArray<string> {
const constraints: Array<string> = [];
if (!supportsOrchestrationV2) {
constraints.push("No V2 provider adapter is registered.");
}
if (provider === undefined) return constraints;
if (!isProviderAvailable(provider)) {
constraints.push(provider.unavailableReason ?? "Provider driver is unavailable.");
}
if (!provider.enabled) {
constraints.push("Provider instance is disabled.");
// Disabled snapshots contain placeholders for installation, health, and auth.
return constraints;
}
if (!provider.installed) constraints.push("Provider executable is not installed.");
if (provider.status === "error" || provider.status === "disabled") {
constraints.push(provider.message ?? `Provider status is ${provider.status}.`);
}
if (provider.auth.status === "unauthenticated") {
constraints.push("Provider is not authenticated.");
}
return constraints;
}

/** Skip checks without descriptors; reject duplicate IDs because consumers disagree on precedence. */
function invalidOptionSelections(
selections: ReadonlyArray<ProviderOptionSelection>,
descriptors: ReadonlyArray<ProviderOptionDescriptor> | undefined,
): ReadonlyArray<string> {
const problems: Array<string> = [];
const seen = new Set<string>();
for (const selection of selections) {
if (seen.has(selection.id)) {
problems.push(`Option ${selection.id} was specified more than once.`);
continue;
}
seen.add(selection.id);
if (descriptors === undefined) continue;
const descriptor = descriptors.find((candidate) => candidate.id === selection.id);
if (descriptor === undefined) {
const known = descriptors.map((candidate) => candidate.id).join(", ");
problems.push(`Unknown option ${selection.id}; supported options: ${known || "none"}.`);
continue;
}
if (descriptor.type === "boolean" && typeof selection.value !== "boolean") {
problems.push(`Option ${selection.id} expects a boolean value.`);
continue;
}
if (
descriptor.type === "select" &&
!descriptor.options.some((choice) => choice.id === selection.value)
) {
const choices = descriptor.options.map((choice) => choice.id).join(", ");
problems.push(`Option ${selection.id} must be one of: ${choices}.`);
}
}
return problems;
}

function taskStatusForRun(
run: Pick<OrchestrationV2Run, "status" | "restartCancelledBackgroundWork"> | undefined,
): OrchestratorMcpDelegateTaskResult["status"] {
Expand Down Expand Up @@ -1207,18 +1147,11 @@ const make = Effect.gen(function* () {

const loadProviders = providerRegistry.getPublishedProviders;

// A pool has no adapter of its own; routing picks a member's at dispatch.
const loadOrchestrationCapableInstanceIds = () =>
Effect.all([providerAdapters.list(), loadProviders]).pipe(
Effect.map(
([instanceIds, providers]) =>
new Set([
...instanceIds,
...providers
.filter((provider) => provider.pool?.role === "pool")
.map((provider) => provider.instanceId),
]),
),
readOrchestrationProviders().pipe(
Effect.provideService(ProviderRegistry.ProviderRegistry, providerRegistry),
Effect.provideService(ProviderAdapterRegistry.ProviderAdapterRegistryV2, providerAdapters),
Effect.map(({ capableInstanceIds }) => capableInstanceIds),
);

/** Background agents miss foreground-only health probes; re-probe once before refusing. */
Expand Down
Loading
Loading