From 76e9a4ad613e0535095530be1d960bb909c6ceb8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:54:51 +0200 Subject: [PATCH 01/41] feat(providers): add Pi coding agent Adds Pi (pi.dev) as an Early Access, disabled-by-default provider on the orchestrator v2 architecture, speaking Pi's official stdio JSONL RPC mode. The adapter deliberately spawns the user's own pi install with no --no-* flags, so extensions, skills, AGENTS.md/SYSTEM.md context, settings.json, custom models, and auth all load exactly as in the pi TUI. Sessions live in Pi's own session store and resume via switch_session, so threads stay interoperable with the TUI in both directions. Extension UI dialogs bridge into v2 runtime requests; agent_settled (not agent_end) terminalizes turns; steering uses pi's native steer command. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/components/ProviderIcon.tsx | 14 + apps/mobile/src/lib/modelOptions.ts | 1 + .../Adapters/PiAdapterV2.test.ts | 579 +++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 1526 +++++++++++++++++ .../src/orchestration-v2/Adapters/PiRpc.ts | 293 ++++ .../builtInProviderAdapterDrivers.ts | 5 +- apps/server/src/provider/Drivers/PiDriver.ts | 181 ++ apps/server/src/provider/Layers/PiProvider.ts | 414 +++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/builtInDrivers.ts | 5 +- .../src/textGeneration/PiTextGeneration.ts | 224 +++ .../src/textGeneration/TextGeneration.ts | 8 +- .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/AddProviderInstanceDialog.tsx | 7 +- .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 9 + apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/session-logic.ts | 6 + docs/internals/providers.md | 9 +- packages/contracts/src/model.ts | 4 + packages/contracts/src/settings.ts | 42 + 21 files changed, 3323 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/PiRpc.ts create mode 100644 apps/server/src/provider/Drivers/PiDriver.ts create mode 100644 apps/server/src/provider/Layers/PiProvider.ts create mode 100644 apps/server/src/textGeneration/PiTextGeneration.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..e71c54660369 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -50,6 +50,20 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "pi") { + const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 8f3f8c34a4e9..f601956e8d39 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -36,6 +36,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "pi") return "Pi"; return provider.instanceId; } diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts new file mode 100644 index 000000000000..964fdd22e46b --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -0,0 +1,579 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + NodeId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, + type ModelSelection, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { + ProviderAdapterV2RuntimePolicy, + type ProviderAdapterV2Event, + type ProviderAdapterV2SessionRuntime, +} from "../ProviderAdapter.ts"; +import { makePiAdapterV2, PiProviderCapabilitiesV2, PI_PROVIDER } from "./PiAdapterV2.ts"; +import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-pi-v2-adapter-", +}).pipe(Layer.provide(NodeServices.layer)); + +const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer); + +const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const PI_INSTANCE_ID = ProviderInstanceId.make("pi"); +const THREAD_ID = ThreadId.make("thread-pi-test"); +const SESSION_ID = ProviderSessionId.make("provider-session-pi-test"); +const FAKE_SESSION_FILE = "/fake/.pi/agent/sessions/--workspace--/0001_abc.jsonl"; +/** Deliberately outside the valid pid range so a group-kill can never land. */ +const FAKE_PID = 999_999_999; + +const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, +}); + +const modelSelection = (model: string): ModelSelection => ({ + instanceId: PI_INSTANCE_ID, + model, +}); + +interface FakePi { + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly emit: (record: PiRpcRecord) => Effect.Effect; + readonly takeRequest: (type: string) => Effect.Effect; +} + +/** + * In-process fake `pi --mode rpc`: captures every stdin record, auto-acks + * requests with canned data, and lets tests push protocol events to stdout. + */ +const makeFakePi: Effect.Effect = Effect.gen(function* () { + const stdout = yield* Queue.unbounded(); + const requests = yield* Queue.unbounded(); + let stdinBuffer = ""; + + const emit = (record: PiRpcRecord) => + Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( + Effect.asVoid, + ); + + const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { + if (typeof record["id"] !== "string") return null; + const base = { + type: "response", + id: record["id"], + command: String(record["type"]), + success: true, + }; + switch (record["type"]) { + case "get_state": + return { + ...base, + data: { + model: null, + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + sessionFile: FAKE_SESSION_FILE, + sessionId: "abc", + }, + }; + case "switch_session": + return { ...base, data: { cancelled: false } }; + default: + return base; + } + }; + + const handleStdinChunk = (chunk: Uint8Array) => + Effect.gen(function* () { + stdinBuffer += new TextDecoder().decode(chunk); + while (true) { + const newline = stdinBuffer.indexOf("\n"); + if (newline === -1) return; + const line = stdinBuffer.slice(0, newline); + stdinBuffer = stdinBuffer.slice(newline + 1); + if (line.length === 0) continue; + const record = decodeJsonLine(line) as PiRpcRecord; + yield* Queue.offer(requests, record); + const response = respondTo(record); + if (response !== null) yield* emit(response); + } + }); + + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach(handleStdinChunk), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + + const takeRequest = (type: string): Effect.Effect => + Effect.gen(function* () { + while (true) { + const record = yield* Queue.take(requests); + if (record["type"] === type) return record; + } + }); + + return { spawner, emit, takeRequest } satisfies FakePi; +}); + +const makeAdapter = Effect.fnUntraced(function* (fake: FakePi) { + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + return makePiAdapterV2({ + instanceId: PI_INSTANCE_ID, + settings: { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + environment: {}, + spawner: fake.spawner, + fileSystem, + idAllocator, + serverConfig, + }); +}); + +const openRuntime = Effect.fnUntraced(function* (fake: FakePi, model = "default") { + const adapter = yield* makeAdapter(fake); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: SESSION_ID, + modelSelection: modelSelection(model), + runtimePolicy, + }); + const emitted = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(emitted, event)), + Effect.forkScoped, + ); + const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => + Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(emitted); + if (predicate(event)) return event; + } + }); + return { runtime, takeEvent }; +}); + +const makeAppThread = Effect.fnUntraced(function* (model: string) { + const now = yield* DateTime.now; + return { + createdBy: "user", + creationSource: "web", + id: THREAD_ID, + projectId: "project:fixture:pi" as OrchestrationV2AppThread["projectId"], + title: "Pi test thread", + providerInstanceId: PI_INSTANCE_ID, + modelSelection: modelSelection(model), + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: THREAD_ID }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + } satisfies OrchestrationV2AppThread; +}); + +const startTurn = Effect.fnUntraced(function* ( + runtime: ProviderAdapterV2SessionRuntime, + providerThread: OrchestrationV2ProviderThread, + model = "default", +) { + const appThread = yield* makeAppThread(model); + yield* runtime.startTurn({ + appThread, + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("run-attempt:run:thread-pi-test:1:1"), + rootNodeId: NodeId.make("node:run:thread-pi-test:1:root"), + providerThread, + message: { + messageId: "message:thread-pi-test:1" as never, + text: "Hello pi", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: modelSelection(model), + runtimePolicy, + }); +}); + +describe("PiAdapterV2", () => { + it("declares Pi-honest capabilities", () => { + assert.isTrue(PiProviderCapabilitiesV2.turns.supportsActiveSteering); + assert.isFalse(PiProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); + assert.equal(PiProviderCapabilitiesV2.turns.terminalStatusQuality, "strong"); + assert.isFalse(PiProviderCapabilitiesV2.approvals.supportsCommandApproval); + assert.isFalse(PiProviderCapabilitiesV2.tools.supportsMcpTools); + assert.equal(PiProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); + }); + + it.effect("registers the thread from get_state and resumes via switch_session", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + assert.equal(providerThread.driver, PI_PROVIDER); + + yield* runtime.resumeThread({ providerThread }); + const switchRequest = yield* fake.takeRequest("switch_session"); + assert.equal(switchRequest["sessionPath"], FAKE_SESSION_FILE); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("streams assistant text and settles a completed turn on agent_settled", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); + + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "Hel" }, + }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "lo" }, + }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Hello" }, + }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + stopReason: "stop", + }, + }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + yield* fake.emit({ type: "agent_settled" }); + + const assistantItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.streaming === false, + ); + assert.isTrue( + assistantItem.type === "turn_item.updated" && + assistantItem.turnItem.type === "assistant_message" && + assistantItem.turnItem.text === "Hello", + ); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps the turn open across agent_end and fails it on final retry failure", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: true }); + yield* fake.emit({ + type: "auto_retry_end", + success: false, + attempt: 3, + finalError: "529 overloaded", + }); + yield* fake.emit({ type: "agent_settled" }); + + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "failed"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message.includes("overloaded"), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("interrupts with abort and settles the turn as interrupted", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + assert.equal(running.type, "provider_turn.updated"); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + yield* fake.takeRequest("abort"); + yield* fake.emit({ type: "agent_settled" }); + + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect( + "applies an explicit model before prompting and skips set_model for the Pi default", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake, "anthropic/claude-sonnet-5"); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("anthropic/claude-sonnet-5"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "anthropic/claude-sonnet-5"); + const setModel = yield* fake.takeRequest("set_model"); + assert.equal(setModel["provider"], "anthropic"); + assert.equal(setModel["modelId"], "claude-sonnet-5"); + yield* fake.takeRequest("prompt"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("bridges extension select dialogs to runtime requests and answers them", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick one", + options: ["Allow", "Block"], + }); + + const pending = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + assert.isTrue( + pending.type === "runtime_request.updated" && pending.runtimeRequest.kind === "user_input", + ); + const requestItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + assert.isTrue( + requestItem.type === "turn_item.updated" && + requestItem.turnItem.type === "user_input_request" && + requestItem.turnItem.questions[0]?.options.length === 2, + ); + + const requestId = + pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; + yield* runtime.respondToRuntimeRequest({ + requestId: requestId!, + answers: { "ui-1": "Allow" }, + }); + const uiResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(uiResponse["id"], "ui-1"); + assert.equal(uiResponse["value"], "Allow"); + + const resolved = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "resolved", + ); + assert.equal(resolved.type, "runtime_request.updated"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("cancels unanswered extension dialogs when the turn settles", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-2", + method: "confirm", + title: "Continue?", + message: "Really continue?", + }); + yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + yield* fake.emit({ type: "agent_settled" }); + + const cancelledResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(cancelledResponse["id"], "ui-2"); + assert.equal(cancelledResponse["cancelled"], true); + const cancelled = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled", + ); + assert.equal(cancelled.type, "runtime_request.updated"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("steers the active turn through pi's native steer command", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer" as never, + text: "Focus on tests", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const steer = yield* fake.takeRequest("steer"); + assert.equal(steer["message"], "Focus on tests"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); +}); + +describe("PiRpc framing", () => { + it.effect("reassembles records across chunk boundaries and strips CR", () => + Effect.gen(function* () { + const stdout = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + const connection = yield* makePiRpcConnection({ + command: "pi", + args: ["--mode", "rpc"], + cwd: undefined, + env: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const push = (text: string) => + Queue.offer(stdout, new TextEncoder().encode(text)).pipe(Effect.asVoid); + yield* push('{"type":"agent_'); + yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); + + const first = yield* Queue.take(connection.events); + assert.equal(first["type"], "agent_start"); + const second = yield* Queue.take(connection.events); + assert.equal(second["type"], "agent_settled"); + const third = yield* Queue.take(connection.events); + assert.equal(third["type"], "queue_update"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts new file mode 100644 index 000000000000..5255722133d7 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -0,0 +1,1526 @@ +/** + * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent + * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. + * + * Design intent: honor the user's Pi customizations. The process is spawned + * with no `--no-*` flags, so the user's extensions, skills, prompt templates, + * AGENTS.md / SYSTEM.md context, settings.json, custom models, and auth all + * load exactly as they do in the `pi` TUI. Sessions are stored by Pi itself + * (default `~/.pi/agent/sessions/`), and the session file path is the durable + * `nativeThreadRef`, so a thread started in T3 can be resumed from the TUI + * and vice versa. + * + * Turn lifecycle: `agent_settled` is the only terminal signal. `agent_end` + * merely closes one low-level run — compaction retries, auto-retries, and + * queued continuations may still follow it, so the turn stays open until Pi + * reports the session settled. + * + * Extension UI: Pi extensions raise dialogs through `extension_ui_request`. + * Dialog methods become v2 runtime requests (`confirm` → approval_request, + * `select`/`input`/`editor` → user_input_request); answers travel back as + * `extension_ui_response`. `notify` becomes a completed activity item; + * remaining fire-and-forget surfaces (`setStatus`/`setWidget`/`setTitle`) + * are dropped until a dedicated Pi panel exists. + */ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { + defaultInstanceIdForDriver, + PiSettings, + ProviderDriverKind, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ProviderApprovalDecision, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterEventStreamError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunError, + ProviderAdapterTurnStartError, + ProviderAdapterV2, + type ProviderAdapterV2Error, + type ProviderAdapterV2Event, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2SteerInput, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { makePiRpcConnection, type PiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; + +export const PI_PROVIDER = ProviderDriverKind.make("pi"); +export const PI_DRIVER_KIND = PI_PROVIDER; +export const PI_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(PI_DRIVER_KIND); +const DEFAULT_PI_SETTINGS = Schema.decodeSync(PiSettings)({}); + +/** + * Sentinel model slug meaning "do not call set_model": Pi resolves the model + * from the user's own settings.json (`defaultProvider`/`defaultModel`). + */ +export const PI_INHERIT_MODEL_SLUG = "default"; +/** Thinking-level option value meaning "do not call set_thinking_level". */ +export const PI_INHERIT_THINKING_VALUE = "inherit"; + +const STREAM_FLUSH_MS = 50; +const PI_REQUEST_TIMEOUT_MS = 15_000; + +export const PiProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: false, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: false, + supportsDynamicToolCallbacks: false, + }, + approvals: { + // Pi has no native permission system; the only prompts are the ones the + // user's own extensions raise through the extension UI protocol. + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface PiAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: PiSettings; + readonly environment: NodeJS.ProcessEnv; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; +} + +// ── record helpers ──────────────────────────────────────────── + +function recordField(input: unknown, key: string): unknown { + if (typeof input !== "object" || input === null) return undefined; + return (input as Record)[key]; +} + +function recordString(input: unknown, key: string): string | undefined { + const value = recordField(input, key); + return typeof value === "string" ? value : undefined; +} + +function recordNumber(input: unknown, key: string): number | undefined { + const value = recordField(input, key); + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Concatenate the `text` fields of a Pi content-block array. */ +function contentText(content: unknown): string { + if (!Array.isArray(content)) { + return typeof content === "string" ? content : ""; + } + return content + .map((block) => { + if (recordField(block, "type") === "text") return recordString(block, "text") ?? ""; + return ""; + }) + .join(""); +} + +function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { + const separator = slug.indexOf("/"); + if (separator <= 0 || separator === slug.length - 1) return null; + return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; +} + +function providerRef( + nativeId: string, + strength: "strong" | "weak" = "strong", +): OrchestrationV2ProviderRef { + return { driver: PI_PROVIDER, nativeId, strength }; +} + +const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +// ── per-session state ───────────────────────────────────────── + +interface PiStreamItemState { + readonly nativeItemId: string; + readonly kind: "assistant_message" | "reasoning"; + text: string; + completed: boolean; + flushScheduled: boolean; + readonly startedAt: DateTime.Utc; +} + +interface ActivePiTurn { + readonly turnInput: ProviderAdapterV2TurnInput; + readonly providerTurn: OrchestrationV2ProviderTurn; + readonly startedAt: DateTime.Utc; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + /** Increments on assistant `message_start` so content indexes stay unique. */ + messageOrdinal: number; + readonly streamItems: Map; + readonly toolArgs: Map; + interrupted: boolean; + failure: ReturnType | null; +} + +interface PendingPiPrompt { + readonly nativeRequestId: string; + readonly method: "select" | "confirm" | "input" | "editor"; + readonly questionId: string; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; +} + +interface PiThreadState { + providerThread: OrchestrationV2ProviderThread; + activeTurn: ActivePiTurn | null; +} + +// ── adapter ─────────────────────────────────────────────────── + +export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2Shape { + const { idAllocator } = options; + + const protocolError = (detail: string, payload?: unknown) => + new ProviderAdapterProtocolError({ + driver: PI_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); + + return ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: PI_PROVIDER, + getCapabilities: () => Effect.succeed(PiProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession: Effect.fn("PiAdapterV2.openSession")(function* ( + input: ProviderAdapterV2OpenSessionInput, + ) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; + const connection: PiRpcConnection = yield* makePiRpcConnection({ + command: options.settings.binaryPath || "pi", + args: ["--mode", "rpc", ...tokenizeCliArgs(options.settings.launchArgs)], + cwd, + env: options.environment, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: PiProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded(); + const pendingPrompts = new Map(); + let threadState: PiThreadState | null = null; + let appliedModel: string | null = null; + let appliedThinking: string | null = null; + + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const updateProviderSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = ( + state: PiThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emit({ + type: "provider_thread.updated", + driver: PI_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const itemOrdinal = (turn: ActivePiTurn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const request = (record: PiRpcRecord, timeoutMs = PI_REQUEST_TIMEOUT_MS) => + connection.request(record, timeoutMs); + + const baseItemFields = ( + turn: ActivePiTurn, + nativeItemId: string, + startedAt: DateTime.Utc, + updatedAt: DateTime.Utc, + ) => ({ + id: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + startedAt, + updatedAt, + }); + + const emitItemNode = ( + turn: ActivePiTurn, + nativeItemId: string, + kind: OrchestrationV2ExecutionNode["kind"], + status: OrchestrationV2ExecutionNode["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + ) => + emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { + id: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: turn.turnInput.rootNodeId, + rootNodeId: turn.turnInput.rootNodeId, + kind, + status, + countsForRun: false, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + // ── streaming text / reasoning ──────────────────────── + + const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => + Effect.gen(function* () { + const emittedAt = yield* DateTime.now; + const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); + yield* emitItemNode( + turn, + item.nativeItemId, + item.kind, + streaming ? "running" : "completed", + item.startedAt, + streaming ? null : emittedAt, + ); + if (item.kind === "assistant_message") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: item.nativeItemId, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "assistant_message", + messageId, + text: item.text, + streaming, + }, + }); + yield* emit({ + type: "message.updated", + driver: PI_PROVIDER, + message: { + id: messageId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: item.nativeItemId, + }), + role: "assistant", + text: item.text, + attachments: [], + streaming, + createdBy: "agent", + creationSource: "provider", + createdAt: item.startedAt, + updatedAt: emittedAt, + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "reasoning", + text: item.text, + streaming, + }, + }); + }); + + const scheduleStreamFlush = (turn: ActivePiTurn, item: PiStreamItemState) => + Effect.gen(function* () { + if (item.flushScheduled || item.completed) return; + item.flushScheduled = true; + yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( + Effect.andThen( + Effect.suspend(() => { + item.flushScheduled = false; + return item.completed ? Effect.void : emitStreamItem(turn, item, true); + }), + ), + Effect.forkIn(scope), + ); + }); + + const streamItemFor = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + kind: PiStreamItemState["kind"], + contentIndex: number, + ) { + const nativeItemId = `${turn.providerTurn.id}:m${turn.messageOrdinal}:c${contentIndex}`; + const existing = turn.streamItems.get(nativeItemId); + if (existing !== undefined) return existing; + const startedAt = yield* DateTime.now; + const item: PiStreamItemState = { + nativeItemId, + kind, + text: "", + completed: false, + flushScheduled: false, + startedAt, + }; + turn.streamItems.set(nativeItemId, item); + // Ordinal reserved on first delta so items appear in stream order. + itemOrdinal(turn, nativeItemId); + return item; + }); + + const completeStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, text?: string) => + Effect.suspend(() => { + if (item.completed) return Effect.void; + item.completed = true; + if (text !== undefined && text.length > 0) item.text = text; + return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); + }); + + const completeOpenStreamItems = (turn: ActivePiTurn) => + Effect.forEach( + Array.from(turn.streamItems.values()).filter((item) => !item.completed), + (item) => completeStreamItem(turn, item), + { discard: true }, + ); + + // ── tools ───────────────────────────────────────────── + + const emitToolItem = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + event: PiRpcRecord, + phase: "start" | "update" | "end", + ) { + const toolCallId = recordString(event, "toolCallId"); + const toolName = recordString(event, "toolName") ?? "tool"; + if (toolCallId === undefined) return; + if (phase === "start") { + turn.toolArgs.set(toolCallId, event["args"]); + } + const args = event["args"] ?? turn.toolArgs.get(toolCallId); + const emittedAt = yield* DateTime.now; + const completed = phase === "end"; + const isError = event["isError"] === true; + const resultRecord = completed ? event["result"] : event["partialResult"]; + const outputText = contentText(recordField(resultRecord, "content")); + const status = completed ? (isError ? "failed" : "completed") : "running"; + const base = baseItemFields(turn, toolCallId, emittedAt, emittedAt); + yield* emitItemNode( + turn, + toolCallId, + "tool_call", + status, + emittedAt, + completed ? emittedAt : null, + ); + const shared = { + ...base, + status, + completedAt: completed ? emittedAt : null, + } as const; + if (toolName === "bash") { + const exitCode = recordNumber(recordField(resultRecord, "details"), "exitCode"); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "command_execution", + input: recordString(args, "command") ?? "", + ...(outputText.length > 0 ? { output: outputText } : {}), + ...(exitCode === undefined ? {} : { exitCode }), + }, + }); + return; + } + if (toolName === "edit" || toolName === "write") { + const fileName = recordString(args, "path") ?? recordString(args, "file_path"); + if (fileName !== undefined) { + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "file_change", + fileName, + }, + }); + return; + } + } + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "dynamic_tool", + toolName, + input: args ?? {}, + ...(outputText.length > 0 ? { output: outputText } : {}), + }, + }); + }); + + // ── extension UI prompts ────────────────────────────── + + const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => + Effect.gen(function* () { + yield* connection + .send({ + type: "extension_ui_response", + id: pending.nativeRequestId, + cancelled: true, + }) + .pipe(Effect.ignore); + pending.runtimeRequest = { + ...pending.runtimeRequest, + status: "cancelled", + resolvedAt, + }; + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "cancelled", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelPendingPrompts = (resolvedAt: DateTime.Utc) => + Effect.gen(function* () { + const pending = Array.from(pendingPrompts.values()); + pendingPrompts.clear(); + yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { + discard: true, + }); + }); + + const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { + const method = recordString(event, "method"); + const nativeRequestId = recordString(event, "id"); + if (method === undefined) return; + if (method === "notify") { + const state = threadState; + const turn = state?.activeTurn ?? null; + const message = recordString(event, "message") ?? ""; + if (turn === null || message.length === 0) return; + const emittedAt = yield* DateTime.now; + const nativeItemId = `notify:${turn.nextItemOrdinal}`; + yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), + status: "completed", + completedAt: emittedAt, + title: "notify", + type: "dynamic_tool", + toolName: "notify", + input: { + message, + notifyType: recordString(event, "notifyType") ?? "info", + }, + }, + }); + return; + } + if ( + method !== "select" && + method !== "confirm" && + method !== "input" && + method !== "editor" + ) { + // setStatus / setWidget / setTitle / set_editor_text: no Pi panel + // yet, so these fire-and-forget surfaces are dropped. + yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); + return; + } + if (nativeRequestId === undefined) return; + const state = threadState; + const turn = state?.activeTurn ?? null; + if (state === null || turn === null) { + // No turn to attach UI to; cancel so the extension is not stuck. + yield* connection + .send({ type: "extension_ui_response", id: nativeRequestId, cancelled: true }) + .pipe(Effect.ignore); + return; + } + const createdAt = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: PI_PROVIDER, + providerTurnId: turn.providerTurn.id, + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const title = recordString(event, "title") ?? method; + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: turn.providerTurn.id, + nativeRequestRef: providerRef(nativeRequestId), + kind: method === "confirm" ? "command" : "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: turn.turnInput.rootNodeId, + rootNodeId: turn.turnInput.rootNodeId, + kind: method === "confirm" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const itemBase = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeRequestId), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeRequestId), + status: "waiting" as const, + title, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + }; + const turnItem: OrchestrationV2TurnItem = + method === "confirm" + ? { + ...itemBase, + type: "approval_request", + requestId, + requestKind: "command", + prompt: recordString(event, "message") ?? title, + } + : { + ...itemBase, + type: "user_input_request", + requestId, + questions: [piQuestion(nativeRequestId, method, title, event)], + }; + pendingPrompts.set(String(requestId), { + nativeRequestId, + method, + questionId: nativeRequestId, + runtimeRequest, + node, + turnItem, + }); + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: turn.turnInput.threadId, + runtimeRequest, + }); + yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); + yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); + }); + + // ── turn lifecycle ──────────────────────────────────── + + const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState) { + const turn = state.activeTurn; + if (turn === null) return; + state.activeTurn = null; + const completedAt = yield* DateTime.now; + yield* completeOpenStreamItems(turn); + yield* cancelPendingPrompts(completedAt); + const failure = turn.interrupted ? null : turn.failure; + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: { + ...turn.providerTurn, + status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", + completedAt, + }, + }); + yield* updateProviderThread(state, { status: "idle" }); + yield* updateProviderSession(failure !== null ? "error" : "ready"); + if (failure !== null) { + const failureItemId = `terminal-failure:${turn.providerTurn.id}`; + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, failureItemId, completedAt, completedAt), + status: "failed", + title: null, + completedAt, + type: "error", + failure, + }, + }); + yield* emit({ + type: "turn.terminal", + driver: PI_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, failureItemId), + status: "failed", + failure, + threadDisposition: "reusable", + }); + return; + } + yield* emit({ + type: "turn.terminal", + driver: PI_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + status: turn.interrupted ? "interrupted" : "completed", + failure: null, + threadDisposition: "reusable", + }); + }); + + // ── event pump ──────────────────────────────────────── + + const handleSessionEvent = Effect.fnUntraced(function* (event: PiRpcRecord) { + const state = threadState; + const turn = state?.activeTurn ?? null; + switch (event["type"]) { + case "message_start": { + if (turn !== null && recordString(event["message"], "role") === "assistant") { + turn.messageOrdinal += 1; + } + return; + } + case "message_update": { + if (turn === null) return; + const delta = event["assistantMessageEvent"]; + const deltaType = recordString(delta, "type"); + const contentIndex = recordNumber(delta, "contentIndex") ?? 0; + if (deltaType === "text_delta" || deltaType === "thinking_delta") { + const item = yield* streamItemFor( + turn, + deltaType === "text_delta" ? "assistant_message" : "reasoning", + contentIndex, + ); + item.text += recordString(delta, "delta") ?? ""; + yield* scheduleStreamFlush(turn, item); + return; + } + if (deltaType === "text_end" || deltaType === "thinking_end") { + const item = yield* streamItemFor( + turn, + deltaType === "text_end" ? "assistant_message" : "reasoning", + contentIndex, + ); + yield* completeStreamItem( + turn, + item, + recordString(delta, "content") ?? recordString(delta, "thinking"), + ); + return; + } + return; + } + case "message_end": { + if (turn === null) return; + const message = event["message"]; + if (recordString(message, "role") !== "assistant") return; + yield* completeOpenStreamItems(turn); + if (recordString(message, "stopReason") === "error" && turn.failure === null) { + turn.failure = makeProviderFailure({ + message: recordString(message, "errorMessage") ?? "Pi reported a model error.", + class: "provider_error", + }); + } + return; + } + case "tool_execution_start": + if (turn !== null) yield* emitToolItem(turn, event, "start"); + return; + case "tool_execution_update": + if (turn !== null) yield* emitToolItem(turn, event, "update"); + return; + case "tool_execution_end": + if (turn !== null) yield* emitToolItem(turn, event, "end"); + return; + case "compaction_end": { + if (turn === null) return; + const emittedAt = yield* DateTime.now; + const result = event["result"]; + if (result === null || result === undefined) return; + const nativeItemId = `compaction:${turn.nextItemOrdinal}`; + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), + status: "completed", + title: null, + completedAt: emittedAt, + type: "compaction", + driver: PI_PROVIDER, + ...(recordString(result, "summary") === undefined + ? {} + : { summary: recordString(result, "summary") }), + ...(recordNumber(result, "tokensBefore") === undefined + ? {} + : { beforeTokenCount: recordNumber(result, "tokensBefore") }), + ...(recordNumber(result, "estimatedTokensAfter") === undefined + ? {} + : { afterTokenCount: recordNumber(result, "estimatedTokensAfter") }), + }, + }); + return; + } + case "auto_retry_end": { + if (turn === null) return; + if (event["success"] === true) return; + turn.failure = makeProviderFailure({ + message: recordString(event, "finalError") ?? "Pi auto-retry failed.", + class: "provider_error", + retryable: false, + }); + return; + } + case "extension_ui_request": + yield* handleExtensionUiRequest(event); + return; + case "extension_error": { + yield* Effect.logWarning("Pi extension error.", { + extensionPath: recordString(event, "extensionPath"), + event: recordString(event, "event"), + error: recordString(event, "error"), + }); + return; + } + case "agent_settled": { + if (state !== null) yield* finalizeTurn(state); + return; + } + default: + return; + } + }); + + yield* Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(connection.events); + yield* handleSessionEvent(event); + } + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + // Transport death: fail any live turn, then surface the error. + const state = threadState; + if (state?.activeTurn != null) { + state.activeTurn.failure = makeProviderFailure({ + cause, + message: "Pi process exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state); + } + yield* updateProviderSession("error", "Pi process exited unexpectedly."); + yield* Queue.fail( + events, + new ProviderAdapterEventStreamError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + }), + ), + Effect.forkIn(scope), + ); + + // ── session runtime ─────────────────────────────────── + + const registerThread = Effect.fnUntraced(function* ( + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + const existing = threadInput.existingProviderThread; + if (existing?.nativeThreadRef?.nativeId != null) { + yield* request({ + type: "switch_session", + sessionPath: existing.nativeThreadRef.nativeId, + }); + } + const stateData = yield* request({ type: "get_state" }); + const nativeId = + recordString(stateData, "sessionFile") ?? recordString(stateData, "sessionId"); + if (nativeId === undefined) { + return yield* protocolError( + "get_state returned neither sessionFile nor sessionId", + stateData, + ); + } + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = + existing !== undefined + ? { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: providerRef(nativeId), + status: "idle", + updatedAt: createdAt, + } + : { + id: idAllocator.derive.providerThread({ + driver: PI_PROVIDER, + nativeThreadId: nativeId, + }), + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + createdAt, + updatedAt: createdAt, + }; + threadState = { providerThread, activeTurn: null }; + yield* emit({ + type: "provider_thread.updated", + driver: PI_PROVIDER, + providerThread, + }); + return providerThread; + }); + + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { + if ( + modelSelection.model !== PI_INHERIT_MODEL_SLUG && + modelSelection.model !== appliedModel + ) { + const parsed = parsePiModelSlug(modelSelection.model); + if (parsed === null) { + return yield* protocolError( + `Pi model '${modelSelection.model}' must use provider/model format`, + ); + } + yield* request({ + type: "set_model", + provider: parsed.provider, + modelId: parsed.modelId, + }); + appliedModel = modelSelection.model; + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, model: modelSelection.model, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + } + const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); + if ( + thinking !== undefined && + thinking !== PI_INHERIT_THINKING_VALUE && + thinking !== appliedThinking && + PI_THINKING_LEVELS.has(thinking) + ) { + yield* request({ type: "set_thinking_level", level: thinking }); + appliedThinking = thinking; + } + }); + + const resolvePromptPayload = Effect.fnUntraced(function* ( + text: string, + attachments: ReadonlyArray, + ) { + const images: Array<{ type: "image"; data: string; mimeType: string }> = []; + const extraLines: Array = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: options.serverConfig.attachmentsDir, + attachment, + }); + if (path === null) continue; + if (attachment.mimeType.startsWith("image/")) { + const bytes = yield* options.fileSystem.readFile(path); + images.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } else { + extraLines.push(`[Attachment saved at ${path}]`); + } + } + const message = extraLines.length === 0 ? text : `${text}\n\n${extraLines.join("\n")}`; + return { message, images }; + }); + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + providerSession: sessionEntity, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + registerThread(threadInput).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: PI_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + registerThread({ + threadId: + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const state = threadState; + if (state === null) { + return yield* protocolError("Pi session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* protocolError( + `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, + ); + } + yield* applySelection(turnInput.modelSelection); + const startedAt = yield* DateTime.now; + const syntheticNativeTurnId = `${state.providerThread.id}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ + driver: PI_PROVIDER, + nativeTurnId: syntheticNativeTurnId, + }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + state.activeTurn = { + turnInput, + providerTurn, + startedAt, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + messageOrdinal: 0, + streamItems: new Map(), + toolArgs: new Map(), + interrupted: false, + failure: null, + }; + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + const payload = yield* resolvePromptPayload( + turnInput.message.text, + turnInput.message.attachments, + ); + yield* request({ + type: "prompt", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }).pipe( + Effect.tapError(() => + Effect.gen(function* () { + const current = threadState; + if (current?.activeTurn?.providerTurn.id === providerTurn.id) { + current.activeTurn.failure = makeProviderFailure({ + message: "Pi rejected the prompt.", + class: "provider_error", + }); + yield* finalizeTurn(current); + } + }), + ), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput: ProviderAdapterV2SteerInput) => + Effect.gen(function* () { + const turn = threadState?.activeTurn ?? null; + if (turn === null || turn.providerTurn.id !== steerInput.providerTurnId) { + return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); + } + const payload = yield* resolvePromptPayload( + steerInput.message.text, + steerInput.message.attachments, + ); + yield* request({ + type: "steer", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterSteerRunError({ + driver: PI_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const turn = threadState?.activeTurn ?? null; + if (turn === null || turn.providerTurn.id !== interruptInput.providerTurnId) { + return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); + } + turn.interrupted = true; + yield* request({ type: "abort" }).pipe( + Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: PI_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingPrompts.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* protocolError( + `No pending Pi extension request ${requestInput.requestId}`, + ); + } + pendingPrompts.delete(String(requestInput.requestId)); + const response = piUiResponse(pending, requestInput.decision, requestInput.answers); + yield* connection.send({ + type: "extension_ui_response", + id: pending.nativeRequestId, + ...response, + }); + const resolvedAt = yield* DateTime.now; + pending.runtimeRequest = { + ...pending.runtimeRequest, + status: "resolved", + resolvedAt, + }; + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { ...pending.node, status: "completed", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "completed", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRuntimeRequestResponseError({ + driver: PI_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + Effect.fail( + new ProviderAdapterReadThreadSnapshotError({ + driver: PI_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + }), + ), + rollbackThread: (rollbackInput) => + Effect.fail( + new ProviderAdapterRollbackThreadError({ + driver: PI_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + }), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapterForkThreadError({ + driver: PI_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + }), + ), + }; + return runtime; + }), + }); +} + +function piQuestion( + questionId: string, + method: "select" | "input" | "editor", + title: string, + event: PiRpcRecord, +): OrchestrationV2UserInputQuestion { + const options = + method === "select" && Array.isArray(event["options"]) + ? event["options"] + .filter((option): option is string => typeof option === "string") + .map((option) => ({ label: option, description: option })) + : []; + return { + id: questionId, + header: title, + question: recordString(event, "message") ?? recordString(event, "placeholder") ?? title, + options, + }; +} + +function piUiResponse( + pending: PendingPiPrompt, + decision: ProviderApprovalDecision | undefined, + answers: Record | undefined, +): PiRpcRecord { + if (pending.method === "confirm") { + if (decision === "accept" || decision === "acceptForSession") return { confirmed: true }; + if (decision === "decline") return { confirmed: false }; + return { cancelled: true }; + } + const answer = answers?.[pending.questionId]; + if (typeof answer === "string" && answer.length > 0) return { value: answer }; + return { cancelled: true }; +} + +// ── driver ──────────────────────────────────────────────────── + +export type PiAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | IdAllocatorV2 + | ServerConfig; + +export const PiAdapterV2Driver: ProviderAdapterDriver = { + driverKind: PI_DRIVER_KIND, + configSchema: PiSettings, + defaultConfig: (): PiSettings => DEFAULT_PI_SETTINGS, + create: Effect.fn("PiAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + return makePiAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + spawner, + fileSystem, + idAllocator, + serverConfig, + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: PI_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Pi adapter.", + cause, + }), + ), + ), + ), +}; + +export const layer: Layer.Layer = Layer.effect( + ProviderAdapterV2, + Effect.gen(function* () { + const hostEnvironment = yield* HostProcessEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + return makePiAdapterV2({ + instanceId: PI_DEFAULT_INSTANCE_ID, + settings: DEFAULT_PI_SETTINGS, + environment: hostEnvironment, + spawner, + fileSystem, + idAllocator, + serverConfig, + }); + }), +); diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts new file mode 100644 index 000000000000..8d30789359b9 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -0,0 +1,293 @@ +/** + * PiRpc — stdio JSONL transport for the Pi coding agent's RPC mode. + * + * Spawns `pi --mode rpc` and speaks Pi's line-delimited JSON protocol: + * requests go to stdin as `{"type": "...", "id": "..."}` records, responses + * come back as `{"type": "response", "id": ..., "success": ...}` and are + * correlated by `id`; every other stdout record is a session event and is + * surfaced on the `events` queue in arrival order. + * + * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` + * stripped. Lines are split manually (never with `readline`, which also + * splits on U+2028/U+2029 and would corrupt frames). Records that fail to + * parse as JSON are dropped with a debug log rather than failing the + * transport, so a chatty extension cannot take the session down. + * + * Used by `PiAdapterV2` for sessions and by `PiTextGeneration` / + * `PiProvider` for ephemeral one-shot processes. + */ +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +export class PiRpcError extends Schema.TaggedErrorClass()("PiRpcError", { + operation: Schema.String, + detail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Pi RPC ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; + } +} + +export type PiRpcRecord = Record; + +export interface PiRpcSpawnOptions { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string | undefined; + readonly env: NodeJS.ProcessEnv; +} + +export interface PiRpcConnection { + /** Fire-and-forget write (used for `extension_ui_response`). */ + readonly send: (record: PiRpcRecord) => Effect.Effect; + /** + * Correlated request: assigns an `id`, waits for the matching response + * record, and returns its `data` (undefined when the command carries none). + * Fails on `success: false`, transport death, or timeout. + */ + readonly request: (record: PiRpcRecord, timeoutMs?: number) => Effect.Effect; + /** Session events (every non-response stdout record) in arrival order. */ + readonly events: Queue.Dequeue; + /** Resolves when the process has exited, with its exit code. */ + readonly exited: Effect.Effect; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const TERMINATION_GRACE = Duration.seconds(1); + +interface PendingPiRequest { + readonly deferred: Deferred.Deferred; +} + +function splitJsonlChunks(buffer: string, chunk: string): readonly [ReadonlyArray, string] { + const combined = buffer + chunk; + const parts = combined.split("\n"); + const remainder = parts.pop() ?? ""; + const lines = parts + .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0); + return [lines, remainder]; +} + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); +const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); + +function parsePiRecord(line: string): PiRpcRecord | undefined { + try { + const parsed: unknown = decodeJsonLine(line); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as PiRpcRecord; + } + return undefined; + } catch { + return undefined; + } +} + +/** Kill the pi process group: SIGTERM, short grace, then SIGKILL. */ +const terminatePiProcess = (pid: number, kill: (signal: NodeJS.Signals) => boolean) => + Effect.gen(function* () { + if (!kill("SIGTERM")) return; + yield* Effect.sleep(TERMINATION_GRACE); + kill("SIGKILL"); + }); + +export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSpawnOptions) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; + const scope = yield* Effect.scope; + + const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { + env: options.env, + }).pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: options.env, + extendEnv: false, + shell: spawnCommand.shell, + detached: platform !== "win32", + }), + ) + .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); + + const killProcessGroup = (signal: NodeJS.Signals): boolean => { + try { + if (platform === "win32") { + process.kill(Number(child.pid), signal); + } else { + process.kill(-Number(child.pid), signal); + } + return true; + } catch { + return false; + } + }; + + const pendingRequests = new Map(); + const events = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); + const transportDown = yield* Deferred.make(); + const exitDeferred = yield* Deferred.make(); + let nextRequestId = 0; + + const failTransport = (error: PiRpcError) => + Effect.gen(function* () { + yield* Deferred.fail(transportDown, error); + for (const [key, pending] of pendingRequests) { + pendingRequests.delete(key); + yield* Deferred.fail(pending.deferred, error); + } + yield* Queue.fail(events, error); + }); + + // Writer: drain the outgoing queue into the child's stdin. If the writer + // dies, the transport is failed so later sends surface the error instead of + // silently queueing into a dead pipe. + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(child.stdin), + Effect.catchCause((cause) => failTransport(new PiRpcError({ operation: "write", cause }))), + Effect.forkIn(scope), + ); + + const routeRecord = (record: PiRpcRecord) => + Effect.gen(function* () { + if (record["type"] === "response" && typeof record["id"] === "string") { + const pending = pendingRequests.get(record["id"]); + if (pending !== undefined) { + pendingRequests.delete(record["id"]); + if (record["success"] === true) { + yield* Deferred.succeed(pending.deferred, record["data"]); + } else { + yield* Deferred.fail( + pending.deferred, + new PiRpcError({ + operation: String(record["command"] ?? "request"), + detail: String(record["error"] ?? "unknown error"), + }), + ); + } + return; + } + } + yield* Queue.offer(events, record); + }); + + // Reader: decode stdout into LF-delimited JSON records. + yield* Effect.gen(function* () { + let buffer = ""; + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.gen(function* () { + const [lines, remainder] = splitJsonlChunks(buffer, chunk); + buffer = remainder; + for (const line of lines) { + const record = parsePiRecord(line); + if (record === undefined) { + yield* Effect.logDebug("Dropping non-JSON pi stdout line.", { + lineLength: line.length, + }); + continue; + } + yield* routeRecord(record); + } + }), + ), + ); + const trailing = buffer.length > 0 ? parsePiRecord(buffer) : undefined; + if (trailing !== undefined) { + yield* routeRecord(trailing); + } + }).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => failTransport(new PiRpcError({ operation: "read", cause })), + onSuccess: () => + failTransport(new PiRpcError({ operation: "read", detail: "pi process closed stdout" })), + }), + Effect.forkIn(scope), + ); + + // Surface stderr as debug logs; pi reserves stdout for the protocol. + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + chunk.trim().length === 0 + ? Effect.void + : Effect.logDebug("pi stderr", { chunk: chunk.slice(0, 2_000) }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + yield* child.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => + Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), + onSuccess: (code) => Deferred.succeed(exitDeferred, Number(code)), + }), + Effect.forkIn(scope), + ); + + yield* Scope.addFinalizer( + scope, + terminatePiProcess(Number(child.pid), killProcessGroup).pipe( + Effect.ignore, + Effect.uninterruptible, + ), + ); + + const send = (record: PiRpcRecord): Effect.Effect => + Effect.gen(function* () { + const down = yield* Deferred.isDone(transportDown); + if (down) { + return yield* Deferred.await(transportDown); + } + yield* Queue.offer(outgoing, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); + }); + + const request = ( + record: PiRpcRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Effect.Effect => + Effect.gen(function* () { + const id = `t3-${nextRequestId++}`; + const deferred = yield* Deferred.make(); + pendingRequests.set(id, { deferred }); + yield* send({ ...record, id }).pipe( + Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.fail( + new PiRpcError({ + operation: String(record["type"] ?? "request"), + detail: `timed out after ${timeoutMs}ms`, + }), + ), + }), + Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), + Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + }); + + return { + send, + request, + events, + exited: Deferred.await(exitDeferred), + } satisfies PiRpcConnection; +}); diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0a..8f9db9248909 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -18,6 +18,7 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "./Adapters/OpenCodeAdapterV2.ts"; +import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; export type BuiltInProviderAdapterDriversV2Env = @@ -26,7 +27,8 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv - | OpenCodeAdapterV2DriverEnv; + | OpenCodeAdapterV2DriverEnv + | PiAdapterV2DriverEnv; export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< AnyProviderAdapterDriver @@ -36,6 +38,7 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< CursorAdapterV2Driver, OpenCodeAdapterV2Driver, GrokAdapterV2Driver, + PiAdapterV2Driver, AcpRegistryAdapterV2Driver, ]; diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts new file mode 100644 index 000000000000..1d6bb08d6813 --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -0,0 +1,181 @@ +/** + * PiDriver — v1 `ProviderDriver` for the Pi coding agent, composing the + * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer + * (`PiProvider`), and Pi-backed text generation. + * + * Pi state (sessions, settings, extensions, auth) lives in the user's own + * `~/.pi/agent`, so continuation identity uses the default instance grouping. + */ +import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; +import { + PiAdapterV2Driver, + type PiAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/PiAdapterV2.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialPiProviderSnapshot, + checkPiProviderStatus, + enrichPiSnapshot, +} from "../Layers/PiProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodePiSettings = Schema.decodeSync(PiSettings); + +const DRIVER_KIND = ProviderDriverKind.make("pi"); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@earendil-works/pi-coding-agent", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type PiDriverEnv = + | PiAdapterV2DriverEnv + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const PiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Pi", + supportsMultipleInstances: true, + }, + configSchema: PiSettings, + defaultConfig: (): PiSettings => decodePiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const orchestrationAdapter = yield* PiAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichPiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Pi snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts new file mode 100644 index 000000000000..a5cc3b6c97a5 --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -0,0 +1,414 @@ +/** + * PiProvider — snapshot/probe layer for the Pi coding agent. + * + * Health is probed with `pi --version`. Models, the user's default model, and + * the user's commands (extension slash commands, prompt templates, skills) + * are discovered through a short-lived ephemeral RPC session + * (`pi --mode rpc --no-session`), so everything the user configured in + * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — + * shows up in T3 without any hardcoded catalog. + */ +import { + type ModelCapabilities, + type PiSettings, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { makePiRpcConnection } from "../../orchestration-v2/Adapters/PiRpc.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const PI_PRESENTATION = { + displayName: "Pi", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +/** + * Reasoning-capable Pi models expose Pi's thinking levels. "Inherit" leaves + * the user's settings.json `defaultThinkingLevel` untouched. + */ +const THINKING_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + options: [ + { id: "inherit", label: "Pi default", isDefault: true }, + { id: "off", label: "Off" }, + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, + { id: "max", label: "Max" }, + ], + }, + ], +}); + +/** Deferring to the user's own settings.json default model. */ +const PI_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Pi default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; + +interface PiDiscovery { + readonly models: ReadonlyArray; + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly authenticated: boolean; +} + +function piModelsFromSettings( + customModels: ReadonlyArray | undefined, + discovered: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + [PI_DEFAULT_MODEL, ...discovered], + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +function recordField(input: unknown, key: string): unknown { + if (typeof input !== "object" || input === null) return undefined; + return (input as Record)[key]; +} + +function recordString(input: unknown, key: string): string | undefined { + const value = recordField(input, key); + return typeof value === "string" ? value : undefined; +} + +function parseDiscoveredModels(data: unknown): ReadonlyArray { + const models = recordField(data, "models"); + if (!Array.isArray(models)) return []; + const seen = new Set(); + const parsed: Array = []; + for (const model of models) { + const provider = recordString(model, "provider"); + const id = recordString(model, "id"); + if (provider === undefined || id === undefined) continue; + const slug = `${provider}/${id}`; + if (seen.has(slug)) continue; + seen.add(slug); + parsed.push({ + slug, + name: recordString(model, "name") ?? slug, + isCustom: false, + capabilities: + recordField(model, "reasoning") === true ? THINKING_CAPABILITIES : EMPTY_CAPABILITIES, + }); + } + return parsed; +} + +function parseDiscoveredCommands(data: unknown): { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} { + const commands = recordField(data, "commands"); + if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; + const slashCommands: Array = []; + const skills: Array = []; + for (const command of commands) { + const name = recordString(command, "name"); + if (name === undefined || name.length === 0) continue; + const description = recordString(command, "description"); + if (recordString(command, "source") === "skill") { + const path = recordString(command, "path"); + if (path === undefined) continue; + skills.push({ + name, + ...(description === undefined ? {} : { description }), + path, + ...(recordString(command, "location") === undefined + ? {} + : { scope: recordString(command, "location") }), + enabled: true, + }); + continue; + } + slashCommands.push({ + name, + ...(description === undefined ? {} : { description }), + }); + } + return { slashCommands, skills }; +} + +const discoverPiViaRpc = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + args: ["--mode", "rpc", "--no-session", ...tokenizeCliArgs(piSettings.launchArgs)], + cwd: undefined, + env: environment, + }); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const commandsData = yield* connection + .request({ type: "get_commands" }) + .pipe(Effect.orElseSucceed(() => undefined)); + const discoveredModels = parseDiscoveredModels(modelsData); + const { slashCommands, skills } = parseDiscoveredCommands(commandsData); + return { + models: discoveredModels, + slashCommands, + skills, + authenticated: discoveredModels.length > 0, + } satisfies PiDiscovery; + }).pipe(Effect.scoped); + +const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = piSettings.binaryPath || "pi"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export function buildInitialPiProviderSnapshot( + piSettings: PiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = piModelsFromSettings(piSettings.customModels); + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi CLI availability...", + }, + }); + }); +} + +export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = piModelsFromSettings(piSettings.customModels); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." + : "Failed to execute Pi CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but timed out while running `pi --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverPiViaRpc(piSettings, environment).pipe( + Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Pi RPC discovery failed.", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but RPC startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Pi CLI is installed but RPC startup timed out after ${PI_RPC_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + + const discovery = discoveryExit.value.value; + const models = piModelsFromSettings(piSettings.customModels, discovery.models); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, + probe: { + installed: true, + version, + status: discovery.authenticated ? "ready" : "warning", + auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, + ...(discovery.authenticated + ? {} + : { + message: + "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", + }), + }, + }); +}); + +export const enrichPiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Pi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 32f6448dab5f..de33797367e5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1808,6 +1808,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "pi", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2b..f4e6c4e0a7ab 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,6 +26,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -39,7 +40,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | PiDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + args: ["--mode", "rpc", "--no-session", ...tokenizeCliArgs(piSettings.launchArgs)], + cwd, + env: environment, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + if (modelSelection.model !== "default") { + const separator = modelSelection.model.indexOf("/"); + if (separator > 0 && separator < modelSelection.model.length - 1) { + yield* connection.request({ + type: "set_model", + provider: modelSelection.model.slice(0, separator), + modelId: modelSelection.model.slice(separator + 1), + }); + } + } + + yield* connection.request({ type: "prompt", message: prompt }); + yield* Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(connection.events); + if (event["type"] === "agent_settled") return; + } + }); + const data = yield* connection.request({ type: "get_last_assistant_text" }); + const text = + typeof data === "object" && + data !== null && + typeof (data as { text?: unknown }).text === "string" + ? (data as { text: string }).text.trim() + : ""; + if (!text) { + return yield* new TextGenerationError({ + operation, + detail: "Pi returned empty output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(text)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.timeoutOption(PI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Pi text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("PiTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runPiJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("PiTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runPiJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("PiTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runPiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("PiTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runPiJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..24af04dc69b4 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "pi" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..7af540534eb9 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon, PiAgentIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("pi")]: PiAgentIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 260d895ff2bf..25fda96b347a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,7 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, GithubCopilotIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -88,11 +88,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - }, ]; /** diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..6fcbc5ebb4da 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", + [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2ae720410528..81a22094d699 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -445,6 +445,39 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const PiSettings = makeProviderSettingsSchema( + { + // Disabled by default while Pi support is Early Access. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("pi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Pi coding agent binary.", + providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to pi --mode rpc on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type PiSettings = typeof PiSettings.Type; + export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; @@ -686,6 +719,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -780,6 +814,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const PiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -828,6 +869,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + pi: Schema.optionalKey(PiSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), From 77a1d92a7e78b2c4f55972813b256da2ec724b35 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:13:06 +0200 Subject: [PATCH 02/41] fix(providers): address Pi review findings Handles the Macroscope review on the Pi provider: - startTurn resolves the prompt payload before installing the active turn, so an unreadable attachment can no longer wedge the thread with a turn that is never terminalized. - Dialog responses and turn terminalization now share a semaphore, and the pending entry is dropped only after `extension_ui_response` is sent, so a failed send stays retryable and settlement cannot overtake resolution. - Selecting "Pi default"/"inherit" again replays the baseline model and thinking level captured from the first `get_state`. - Stop advertising the model-specific `xhigh`/`max` thinking levels, which made `set_thinking_level` fail and blocked the turn on models without them. - Text generation rejects a model slug without a usable `provider/model` separator instead of silently running Pi's default model. - Keep the raw RPC error payload as `cause` rather than in the message. - Web Pi icon is a theme-aware glyph, matching its peers and the mobile icon. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/Adapters/PiAdapterV2.ts | 78 ++++++++++++++----- .../src/orchestration-v2/Adapters/PiRpc.ts | 13 +++- apps/server/src/provider/Layers/PiProvider.ts | 7 +- .../src/textGeneration/PiTextGeneration.ts | 21 +++-- apps/web/src/components/Icons.tsx | 11 ++- 5 files changed, 97 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 5255722133d7..fdd2cf5fbea2 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -50,6 +50,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -87,7 +88,12 @@ import { } from "../ProviderAdapterDriver.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; -import { makePiRpcConnection, type PiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; +import { + makePiRpcConnection, + parsePiModelSlug, + type PiRpcConnection, + type PiRpcRecord, +} from "./PiRpc.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); export const PI_DRIVER_KIND = PI_PROVIDER; @@ -238,12 +244,6 @@ function contentText(content: unknown): string { .join(""); } -function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { - const separator = slug.indexOf("/"); - if (separator <= 0 || separator === slug.length - 1) return null; - return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; -} - function providerRef( nativeId: string, strength: "strong" | "weak" = "strong", @@ -346,9 +346,19 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }; const events = yield* Queue.unbounded(); const pendingPrompts = new Map(); + // Answering a dialog and terminalizing a turn both publish lifecycle + // events. Pi can settle immediately after `extension_ui_response`, so + // serialize the two paths to stop `turn.terminal` from overtaking the + // dialog's own resolution updates. + const sessionEventPermit = yield* Semaphore.make(1); let threadState: PiThreadState | null = null; let appliedModel: string | null = null; let appliedThinking: string | null = null; + // Pi's own configured defaults, captured from the first `get_state` so + // that selecting "Pi default"/"inherit" again can restore them. Pi has no + // "unset model" command, so the baseline has to be replayed explicitly. + let baselineModel: { provider: string; modelId: string } | null = null; + let baselineThinking: string | null = null; const emit = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); @@ -1025,7 +1035,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S yield* Effect.gen(function* () { while (true) { const event = yield* Queue.take(connection.events); - yield* handleSessionEvent(event); + yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); } }).pipe( Effect.catchCause((cause) => @@ -1067,6 +1077,15 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); } const stateData = yield* request({ type: "get_state" }); + if (baselineModel === null && baselineThinking === null) { + const stateModel = recordField(stateData, "model"); + const provider = recordString(stateModel, "provider"); + const modelId = recordString(stateModel, "id"); + if (provider !== undefined && modelId !== undefined) { + baselineModel = { provider, modelId }; + } + baselineThinking = recordString(stateData, "thinkingLevel") ?? null; + } const nativeId = recordString(stateData, "sessionFile") ?? recordString(stateData, "sessionId"); if (nativeId === undefined) { @@ -1116,10 +1135,21 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { - if ( - modelSelection.model !== PI_INHERIT_MODEL_SLUG && - modelSelection.model !== appliedModel - ) { + if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { + // Returning to "Pi default" after an explicit pick has to replay the + // captured baseline, otherwise Pi stays on the last model applied. + if (appliedModel !== null && baselineModel !== null) { + yield* request({ type: "set_model", ...baselineModel }); + appliedModel = null; + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, model: PI_INHERIT_MODEL_SLUG, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + } + } else if (modelSelection.model !== appliedModel) { const parsed = parsePiModelSlug(modelSelection.model); if (parsed === null) { return yield* protocolError( @@ -1141,9 +1171,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); } const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); - if ( + if (thinking === PI_INHERIT_THINKING_VALUE) { + if (appliedThinking !== null && baselineThinking !== null) { + yield* request({ type: "set_thinking_level", level: baselineThinking }); + appliedThinking = null; + } + } else if ( thinking !== undefined && - thinking !== PI_INHERIT_THINKING_VALUE && thinking !== appliedThinking && PI_THINKING_LEVELS.has(thinking) ) { @@ -1226,6 +1260,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ); } yield* applySelection(turnInput.modelSelection); + // Resolved before the turn is installed: a failure here (an + // unreadable attachment) must not leave `activeTurn` set, which + // would reject every later turn as already active. + const payload = yield* resolvePromptPayload( + turnInput.message.text, + turnInput.message.attachments, + ); const startedAt = yield* DateTime.now; const syntheticNativeTurnId = `${state.providerThread.id}:attempt:${turnInput.attemptId}`; const providerTurn: OrchestrationV2ProviderTurn = { @@ -1266,10 +1307,6 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S lastRunOrdinal: turnInput.runOrdinal, }); yield* updateProviderSession("running", null); - const payload = yield* resolvePromptPayload( - turnInput.message.text, - turnInput.message.attachments, - ); yield* request({ type: "prompt", message: payload.message, @@ -1355,13 +1392,15 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S `No pending Pi extension request ${requestInput.requestId}`, ); } - pendingPrompts.delete(String(requestInput.requestId)); const response = piUiResponse(pending, requestInput.decision, requestInput.answers); yield* connection.send({ type: "extension_ui_response", id: pending.nativeRequestId, ...response, }); + // Dropped only once Pi has the answer, so a failed send leaves the + // request retryable and still cancellable during teardown. + pendingPrompts.delete(String(requestInput.requestId)); const resolvedAt = yield* DateTime.now; pending.runtimeRequest = { ...pending.runtimeRequest, @@ -1390,6 +1429,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }, }); }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterRuntimeRequestResponseError({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index 8d30789359b9..0373e77f7ee8 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -39,6 +39,17 @@ export class PiRpcError extends Schema.TaggedErrorClass()("PiRpcErro export type PiRpcRecord = Record; +/** + * Splits a `provider/model` slug into the two fields `set_model` expects. + * Returns null for slugs without a usable separator so callers can reject the + * selection instead of silently leaving Pi on its configured default. + */ +export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { + const separator = slug.indexOf("/"); + if (separator <= 0 || separator === slug.length - 1) return null; + return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; +} + export interface PiRpcSpawnOptions { readonly command: string; readonly args: ReadonlyArray; @@ -174,7 +185,7 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp pending.deferred, new PiRpcError({ operation: String(record["command"] ?? "request"), - detail: String(record["error"] ?? "unknown error"), + ...(record["error"] === undefined ? {} : { cause: record["error"] }), }), ); } diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index a5cc3b6c97a5..ecdc9b276206 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -59,6 +59,11 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ /** * Reasoning-capable Pi models expose Pi's thinking levels. "Inherit" leaves * the user's settings.json `defaultThinkingLevel` untouched. + * + * Only the levels every reasoning model accepts are advertised. Pi exposes + * `xhigh` and `max` per model (see `get_available_thinking_levels`), and + * offering them globally makes `set_thinking_level` fail on models that lack + * them, which blocks the turn from starting. */ const THINKING_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -73,8 +78,6 @@ const THINKING_CAPABILITIES: ModelCapabilities = createModelCapabilities({ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, - { id: "xhigh", label: "Extra high" }, - { id: "max", label: "Max" }, ], }, ], diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index 7446cdb525aa..ce86ee2d36f3 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -15,7 +15,7 @@ import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { makePiRpcConnection } from "../orchestration-v2/Adapters/PiRpc.ts"; +import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -65,14 +65,21 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); if (modelSelection.model !== "default") { - const separator = modelSelection.model.indexOf("/"); - if (separator > 0 && separator < modelSelection.model.length - 1) { - yield* connection.request({ - type: "set_model", - provider: modelSelection.model.slice(0, separator), - modelId: modelSelection.model.slice(separator + 1), + // `customModels` accepts arbitrary strings, so an unusable slug is + // rejected rather than skipped: running Pi's default model here would + // report success for a model the caller never asked for. + const parsed = parsePiModelSlug(modelSelection.model); + if (parsed === null) { + return yield* new TextGenerationError({ + operation, + detail: `Pi model '${modelSelection.model}' must use provider/model format.`, }); } + yield* connection.request({ + type: "set_model", + provider: parsed.provider, + modelId: parsed.modelId, + }); } yield* connection.request({ type: "prompt", message: prompt }); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..8b5a44501e7d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -686,13 +686,16 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); export const PiAgentIcon: Icon = ({ className, ...props }) => ( - - + - + ); From e2095354d54c20d4e90be744fd22ed9c8fd91da7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:19:52 +0200 Subject: [PATCH 03/41] fix(providers): never gate Pi turn start on the prompt ack Pi acks the prompt command only after slash-command expansion completes, and extension commands can block on user dialogs indefinitely. Awaiting the ack with a 15s timeout failed the turn mid-dialog and cancelled the prompt the user was answering. Send prompt and steer fire-and-forget; rejections come back as id-less response records and fail the turn from the event pump. Found by exercising /commands against a real pi install. Also carries concurrent review fixes staged in the shared worktree by the review-babysitting agent: theme-aware Pi icons on web and mobile, bounded RPC error summaries, and transport/request hardening in PiRpc. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/components/ProviderIcon.tsx | 2 +- .../Adapters/PiAdapterV2.test.ts | 57 ++++++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 56 ++++++++++++------ .../src/orchestration-v2/Adapters/PiRpc.ts | 41 ++++++++++--- apps/web/src/components/Icons.tsx | 2 +- 5 files changed, 129 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index e71c54660369..73c8b8a9ecff 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -53,7 +53,7 @@ export function ProviderIcon(props: ProviderIconProps) { if (props.provider === "pi") { const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; return ( - + = [], ) { const appThread = yield* makeAppThread(model); yield* runtime.startTurn({ @@ -234,7 +236,7 @@ const startTurn = Effect.fnUntraced(function* ( message: { messageId: "message:thread-pi-test:1" as never, text: "Hello pi", - attachments: [], + attachments, createdBy: "user", creationSource: "web", }, @@ -271,6 +273,33 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("keeps the thread usable when an attachment cannot be read", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn(runtime, providerThread, "default", [ + { + type: "image", + id: "missingpiattachment", + name: "missing.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ]).pipe(Effect.flip); + + // The failed turn was never installed, so the next one still starts. + yield* startTurn(runtime, providerThread); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("streams assistant text and settles a completed turn on agent_settled", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -283,6 +312,9 @@ describe("PiAdapterV2", () => { yield* startTurn(runtime, providerThread); const prompt = yield* fake.takeRequest("prompt"); assert.equal(prompt["message"], "Hello pi"); + // Fire-and-forget: extension slash commands can hold the ack open on a + // user dialog, so the prompt must carry no correlation id to await. + assert.equal(prompt["id"], undefined); yield* fake.emit({ type: "agent_start" }); yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); @@ -325,6 +357,29 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("fails the turn when pi rejects a fire-and-forget prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "response", command: "prompt", success: false, error: "boom" }); + + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "failed"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === "boom", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("keeps the turn open across agent_end and fails it on final retry failure", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index fdd2cf5fbea2..ca106d6ea1d2 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1027,6 +1027,24 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (state !== null) yield* finalizeTurn(state); return; } + case "response": { + // Correlated responses never reach the pump; an id-less response + // is the deferred ack of a fire-and-forget prompt/steer. A + // rejection here means the turn never started on the Pi side. + const command = recordString(event, "command"); + if ( + turn !== null && + event["success"] === false && + (command === "prompt" || command === "steer" || command === "parse") + ) { + turn.failure = makeProviderFailure({ + message: recordString(event, "error") ?? "Pi rejected the prompt.", + class: "provider_error", + }); + if (state !== null) yield* finalizeTurn(state); + } + return; + } default: return; } @@ -1075,15 +1093,25 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S type: "switch_session", sessionPath: existing.nativeThreadRef.nativeId, }); + // The applied-selection cache describes the session we just left. + // Clearing it stops the next `applySelection` from treating this + // session as already configured and skipping set_model. + appliedModel = null; + appliedThinking = null; } const stateData = yield* request({ type: "get_state" }); - if (baselineModel === null && baselineThinking === null) { + // Each baseline is captured independently, and only while nothing has + // been applied yet, so a `get_state` that omits one field still lets + // the other be picked up later without recording our own selection. + if (baselineModel === null && appliedModel === null) { const stateModel = recordField(stateData, "model"); const provider = recordString(stateModel, "provider"); const modelId = recordString(stateModel, "id"); if (provider !== undefined && modelId !== undefined) { baselineModel = { provider, modelId }; } + } + if (baselineThinking === null && appliedThinking === null) { baselineThinking = recordString(stateData, "thinkingLevel") ?? null; } const nativeId = @@ -1307,24 +1335,16 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S lastRunOrdinal: turnInput.runOrdinal, }); yield* updateProviderSession("running", null); - yield* request({ + // Fire-and-forget: pi acks `prompt` only after slash-command + // expansion completes, and extension commands may block on user + // dialogs indefinitely, so turn start must never await the ack. + // Rejections come back as an id-less response record and are + // handled by the event pump. + yield* connection.send({ type: "prompt", message: payload.message, ...(payload.images.length === 0 ? {} : { images: payload.images }), - }).pipe( - Effect.tapError(() => - Effect.gen(function* () { - const current = threadState; - if (current?.activeTurn?.providerTurn.id === providerTurn.id) { - current.activeTurn.failure = makeProviderFailure({ - message: "Pi rejected the prompt.", - class: "provider_error", - }); - yield* finalizeTurn(current); - } - }), - ), - ); + }); }).pipe( Effect.mapError( (cause) => @@ -1347,7 +1367,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S steerInput.message.text, steerInput.message.attachments, ); - yield* request({ + // Same fire-and-forget contract as `prompt`: a steer that expands + // a slash command must not block on the ack. + yield* connection.send({ type: "steer", message: payload.message, ...(payload.images.length === 0 ? {} : { images: payload.images }), diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index 0373e77f7ee8..e04f21665dc0 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -93,6 +93,21 @@ const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); +const PI_ERROR_DETAIL_MAX_CHARS = 200; + +/** + * Bounded, human-readable summary of a failed response's `error` payload. + * The untruncated value stays on the error's `cause`, so `message` never + * carries unbounded remote text while logs keep something diagnostic. + */ +function summarizePiError(error: unknown): string { + const text = typeof error === "string" ? error : JSON.stringify(error); + if (text === undefined) return "unknown error"; + return text.length > PI_ERROR_DETAIL_MAX_CHARS + ? `${text.slice(0, PI_ERROR_DETAIL_MAX_CHARS)}…` + : text; +} + function parsePiRecord(line: string): PiRpcRecord | undefined { try { const parsed: unknown = decodeJsonLine(line); @@ -146,6 +161,17 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp } }; + // Registered before any further setup: an interrupt or failure between the + // spawn and the rest of this constructor would otherwise leak a detached + // pi process with no finalizer to reap it. + yield* Scope.addFinalizer( + scope, + terminatePiProcess(Number(child.pid), killProcessGroup).pipe( + Effect.ignore, + Effect.uninterruptible, + ), + ); + const pendingRequests = new Map(); const events = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded(); @@ -185,6 +211,7 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp pending.deferred, new PiRpcError({ operation: String(record["command"] ?? "request"), + detail: summarizePiError(record["error"]), ...(record["error"] === undefined ? {} : { cause: record["error"] }), }), ); @@ -251,14 +278,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Effect.forkIn(scope), ); - yield* Scope.addFinalizer( - scope, - terminatePiProcess(Number(child.pid), killProcessGroup).pipe( - Effect.ignore, - Effect.uninterruptible, - ), - ); - const send = (record: PiRpcRecord): Effect.Effect => Effect.gen(function* () { const down = yield* Deferred.isDone(transportDown); @@ -279,7 +298,11 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp yield* send({ ...record, id }).pipe( Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), ); - return yield* Deferred.await(deferred).pipe( + // Raced against the transport: a death that lands after this request was + // registered (or between `send`'s check and its enqueue) would otherwise + // leave the caller waiting out the full timeout for a reply that is + // never coming. + return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), orElse: () => diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8b5a44501e7d..b1642036b3e8 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -688,7 +688,7 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( export const PiAgentIcon: Icon = ({ className, ...props }) => ( From 7dbca279f817ad3e71db9d56dd193f74885322cb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:28:57 +0200 Subject: [PATCH 04/41] fix(providers): settle command-only Pi prompts without an agent run A prompt that only runs an extension slash command never starts an agent run, so pi emits no agent_settled and the turn spun forever. The deferred id-less prompt ack is the completion signal for that shape: on ack with no agent activity observed, probe get_state and settle the turn when Pi reports idle. The probe result re-enters the event queue so the check stays ordered behind any agent activity Pi emitted first. Also carries concurrent review-fix work from the shared worktree: additional PiRpc request-lifecycle hardening beyond the settle probe. Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 28 +++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 80 +++++++++++++++++-- .../src/orchestration-v2/Adapters/PiRpc.ts | 24 ++++-- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index aa3dfdb85d19..0b9a67b2467d 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -357,6 +357,34 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + // A pure extension command: dialog + notify, then the deferred ack — + // pi emits no agent_start/agent_settled at all. + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-cmd", + method: "notify", + message: "done", + notifyType: "info", + }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + // The adapter probes get_state (auto-acked idle by the fake), then + // settles the turn as completed. + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("fails the turn when pi rejects a fire-and-forget prompt", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index ca106d6ea1d2..cdfc227fc17e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -275,6 +275,13 @@ interface ActivePiTurn { readonly streamItems: Map; readonly toolArgs: Map; interrupted: boolean; + /** + * Whether any agent run activity was observed. Command-only prompts (pure + * extension slash commands) never start an agent run and never emit + * `agent_settled`; their deferred prompt ack plus an idle probe settles + * the turn instead. + */ + sawAgentActivity: boolean; failure: ReturnType | null; } @@ -915,14 +922,20 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const state = threadState; const turn = state?.activeTurn ?? null; switch (event["type"]) { + case "agent_start": { + if (turn !== null) turn.sawAgentActivity = true; + return; + } case "message_start": { if (turn !== null && recordString(event["message"], "role") === "assistant") { + turn.sawAgentActivity = true; turn.messageOrdinal += 1; } return; } case "message_update": { if (turn === null) return; + turn.sawAgentActivity = true; const delta = event["assistantMessageEvent"]; const deltaType = recordString(delta, "type"); const contentIndex = recordNumber(delta, "contentIndex") ?? 0; @@ -965,7 +978,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S return; } case "tool_execution_start": - if (turn !== null) yield* emitToolItem(turn, event, "start"); + if (turn !== null) { + turn.sawAgentActivity = true; + yield* emitToolItem(turn, event, "start"); + } return; case "tool_execution_update": if (turn !== null) yield* emitToolItem(turn, event, "update"); @@ -1032,11 +1048,39 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // is the deferred ack of a fire-and-forget prompt/steer. A // rejection here means the turn never started on the Pi side. const command = recordString(event, "command"); - if ( - turn !== null && - event["success"] === false && - (command === "prompt" || command === "steer" || command === "parse") - ) { + if (event["success"] === true) { + // Deferred success ack. Command-only prompts (pure extension + // slash commands) never start an agent run and never emit + // `agent_settled`, so probe for idleness. The probe result is + // re-queued behind any events Pi emitted before answering + // get_state, which keeps the check stream-ordered. + if (command === "prompt" && turn !== null && !turn.sawAgentActivity) { + const providerTurnId = turn.providerTurn.id; + yield* request({ type: "get_state" }).pipe( + Effect.flatMap((data) => + Queue.offer(connection.events, { + type: "t3.settle_probe", + providerTurnId, + data, + }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + } + return; + } + if (event["success"] !== false) return; + if (command === "steer") { + // A rejected steer only means that one message was refused. The + // turn it was aimed at is still running on Pi, so terminalizing + // here would report a failure while output keeps streaming. + yield* Effect.logWarning("Pi rejected a steer message.", { + error: recordString(event, "error"), + }); + return; + } + if (turn !== null && (command === "prompt" || command === "parse")) { turn.failure = makeProviderFailure({ message: recordString(event, "error") ?? "Pi rejected the prompt.", class: "provider_error", @@ -1045,6 +1089,23 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } return; } + case "t3.settle_probe": { + // Synthetic idle probe queued after a command-only prompt ack. + // Any agent activity Pi emitted before answering get_state has + // already been processed, so an untouched turn that reports + // no streaming and no pending messages is genuinely done. + const data = event["data"]; + if ( + turn !== null && + turn.providerTurn.id === event["providerTurnId"] && + !turn.sawAgentActivity && + recordField(data, "isStreaming") !== true && + (recordNumber(data, "pendingMessageCount") ?? 0) === 0 + ) { + if (state !== null) yield* finalizeTurn(state); + } + return; + } default: return; } @@ -1321,6 +1382,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S streamItems: new Map(), toolArgs: new Map(), interrupted: false, + sawAgentActivity: false, failure: null, }; yield* emit({ @@ -1367,6 +1429,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S steerInput.message.text, steerInput.message.attachments, ); + // Reading attachments suspends, so the turn is revalidated here: + // without this a steer resolved after the turn ended would be + // accepted by an idle session or by the next turn. + if (threadState?.activeTurn !== turn) { + return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); + } // Same fire-and-forget contract as `prompt`: a steer that expands // a slash command must not block on the ack. yield* connection.send({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index e04f21665dc0..a7ea221fee81 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -66,8 +66,12 @@ export interface PiRpcConnection { * Fails on `success: false`, transport death, or timeout. */ readonly request: (record: PiRpcRecord, timeoutMs?: number) => Effect.Effect; - /** Session events (every non-response stdout record) in arrival order. */ - readonly events: Queue.Dequeue; + /** + * Session events (every non-response stdout record) in arrival order. The + * full queue is exposed so consumers can append order-preserving synthetic + * records of their own (see PiAdapterV2's settle probe). + */ + readonly events: Queue.Queue; /** Resolves when the process has exited, with its exit code. */ readonly exited: Effect.Effect; } @@ -174,7 +178,7 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp const pendingRequests = new Map(); const events = yield* Queue.unbounded(); - const outgoing = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); const transportDown = yield* Deferred.make(); const exitDeferred = yield* Deferred.make(); let nextRequestId = 0; @@ -186,6 +190,9 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp pendingRequests.delete(key); yield* Deferred.fail(pending.deferred, error); } + // Closing `outgoing` is what makes `send` non-racy: once the writer is + // gone every later offer is refused rather than silently buffered. + yield* Queue.fail(outgoing, error); yield* Queue.fail(events, error); }); @@ -280,11 +287,16 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp const send = (record: PiRpcRecord): Effect.Effect => Effect.gen(function* () { - const down = yield* Deferred.isDone(transportDown); - if (down) { + const accepted = yield* Queue.offer( + outgoing, + new TextEncoder().encode(`${encodeJsonLine(record)}\n`), + ); + // A refused offer means `failTransport` already closed the queue, so the + // write can never land; surface the transport error instead of + // reporting a success the caller cannot rely on. + if (!accepted) { return yield* Deferred.await(transportDown); } - yield* Queue.offer(outgoing, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); }); const request = ( From 8e2ca1fb213dbd0bac48e447393e19acbfc2f20f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:34:19 +0200 Subject: [PATCH 05/41] fix(providers): keep one start time per Pi tool call Every tool lifecycle event passed the current time as `startedAt`, so each update rewrote the start timestamp and a completed tool always rendered with zero duration. The turn now remembers the first time it saw each `toolCallId` and reuses it for later updates and completion. Co-Authored-By: Claude Fable 5 --- .../src/orchestration-v2/Adapters/PiAdapterV2.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index cdfc227fc17e..ffa5abd90359 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -274,6 +274,11 @@ interface ActivePiTurn { messageOrdinal: number; readonly streamItems: Map; readonly toolArgs: Map; + /** + * First-seen time per `toolCallId`. Later update/end events reuse it so a + * tool keeps one start timestamp and reports a real duration. + */ + readonly toolStartedAt: Map; interrupted: boolean; /** * Whether any agent run activity was observed. Command-only prompts (pure @@ -606,18 +611,20 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } const args = event["args"] ?? turn.toolArgs.get(toolCallId); const emittedAt = yield* DateTime.now; + const startedAt = turn.toolStartedAt.get(toolCallId) ?? emittedAt; + turn.toolStartedAt.set(toolCallId, startedAt); const completed = phase === "end"; const isError = event["isError"] === true; const resultRecord = completed ? event["result"] : event["partialResult"]; const outputText = contentText(recordField(resultRecord, "content")); const status = completed ? (isError ? "failed" : "completed") : "running"; - const base = baseItemFields(turn, toolCallId, emittedAt, emittedAt); + const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); yield* emitItemNode( turn, toolCallId, "tool_call", status, - emittedAt, + startedAt, completed ? emittedAt : null, ); const shared = { @@ -1381,6 +1388,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S messageOrdinal: 0, streamItems: new Map(), toolArgs: new Map(), + toolStartedAt: new Map(), interrupted: false, sawAgentActivity: false, failure: null, From 2311c999caca5d8393d06e6cd95a88c206df3d92 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:39:40 +0200 Subject: [PATCH 06/41] fix(providers): settle Pi command-only turns when the idle probe fails The command-only settle probe dropped a failed `get_state` on the floor. Pi emits no agent events for a pure extension command, so the turn stayed active and the session rejected every later turn until restart. The probe now queues a `probeFailed` record instead of swallowing the error, so the same ordered handler settles the turn. The existing no-agent-activity guard still keeps a genuinely running turn open. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/Adapters/PiAdapterV2.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index ffa5abd90359..b19db1aed18c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1064,13 +1064,23 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (command === "prompt" && turn !== null && !turn.sawAgentActivity) { const providerTurnId = turn.providerTurn.id; yield* request({ type: "get_state" }).pipe( - Effect.flatMap((data) => - Queue.offer(connection.events, { - type: "t3.settle_probe", - providerTurnId, - data, - }), - ), + Effect.matchEffect({ + onSuccess: (data) => + Queue.offer(connection.events, { + type: "t3.settle_probe", + providerTurnId, + data, + }), + // A failed probe still has to reach the pump. Dropping it + // would leave a command-only turn active forever, because + // Pi never emits agent events for one. + onFailure: () => + Queue.offer(connection.events, { + type: "t3.settle_probe", + providerTurnId, + probeFailed: true, + }), + }), Effect.ignore, Effect.forkIn(scope), ); @@ -1102,12 +1112,17 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // already been processed, so an untouched turn that reports // no streaming and no pending messages is genuinely done. const data = event["data"]; + // A probe that could not be answered settles the turn too: the + // prompt was acked, and the no-agent-activity guard below still + // keeps a genuinely running turn open. + const probeFailed = event["probeFailed"] === true; if ( turn !== null && turn.providerTurn.id === event["providerTurnId"] && !turn.sawAgentActivity && - recordField(data, "isStreaming") !== true && - (recordNumber(data, "pendingMessageCount") ?? 0) === 0 + (probeFailed || + (recordField(data, "isStreaming") !== true && + (recordNumber(data, "pendingMessageCount") ?? 0) === 0)) ) { if (state !== null) yield* finalizeTurn(state); } From a857db55b9c649ebb149394d20abaa02263e0758 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:46:33 +0200 Subject: [PATCH 07/41] fix(providers): build the Pi snapshot error from its own attributes `ProviderDriverError.message` is derived from `detail`, so interpolating `cause.message` duplicated the underlying failure into the wrapper message while the cause already carried it. The sibling mapping in this same file ("Failed to build Pi orchestration adapter.") already used the bounded form, so the two wrappers now match. Co-Authored-By: Claude Fable 5 --- apps/server/src/provider/Drivers/PiDriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 1d6bb08d6813..b8252b8b1979 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -160,7 +160,7 @@ export const PiDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Pi snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build Pi snapshot.", cause, }), ), From 9ec848c06b5facf634146499ff65f585f3c3a30c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:49:05 +0200 Subject: [PATCH 08/41] feat(providers): Pi session-tree rollback and session-name sync Turn boundaries now capture pi session-tree refs: each provider turn's nativeTurnRef becomes its first user entry id and the thread's nativeConversationHeadRef tracks the tree leaf, giving rollback a durable target. rollbackThread re-roots the active branch with pi's fork command, so reverting a checkpoint also rewinds the provider conversation instead of marking it divergent. Thread titles sync into pi's session name so T3 threads stay identifiable in pi's own /resume listing. Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 129 ++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 153 +++++++++++++++++- 2 files changed, 272 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 0b9a67b2467d..463e7cfafc5c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1,9 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CheckpointId, NodeId, ProviderInstanceId, ProviderSessionId, + ProviderTurnId, RunAttemptId, RunId, ThreadId, @@ -11,6 +13,7 @@ import { type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -29,7 +32,12 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; -import { makePiAdapterV2, PiProviderCapabilitiesV2, PI_PROVIDER } from "./PiAdapterV2.ts"; +import { + makePiAdapterV2, + piRollbackForkEntry, + PiProviderCapabilitiesV2, + PI_PROVIDER, +} from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -63,6 +71,8 @@ interface FakePi { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly emit: (record: PiRpcRecord) => Effect.Effect; readonly takeRequest: (type: string) => Effect.Effect; + /** Data returned by the next `get_entries` acks, consumed in order. */ + readonly queueEntries: (data: unknown) => void; } /** @@ -72,6 +82,7 @@ interface FakePi { const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); + const entriesQueue: Array = []; let stdinBuffer = ""; const emit = (record: PiRpcRecord) => @@ -102,6 +113,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }; case "switch_session": return { ...base, data: { cancelled: false } }; + case "get_entries": + return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; + case "fork": + return { ...base, data: { cancelled: false, message: "forked" } }; default: return base; } @@ -149,7 +164,12 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } }); - return { spawner, emit, takeRequest } satisfies FakePi; + return { + spawner, + emit, + takeRequest, + queueEntries: (data) => entriesQueue.push(data), + } satisfies FakePi; }); const makeAdapter = Effect.fnUntraced(function* (fake: FakePi) { @@ -357,6 +377,111 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("syncs the thread title into pi's session name before prompting", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + // takeRequest discards preceding records, so this also proves ordering. + const setName = yield* fake.takeRequest("set_session_name"); + assert.equal(setName["name"], "Pi test thread"); + yield* fake.takeRequest("prompt"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("captures session-tree refs at turn boundaries and rolls back via fork", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + // First get_entries ack baselines the leaf during ensureThread; the + // second answers the finalize capture with this turn's user entry. + fake.queueEntries({ entries: [], leafId: "leaf-0" }); + fake.queueEntries({ + entries: [{ type: "message", id: "u1", message: { role: "user" } }], + leafId: "a1", + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const finalTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + finalTurn.type === "provider_turn.updated" && + finalTurn.providerTurn.nativeTurnRef?.nativeId === "u1" && + finalTurn.providerTurn.nativeTurnRef.strength === "strong", + ); + + const turnRef = (ordinal: number, nativeId: string): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`provider-turn:test:${ordinal}`), + providerThreadId: providerThread.id, + nodeId: NodeId.make(`node:test:${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + yield* runtime.rollbackThread({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:test:1"), + appRunOrdinal: 1, + providerTurn: turnRef(1, "u1"), + }, + providerThreadTurns: [turnRef(1, "u1"), turnRef(2, "u2")], + }); + const fork = yield* fake.takeRequest("fork"); + assert.equal(fork["entryId"], "u2"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it("resolves rollback fork entries from captured turn refs", () => { + const turn = (ordinal: number, ref: OrchestrationV2ProviderTurn["nativeTurnRef"]) => + ({ ordinal, nativeTurnRef: ref }) as OrchestrationV2ProviderTurn; + const strong = (nativeId: string) => + ({ driver: PI_PROVIDER, nativeId, strength: "strong" }) as const; + const weak = (nativeId: string) => + ({ driver: PI_PROVIDER, nativeId, strength: "weak" }) as const; + // No turns after the target: nothing to discard. + assert.isNull( + piRollbackForkEntry({ + target: { type: "provider_turn", providerTurn: turn(2, strong("u2")) }, + providerThreadTurns: [turn(1, strong("u1")), turn(2, strong("u2"))], + }), + ); + // Boundary turn without a captured (strong) entry ref cannot roll back. + assert.isUndefined( + piRollbackForkEntry({ + target: { type: "provider_turn", providerTurn: turn(1, strong("u1")) }, + providerThreadTurns: [turn(1, strong("u1")), turn(2, weak("synthetic"))], + }), + ); + // thread_start discards everything from the first captured turn. + assert.equal( + piRollbackForkEntry({ + target: { type: "thread_start" }, + providerThreadTurns: [turn(2, strong("u2")), turn(1, strong("u1"))], + }), + "u1", + ); + }); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index b19db1aed18c..d9cc6696fb3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -79,6 +79,7 @@ import { type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2Shape, type ProviderAdapterV2SteerInput, + type ProviderAdapterV2ThreadSnapshot, type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import { @@ -122,7 +123,7 @@ export const PiProviderCapabilitiesV2 = { threads: { canCreateEmptyThread: true, canReadThreadSnapshot: false, - canRollbackThread: false, + canRollbackThread: true, canForkThread: false, canForkFromTurn: false, canForkFromSubagentThread: false, @@ -192,7 +193,7 @@ export const PiProviderCapabilitiesV2 = { checkpointing: { appCanCheckpointFilesystem: true, supportsNestedCheckpointScopes: false, - providerCanRollbackConversation: false, + providerCanRollbackConversation: true, providerRollbackReturnsSnapshot: false, providerCanReadConversationSnapshot: false, }, @@ -366,6 +367,14 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S let threadState: PiThreadState | null = null; let appliedModel: string | null = null; let appliedThinking: string | null = null; + /** Last thread title synced into pi's session name (`/resume` listing). */ + let appliedSessionName: string | null = null; + /** + * Leaf entry id of the pi session tree as of the last turn boundary. + * Turn-start user entries are located relative to it, giving each + * provider turn a durable native ref for session-tree rollback. + */ + let lastKnownLeaf: string | null = null; // Pi's own configured defaults, captured from the first `get_state` so // that selecting "Pi default"/"inherit" again can restore them. Pi has no // "unset model" command, so the baseline has to be replayed explicitly. @@ -864,6 +873,37 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // ── turn lifecycle ──────────────────────────────────── + /** + * Locate this turn's first user entry and the new leaf in pi's session + * tree. The user-entry id becomes the provider turn's native ref (the + * point `fork` rolls back to); the leaf becomes the conversation head. + * Pure bookkeeping: failures degrade to the synthetic refs. + */ + const captureTurnTreeRefs = Effect.fnUntraced(function* () { + const data = yield* request({ + type: "get_entries", + ...(lastKnownLeaf === null ? {} : { since: lastKnownLeaf }), + }).pipe(Effect.orElseSucceed(() => undefined)); + if (data === undefined) return null; + const entries = recordField(data, "entries"); + const leafId = recordString(data, "leafId"); + if (leafId !== undefined) lastKnownLeaf = leafId; + const firstUserEntryId = Array.isArray(entries) + ? entries + .filter( + (entry) => + recordField(entry, "type") === "message" && + recordString(recordField(entry, "message"), "role") === "user", + ) + .map((entry) => recordString(entry, "id")) + .find((id) => id !== undefined) + : undefined; + return { + turnStartEntryId: firstUserEntryId ?? null, + leafId: leafId ?? null, + }; + }); + const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState) { const turn = state.activeTurn; if (turn === null) return; @@ -871,6 +911,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const completedAt = yield* DateTime.now; yield* completeOpenStreamItems(turn); yield* cancelPendingPrompts(completedAt); + const treeRefs = yield* captureTurnTreeRefs(); const failure = turn.interrupted ? null : turn.failure; yield* emit({ type: "provider_turn.updated", @@ -878,11 +919,19 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S threadId: turn.turnInput.threadId, providerTurn: { ...turn.providerTurn, + ...(treeRefs?.turnStartEntryId == null + ? {} + : { nativeTurnRef: providerRef(treeRefs.turnStartEntryId) }), status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", completedAt, }, }); - yield* updateProviderThread(state, { status: "idle" }); + yield* updateProviderThread(state, { + status: "idle", + ...(treeRefs?.leafId == null + ? {} + : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), + }); yield* updateProviderSession(failure !== null ? "error" : "ready"); if (failure !== null) { const failureItemId = `terminal-failure:${turn.providerTurn.id}`; @@ -1237,6 +1286,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S updatedAt: createdAt, }; threadState = { providerThread, activeTurn: null }; + // Baseline the session-tree leaf so the first turn's user entry can + // be located with a `since` cursor instead of a full entry scan. + lastKnownLeaf = + recordString( + yield* request({ type: "get_entries" }).pipe(Effect.orElseSucceed(() => undefined)), + "leafId", + ) ?? null; yield* emit({ type: "provider_thread.updated", driver: PI_PROVIDER, @@ -1371,6 +1427,20 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ); } yield* applySelection(turnInput.modelSelection); + // Mirror the thread title into pi's session name so the session + // stays identifiable in pi's own /resume listing. Best-effort: + // naming must never block a turn. + if (turnInput.appThread.title !== appliedSessionName) { + yield* request({ + type: "set_session_name", + name: turnInput.appThread.title, + }).pipe( + Effect.tap(() => + Effect.sync(() => (appliedSessionName = turnInput.appThread.title)), + ), + Effect.ignore, + ); + } // Resolved before the turn is installed: a failure here (an // unreadable attachment) must not leave `activeTurn` set, which // would reject every later turn as already active. @@ -1560,11 +1630,48 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }), ), rollbackThread: (rollbackInput) => - Effect.fail( - new ProviderAdapterRollbackThreadError({ - driver: PI_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - }), + Effect.gen(function* () { + const state = threadState; + if (state === null) { + return yield* protocolError("Pi session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* protocolError("Cannot roll back while a Pi turn is active"); + } + // `fork(entryId)` re-roots the active branch before that user + // message, so the rollback boundary is the first user entry of + // the earliest turn being discarded. + const forkEntryId = piRollbackForkEntry(rollbackInput); + if (forkEntryId === null) { + // Nothing after the target: the conversation is already there. + return piThreadSnapshot(state.providerThread); + } + if (forkEntryId === undefined) { + return yield* protocolError("Pi rollback target has no captured session-tree entry"); + } + const forkData = yield* request({ type: "fork", entryId: forkEntryId }); + if (recordField(forkData, "cancelled") === true) { + return yield* protocolError("A Pi extension cancelled the session fork"); + } + const entriesData = yield* request({ type: "get_entries" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + const leafId = recordString(entriesData, "leafId") ?? null; + lastKnownLeaf = leafId; + yield* updateProviderThread(state, { + nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), + }); + return piThreadSnapshot(state.providerThread); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRollbackThreadError({ + driver: PI_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), ), forkThread: (forkInput) => Effect.fail( @@ -1579,6 +1686,36 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); } +/** + * Resolve the pi session-tree entry `fork` should re-root at for a rollback. + * Returns `null` when no turns follow the target (nothing to discard) and + * `undefined` when the boundary turn has no captured entry ref (only + * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). + */ +export function piRollbackForkEntry(input: { + readonly target: + | { readonly type: "thread_start" } + | { readonly type: "provider_turn"; readonly providerTurn: OrchestrationV2ProviderTurn }; + readonly providerThreadTurns: ReadonlyArray; +}): string | null | undefined { + const boundaryOrdinal = + input.target.type === "thread_start" ? 0 : input.target.providerTurn.ordinal; + const discarded = input.providerThreadTurns + .filter((turn) => turn.ordinal > boundaryOrdinal) + .sort((a, b) => a.ordinal - b.ordinal); + const boundary = discarded[0]; + if (boundary === undefined) return null; + const ref = boundary.nativeTurnRef; + if (ref === null || ref.strength !== "strong" || ref.nativeId === null) return undefined; + return ref.nativeId; +} + +function piThreadSnapshot( + providerThread: OrchestrationV2ProviderThread, +): ProviderAdapterV2ThreadSnapshot { + return { providerThread, providerTurns: [], messages: [], runtimeRequests: [] }; +} + function piQuestion( questionId: string, method: "select" | "input" | "editor", From 640cd48c7a4e2bd1a192466d2eb30f32de268112 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:43 +0200 Subject: [PATCH 09/41] fix(providers): clear the Pi session-name cache on session switch `switch_session` reset the applied model and thinking caches but left `appliedSessionName`, so a newly selected session whose thread title matched the previous one skipped `set_session_name` and kept the old name in pi's `/resume` listing. Co-Authored-By: Claude Fable 5 --- apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index d9cc6696fb3e..a3876ec8a3d8 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1225,11 +1225,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S type: "switch_session", sessionPath: existing.nativeThreadRef.nativeId, }); - // The applied-selection cache describes the session we just left. - // Clearing it stops the next `applySelection` from treating this - // session as already configured and skipping set_model. + // These caches describe the session we just left. Clearing them + // stops the next turn from treating this session as already + // configured and skipping set_model or set_session_name. appliedModel = null; appliedThinking = null; + appliedSessionName = null; } const stateData = yield* request({ type: "get_state" }); // Each baseline is captured independently, and only while nothing has From ac5b93f29b26deda440ac834dd256431987dfa51 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:03:04 +0200 Subject: [PATCH 10/41] fix(providers): keep Pi turn refs honest after a failed entry capture Two review findings on the session-tree work: - When `get_entries` failed, `lastKnownLeaf` was left pointing at an entry Pi may have already advanced past. The next successful capture then used that stale `since` cursor, so its window spanned several turns and the first user entry it found belonged to an earlier one, aiming rollback too far back. A failed capture now marks the cursor stale; the next capture re-syncs it and skips the turn-start ref for that one turn instead. - Log only the length of pi's stderr. The chunk is unbounded remote output and can carry credentials or prompt text, matching the `stderrLength` form used elsewhere in the server. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/Adapters/PiAdapterV2.ts | 44 ++++++++++++++----- .../src/orchestration-v2/Adapters/PiRpc.ts | 4 +- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index a3876ec8a3d8..cf056e0a47c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -375,6 +375,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S * provider turn a durable native ref for session-tree rollback. */ let lastKnownLeaf: string | null = null; + /** + * Set when a `get_entries` capture failed. Pi may have advanced past + * `lastKnownLeaf` since, so the cursor no longer bounds a single turn + * and the next capture re-syncs it instead of trusting it. + */ + let leafCursorStale = false; // Pi's own configured defaults, captured from the first `get_state` so // that selecting "Pi default"/"inherit" again can restore them. Pi has no // "unset model" command, so the baseline has to be replayed explicitly. @@ -880,24 +886,38 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S * Pure bookkeeping: failures degrade to the synthetic refs. */ const captureTurnTreeRefs = Effect.fnUntraced(function* () { + const cursorWasStale = leafCursorStale; + const cursor = cursorWasStale ? null : lastKnownLeaf; const data = yield* request({ type: "get_entries", - ...(lastKnownLeaf === null ? {} : { since: lastKnownLeaf }), + ...(cursor === null ? {} : { since: cursor }), }).pipe(Effect.orElseSucceed(() => undefined)); - if (data === undefined) return null; + if (data === undefined) { + // Pi may have advanced past `lastKnownLeaf` while this failed, so the + // cursor can no longer be trusted to bound a single turn. + leafCursorStale = true; + return null; + } const entries = recordField(data, "entries"); const leafId = recordString(data, "leafId"); if (leafId !== undefined) lastKnownLeaf = leafId; - const firstUserEntryId = Array.isArray(entries) - ? entries - .filter( - (entry) => - recordField(entry, "type") === "message" && - recordString(recordField(entry, "message"), "role") === "user", - ) - .map((entry) => recordString(entry, "id")) - .find((id) => id !== undefined) - : undefined; + // Without a trustworthy cursor this window spans more than one turn, so + // its first user entry belongs to an earlier turn. Re-sync the cursor + // and skip the turn-start ref rather than pointing rollback too far + // back; the next turn gets an accurate ref again. + leafCursorStale = false; + const firstUserEntryId = cursorWasStale + ? undefined + : Array.isArray(entries) + ? entries + .filter( + (entry) => + recordField(entry, "type") === "message" && + recordString(recordField(entry, "message"), "role") === "user", + ) + .map((entry) => recordString(entry, "id")) + .find((id) => id !== undefined) + : undefined; return { turnStartEntryId: firstUserEntryId ?? null, leafId: leafId ?? null, diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index a7ea221fee81..ce9127fdedf7 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -270,7 +270,9 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Stream.runForEach((chunk) => chunk.trim().length === 0 ? Effect.void - : Effect.logDebug("pi stderr", { chunk: chunk.slice(0, 2_000) }), + : // Length only: pi's stderr is unbounded remote output and can carry + // credentials or prompt text, so it never enters a log annotation. + Effect.logDebug("pi stderr", { stderrLength: chunk.length }), ), Effect.ignore, Effect.forkIn(scope), From acc044c9f9262f64436423aabf9d66622017e002 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:11:37 +0200 Subject: [PATCH 11/41] fix(providers): clear the Pi leaf cursor flag when the tree is re-baselined `leafCursorStale` was only cleared inside `captureTurnTreeRefs`, but `registerThread` and `rollbackThread` also re-baseline `lastKnownLeaf` from a full `get_entries`. After one failed capture the flag therefore stuck, so later turns kept skipping their `nativeTurnRef` and rollback across them failed with no captured session-tree entry. Both re-baseline sites now clear the flag, and only leave it set when the listing itself failed. An empty tree is a success with no leafId. Co-Authored-By: Claude Fable 5 --- .../src/orchestration-v2/Adapters/PiAdapterV2.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index cf056e0a47c1..99548c9435c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1309,11 +1309,14 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S threadState = { providerThread, activeTurn: null }; // Baseline the session-tree leaf so the first turn's user entry can // be located with a `since` cursor instead of a full entry scan. - lastKnownLeaf = - recordString( - yield* request({ type: "get_entries" }).pipe(Effect.orElseSucceed(() => undefined)), - "leafId", - ) ?? null; + const baselineEntries = yield* request({ type: "get_entries" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + lastKnownLeaf = recordString(baselineEntries, "leafId") ?? null; + // A successful full baseline makes the cursor trustworthy again. Only + // a failed one leaves it stale, so a recovered session does not keep + // skipping turn refs. An empty tree is a success with no leafId. + leafCursorStale = baselineEntries === undefined; yield* emit({ type: "provider_thread.updated", driver: PI_PROVIDER, @@ -1679,6 +1682,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ); const leafId = recordString(entriesData, "leafId") ?? null; lastKnownLeaf = leafId; + // The fork re-baselined the tree, so the cursor is trustworthy + // again unless this listing itself failed. + leafCursorStale = entriesData === undefined; yield* updateProviderThread(state, { nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), }); From 79514a3bb7bd0b477a0aa79da465e137ca4ad3fb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:08:28 +0200 Subject: [PATCH 12/41] fix(providers): declare the Pi rollback snapshot capability CommandPolicy.ensureRollback rejects every rollback command unless providerRollbackReturnsSnapshot accompanies the rollback capabilities, so checkpoint reverts on Pi threads failed with 'rollback must return a providerInstanceId thread snapshot'. rollbackThread already returns the updated provider thread; declare it. Co-Authored-By: Claude Fable 5 --- .../src/orchestration-v2/Adapters/PiAdapterV2.test.ts | 8 ++++++++ apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 463e7cfafc5c..7153fbb52980 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -266,6 +266,14 @@ const startTurn = Effect.fnUntraced(function* ( }); describe("PiAdapterV2", () => { + it("keeps the rollback capability triple consistent with CommandPolicy", () => { + // ensureRollback rejects rollback commands unless all three hold, so a + // partially-enabled combination is user-visibly broken, not conservative. + assert.isTrue(PiProviderCapabilitiesV2.threads.canRollbackThread); + assert.isTrue(PiProviderCapabilitiesV2.checkpointing.providerCanRollbackConversation); + assert.isTrue(PiProviderCapabilitiesV2.checkpointing.providerRollbackReturnsSnapshot); + }); + it("declares Pi-honest capabilities", () => { assert.isTrue(PiProviderCapabilitiesV2.turns.supportsActiveSteering); assert.isFalse(PiProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 99548c9435c5..b30779782d9e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -194,7 +194,9 @@ export const PiProviderCapabilitiesV2 = { appCanCheckpointFilesystem: true, supportsNestedCheckpointScopes: false, providerCanRollbackConversation: true, - providerRollbackReturnsSnapshot: false, + // CommandPolicy.ensureRollback requires the snapshot whenever provider + // rollback is enabled; rollbackThread returns the updated provider thread. + providerRollbackReturnsSnapshot: true, providerCanReadConversationSnapshot: false, }, identity: { From c084433b4eef41c0268423184d46bef2ded16dc0 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:21:52 +0200 Subject: [PATCH 13/41] feat(providers): surface pi subagent-extension tasks as native subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official pi subagent extension delegates work to separate pi processes through a 'subagent' tool and streams per-task results in its tool details. Project each delegated task into V2's native subagent surface — live progress, model, per-task success/failure, and final output — so extension-driven subagents get real subagent cards instead of an opaque tool row. Any other tool named subagent without that shape is ignored. Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 101 ++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 127 +++++++++++++++++- 2 files changed, 226 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 7153fbb52980..dafd2ee8828d 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -490,6 +490,107 @@ describe("PiAdapterV2", () => { ); }); + it.effect("projects the subagent extension's tasks as native subagents", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_sub", + toolName: "subagent", + args: { tasks: [{ agent: "scout", task: "map the repo" }] }, + }); + yield* fake.emit({ + type: "tool_execution_update", + toolCallId: "call_sub", + toolName: "subagent", + partialResult: { + content: [{ type: "text", text: "(running...)" }], + details: { + mode: "parallel", + results: [ + { + agent: "scout", + task: "map the repo", + exitCode: 0, + stderr: "", + messages: [ + { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, + ], + }, + ], + }, + }, + }); + const running = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "running", + ); + assert.isTrue( + running.type === "subagent.updated" && + running.subagent.title === "scout" && + running.subagent.prompt === "map the repo" && + running.subagent.progress === "scanning files", + ); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_sub", + toolName: "subagent", + isError: false, + result: { + content: [{ type: "text", text: "done" }], + details: { + mode: "parallel", + results: [ + { + agent: "scout", + task: "map the repo", + exitCode: 0, + stopReason: "stop", + stderr: "", + messages: [ + { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, + ], + }, + { + agent: "worker", + task: "broken task", + exitCode: 1, + stderr: "boom", + messages: [], + }, + ], + }, + }, + }); + const doneCard = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ); + assert.isTrue( + doneCard.type === "subagent.updated" && doneCard.subagent.result === "repo has one file", + ); + const failedCard = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "failed", + ); + assert.isTrue( + failedCard.type === "subagent.updated" && + failedCard.subagent.title === "worker" && + failedCard.subagent.result === "boom", + ); + const subagentItem = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "subagent", + ); + assert.equal(subagentItem.type, "turn_item.updated"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index b30779782d9e..a7c7309d39d3 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -173,9 +173,12 @@ export const PiProviderCapabilitiesV2 = { planDeltasHaveItemIds: false, }, subagents: { - supportsSubagents: false, + // Pi has no core subagents; the official subagent extension delegates to + // separate pi processes through a tool, and the adapter projects its + // per-task progress into native subagent lifecycle events when present. + supportsSubagents: true, exposesSubagentThreadIds: false, - emitsSubagentLifecycle: false, + emitsSubagentLifecycle: true, canWaitForSubagents: false, canCloseSubagents: false, canForkSubagentThread: false, @@ -693,6 +696,101 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ...(outputText.length > 0 ? { output: outputText } : {}), }, }); + if (toolName === "subagent") { + yield* emitSubagentTasks(turn, toolCallId, resultRecord, completed); + } + }); + + /** + * Project the official pi subagent extension's per-task progress into + * v2's native subagent surface. The extension reports + * `details: { results: [{agent, task, exitCode, stopReason, messages, + * step?, model?}] }` on every tool update, so each delegated task + * becomes a first-class subagent card with live progress. Tolerant by + * design: any other tool named `subagent` without that shape is simply + * ignored. + */ + const emitSubagentTasks = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + toolCallId: string, + resultRecord: unknown, + completed: boolean, + ) { + const results = recordField(recordField(resultRecord, "details"), "results"); + if (!Array.isArray(results)) return; + const emittedAt = yield* DateTime.now; + for (const [index, result] of results.entries()) { + const agent = recordString(result, "agent"); + const task = recordString(result, "task"); + if (agent === undefined || task === undefined) continue; + const nativeTaskId = `${toolCallId}:subagent:${recordNumber(result, "step") ?? index}`; + const subagentId = idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: nativeTaskId, + }); + const startedAt = turn.toolStartedAt.get(nativeTaskId) ?? emittedAt; + turn.toolStartedAt.set(nativeTaskId, startedAt); + const stopReason = recordString(result, "stopReason"); + const exitCode = recordNumber(result, "exitCode") ?? 0; + const failed = + (completed && exitCode !== 0) || stopReason === "error" || stopReason === "aborted"; + const finished = completed || failed || stopReason !== undefined; + const status = failed ? "failed" : finished ? "completed" : "running"; + const outputText = piSubagentOutput(result); + const title = recordString(result, "step") === undefined ? agent : `${agent}`; + yield* emit({ + type: "subagent.updated", + driver: PI_PROVIDER, + subagent: { + id: subagentId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: toolCallId, + }), + origin: "provider_native", + createdBy: "agent", + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: turn.turnInput.providerThread.id, + childThreadId: null, + nativeTaskRef: providerRef(nativeTaskId), + prompt: task, + title, + model: recordString(result, "model") ?? null, + status, + ...(finished || outputText.length === 0 + ? {} + : { progress: outputText.slice(0, 200) }), + result: finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null, + startedAt, + completedAt: finished ? emittedAt : null, + updatedAt: emittedAt, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeTaskId, startedAt, emittedAt), + status, + title, + completedAt: finished ? emittedAt : null, + type: "subagent", + subagentId, + origin: "provider_native", + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + childThreadId: null, + prompt: task, + ...(finished || outputText.length === 0 + ? {} + : { progress: outputText.slice(0, 200) }), + result: finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null, + }, + }); + } }); // ── extension UI prompts ────────────────────────────── @@ -1739,6 +1837,31 @@ export function piRollbackForkEntry(input: { return ref.nativeId; } +/** + * Human-readable output for one subagent-extension task result: the last + * assistant text from its transcript, or the error/stderr when it failed. + */ +function piSubagentOutput(result: unknown): string { + const stopReason = recordString(result, "stopReason"); + const failed = + (recordNumber(result, "exitCode") ?? 0) !== 0 || + stopReason === "error" || + stopReason === "aborted"; + if (failed) { + const failure = recordString(result, "errorMessage") ?? recordString(result, "stderr"); + if (failure !== undefined && failure.length > 0) return failure; + } + const messages = recordField(result, "messages"); + if (!Array.isArray(messages)) return ""; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (recordString(message, "role") !== "assistant") continue; + const text = contentText(recordField(message, "content")); + if (text.length > 0) return text; + } + return ""; +} + function piThreadSnapshot( providerThread: OrchestrationV2ProviderThread, ): ProviderAdapterV2ThreadSnapshot { From f4f08e4792763dfeb9065d1dea8a3fd68704795c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:34:32 +0200 Subject: [PATCH 14/41] fix(providers): correct Pi subagent failure status and bound error logs Review findings on the subagent surface: - A non-zero subagent `exitCode` now counts as a failure regardless of whether the parent tool has ended. Gating it on `completed` let a finished child report "completed" while `piSubagentOutput` returned its stderr as the result, and left exit-code-only failures "running" until the parent tool finished. - Fall back to `stderr` with `||` rather than `??`, so a failure carrying an empty `errorMessage` no longer hides a non-empty `stderr`. - Log only the length of pi's extension-error and rejected-steer payloads. Both are unbounded remote output that can carry prompt text or credentials, matching the `stderrLength` form used in `PiRpc.ts`. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/Adapters/PiAdapterV2.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index a7c7309d39d3..2c118f8d4d26 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -732,8 +732,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S turn.toolStartedAt.set(nativeTaskId, startedAt); const stopReason = recordString(result, "stopReason"); const exitCode = recordNumber(result, "exitCode") ?? 0; - const failed = - (completed && exitCode !== 0) || stopReason === "error" || stopReason === "aborted"; + // A non-zero exit code is a failure whether or not the parent tool + // has ended, matching `piSubagentOutput`. Gating it on `completed` + // let a finished child report "completed" while its result text was + // the stderr of a failure. + const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted"; const finished = completed || failed || stopReason !== undefined; const status = failed ? "failed" : finished ? "completed" : "running"; const outputText = piSubagentOutput(result); @@ -1208,10 +1211,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S yield* handleExtensionUiRequest(event); return; case "extension_error": { + // Length only: extension errors are unbounded remote output and + // can carry prompt text or credentials. yield* Effect.logWarning("Pi extension error.", { extensionPath: recordString(event, "extensionPath"), event: recordString(event, "event"), - error: recordString(event, "error"), + errorLength: recordString(event, "error")?.length, }); return; } @@ -1262,7 +1267,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // turn it was aimed at is still running on Pi, so terminalizing // here would report a failure while output keeps streaming. yield* Effect.logWarning("Pi rejected a steer message.", { - error: recordString(event, "error"), + errorLength: recordString(event, "error")?.length, }); return; } @@ -1848,7 +1853,9 @@ function piSubagentOutput(result: unknown): string { stopReason === "error" || stopReason === "aborted"; if (failed) { - const failure = recordString(result, "errorMessage") ?? recordString(result, "stderr"); + // Falsy fallback, not `??`: an empty `errorMessage` must not suppress a + // non-empty `stderr`, which is often the only description of the failure. + const failure = recordString(result, "errorMessage") || recordString(result, "stderr"); if (failure !== undefined && failure.length > 0) return failure; } const messages = recordField(result, "messages"); From 20b1ad121876f9061f42320b64b3fb20f8c79b8c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:27:34 +0200 Subject: [PATCH 15/41] fix(providers): present Pi tools aborted by Stop as interrupted Stop aborts pi's in-flight tools, which pi reports as tool_execution_end with isError. Mapping every error end to failed painted a red failed command card under a correctly interrupted run. Error ends on an interrupted turn now close as interrupted, and aborted subagent tasks follow the same rule, matching how OpenCode presents the same path. Reported from the provider manual live-test pass. Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 51 +++++++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 26 ++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index dafd2ee8828d..2c920d97847e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -490,6 +490,57 @@ describe("PiAdapterV2", () => { ); }); + it.effect("presents tools aborted by Stop as interrupted, not failed", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_sleep", + toolName: "bash", + args: { command: "sleep 30" }, + }); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + yield* fake.takeRequest("abort"); + // Pi reports the aborted tool as an error end before settling. + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_sleep", + toolName: "bash", + isError: true, + result: { content: [{ type: "text", text: "Command aborted" }] }, + }); + yield* fake.emit({ type: "agent_settled" }); + + const toolItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status !== "running", + ); + assert.isTrue( + toolItem.type === "turn_item.updated" && toolItem.turnItem.status === "interrupted", + ); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("projects the subagent extension's tasks as native subagents", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 2c118f8d4d26..a2e471e964aa 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -637,7 +637,15 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const isError = event["isError"] === true; const resultRecord = completed ? event["result"] : event["partialResult"]; const outputText = contentText(recordField(resultRecord, "content")); - const status = completed ? (isError ? "failed" : "completed") : "running"; + // A Stop aborts in-flight tools, and pi reports those as error ends. + // Present them as interrupted (matching the run) rather than failed. + const status = completed + ? isError + ? turn.interrupted + ? "interrupted" + : "failed" + : "completed" + : "running"; const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); yield* emitItemNode( turn, @@ -735,10 +743,18 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // A non-zero exit code is a failure whether or not the parent tool // has ended, matching `piSubagentOutput`. Gating it on `completed` // let a finished child report "completed" while its result text was - // the stderr of a failure. - const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted"; - const finished = completed || failed || stopReason !== undefined; - const status = failed ? "failed" : finished ? "completed" : "running"; + // the stderr of a failure. An aborted child (user Stop) presents as + // interrupted, matching the run and tool cards. + const interrupted = stopReason === "aborted"; + const failed = !interrupted && (exitCode !== 0 || stopReason === "error"); + const finished = completed || interrupted || failed || stopReason !== undefined; + const status = interrupted + ? "interrupted" + : failed + ? "failed" + : finished + ? "completed" + : "running"; const outputText = piSubagentOutput(result); const title = recordString(result, "step") === undefined ? agent : `${agent}`; yield* emit({ From a5f1ebbef909924007616a5c00a8e12cd6a67889 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:49:03 +0200 Subject: [PATCH 16/41] fix(providers): close Pi RPC spec gaps from the protocol cross-check Five spec-fidelity fixes against pi's rpc.md: - a session_before_switch extension veto now fails the resume instead of silently adopting whichever session stayed active - failed compactions surface as a failed compaction item with the error instead of disappearing - empty input/editor dialog answers deliver as values (the spec's 'extension receives ""'), no longer converted to cancels - editor dialogs show their prefill inside the question text so the user is not editing blind - text generation passes --no-extensions so an unanswerable extension dialog cannot stall commit-message generation until its timeout Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 104 +++++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 44 +++++++- .../src/textGeneration/PiTextGeneration.ts | 11 +- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 2c920d97847e..b3495c47ed06 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -73,6 +73,8 @@ interface FakePi { readonly takeRequest: (type: string) => Effect.Effect; /** Data returned by the next `get_entries` acks, consumed in order. */ readonly queueEntries: (data: unknown) => void; + /** Make the next `switch_session` ack report an extension veto. */ + readonly vetoNextSwitch: () => void; } /** @@ -83,6 +85,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); const entriesQueue: Array = []; + let vetoSwitch = false; let stdinBuffer = ""; const emit = (record: PiRpcRecord) => @@ -111,8 +114,11 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { sessionId: "abc", }, }; - case "switch_session": - return { ...base, data: { cancelled: false } }; + case "switch_session": { + const cancelled = vetoSwitch; + vetoSwitch = false; + return { ...base, data: { cancelled } }; + } case "get_entries": return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; case "fork": @@ -169,6 +175,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { emit, takeRequest, queueEntries: (data) => entriesQueue.push(data), + vetoNextSwitch: () => { + vetoSwitch = true; + }, } satisfies FakePi; }); @@ -490,6 +499,97 @@ describe("PiAdapterV2", () => { ); }); + it.effect("fails a resume when an extension vetoes the session switch", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + fake.vetoNextSwitch(); + const error = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("delivers empty dialog answers as values and shows editor prefill", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-editor", + method: "editor", + title: "Edit the note", + prefill: "line one\nline two", + }); + const pending = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + const requestId = + pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; + const requestItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + assert.isTrue( + requestItem.type === "turn_item.updated" && + requestItem.turnItem.type === "user_input_request" && + requestItem.turnItem.questions[0]!.question.includes("Current value:\nline one"), + ); + // An empty string clears the note; it must arrive as a value, not a cancel. + yield* runtime.respondToRuntimeRequest({ + requestId: requestId!, + answers: { "ui-editor": "" }, + }); + const uiResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(uiResponse["value"], ""); + assert.notProperty(uiResponse, "cancelled"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("shows failed compactions instead of dropping them", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "compaction_end", + reason: "threshold", + result: null, + aborted: false, + errorMessage: "API quota exceeded", + }); + const compaction = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + assert.isTrue( + compaction.type === "turn_item.updated" && + compaction.turnItem.type === "compaction" && + compaction.turnItem.status === "failed" && + compaction.turnItem.summary === "API quota exceeded", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("presents tools aborted by Stop as interrupted, not failed", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index a2e471e964aa..ebd8a7c73768 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1188,7 +1188,27 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (turn === null) return; const emittedAt = yield* DateTime.now; const result = event["result"]; - if (result === null || result === undefined) return; + if (result === null || result === undefined) { + // Aborted compactions vanish silently; failed ones carry an + // errorMessage and deserve a visible failed compaction item. + const errorMessage = recordString(event, "errorMessage"); + if (event["aborted"] === true || errorMessage === undefined) return; + const failedItemId = `compaction:${turn.nextItemOrdinal}`; + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, failedItemId, emittedAt, emittedAt), + status: "failed", + title: null, + completedAt: emittedAt, + type: "compaction", + driver: PI_PROVIDER, + summary: errorMessage.slice(0, 1_000), + }, + }); + return; + } const nativeItemId = `compaction:${turn.nextItemOrdinal}`; yield* emit({ type: "turn_item.updated", @@ -1362,10 +1382,16 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ) { const existing = threadInput.existingProviderThread; if (existing?.nativeThreadRef?.nativeId != null) { - yield* request({ + const switchData = yield* request({ type: "switch_session", sessionPath: existing.nativeThreadRef.nativeId, }); + // A session_before_switch extension handler can veto the switch. + // Proceeding would silently adopt whatever session is active and + // write the wrong thread's turns into it. + if (recordField(switchData, "cancelled") === true) { + return yield* protocolError("A Pi extension cancelled the session switch"); + } // These caches describe the session we just left. Clearing them // stops the next turn from treating this session as already // configured and skipping set_model or set_session_name. @@ -1903,10 +1929,18 @@ function piQuestion( .filter((option): option is string => typeof option === "string") .map((option) => ({ label: option, description: option })) : []; + // The user-input contract has no prefill field, so an editor dialog's + // prefill is surfaced inside the question text; without it the user would + // edit blind against content they cannot see. + const prefill = method === "editor" ? recordString(event, "prefill") : undefined; + const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; return { id: questionId, header: title, - question: recordString(event, "message") ?? recordString(event, "placeholder") ?? title, + question: + prefill === undefined || prefill.length === 0 + ? question + : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, options, }; } @@ -1922,7 +1956,9 @@ function piUiResponse( return { cancelled: true }; } const answer = answers?.[pending.questionId]; - if (typeof answer === "string" && answer.length > 0) return { value: answer }; + // An empty string is a valid dialog value per the RPC spec (the extension + // receives ""), distinct from cancelling (the extension receives undefined). + if (typeof answer === "string") return { value: answer }; return { cancelled: true }; } diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index ce86ee2d36f3..b849728e575a 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -59,7 +59,16 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* Effect.gen(function* () { const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", - args: ["--mode", "rpc", "--no-session", ...tokenizeCliArgs(piSettings.launchArgs)], + // --no-extensions is deliberate: an extension raising a dialog here + // would stall commit-message generation until the timeout, and no + // one is present to answer it. User model config and auth still apply. + args: [ + "--mode", + "rpc", + "--no-session", + "--no-extensions", + ...tokenizeCliArgs(piSettings.launchArgs), + ], cwd, env: environment, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); From 1b526ee8f89b3feaf1fe76da161fb4131184d3ef Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:04:50 +0200 Subject: [PATCH 17/41] fix(providers): correct Pi retry, teardown and turn-boundary handling Six review findings from the protocol-gap round: - A recovered auto-retry no longer terminalizes as failed. Pi emits the erroring `message_end` before retrying, and `auto_retry_end` success left that failure in place, so `agent_settled` failed the whole turn. - A failed `prompt` send finalizes the turn again. The fire-and-forget change moved the send off `request`, dropping the cleanup, so a send failure left `activeTurn` set and wedged every later turn. - `startTurn` now holds the session permit, so a new turn cannot start while the previous `finalizeTurn` is still awaiting `get_entries`. That race let the old finalizer capture the new turn's entry and publish idle after the new turn had already published active. - `switch_session` clears the model and thinking baselines too, so "Pi default"/"inherit" cannot replay the previous session's defaults. - Termination re-checks liveness before SIGTERM and before escalating to SIGKILL. Signalling a pid that already exited can hit an unrelated process once the OS recycles the pid or pgid. - Windows tears down the whole tree with `taskkill /T`, matching `AcpSessionRuntime`. `process.kill` reached only pi itself and left extension subprocesses holding inherited stdio handles. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/Adapters/PiAdapterV2.ts | 42 ++++++++++++--- .../src/orchestration-v2/Adapters/PiRpc.ts | 52 ++++++++++++++++--- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index ebd8a7c73768..d454d4757d59 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1235,7 +1235,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } case "auto_retry_end": { if (turn === null) return; - if (event["success"] === true) return; + if (event["success"] === true) { + // The retry recovered. Pi emits the erroring `message_end` + // before retrying, so leaving that failure in place would make + // `agent_settled` terminalize a successful turn as failed. + turn.failure = null; + return; + } turn.failure = makeProviderFailure({ message: recordString(event, "finalError") ?? "Pi auto-retry failed.", class: "provider_error", @@ -1398,6 +1404,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S appliedModel = null; appliedThinking = null; appliedSessionName = null; + // The baselines describe the session we just left too. Dropping them + // lets the `get_state` below re-capture this session's own defaults, + // so "Pi default"/"inherit" cannot replay the previous session's. + baselineModel = null; + baselineThinking = null; } const stateData = yield* request({ type: "get_state" }); // Each baseline is captured independently, and only while nothing has @@ -1666,12 +1677,31 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // dialogs indefinitely, so turn start must never await the ack. // Rejections come back as an id-less response record and are // handled by the event pump. - yield* connection.send({ - type: "prompt", - message: payload.message, - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }); + yield* connection + .send({ + type: "prompt", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }) + .pipe( + // The turn is already installed and published, so a failed send + // has to finalize it here. Otherwise `activeTurn` stays set and + // every later turn is rejected as already active. + Effect.tapError(() => + Effect.gen(function* () { + const current = threadState; + if (current?.activeTurn?.providerTurn.id === providerTurn.id) { + current.activeTurn.failure = makeProviderFailure({ + message: "Pi rejected the prompt.", + class: "provider_error", + }); + yield* finalizeTurn(current); + } + }), + ), + ); }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterTurnStartError({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index ce9127fdedf7..f92e8089538a 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -124,11 +124,19 @@ function parsePiRecord(line: string): PiRpcRecord | undefined { } } -/** Kill the pi process group: SIGTERM, short grace, then SIGKILL. */ -const terminatePiProcess = (pid: number, kill: (signal: NodeJS.Signals) => boolean) => +/** + * Kill the pi process group: SIGTERM, short grace, then SIGKILL. + * + * `hasExited` is consulted before each signal. Once the original child is + * gone its pid/pgid can be recycled by the OS, so escalating blindly could + * deliver SIGKILL to an unrelated process. + */ +const terminatePiProcess = (kill: (signal: NodeJS.Signals) => boolean, hasExited: () => boolean) => Effect.gen(function* () { + if (hasExited()) return; if (!kill("SIGTERM")) return; yield* Effect.sleep(TERMINATION_GRACE); + if (hasExited()) return; kill("SIGKILL"); }); @@ -152,6 +160,8 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp ) .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); + let childExited = false; + const killProcessGroup = (signal: NodeJS.Signals): boolean => { try { if (platform === "win32") { @@ -165,15 +175,39 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp } }; + /** Signal 0 probes liveness without delivering anything. */ + const hasExited = (): boolean => { + if (childExited) return true; + try { + process.kill(platform === "win32" ? Number(child.pid) : -Number(child.pid), 0); + return false; + } catch { + return true; + } + }; + + /** + * Windows has no process groups, so `process.kill` reaches only pi itself + * and leaves extension subprocesses running with inherited stdio handles. + * `taskkill /T` reaps the whole tree. + */ + const terminateWindowsTree = Effect.gen(function* () { + if (hasExited()) return; + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), + ); + yield* taskkill.exitCode; + }).pipe(Effect.scoped, Effect.ignore); + // Registered before any further setup: an interrupt or failure between the // spawn and the rest of this constructor would otherwise leak a detached // pi process with no finalizer to reap it. yield* Scope.addFinalizer( scope, - terminatePiProcess(Number(child.pid), killProcessGroup).pipe( - Effect.ignore, - Effect.uninterruptible, - ), + (platform === "win32" + ? terminateWindowsTree + : terminatePiProcess(killProcessGroup, hasExited) + ).pipe(Effect.ignore, Effect.uninterruptible), ); const pendingRequests = new Map(); @@ -282,7 +316,11 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Effect.matchEffect({ onFailure: (cause) => Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), - onSuccess: (code) => Deferred.succeed(exitDeferred, Number(code)), + onSuccess: (code) => + Effect.suspend(() => { + childExited = true; + return Deferred.succeed(exitDeferred, Number(code)); + }), }), Effect.forkIn(scope), ); From 34a326096f2a0a248f1b9f6996f8a894c892fb23 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:01:19 +0200 Subject: [PATCH 18/41] fix(server): translate policy capability rejections into readable errors Capability rejections surfaced their internal diagnostic string, so a checkpoint revert on a provider without rollback support showed 'pi cannot satisfy rollback_snapshot for command : rollback must return a providerInstanceId thread snapshot'. userFacingDispatchErrorMessage now renders known policy rejections as provider-named prose ('Pi did not report its rewound conversation state, so the checkpoint was not restored.') and keeps the diagnostic form for logs and unknown codes. Co-Authored-By: Claude Fable 5 --- .../orchestration-v2/UserFacingErrors.test.ts | 47 +++++++++++++++++ .../src/orchestration-v2/UserFacingErrors.ts | 51 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts index 63e3a77a5b3f..6346a2b0e3fd 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts @@ -21,6 +21,53 @@ describe("userFacingDispatchErrorMessage", () => { ); }); + it("translates policy capability rejections into provider-named prose", () => { + assert.equal( + userFacingDispatchErrorMessage({ + message: "Failed to dispatch orchestration command checkpoint.rollback (command-1).", + cause: { + _tag: "CommandPolicyCapabilityUnsupportedError", + commandId: "command-1", + threadId: "thread-1", + providerInstanceId: "pi", + capability: "rollback_snapshot", + detail: "rollback must return a provider thread snapshot", + message: + "pi cannot satisfy rollback_snapshot for command command-1: rollback must return a provider thread snapshot", + }, + }), + "Pi did not report its rewound conversation state, so the checkpoint was not restored.", + ); + assert.equal( + userFacingDispatchErrorMessage({ + message: "Failed to dispatch orchestration command checkpoint.rollback (command-2).", + cause: { + _tag: "CommandPolicyCapabilityUnsupportedError", + commandId: "command-2", + threadId: "thread-1", + providerInstanceId: "grok", + capability: "rollback", + detail: "provider conversation rollback is unavailable", + }, + }), + "Grok cannot rewind its conversation, so this checkpoint cannot be restored on this thread.", + ); + }); + + it("falls back to the raw message for unknown capability codes", () => { + assert.equal( + userFacingDispatchErrorMessage({ + cause: { + _tag: "CommandPolicyCapabilityUnsupportedError", + providerInstanceId: "pi", + capability: "some_future_capability", + message: "pi cannot satisfy some_future_capability for command command-3: details", + }, + }), + "pi cannot satisfy some_future_capability for command command-3: details", + ); + }); + it("uses explicit detail fields as user-facing messages", () => { assert.equal( userFacingDispatchErrorMessage({ diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.ts b/apps/server/src/orchestration-v2/UserFacingErrors.ts index 3765312da39b..535dd05babaa 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.ts @@ -1,9 +1,56 @@ +import { PROVIDER_DISPLAY_NAMES, type ProviderDriverKind } from "@t3tools/contracts"; + const GENERIC_ERROR_PREFIXES = [ "Failed to dispatch orchestration V2 command", "Failed to dispatch orchestration command ", "Provider adapter failed while dispatching orchestration command ", ]; +/** + * User-facing prose for capability rejections. The internal error messages + * keep command ids and capability codes for logs; the toast should say what + * did not happen and why in the provider's own name. + */ +const CAPABILITY_REJECTION_MESSAGES: Record string> = { + queued_messages: (p) => `${p} cannot queue messages behind an active run.`, + active_steering: (p) => + `${p} cannot redirect an active run. Stop it first, then send the message.`, + interrupt_restart_steering: (p) => + `${p} cannot redirect an active run. Stop it first, then send the message.`, + interrupt: (p) => `${p} cannot stop a run once it has started.`, + native_fork: (p) => `${p} cannot fork this thread natively.`, + fork_from_turn: (p) => `${p} cannot fork from an earlier point in this thread.`, + rollback: (p) => + `${p} cannot rewind its conversation, so this checkpoint cannot be restored on this thread.`, + rollback_snapshot: (p) => + `${p} did not report its rewound conversation state, so the checkpoint was not restored.`, + context_handoff: (p) => `${p} cannot receive the context handoff needed for this switch.`, + strong_terminal_status: (p) => + `${p} cannot confirm when its runs finish reliably enough for this.`, +}; + +function providerDisplayName(instanceId: string): string { + const known = PROVIDER_DISPLAY_NAMES[instanceId as ProviderDriverKind]; + if (known !== undefined) return known; + const trimmed = instanceId.replace(/Agent$/i, "").trim(); + if (trimmed.length === 0) return instanceId; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +/** Friendly translation for command-policy rejections; undefined otherwise. */ +function policyRejectionMessage(value: unknown): string | undefined { + if (!isRecord(value) || typeof value.providerInstanceId !== "string") return undefined; + const provider = providerDisplayName(value.providerInstanceId); + if (value._tag === "CommandPolicyCapabilityUnsupportedError") { + const capability = typeof value.capability === "string" ? value.capability : ""; + return CAPABILITY_REJECTION_MESSAGES[capability]?.(provider); + } + if (value._tag === "CommandPolicyUnsupportedError") { + return `${provider} cannot deliver a message that way right now.`; + } + return undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -16,6 +63,10 @@ function messageFrom(value: unknown): string | undefined { if (typeof value === "string") { return textValue(value); } + const friendly = policyRejectionMessage(value); + if (friendly !== undefined) { + return friendly; + } if (value instanceof Error) { return textValue(value.message); } From cab5051abc5ba36d1978fd5499c8820df78f6b5f Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 16 Aug 2026 17:30:26 -0400 Subject: [PATCH 19/41] feat(pi): inject the T3 MCP server into Pi sessions Pi core has no MCP client. V2 already minted a scoped t3-code bearer before openSession. This writes a T3-owned extension into the server cache and spawns pi --mode rpc --extension with T3_MCP_URL and T3_MCP_BEARER_TOKEN. Each MCP tool is registered under its original name. User launchArgs are preserved. The first turn receives the shared orchestration instructions. --- .../Adapters/PiAdapterV2.test.ts | 56 +++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 38 ++- .../Adapters/piT3McpExtensionSource.ts | 239 ++++++++++++++++++ .../Adapters/piT3McpInjection.test.ts | 100 ++++++++ .../Adapters/piT3McpInjection.ts | 68 +++++ .../orchestrator-mcp-server.md | 22 +- 6 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index b3495c47ed06..f4a0edd3a955 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CheckpointId, + EnvironmentId, NodeId, ProviderInstanceId, ProviderSessionId, @@ -23,9 +24,10 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -75,6 +77,10 @@ interface FakePi { readonly queueEntries: (data: unknown) => void; /** Make the next `switch_session` ack report an extension veto. */ readonly vetoNextSwitch: () => void; + readonly lastSpawn: () => { + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + }; } /** @@ -144,9 +150,19 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } }); - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ + let lastSpawn: { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv } = { + args: [], + env: {}, + }; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (ChildProcess.isStandardCommand(command)) { + lastSpawn = { + args: command.args, + env: command.options.env ?? {}, + }; + } + return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(FAKE_PID), exitCode: Effect.never, isRunning: Effect.succeed(true), @@ -158,8 +174,8 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, - }), - ), + }); + }), ); const takeRequest = (type: string): Effect.Effect => @@ -178,6 +194,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { vetoNextSwitch: () => { vetoSwitch = true; }, + lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -288,10 +305,35 @@ describe("PiAdapterV2", () => { assert.isFalse(PiProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); assert.equal(PiProviderCapabilitiesV2.turns.terminalStatusQuality, "strong"); assert.isFalse(PiProviderCapabilitiesV2.approvals.supportsCommandApproval); - assert.isFalse(PiProviderCapabilitiesV2.tools.supportsMcpTools); + assert.isTrue(PiProviderCapabilitiesV2.tools.supportsMcpTools); assert.equal(PiProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); }); + it.effect("injects the T3 MCP extension and bearer when a session exists", () => + Effect.gen(function* () { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-pi-mcp"), + threadId: THREAD_ID, + providerSessionId: "mcp-session-pi", + providerInstanceId: PI_INSTANCE_ID, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-pi-token", + }); + const fake = yield* makeFakePi; + yield* openRuntime(fake); + const spawn = fake.lastSpawn(); + assert.isTrue(spawn.args.includes("--extension")); + const extension = spawn.args[spawn.args.indexOf("--extension") + 1]; + assert.isTrue(extension?.endsWith("pi-t3-mcp-extension.ts")); + assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); + assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); + }).pipe( + Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID))), + Effect.scoped, + Effect.provide(testLayer), + ), + ); + it.effect("registers the thread from get_state and resumes via switch_session", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index d454d4757d59..1ce98aaa3d65 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -23,7 +23,6 @@ * are dropped until a dedicated Pi panel exists. */ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { defaultInstanceIdForDriver, @@ -56,6 +55,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { IdAllocatorV2 } from "../IdAllocator.ts"; import { @@ -95,6 +96,7 @@ import { type PiRpcConnection, type PiRpcRecord, } from "./PiRpc.ts"; +import { buildPiRpcLaunch, materializePiT3McpExtension } from "./piT3McpInjection.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); export const PI_DRIVER_KIND = PI_PROVIDER; @@ -151,7 +153,7 @@ export const PiProviderCapabilitiesV2 = { emitsToolStarted: true, emitsToolCompleted: true, emitsToolOutput: true, - supportsMcpTools: false, + supportsMcpTools: true, supportsDynamicToolCallbacks: false, }, approvals: { @@ -332,11 +334,33 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ) { const scope = yield* Effect.scope; const cwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const extensionPath = + mcpSession === undefined + ? undefined + : yield* materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir).pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const launch = buildPiRpcLaunch({ + launchArgs: options.settings.launchArgs, + environment: options.environment, + mcpSession, + extensionPath, + }); + const hasT3Mcp = launch.hasT3Mcp; const connection: PiRpcConnection = yield* makePiRpcConnection({ command: options.settings.binaryPath || "pi", - args: ["--mode", "rpc", ...tokenizeCliArgs(options.settings.launchArgs)], + args: launch.args, cwd, - env: options.environment, + env: launch.env, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), Effect.mapError( @@ -1627,7 +1651,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // unreadable attachment) must not leave `activeTurn` set, which // would reject every later turn as already active. const payload = yield* resolvePromptPayload( - turnInput.message.text, + t3OrchestrationPromptForFirstRun({ + prompt: turnInput.message.text, + runOrdinal: turnInput.runOrdinal, + hasT3Mcp, + }), turnInput.message.attachments, ); const startedAt = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts new file mode 100644 index 000000000000..8fc657dc09f3 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -0,0 +1,239 @@ +/** + * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. + * + * Pi core has no MCP client. This file is TypeScript that Pi itself loads via + * `--extension`. It is written to a cache path at session open so packaged + * AppImage builds do not need a sibling .ts file next to the bundled server. + * + * Do not import t3code modules from the string body. The Pi process resolves + * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. + */ +export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; + +export const T3_MCP_URL_ENV = "T3_MCP_URL"; +export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; + +export const PI_T3_MCP_EXTENSION_SOURCE = `\ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; +const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; +const PROTOCOL = "2025-06-18"; + +type JsonRpcResponse = { + readonly id?: number | string; + readonly result?: unknown; + readonly error?: { readonly message?: string }; +}; + +type McpTool = { + readonly name: string; + readonly description?: string; + readonly inputSchema?: Record; +}; + +function env(name: string): string | undefined { + const value = process.env[name]; + return value && value.length > 0 ? value : undefined; +} + +function parseSseOrJson(body: string, contentType: string): JsonRpcResponse { + if (contentType.includes("text/event-stream")) { + for (const line of body.split("\\n")) { + const trimmed = line.startsWith("data:") ? line.slice(5).trim() : ""; + if (trimmed.length === 0) continue; + const parsed = JSON.parse(trimmed) as JsonRpcResponse; + if (parsed.id !== undefined || parsed.result !== undefined || parsed.error !== undefined) { + return parsed; + } + } + throw new Error("MCP SSE response had no JSON-RPC payload."); + } + return JSON.parse(body) as JsonRpcResponse; +} + +function jsonSchemaToTypebox(schema: Record | undefined) { + const unsafe = (Type as { Unsafe?: (value: unknown) => unknown }).Unsafe; + if (typeof unsafe === "function" && schema !== undefined) { + return unsafe(schema); + } + return Type.Object({}, { additionalProperties: true }); +} + +function formatMcpContent(result: unknown): string { + if (result === null || result === undefined) return ""; + if (typeof result !== "object") return String(result); + const record = result as { + readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; + readonly structuredContent?: unknown; + readonly isError?: boolean; + }; + const texts: string[] = []; + if (Array.isArray(record.content)) { + for (const part of record.content) { + if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); + } + } + if (record.structuredContent !== undefined) { + texts.push(JSON.stringify(record.structuredContent)); + } + if (texts.length > 0) return texts.join("\\n"); + return JSON.stringify(result); +} + +function createMcpClient(endpoint: string, token: string) { + let nextId = 1; + let sessionId: string | undefined; + + const headers = (): Record => { + const next: Record = { + accept: "application/json, text/event-stream", + authorization: token.startsWith("Bearer ") ? token : \`Bearer \${token}\`, + "content-type": "application/json", + // Effect's HTTP MCP rejects post-initialize requests without this + // (400). The worktree client in McpHttpServer tests sends the same + // header; initialize itself does not require it. + "mcp-protocol-version": PROTOCOL, + }; + if (sessionId !== undefined) next["mcp-session-id"] = sessionId; + return next; + }; + + const request = async (method: string, params?: unknown, signal?: AbortSignal) => { + const id = nextId++; + const response = await fetch(endpoint, { + method: "POST", + headers: headers(), + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + signal, + }); + const nextSession = response.headers.get("mcp-session-id"); + if (nextSession) sessionId = nextSession; + const body = await response.text(); + if (!response.ok) { + throw new Error(\`MCP \${method} failed (\${response.status}): \${body.slice(0, 400)}\`); + } + if (body.length === 0) return undefined; + const parsed = parseSseOrJson(body, response.headers.get("content-type") ?? ""); + if (parsed.error) { + throw new Error(parsed.error.message ?? \`MCP \${method} returned an error\`); + } + return parsed.result; + }; + + const notify = async (method: string, params?: unknown, signal?: AbortSignal) => { + await fetch(endpoint, { + method: "POST", + headers: headers(), + body: JSON.stringify({ jsonrpc: "2.0", method, params }), + signal, + }); + }; + + return { + async connect(signal?: AbortSignal) { + await request( + "initialize", + { + protocolVersion: PROTOCOL, + capabilities: {}, + clientInfo: { name: "t3-pi-mcp", version: "1.0.0" }, + }, + signal, + ); + await notify("notifications/initialized", {}, signal).catch(() => undefined); + }, + async listTools(signal?: AbortSignal) { + const tools: McpTool[] = []; + let cursor: string | undefined; + do { + const result = (await request( + "tools/list", + cursor === undefined ? {} : { cursor }, + signal, + )) as { tools?: McpTool[]; nextCursor?: string } | undefined; + tools.push(...(result?.tools ?? [])); + cursor = result?.nextCursor; + } while (cursor); + return tools; + }, + async callTool(name: string, args: Record, signal?: AbortSignal) { + return request("tools/call", { name, arguments: args }, signal); + }, + }; +} + +export default async function t3McpExtension(pi: ExtensionAPI) { + const endpoint = env(URL_ENV); + const token = env(TOKEN_ENV); + if (endpoint === undefined || token === undefined) { + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify( + "t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.", + "warning", + ); + }); + return; + } + + const client = createMcpClient(endpoint, token); + let started: Promise | undefined; + + const ensureStarted = () => { + started ??= (async () => { + const signal = AbortSignal.timeout(10_000); + await client.connect(signal); + const tools = await client.listTools(signal); + for (const tool of tools) { + const name = tool.name; + const description = tool.description ?? name; + pi.registerTool({ + name, + label: name, + description, + promptSnippet: description.split("\\n")[0] ?? name, + promptGuidelines: [ + \`Use \${name} from the t3-code MCP server when the user asks for T3 orchestration that this tool covers.\`, + ], + parameters: jsonSchemaToTypebox(tool.inputSchema), + async execute(_toolCallId, params, signal) { + const result = await client.callTool( + name, + (params ?? {}) as Record, + signal, + ); + const text = formatMcpContent(result); + return { + content: [{ type: "text", text }], + details: { server: "t3-code", tool: name }, + }; + }, + }); + } + })(); + return started; + }; + + // Await here so tools exist before session_start and the first prompt. + // session_start is a retry if the process later reloads the extension. + try { + await ensureStarted(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); + }); + return; + } + + pi.on("session_start", async (_event, ctx) => { + try { + await ensureStarted(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); + } + }); +} +`; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts new file mode 100644 index 000000000000..21c81f299d02 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -0,0 +1,100 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + PI_T3_MCP_EXTENSION_FILENAME, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, +} from "./piT3McpExtensionSource.ts"; +import { + bearerTokenFromAuthorizationHeader, + buildPiRpcLaunch, + materializePiT3McpExtension, + piT3McpExtensionDestPath, +} from "./piT3McpInjection.ts"; + +const threadId = ThreadId.make("thread-pi-t3-mcp"); + +const mcpSession = { + environmentId: EnvironmentId.make("environment-pi-t3-mcp"), + threadId, + providerSessionId: "mcp-session-pi", + providerInstanceId: ProviderInstanceId.make("pi"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-pi-token", +}; + +describe("pi T3 MCP injection", () => { + it("strips the Bearer prefix for the child env", () => { + assert.equal(bearerTokenFromAuthorizationHeader("Bearer secret-pi-token"), "secret-pi-token"); + assert.equal(bearerTokenFromAuthorizationHeader("secret-pi-token"), "secret-pi-token"); + }); + + it("leaves spawn args unchanged when no MCP session exists", () => { + const launch = buildPiRpcLaunch({ + launchArgs: "--session-dir /tmp/pi-sessions", + environment: { PATH: "/usr/bin" }, + mcpSession: undefined, + extensionPath: "/tmp/pi-t3-mcp-extension.ts", + }); + assert.isFalse(launch.hasT3Mcp); + assert.deepEqual(launch.args, ["--mode", "rpc", "--session-dir", "/tmp/pi-sessions"]); + assert.equal(launch.env.PATH, "/usr/bin"); + assert.isUndefined(launch.env[T3_MCP_URL_ENV]); + }); + + it("appends --extension and scoped env when a session exists", () => { + const launch = buildPiRpcLaunch({ + launchArgs: "--session-dir /tmp/pi-sessions", + environment: { PATH: "/usr/bin" }, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + }); + assert.isTrue(launch.hasT3Mcp); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--session-dir", + "/tmp/pi-sessions", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); + assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); + }); + + it("does not duplicate an already-present extension path", () => { + const launch = buildPiRpcLaunch({ + launchArgs: "--extension /tmp/cache/pi-t3-mcp-extension.ts", + environment: {}, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + }); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + }); + + it.effect("writes the extension source to the cache directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-t3-mcp-" }); + const dest = yield* materializePiT3McpExtension(cacheDir); + assert.equal(dest, piT3McpExtensionDestPath(cacheDir)); + assert.isTrue(dest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); + const source = yield* fs.readFileString(dest); + assert.include(source, "export default async function t3McpExtension"); + assert.include(source, T3_MCP_URL_ENV); + assert.include(source, '"mcp-protocol-version"'); + assert.include(source, '"tools/call"'); + const again = yield* materializePiT3McpExtension(cacheDir); + assert.equal(again, dest); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts new file mode 100644 index 000000000000..9e07088317fc --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; +import { + PI_T3_MCP_EXTENSION_FILENAME, + PI_T3_MCP_EXTENSION_SOURCE, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, +} from "./piT3McpExtensionSource.ts"; + +export { PI_T3_MCP_EXTENSION_FILENAME, T3_MCP_BEARER_ENV, T3_MCP_URL_ENV }; + +export function bearerTokenFromAuthorizationHeader(header: string): string { + return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; +} + +export function piT3McpExtensionDestPath(cacheDir: string): string { + return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; +} + +export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( + cacheDir: string, +) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(cacheDir, { recursive: true }); + const dest = piT3McpExtensionDestPath(cacheDir); + const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); + if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { + yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); + } + return dest; +}); + +export function buildPiRpcLaunch(input: { + readonly launchArgs: string; + readonly environment: NodeJS.ProcessEnv; + readonly mcpSession: McpProviderSessionConfig | undefined; + readonly extensionPath: string | undefined; +}): { + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly hasT3Mcp: boolean; +} { + const userArgs = tokenizeCliArgs(input.launchArgs); + const hasT3Mcp = input.mcpSession !== undefined && input.extensionPath !== undefined; + if (!hasT3Mcp || input.mcpSession === undefined || input.extensionPath === undefined) { + return { args: ["--mode", "rpc", ...userArgs], env: input.environment, hasT3Mcp: false }; + } + + const alreadyHasExtension = userArgs.some( + (arg, index) => arg === "--extension" && userArgs[index + 1] === input.extensionPath, + ); + const args = alreadyHasExtension + ? ["--mode", "rpc", ...userArgs] + : ["--mode", "rpc", ...userArgs, "--extension", input.extensionPath]; + + return { + args, + env: { + ...input.environment, + [T3_MCP_URL_ENV]: input.mcpSession.endpoint, + [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader(input.mcpSession.authorizationHeader), + }, + hasT3Mcp: true, + }; +} diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401a..9d27dc162f9e 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -130,10 +130,28 @@ forking uses portable context when native `session/fork` is unavailable, and subagents use orchestrator-owned child threads. Registry agents do not receive provider-specific extensions; those remain in flavors such as Grok. +### Pi V2 + +Pi core has no MCP client. When a provider session credential exists, the +adapter writes a T3-owned extension into the server cache and spawns +`pi --mode rpc --extension /pi-t3-mcp-extension.ts` with: + +```text +T3_MCP_URL=http://127.0.0.1:/mcp +T3_MCP_BEARER_TOKEN= +``` + +The extension connects to that HTTP endpoint, lists tools, and registers each +one with `pi.registerTool` under its original name (`delegate_task`, +`t3_thread_start`, and the rest). Follow-up HTTP requests send +`mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400 +without it. User `launchArgs` are preserved. The first turn of a session +also receives the shared T3 orchestration instructions. + ### Initial Provider Support -The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, and -Grok plus generic registry agents over ACP. +The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, Grok +plus generic registry agents over ACP, OpenCode, OpenCode 2, and Pi. Capability discovery still reports other registered provider instances, but marks them unavailable for orchestration when no V2 adapter exists. This keeps provider selection model-visible without allowing a request that cannot run. From c943d167bf5ce55694acfc3370475f4949732d40 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 16 Aug 2026 21:04:43 -0400 Subject: [PATCH 20/41] feat(pi): bind subagent tasks as resumeable child threads Official subagent uses --no-session, so T3 could project cards but could not open the child. Inject a T3-owned override that persists --session, reports sessionFile, and binds each result as a child thread. Follow-up sends allocate a new RPC and resume with switch_session. Duplicate subagent registrations abort Pi, so spawn disables extension discovery and drops the official tool from launchArgs. --- .../Adapters/PiAdapterV2.test.ts | 61 +- .../orchestration-v2/Adapters/PiAdapterV2.ts | 291 +++++++-- .../Adapters/piT3McpInjection.test.ts | 66 +++ .../Adapters/piT3McpInjection.ts | 124 +++- .../Adapters/piT3SubagentExtensionSource.ts | 561 ++++++++++++++++++ .../orchestrator-mcp-server.md | 6 + 6 files changed, 1054 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index f4a0edd3a955..cca7daa0f97c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -306,6 +306,7 @@ describe("PiAdapterV2", () => { assert.equal(PiProviderCapabilitiesV2.turns.terminalStatusQuality, "strong"); assert.isFalse(PiProviderCapabilitiesV2.approvals.supportsCommandApproval); assert.isTrue(PiProviderCapabilitiesV2.tools.supportsMcpTools); + assert.isTrue(PiProviderCapabilitiesV2.subagents.exposesSubagentThreadIds); assert.equal(PiProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); }); @@ -323,8 +324,11 @@ describe("PiAdapterV2", () => { yield* openRuntime(fake); const spawn = fake.lastSpawn(); assert.isTrue(spawn.args.includes("--extension")); - const extension = spawn.args[spawn.args.indexOf("--extension") + 1]; - assert.isTrue(extension?.endsWith("pi-t3-mcp-extension.ts")); + const extensions = spawn.args.flatMap((arg, index) => + arg === "--extension" ? [spawn.args[index + 1]] : [], + ); + assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-subagent-extension.ts"))); + assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); }).pipe( @@ -345,6 +349,13 @@ describe("PiAdapterV2", () => { }); assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); assert.equal(providerThread.driver, PI_PROVIDER); + const spawn = fake.lastSpawn(); + assert.isTrue( + spawn.args.some( + (arg, index) => + arg === "--extension" && spawn.args[index + 1]?.endsWith("pi-t3-subagent-extension.ts"), + ), + ); yield* runtime.resumeThread({ providerThread }); const switchRequest = yield* fake.takeRequest("switch_session"); @@ -715,6 +726,7 @@ describe("PiAdapterV2", () => { task: "map the repo", exitCode: 0, stderr: "", + sessionFile: "/tmp/pi-children/scout.jsonl", messages: [ { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, ], @@ -723,6 +735,23 @@ describe("PiAdapterV2", () => { }, }, }); + const childThread = yield* takeEvent((event) => event.type === "app_thread.created"); + if (childThread.type !== "app_thread.created") { + assert.fail("expected app_thread.created"); + return; + } + const childThreadId = childThread.appThread.id; + const childProviderThread = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.appThreadId === childThreadId, + ); + assert.isTrue( + childProviderThread.type === "provider_thread.updated" && + childProviderThread.providerThread.nativeThreadRef?.nativeId === + "/tmp/pi-children/scout.jsonl" && + childProviderThread.providerThread.providerSessionId === null, + ); const running = yield* takeEvent( (event) => event.type === "subagent.updated" && event.subagent.status === "running", ); @@ -730,7 +759,8 @@ describe("PiAdapterV2", () => { running.type === "subagent.updated" && running.subagent.title === "scout" && running.subagent.prompt === "map the repo" && - running.subagent.progress === "scanning files", + running.subagent.progress === "scanning files" && + running.subagent.childThreadId === childThreadId, ); yield* fake.emit({ type: "tool_execution_end", @@ -748,6 +778,7 @@ describe("PiAdapterV2", () => { exitCode: 0, stopReason: "stop", stderr: "", + sessionFile: "/tmp/pi-children/scout.jsonl", messages: [ { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, ], @@ -767,7 +798,22 @@ describe("PiAdapterV2", () => { (event) => event.type === "subagent.updated" && event.subagent.status === "completed", ); assert.isTrue( - doneCard.type === "subagent.updated" && doneCard.subagent.result === "repo has one file", + doneCard.type === "subagent.updated" && + doneCard.subagent.result === "repo has one file" && + doneCard.subagent.childThreadId === childThreadId, + ); + // Completed turn_item is emitted immediately after the completed card; + // waiting for the failed card first would consume it. + const subagentItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status === "completed", + ); + assert.isTrue( + subagentItem.type === "turn_item.updated" && + subagentItem.turnItem.type === "subagent" && + subagentItem.turnItem.childThreadId === childThreadId, ); const failedCard = yield* takeEvent( (event) => event.type === "subagent.updated" && event.subagent.status === "failed", @@ -775,12 +821,9 @@ describe("PiAdapterV2", () => { assert.isTrue( failedCard.type === "subagent.updated" && failedCard.subagent.title === "worker" && - failedCard.subagent.result === "boom", - ); - const subagentItem = yield* takeEvent( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "subagent", + failedCard.subagent.result === "boom" && + failedCard.subagent.childThreadId === null, ); - assert.equal(subagentItem.type, "turn_item.updated"); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 1ce98aaa3d65..4227a0eb088d 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -36,6 +36,7 @@ import { type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type ThreadId, type OrchestrationV2RuntimeRequest, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, @@ -90,13 +91,22 @@ import { } from "../ProviderAdapterDriver.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "../SubagentProjection.ts"; import { makePiRpcConnection, parsePiModelSlug, type PiRpcConnection, type PiRpcRecord, } from "./PiRpc.ts"; -import { buildPiRpcLaunch, materializePiT3McpExtension } from "./piT3McpInjection.ts"; +import { + buildPiRpcLaunch, + materializePiT3McpExtension, + materializePiT3SubagentExtension, +} from "./piT3McpInjection.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); export const PI_DRIVER_KIND = PI_PROVIDER; @@ -175,11 +185,11 @@ export const PiProviderCapabilitiesV2 = { planDeltasHaveItemIds: false, }, subagents: { - // Pi has no core subagents; the official subagent extension delegates to - // separate pi processes through a tool, and the adapter projects its - // per-task progress into native subagent lifecycle events when present. + // Pi has no core subagents; the T3-owned `subagent` override persists a + // session file per task so each child is a resumeable T3 thread. The + // official tool is omitted because a second `subagent` registration aborts Pi. supportsSubagents: true, - exposesSubagentThreadIds: false, + exposesSubagentThreadIds: true, emitsSubagentLifecycle: true, canWaitForSubagents: false, canCloseSubagents: false, @@ -287,6 +297,7 @@ interface ActivePiTurn { * tool keeps one start timestamp and reports a real duration. */ readonly toolStartedAt: Map; + readonly childSubagents: Map; interrupted: boolean; /** * Whether any agent run activity was observed. Command-only prompts (pure @@ -298,6 +309,16 @@ interface ActivePiTurn { failure: ReturnType | null; } +interface PiChildSubagent { + readonly nativeTaskId: string; + readonly sessionFile: string; + readonly childThreadId: ThreadId; + readonly childProviderThreadId: OrchestrationV2ProviderThread["id"]; + readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; + emittedUserPrompt: boolean; + emittedMessageCount: number; +} + interface PendingPiPrompt { readonly nativeRequestId: string; readonly method: "select" | "confirm" | "input" | "editor"; @@ -335,25 +356,33 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const scope = yield* Effect.scope; const cwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const provideCacheFs = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); const extensionPath = mcpSession === undefined ? undefined - : yield* materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir).pipe( - Effect.provideService(FileSystem.FileSystem, options.fileSystem), - Effect.mapError( - (cause) => - new ProviderAdapterOpenSessionError({ - driver: PI_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ), + : yield* provideCacheFs( + materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir), ); + const subagentExtensionPath = yield* provideCacheFs( + materializePiT3SubagentExtension(options.serverConfig.providerStatusCacheDir), + ); const launch = buildPiRpcLaunch({ launchArgs: options.settings.launchArgs, environment: options.environment, mcpSession, extensionPath, + subagentExtensionPath, }); const hasT3Mcp = launch.hasT3Mcp; const connection: PiRpcConnection = yield* makePiRpcConnection({ @@ -734,12 +763,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); /** - * Project the official pi subagent extension's per-task progress into - * v2's native subagent surface. The extension reports - * `details: { results: [{agent, task, exitCode, stopReason, messages, - * step?, model?}] }` on every tool update, so each delegated task - * becomes a first-class subagent card with live progress. Tolerant by - * design: any other tool named `subagent` without that shape is simply + * Project the T3-owned pi subagent override's per-task progress into + * v2's native subagent surface. Each result may include `sessionFile`; + * when present the adapter binds a resumeable child thread. Tolerant by + * design: any other tool named `subagent` without the results shape is * ignored. */ const emitSubagentTasks = Effect.fnUntraced(function* ( @@ -751,6 +778,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const results = recordField(recordField(resultRecord, "details"), "results"); if (!Array.isArray(results)) return; const emittedAt = yield* DateTime.now; + const parentNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: toolCallId, + }); for (const [index, result] of results.entries()) { const agent = recordString(result, "agent"); const task = recordString(result, "task"); @@ -764,11 +795,6 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S turn.toolStartedAt.set(nativeTaskId, startedAt); const stopReason = recordString(result, "stopReason"); const exitCode = recordNumber(result, "exitCode") ?? 0; - // A non-zero exit code is a failure whether or not the parent tool - // has ended, matching `piSubagentOutput`. Gating it on `completed` - // let a finished child report "completed" while its result text was - // the stderr of a failure. An aborted child (user Stop) presents as - // interrupted, matching the run and tool cards. const interrupted = stopReason === "aborted"; const failed = !interrupted && (exitCode !== 0 || stopReason === "error"); const finished = completed || interrupted || failed || stopReason !== undefined; @@ -780,7 +806,208 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ? "completed" : "running"; const outputText = piSubagentOutput(result); - const title = recordString(result, "step") === undefined ? agent : `${agent}`; + const title = agent; + const sessionFile = recordString(result, "sessionFile"); + let child = turn.childSubagents.get(nativeTaskId); + if (sessionFile !== undefined && child === undefined) { + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: PI_PROVIDER, + nativeThreadId: sessionFile, + }); + const childProviderThreadId = idAllocator.derive.providerThread({ + driver: PI_PROVIDER, + nativeThreadId: sessionFile, + }); + const childRootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: `${nativeTaskId}:child-root`, + }); + child = { + nativeTaskId, + sessionFile, + childThreadId, + childProviderThreadId, + childRootNodeId, + emittedUserPrompt: false, + emittedMessageCount: 0, + }; + turn.childSubagents.set(nativeTaskId, child); + const childModelSelection = { + ...turn.turnInput.modelSelection, + model: recordString(result, "model") ?? turn.turnInput.modelSelection.model, + }; + const childThread = makeSubagentChildThread({ + parentThread: turn.turnInput.appThread, + childThreadId, + parentNodeId, + activeProviderThreadId: childProviderThreadId, + providerInstanceId: options.instanceId, + modelSelection: childModelSelection, + title: subagentThreadTitle({ + parentTitle: turn.turnInput.appThread.title, + title, + prompt: task, + ordinal: index + 1, + }), + now: emittedAt, + createdBy: "agent", + creationSource: "provider", + }); + // Null session id so a later send allocates a fresh RPC. Pi cannot + // host two threads on the parent process; resume uses switch_session + // against nativeThreadRef on that new session. + const childProviderThread: OrchestrationV2ProviderThread = { + id: childProviderThreadId, + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: null, + appThreadId: childThreadId, + ownerNodeId: parentNodeId, + nativeThreadRef: providerRef(sessionFile), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: { + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + }, + pendingBackgroundTasks: [], + createdAt: emittedAt, + updatedAt: emittedAt, + }; + yield* emit({ + type: "app_thread.created", + driver: PI_PROVIDER, + appThread: childThread, + }); + yield* emit({ + type: "provider_thread.updated", + driver: PI_PROVIDER, + providerThread: childProviderThread, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { + id: childRootNodeId, + threadId: childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: childProviderThreadId, + providerTurnId: null, + nativeItemRef: providerRef(sessionFile), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }, + }); + } + if (child !== undefined && !child.emittedUserPrompt) { + child.emittedUserPrompt = true; + const promptArtifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: `${nativeTaskId}:prompt`, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: `${nativeTaskId}:prompt`, + }), + threadId: child.childThreadId, + rootNodeId: child.childRootNodeId, + providerThreadId: child.childProviderThreadId, + providerTurnId: null, + nativeItemRef: providerRef(`${nativeTaskId}:prompt`), + role: "user", + text: task, + ordinal: 100, + now: emittedAt, + }); + yield* emit({ + type: "message.updated", + driver: PI_PROVIDER, + message: promptArtifacts.message, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: promptArtifacts.turnItem, + }); + } + if (child !== undefined) { + const messages = recordField(result, "messages"); + if (Array.isArray(messages)) { + for (let messageIndex = child.emittedMessageCount; messageIndex < messages.length; ) { + const message = messages[messageIndex]; + messageIndex += 1; + child.emittedMessageCount = messageIndex; + if (recordString(message, "role") !== "assistant") continue; + const text = contentText(recordField(message, "content")); + if (text.length === 0) continue; + const nativeMessageId = `${nativeTaskId}:assistant:${messageIndex}`; + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: nativeMessageId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: nativeMessageId, + }), + threadId: child.childThreadId, + rootNodeId: child.childRootNodeId, + providerThreadId: child.childProviderThreadId, + providerTurnId: null, + nativeItemRef: providerRef(nativeMessageId), + role: "assistant", + text, + ordinal: 100 + messageIndex, + now: emittedAt, + }); + yield* emit({ + type: "message.updated", + driver: PI_PROVIDER, + message: artifacts.message, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: artifacts.turnItem, + }); + } + } + if (finished) { + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { + id: child.childRootNodeId, + threadId: child.childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: child.childRootNodeId, + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: child.childProviderThreadId, + providerTurnId: null, + nativeItemRef: providerRef(child.sessionFile), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: emittedAt, + }, + }); + } + } + const childThreadId = child?.childThreadId ?? null; yield* emit({ type: "subagent.updated", driver: PI_PROVIDER, @@ -788,16 +1015,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S id: subagentId, threadId: turn.turnInput.threadId, runId: turn.turnInput.runId, - parentNodeId: idAllocator.derive.nodeFromProviderItem({ - driver: PI_PROVIDER, - nativeItemId: toolCallId, - }), + parentNodeId, origin: "provider_native", createdBy: "agent", driver: PI_PROVIDER, providerInstanceId: options.instanceId, providerThreadId: turn.turnInput.providerThread.id, - childThreadId: null, + childThreadId, nativeTaskRef: providerRef(nativeTaskId), prompt: task, title, @@ -825,7 +1049,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S origin: "provider_native", driver: PI_PROVIDER, providerInstanceId: options.instanceId, - childThreadId: null, + childThreadId, prompt: task, ...(finished || outputText.length === 0 ? {} @@ -1684,6 +1908,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S streamItems: new Map(), toolArgs: new Map(), toolStartedAt: new Map(), + childSubagents: new Map(), interrupted: false, sawAgentActivity: false, failure: null, diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 21c81f299d02..f3e449714765 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -9,11 +9,19 @@ import { T3_MCP_BEARER_ENV, T3_MCP_URL_ENV, } from "./piT3McpExtensionSource.ts"; +import { + PI_T3_SUBAGENT_EXTENSION_FILENAME, + T3_PI_CHILD_SESSION_ROOT_ENV, +} from "./piT3SubagentExtensionSource.ts"; import { bearerTokenFromAuthorizationHeader, buildPiRpcLaunch, + isConflictingPiSubagentExtensionPath, materializePiT3McpExtension, + materializePiT3SubagentExtension, + piChildSessionRootFromLaunchArgs, piT3McpExtensionDestPath, + piT3SubagentExtensionDestPath, } from "./piT3McpInjection.ts"; const threadId = ThreadId.make("thread-pi-t3-mcp"); @@ -46,6 +54,41 @@ describe("pi T3 MCP injection", () => { assert.isUndefined(launch.env[T3_MCP_URL_ENV]); }); + it("identifies official subagent paths and keeps the T3 override", () => { + assert.isTrue( + isConflictingPiSubagentExtensionPath("/opt/pi/examples/extensions/subagent/index.ts"), + ); + assert.isTrue( + isConflictingPiSubagentExtensionPath("/home/user/.pi/agent/extensions/subagent/index.ts"), + ); + assert.isFalse(isConflictingPiSubagentExtensionPath("/tmp/cache/pi-t3-subagent-extension.ts")); + }); + + it("prepends the subagent override and drops the official tool", () => { + const launch = buildPiRpcLaunch({ + launchArgs: + "--session-dir /tmp/pi-sessions --extension /opt/pi/examples/extensions/subagent/index.ts", + environment: { PATH: "/usr/bin" }, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + subagentExtensionPath: "/tmp/cache/pi-t3-subagent-extension.ts", + }); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--no-extensions", + "--extension", + "/tmp/cache/pi-t3-subagent-extension.ts", + "--session-dir", + "/tmp/pi-sessions", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + assert.equal(launch.env[T3_PI_CHILD_SESSION_ROOT_ENV], "/tmp/pi-sessions/children"); + assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); + assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); + }); + it("appends --extension and scoped env when a session exists", () => { const launch = buildPiRpcLaunch({ launchArgs: "--session-dir /tmp/pi-sessions", @@ -81,6 +124,14 @@ describe("pi T3 MCP injection", () => { ]); }); + it("derives the child session root from --session-dir", () => { + assert.equal( + piChildSessionRootFromLaunchArgs("--session-dir /tmp/pi-sessions --extension x.ts"), + "/tmp/pi-sessions/children", + ); + assert.isUndefined(piChildSessionRootFromLaunchArgs("")); + }); + it.effect("writes the extension source to the cache directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -97,4 +148,19 @@ describe("pi T3 MCP injection", () => { assert.equal(again, dest); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("writes the subagent override source to the cache directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-t3-subagent-" }); + const dest = yield* materializePiT3SubagentExtension(cacheDir); + assert.equal(dest, piT3SubagentExtensionDestPath(cacheDir)); + assert.isTrue(dest.endsWith(PI_T3_SUBAGENT_EXTENSION_FILENAME)); + const source = yield* fs.readFileString(dest); + assert.include(source, "export default function t3SubagentExtension"); + assert.include(source, "--session"); + assert.include(source, T3_PI_CHILD_SESSION_ROOT_ENV); + assert.isFalse(source.includes("--no-session")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 9e07088317fc..0637de2ca4e5 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -9,8 +9,19 @@ import { T3_MCP_BEARER_ENV, T3_MCP_URL_ENV, } from "./piT3McpExtensionSource.ts"; +import { + PI_T3_SUBAGENT_EXTENSION_FILENAME, + PI_T3_SUBAGENT_EXTENSION_SOURCE, + T3_PI_CHILD_SESSION_ROOT_ENV, +} from "./piT3SubagentExtensionSource.ts"; -export { PI_T3_MCP_EXTENSION_FILENAME, T3_MCP_BEARER_ENV, T3_MCP_URL_ENV }; +export { + PI_T3_MCP_EXTENSION_FILENAME, + PI_T3_SUBAGENT_EXTENSION_FILENAME, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, + T3_PI_CHILD_SESSION_ROOT_ENV, +}; export function bearerTokenFromAuthorizationHeader(header: string): string { return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; @@ -20,6 +31,18 @@ export function piT3McpExtensionDestPath(cacheDir: string): string { return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; } +export function piT3SubagentExtensionDestPath(cacheDir: string): string { + return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_SUBAGENT_EXTENSION_FILENAME}`; +} + +export function piChildSessionRootFromLaunchArgs(launchArgs: string): string | undefined { + const args = tokenizeCliArgs(launchArgs); + const index = args.indexOf("--session-dir"); + const sessionDir = index >= 0 ? args[index + 1] : undefined; + if (sessionDir === undefined || sessionDir.length === 0) return undefined; + return `${sessionDir.replace(/\\/g, "/")}/children`; +} + export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( cacheDir: string, ) { @@ -33,11 +56,71 @@ export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtensio return dest; }); +export const materializePiT3SubagentExtension = Effect.fn("materializePiT3SubagentExtension")( + function* (cacheDir: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(cacheDir, { recursive: true }); + const dest = piT3SubagentExtensionDestPath(cacheDir); + const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); + if (existing !== PI_T3_SUBAGENT_EXTENSION_SOURCE) { + yield* fs.writeFileString(dest, PI_T3_SUBAGENT_EXTENSION_SOURCE); + } + return dest; + }, +); + +function appendExtensionArg( + args: ReadonlyArray, + extensionPath: string | undefined, +): string[] { + if (extensionPath === undefined) return [...args]; + const alreadyHas = args.some( + (arg, index) => (arg === "--extension" || arg === "-e") && args[index + 1] === extensionPath, + ); + return alreadyHas ? [...args] : [...args, "--extension", extensionPath]; +} + +function normalizePiPath(value: string): string { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +/** Official / user-installed `subagent` tool. Not the T3 override file. */ +export function isConflictingPiSubagentExtensionPath(extensionPath: string): boolean { + const normalized = normalizePiPath(extensionPath); + if (normalized.endsWith(`/${PI_T3_SUBAGENT_EXTENSION_FILENAME}`)) return false; + return ( + normalized.endsWith("/extensions/subagent/index.ts") || + normalized.endsWith("/extensions/subagent/index.js") || + normalized.endsWith("/examples/extensions/subagent/index.ts") || + normalized.endsWith("/examples/extensions/subagent/index.js") + ); +} + +export function stripConflictingPiSubagentExtensionArgs(args: ReadonlyArray): string[] { + const stripped: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + const next = args[index + 1]; + if ( + (arg === "--extension" || arg === "-e") && + next !== undefined && + isConflictingPiSubagentExtensionPath(next) + ) { + index += 1; + continue; + } + stripped.push(arg); + } + return stripped; +} + export function buildPiRpcLaunch(input: { readonly launchArgs: string; readonly environment: NodeJS.ProcessEnv; readonly mcpSession: McpProviderSessionConfig | undefined; readonly extensionPath: string | undefined; + readonly subagentExtensionPath?: string | undefined; }): { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -45,24 +128,39 @@ export function buildPiRpcLaunch(input: { } { const userArgs = tokenizeCliArgs(input.launchArgs); const hasT3Mcp = input.mcpSession !== undefined && input.extensionPath !== undefined; - if (!hasT3Mcp || input.mcpSession === undefined || input.extensionPath === undefined) { - return { args: ["--mode", "rpc", ...userArgs], env: input.environment, hasT3Mcp: false }; + // Duplicate `subagent` registrations abort Pi. Disable discovery and drop + // the official tool from launchArgs so only the T3 override remains. + let args = ["--mode", "rpc"]; + if (input.subagentExtensionPath !== undefined) { + if (!userArgs.includes("--no-extensions") && !userArgs.includes("-ne")) { + args.push("--no-extensions"); + } + args = appendExtensionArg(args, input.subagentExtensionPath); + args = [...args, ...stripConflictingPiSubagentExtensionArgs(userArgs)]; + } else { + args = [...args, ...userArgs]; + } + if (hasT3Mcp && input.extensionPath !== undefined) { + args = appendExtensionArg(args, input.extensionPath); } - const alreadyHasExtension = userArgs.some( - (arg, index) => arg === "--extension" && userArgs[index + 1] === input.extensionPath, - ); - const args = alreadyHasExtension - ? ["--mode", "rpc", ...userArgs] - : ["--mode", "rpc", ...userArgs, "--extension", input.extensionPath]; - + const childSessionRoot = piChildSessionRootFromLaunchArgs(input.launchArgs); return { args, env: { ...input.environment, - [T3_MCP_URL_ENV]: input.mcpSession.endpoint, - [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader(input.mcpSession.authorizationHeader), + ...(input.subagentExtensionPath === undefined || childSessionRoot === undefined + ? {} + : { [T3_PI_CHILD_SESSION_ROOT_ENV]: childSessionRoot }), + ...(hasT3Mcp && input.mcpSession !== undefined + ? { + [T3_MCP_URL_ENV]: input.mcpSession.endpoint, + [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( + input.mcpSession.authorizationHeader, + ), + } + : {}), }, - hasT3Mcp: true, + hasT3Mcp, }; } diff --git a/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts new file mode 100644 index 000000000000..3b32dfd83533 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts @@ -0,0 +1,561 @@ +/** + * T3-owned override of the official Pi `subagent` tool. + * + * Official spawn is `pi --mode json -p --no-session`, so there is no session + * file to bind as a T3 child thread. This copy persists `--session ` and + * reports `sessionFile` on each result so the adapter can resume it. + * + * Loaded via CLI `--extension`. Pi aborts if two extensions register + * `subagent`, so the launcher omits the official tool. + */ +export const PI_T3_SUBAGENT_EXTENSION_FILENAME = "pi-t3-subagent-extension.ts"; + +export const T3_PI_CHILD_SESSION_ROOT_ENV = "T3_PI_CHILD_SESSION_ROOT"; + +export const PI_T3_SUBAGENT_EXTENSION_SOURCE = `\ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomBytes } from "node:crypto"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { + CONFIG_DIR_NAME, + type ExtensionAPI, + getAgentDir, + parseFrontmatter, +} from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const SESSION_ROOT_ENV = ${JSON.stringify(T3_PI_CHILD_SESSION_ROOT_ENV)}; +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; + +type AgentScope = "user" | "project" | "both"; + +type AgentConfig = { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: "user" | "project"; +}; + +type SingleResult = { + agent: string; + agentSource: "user" | "project" | "unknown"; + task: string; + exitCode: number; + messages: unknown[]; + stderr: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens: number; + turns: number; + }; + model?: string; + stopReason?: string; + errorMessage?: string; + step?: number; + sessionFile?: string; +}; + +function parseToolList(value: unknown): string[] | undefined { + const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; + const tools = raw + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} + +function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { + if (!fs.existsSync(dir)) return []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const agents: AgentConfig[] = []; + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + const filePath = path.join(dir, entry.name); + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + const { frontmatter, body } = parseFrontmatter<{ + name?: unknown; + description?: unknown; + tools?: unknown; + model?: unknown; + }>(content); + if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") { + continue; + } + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: parseToolList(frontmatter.tools), + model: typeof frontmatter.model === "string" ? frontmatter.model : undefined, + systemPrompt: body, + source, + }); + } + return agents; +} + +function discoverAgents(cwd: string, scope: AgentScope) { + const userDir = path.join(getAgentDir(), "agents"); + let projectAgentsDir: string | null = null; + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents"); + try { + if (fs.statSync(candidate).isDirectory()) { + projectAgentsDir = candidate; + break; + } + } catch { + /* keep walking */ + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user"); + const projectAgents = + scope === "user" || projectAgentsDir === null + ? [] + : loadAgentsFromDir(projectAgentsDir, "project"); + const agentMap = new Map(); + if (scope === "both") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } else if (scope === "user") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + } else { + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + return { agents: Array.from(agentMap.values()), projectAgentsDir }; +} + +function getFinalOutput(messages: unknown[]): string { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] as { role?: string; content?: unknown }; + if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + const record = part as { type?: string; text?: string }; + if (record.type === "text" && typeof record.text === "string" && record.text.length > 0) { + return record.text; + } + } + } + return ""; +} + +function isFailedResult(result: SingleResult): boolean { + return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; +} + +function getResultOutput(result: SingleResult): string { + if (isFailedResult(result)) { + return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + } + return getFinalOutput(result.messages) || "(no output)"; +} + +function getPiInvocation(args: string[]): { command: string; args: string[] } { + const currentScript = process.argv[1]; + const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); + if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { + return { command: process.execPath, args: [currentScript, ...args] }; + } + const execName = path.basename(process.execPath).toLowerCase(); + if (/^(node|bun)(\\.exe)?$/.test(execName)) return { command: "pi", args }; + return { command: process.execPath, args }; +} + +function childSessionFile(): string { + const root = + process.env[SESSION_ROOT_ENV] && process.env[SESSION_ROOT_ENV]!.length > 0 + ? process.env[SESSION_ROOT_ENV]! + : path.join(getAgentDir(), "sessions"); + fs.mkdirSync(root, { recursive: true }); + const id = randomBytes(6).toString("hex"); + return path.join(root, \`t3-subagent-\${Date.now()}-\${id}.jsonl\`); +} + +async function runSingleAgent( + defaultCwd: string, + defaults: { model?: string; thinkingLevel?: string }, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: ((partial: { content: { type: "text"; text: string }[]; details: { results: SingleResult[] } }) => void) | undefined, +): Promise { + const agent = agents.find((entry) => entry.name === agentName); + if (!agent) { + const available = agents.map((entry) => \`"\${entry.name}"\`).join(", ") || "none"; + return { + agent: agentName, + agentSource: "unknown", + task, + exitCode: 1, + messages: [], + stderr: \`Unknown agent: "\${agentName}". Available agents: \${available}.\`, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + step, + }; + } + + const sessionFile = childSessionFile(); + const args: string[] = [ + "--mode", + "json", + "-p", + "--session", + sessionFile, + "--name", + \`t3-subagent \${agentName}\`, + ]; + const model = agent.model ?? defaults.model; + if (model) args.push("--model", model); + if (!agent.model && defaults.thinkingLevel) args.push("--thinking", defaults.thinkingLevel); + if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); + + let tmpPromptDir: string | null = null; + const currentResult: SingleResult = { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + model, + step, + sessionFile, + }; + + const emitUpdate = () => { + onUpdate?.({ + content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], + details: { results: [currentResult] }, + }); + }; + emitUpdate(); + + try { + if (agent.systemPrompt.trim()) { + tmpPromptDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-t3-subagent-")); + const promptPath = path.join(tmpPromptDir, "prompt.md"); + await fs.promises.writeFile(promptPath, agent.systemPrompt, { encoding: "utf-8", mode: 0o600 }); + args.push("--append-system-prompt", promptPath); + } + args.push(\`Task: \${task}\`); + + const exitCode = await new Promise((resolve) => { + const invocation = getPiInvocation(args); + const proc = spawn(invocation.command, invocation.args, { + cwd: cwd ?? defaultCwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let buffer = ""; + const processLine = (line: string) => { + if (!line.trim()) return; + let event: { type?: string; message?: unknown }; + try { + event = JSON.parse(line) as { type?: string; message?: unknown }; + } catch { + return; + } + if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) { + currentResult.messages.push(event.message); + const message = event.message as { + role?: string; + usage?: Record; + model?: string; + stopReason?: string; + errorMessage?: string; + }; + if (message.role === "assistant") { + currentResult.usage.turns += 1; + if (message.usage) { + currentResult.usage.input += message.usage.input || 0; + currentResult.usage.output += message.usage.output || 0; + currentResult.usage.cacheRead += message.usage.cacheRead || 0; + currentResult.usage.cacheWrite += message.usage.cacheWrite || 0; + const cost = message.usage.cost as number | { total?: number } | undefined; + currentResult.usage.cost += + typeof cost === "number" ? cost : typeof cost?.total === "number" ? cost.total : 0; + currentResult.usage.contextTokens = message.usage.totalTokens || 0; + } + if (!currentResult.model && message.model) currentResult.model = message.model; + if (message.stopReason) currentResult.stopReason = message.stopReason; + if (message.errorMessage) currentResult.errorMessage = message.errorMessage; + } + emitUpdate(); + } + }; + proc.stdout?.setEncoding("utf8"); + proc.stdout?.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) processLine(line); + }); + proc.stderr?.setEncoding("utf8"); + proc.stderr?.on("data", (chunk: string) => { + currentResult.stderr += chunk; + }); + const onAbort = () => { + currentResult.stopReason = "aborted"; + proc.kill("SIGTERM"); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + proc.on("close", (code) => { + signal?.removeEventListener("abort", onAbort); + if (buffer.trim()) processLine(buffer); + resolve(code ?? 1); + }); + proc.on("error", (error) => { + currentResult.stderr += error.message; + resolve(1); + }); + }); + currentResult.exitCode = exitCode; + return currentResult; + } finally { + if (tmpPromptDir) { + await fs.promises.rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined); + } + } +} + +const TaskItem = Type.Object({ + agent: Type.String(), + task: Type.String(), + cwd: Type.Optional(Type.String()), +}); + +export default function t3SubagentExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: + "Delegate tasks to specialized Pi subagents with isolated context. Each child persists a Pi session that T3 can open and continue.", + parameters: Type.Object({ + agent: Type.Optional(Type.String()), + task: Type.Optional(Type.String()), + tasks: Type.Optional(Type.Array(TaskItem)), + chain: Type.Optional(Type.Array(TaskItem)), + agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const)), + confirmProjectAgents: Type.Optional(Type.Boolean()), + cwd: Type.Optional(Type.String()), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = params.agentScope ?? "user"; + const discovery = discoverAgents(ctx.cwd, agentScope); + const agents = discovery.agents; + const defaults = { + model: ctx.model ? \`\${ctx.model.provider}/\${ctx.model.id}\` : undefined, + thinkingLevel: ctx.thinkingLevel, + }; + const confirmProjectAgents = params.confirmProjectAgents ?? true; + const hasChain = (params.chain?.length ?? 0) > 0; + const hasTasks = (params.tasks?.length ?? 0) > 0; + const hasSingle = Boolean(params.agent && params.task); + const makeDetails = (results: SingleResult[]) => ({ + mode: hasChain ? "chain" : hasTasks ? "parallel" : "single", + agentScope, + projectAgentsDir: discovery.projectAgentsDir, + results, + }); + + if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) { + const available = agents.map((agent) => agent.name).join(", ") || "none"; + return { + content: [{ type: "text", text: \`Invalid parameters. Available agents: \${available}\` }], + details: makeDetails([]), + }; + } + + if ( + (agentScope === "project" || agentScope === "both") && + confirmProjectAgents && + ctx.hasUI + ) { + const names = new Set(); + if (params.chain) for (const step of params.chain) names.add(step.agent); + if (params.tasks) for (const item of params.tasks) names.add(item.agent); + if (params.agent) names.add(params.agent); + const projectRequested = agents.filter( + (agent) => agent.source === "project" && names.has(agent.name), + ); + if (projectRequested.length > 0) { + const ok = await ctx.ui.confirm( + "Run project-local agents?", + \`Agents: \${projectRequested.map((agent) => agent.name).join(", ")}\\nSource: \${discovery.projectAgentsDir ?? "(unknown)"}\`, + ); + if (!ok) { + return { + content: [{ type: "text", text: "Canceled: project-local agents not approved." }], + details: makeDetails([]), + }; + } + } + } + + if (params.chain && params.chain.length > 0) { + const results: SingleResult[] = []; + let previousOutput = ""; + for (let index = 0; index < params.chain.length; index += 1) { + const step = params.chain[index]; + const result = await runSingleAgent( + ctx.cwd, + defaults, + agents, + step.agent, + step.task.replace(/\\{previous\\}/g, previousOutput), + step.cwd, + index + 1, + signal, + onUpdate + ? (partial) => { + const current = partial.details?.results[0]; + if (current) onUpdate({ ...partial, details: makeDetails([...results, current]) }); + } + : undefined, + ); + results.push(result); + if (isFailedResult(result)) { + return { + content: [ + { + type: "text", + text: \`Chain stopped at step \${index + 1} (\${step.agent}): \${getResultOutput(result)}\`, + }, + ], + details: makeDetails(results), + isError: true, + }; + } + previousOutput = getFinalOutput(result.messages); + } + return { + content: [ + { + type: "text", + text: getFinalOutput(results[results.length - 1]?.messages ?? []) || "(no output)", + }, + ], + details: makeDetails(results), + }; + } + + if (params.tasks && params.tasks.length > 0) { + if (params.tasks.length > MAX_PARALLEL_TASKS) { + return { + content: [{ type: "text", text: \`Too many parallel tasks (\${params.tasks.length}).\` }], + details: makeDetails([]), + }; + } + const allResults: SingleResult[] = params.tasks.map((item) => ({ + agent: item.agent, + agentSource: "unknown", + task: item.task, + exitCode: -1, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + })); + const emitParallel = () => { + onUpdate?.({ + content: [{ type: "text", text: "Parallel tasks running..." }], + details: makeDetails([...allResults]), + }); + }; + let nextIndex = 0; + const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, params.tasks.length) }, async () => { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= params.tasks.length) return; + const item = params.tasks[index]; + const result = await runSingleAgent( + ctx.cwd, + defaults, + agents, + item.agent, + item.task, + item.cwd, + undefined, + signal, + (partial) => { + if (partial.details?.results[0]) { + allResults[index] = partial.details.results[0]; + emitParallel(); + } + }, + ); + allResults[index] = result; + emitParallel(); + } + }); + await Promise.all(workers); + const successCount = allResults.filter((result) => !isFailedResult(result)).length; + return { + content: [ + { + type: "text", + text: \`Parallel: \${successCount}/\${allResults.length} succeeded\`, + }, + ], + details: makeDetails(allResults), + }; + } + + const result = await runSingleAgent( + ctx.cwd, + defaults, + agents, + params.agent as string, + params.task as string, + params.cwd, + undefined, + signal, + onUpdate + ? (partial) => onUpdate({ ...partial, details: makeDetails(partial.details.results) }) + : undefined, + ); + const failed = isFailedResult(result); + return { + content: [ + { + type: "text", + text: failed + ? \`Agent \${result.stopReason || "failed"}: \${getResultOutput(result)}\` + : getFinalOutput(result.messages) || "(no output)", + }, + ], + details: makeDetails([result]), + ...(failed ? { isError: true } : {}), + }; + }, + }); +} +`; diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 9d27dc162f9e..dc7ba1c1abf0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -148,6 +148,12 @@ one with `pi.registerTool` under its original name (`delegate_task`, without it. User `launchArgs` are preserved. The first turn of a session also receives the shared T3 orchestration instructions. +A second T3-owned extension overrides the official `subagent` tool to +persist `--session` and report `sessionFile`. Duplicate `subagent` +registrations abort Pi, so the launcher disables extension discovery and +drops the official tool from `launchArgs`. The adapter binds each result +as a child thread that later sends resume through `switch_session`. + ### Initial Provider Support The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, Grok From 929354f47fcc2fc958d0b9628636344766731519 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 16 Aug 2026 22:18:02 -0400 Subject: [PATCH 21/41] feat(pi): advertise per-model thinking levels including Extra High Pi discovery used one High-capped picker for every reasoning model. get_available_thinking_levels is session-scoped, so catalog discovery now reads each get_available_models thinkingLevelMap instead. Extra High and Max appear only when the map has a non-null entry. --- apps/server/src/provider/Layers/PiProvider.ts | 44 ++-------- .../Layers/piThinkingCapabilities.test.ts | 87 ++++++++++++++++++ .../provider/Layers/piThinkingCapabilities.ts | 88 +++++++++++++++++++ 3 files changed, 182 insertions(+), 37 deletions(-) create mode 100644 apps/server/src/provider/Layers/piThinkingCapabilities.test.ts create mode 100644 apps/server/src/provider/Layers/piThinkingCapabilities.ts diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index ecdc9b276206..19cd59d0ac4c 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -9,7 +9,6 @@ * shows up in T3 without any hardcoded catalog. */ import { - type ModelCapabilities, type PiSettings, type ServerProvider, type ServerProviderModel, @@ -17,7 +16,6 @@ import { type ServerProviderSlashCommand, } from "@t3tools/contracts"; import { causeErrorTag } from "@t3tools/shared/observability"; -import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import * as DateTime from "effect/DateTime"; @@ -41,6 +39,10 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./piThinkingCapabilities.ts"; const PI_PRESENTATION = { displayName: "Pi", @@ -52,43 +54,12 @@ const PI_PRESENTATION = { const VERSION_PROBE_TIMEOUT_MS = 4_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -/** - * Reasoning-capable Pi models expose Pi's thinking levels. "Inherit" leaves - * the user's settings.json `defaultThinkingLevel` untouched. - * - * Only the levels every reasoning model accepts are advertised. Pi exposes - * `xhigh` and `max` per model (see `get_available_thinking_levels`), and - * offering them globally makes `set_thinking_level` fail on models that lack - * them, which blocks the turn from starting. - */ -const THINKING_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [ - { - id: "thinking", - label: "Thinking", - type: "select", - options: [ - { id: "inherit", label: "Pi default", isDefault: true }, - { id: "off", label: "Off" }, - { id: "minimal", label: "Minimal" }, - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - ], -}); - /** Deferring to the user's own settings.json default model. */ const PI_DEFAULT_MODEL: ServerProviderModel = { slug: "default", name: "Pi default", isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: EMPTY_PI_MODEL_CAPABILITIES, }; interface PiDiscovery { @@ -105,7 +76,7 @@ function piModelsFromSettings( return providerModelsFromSettings( [PI_DEFAULT_MODEL, ...discovered], customModels ?? [], - EMPTY_CAPABILITIES, + EMPTY_PI_MODEL_CAPABILITIES, ); } @@ -135,8 +106,7 @@ function parseDiscoveredModels(data: unknown): ReadonlyArray { + it("returns no levels when the model does not advertise reasoning", () => { + assert.deepEqual(supportedPiThinkingLevelsFromModel({ reasoning: false }), []); + assert.deepEqual(supportedPiThinkingLevelsFromModel({}), []); + }); + + it("advertises off through high without Extra High or Max when the map is absent", () => { + assert.deepEqual(supportedPiThinkingLevelsFromModel({ reasoning: true }), [ + "off", + "minimal", + "low", + "medium", + "high", + ]); + }); + + it("adds Extra High and Max only when the map has a non-null entry", () => { + assert.deepEqual( + supportedPiThinkingLevelsFromModel({ + reasoning: true, + thinkingLevelMap: { xhigh: "xhigh", max: "max" }, + }), + ["off", "minimal", "low", "medium", "high", "xhigh", "max"], + ); + }); + + it("hides a mapped-null level and keeps Extra High when only that entry exists", () => { + assert.deepEqual( + supportedPiThinkingLevelsFromModel({ + reasoning: true, + thinkingLevelMap: { off: null, xhigh: "extra_high" }, + }), + ["minimal", "low", "medium", "high", "xhigh"], + ); + }); + + it("does not treat a null Extra High or Max entry as supported", () => { + assert.deepEqual( + supportedPiThinkingLevelsFromModel({ + reasoning: true, + thinkingLevelMap: { xhigh: null, max: null }, + }), + ["off", "minimal", "low", "medium", "high"], + ); + }); +}); + +describe("thinkingCapabilitiesForPiModel", () => { + it("returns empty capabilities for a non-reasoning model", () => { + assert.deepEqual( + thinkingCapabilitiesForPiModel({ reasoning: false }), + EMPTY_PI_MODEL_CAPABILITIES, + ); + }); + + it("prepends inherit and labels Extra High for grok-4.6-shaped maps", () => { + const capabilities = thinkingCapabilitiesForPiModel({ + reasoning: true, + thinkingLevelMap: { xhigh: "xhigh" }, + }); + const descriptors = capabilities.optionDescriptors ?? []; + const thinking = descriptors[0]; + assert.equal(thinking?.id, "thinking"); + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepEqual( + thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), + [ + ["inherit", "Pi default", true], + ["off", "Off", false], + ["minimal", "Minimal", false], + ["low", "Low", false], + ["medium", "Medium", false], + ["high", "High", false], + ["xhigh", "Extra High", false], + ], + ); + }); +}); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.ts new file mode 100644 index 000000000000..f6ffed21ca90 --- /dev/null +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.ts @@ -0,0 +1,88 @@ +import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +/** + * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per + * model via `thinkingLevelMap`; advertising them globally makes + * `set_thinking_level` fail on models that lack them. + */ +export const PI_THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; + +const PI_THINKING_LEVEL_LABELS: Record = { + off: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +const INHERIT_CHOICE: ProviderOptionChoice = { + id: "inherit", + label: "Pi default", + isDefault: true, +}; + +export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export function thinkingCapabilitiesForPiModel(model: unknown): ModelCapabilities { + const levels = supportedPiThinkingLevelsFromModel(model); + if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + options: [ + INHERIT_CHOICE, + ...levels.map((level) => ({ + id: level, + label: PI_THINKING_LEVEL_LABELS[level], + })), + ], + }, + ], + }); +} + +/** + * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. + * + * A reasoning model always exposes off through high unless a map entry is + * `null`. Extra High and Max appear only when the map has a non-null entry. + */ +export function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { + if (recordField(model, "reasoning") !== true) return []; + const thinkingLevelMap = thinkingLevelMapFromModel(model); + return PI_THINKING_LEVELS.filter((level) => { + const mapped = thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }); +} + +function thinkingLevelMapFromModel(model: unknown): Record | undefined { + const value = recordField(model, "thinkingLevelMap"); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function recordField(input: unknown, key: string): unknown { + if (typeof input !== "object" || input === null) return undefined; + return (input as Record)[key]; +} From 56a57b29039dfc029f8293af42b476f52dc7f0f3 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:53:37 +0200 Subject: [PATCH 22/41] feat(server): restore pi user extensions and give subagent children T3 tools The T3 subagent override spawns pi with --no-extensions, which silently cost users every other extension they had installed. Re-discover the user's extensions (agent dir, plus project .pi/extensions only under standing trust) and re-add them with explicit --extension flags. Child subagent spawns now also attach the T3 MCP extension so t3_thread_* tools survive the nested spawn. Co-Authored-By: Claude Fable 5 --- .../Adapters/piT3McpInjection.test.ts | 74 ++++++++++++++++++ .../Adapters/piT3McpInjection.ts | 77 +++++++++++++++++++ .../Adapters/piT3SubagentExtensionSource.ts | 4 + 3 files changed, 155 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index f3e449714765..039daf4cd935 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -16,7 +16,9 @@ import { import { bearerTokenFromAuthorizationHeader, buildPiRpcLaunch, + discoverPiUserExtensions, isConflictingPiSubagentExtensionPath, + T3_PI_MCP_EXTENSION_PATH_ENV, materializePiT3McpExtension, materializePiT3SubagentExtension, piChildSessionRootFromLaunchArgs, @@ -160,7 +162,79 @@ describe("pi T3 MCP injection", () => { assert.include(source, "export default function t3SubagentExtension"); assert.include(source, "--session"); assert.include(source, T3_PI_CHILD_SESSION_ROOT_ENV); + // Children re-attach the T3 MCP extension so t3_thread_* tools survive + // the nested spawn (T3_MCP_* env is inherited from the parent). + assert.include(source, T3_PI_MCP_EXTENSION_PATH_ENV); assert.isFalse(source.includes("--no-session")); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it("re-adds discovered user extensions around the --no-extensions spawn", () => { + const launch = buildPiRpcLaunch({ + launchArgs: "--session-dir /tmp/pi-sessions", + environment: { PATH: "/usr/bin" }, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + subagentExtensionPath: "/tmp/cache/pi-t3-subagent-extension.ts", + discoveredExtensionPaths: ["/home/user/.pi/agent/extensions/demo.ts"], + }); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--no-extensions", + "--extension", + "/tmp/cache/pi-t3-subagent-extension.ts", + "--extension", + "/home/user/.pi/agent/extensions/demo.ts", + "--session-dir", + "/tmp/pi-sessions", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts"); + }); + + it.effect("discovers user extensions from the agent dir and skips the subagent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-home-" }); + const extensionsDir = `${home}/.pi/agent/extensions`; + yield* fs.makeDirectory(`${extensionsDir}/todos`, { recursive: true }); + yield* fs.makeDirectory(`${extensionsDir}/subagent`, { recursive: true }); + yield* fs.writeFileString(`${extensionsDir}/demo.ts`, "export default () => {}"); + yield* fs.writeFileString(`${extensionsDir}/subagent.ts`, "export default () => {}"); + yield* fs.writeFileString(`${extensionsDir}/todos/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${extensionsDir}/subagent/index.ts`, "export default () => {}"); + const found = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: undefined, + }); + assert.deepEqual(found, [`${extensionsDir}/demo.ts`, `${extensionsDir}/todos/index.ts`]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("includes project extensions only under standing project trust", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-home-" }); + const project = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-project-" }); + yield* fs.makeDirectory(`${home}/.pi/agent`, { recursive: true }); + yield* fs.makeDirectory(`${project}/.pi/extensions`, { recursive: true }); + yield* fs.writeFileString(`${project}/.pi/extensions/local.ts`, "export default () => {}"); + const untrusted = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: project, + }); + assert.deepEqual(untrusted, []); + yield* fs.writeFileString( + `${home}/.pi/agent/settings.json`, + '{ "defaultProjectTrust": "always" }', + ); + const trusted = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: project, + }); + assert.deepEqual(trusted, [`${project}/.pi/extensions/local.ts`]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 0637de2ca4e5..b29840991cab 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; @@ -23,10 +24,76 @@ export { T3_PI_CHILD_SESSION_ROOT_ENV, }; +/** Env var telling the T3 subagent override where the MCP extension lives. */ +export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH"; + export function bearerTokenFromAuthorizationHeader(header: string): string { return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; } +const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); + +function piDefaultProjectTrust(settingsRaw: string): string | undefined { + try { + const parsed = decodeJson(settingsRaw); + if (typeof parsed !== "object" || parsed === null) return undefined; + const value = (parsed as Record)["defaultProjectTrust"]; + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +/** + * Re-discover the user's pi extensions for a `--no-extensions` spawn: the + * subagent override forces discovery off (a second `subagent` registration + * aborts pi), which must not cost the user every other extension they have. + * Mirrors pi's own discovery roots: `/extensions/*.ts` and + * `/extensions//index.ts`, plus the project-local + * `.pi/extensions` only when the user's `defaultProjectTrust` is `always` — + * explicit `--extension` paths bypass pi's trust prompt, so anything short + * of standing trust must not be silently loaded. Entries named `subagent` + * are skipped in favor of the T3 override. + */ +export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(function* (input: { + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}) { + const fs = yield* FileSystem.FileSystem; + const home = input.environment["HOME"] ?? input.environment["USERPROFILE"]; + const agentDir = + input.environment["PI_CODING_AGENT_DIR"] ?? + (home === undefined ? undefined : `${normalizePiPath(home)}/.pi/agent`); + const roots: Array = []; + if (agentDir !== undefined) roots.push(`${normalizePiPath(agentDir)}/extensions`); + if (agentDir !== undefined && input.cwd !== undefined) { + const settingsRaw = yield* fs + .readFileString(`${normalizePiPath(agentDir)}/settings.json`) + .pipe(Effect.orElseSucceed(() => "")); + if (piDefaultProjectTrust(settingsRaw) === "always") { + roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); + } + } + const found: Array = []; + for (const root of roots) { + const entries = yield* fs + .readDirectory(root) + .pipe(Effect.orElseSucceed(() => [] as Array)); + for (const entry of entries.toSorted()) { + if (entry === "subagent" || entry === "subagent.ts") continue; + const path = `${root}/${entry}`; + if (entry.endsWith(".ts")) { + found.push(path); + continue; + } + const indexPath = `${path}/index.ts`; + const hasIndex = yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) found.push(indexPath); + } + } + return found; +}); + export function piT3McpExtensionDestPath(cacheDir: string): string { return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; } @@ -121,6 +188,8 @@ export function buildPiRpcLaunch(input: { readonly mcpSession: McpProviderSessionConfig | undefined; readonly extensionPath: string | undefined; readonly subagentExtensionPath?: string | undefined; + /** User extensions re-added around the `--no-extensions` subagent spawn. */ + readonly discoveredExtensionPaths?: ReadonlyArray | undefined; }): { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -136,6 +205,9 @@ export function buildPiRpcLaunch(input: { args.push("--no-extensions"); } args = appendExtensionArg(args, input.subagentExtensionPath); + for (const discovered of input.discoveredExtensionPaths ?? []) { + args = appendExtensionArg(args, discovered); + } args = [...args, ...stripConflictingPiSubagentExtensionArgs(userArgs)]; } else { args = [...args, ...userArgs]; @@ -158,6 +230,11 @@ export function buildPiRpcLaunch(input: { [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( input.mcpSession.authorizationHeader, ), + // The subagent override passes this through to child spawns so + // native children get the T3 tools too (env is inherited). + ...(input.extensionPath === undefined + ? {} + : { [T3_PI_MCP_EXTENSION_PATH_ENV]: input.extensionPath }), } : {}), }, diff --git a/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts index 3b32dfd83533..d2a2ae57f1a2 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts @@ -231,6 +231,10 @@ async function runSingleAgent( "--name", \`t3-subagent \${agentName}\`, ]; + // Children inherit T3_MCP_URL / T3_MCP_BEARER_TOKEN from this process, so + // loading the same T3 MCP extension gives them delegate_task / t3_thread_*. + const t3McpExtension = process.env["T3_PI_MCP_EXTENSION_PATH"]; + if (t3McpExtension) args.push("--extension", t3McpExtension); const model = agent.model ?? defaults.model; if (model) args.push("--model", model); if (!agent.model && defaults.thinkingLevel) args.push("--thinking", defaults.thinkingLevel); From 3d867cff5fd76e5e352b024015a209fd2fbb9480 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:53:49 +0200 Subject: [PATCH 23/41] feat(pi): fork threads, read snapshots, live status rows and safer stops Closes the remaining coverage gaps against pi's RPC surface: - forkThread clones pi's session tree and switches the live process back to the source, so T3-side thread forks map to native clones. - readThreadSnapshot reconstructs the conversation from get_entries, giving handoff and preview surfaces a real transcript. - setStatus/setWidget extension calls project as keyed live work-log rows that update in place and close on settle. - Session-start extension dialogs (e.g. project trust) are buffered and attached to the next turn instead of being cancelled unseen. - Stop-with-restart aborts the turn and terminates the pi process, and transport death during an interrupt reads as interrupted, not failed. - A recovered auto-compaction clears the stashed model error so the turn completes instead of reporting the pre-compaction failure. - Opening a subagent's child thread while its task is still running is refused with a clear message instead of corrupting the child session. Co-Authored-By: Claude Fable 5 --- .../Adapters/PiAdapterV2.test.ts | 265 +++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 328 ++++++++++++++++-- .../src/orchestration-v2/Adapters/PiRpc.ts | 11 + 3 files changed, 579 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index cca7daa0f97c..32fa1fd3365f 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -77,6 +77,8 @@ interface FakePi { readonly queueEntries: (data: unknown) => void; /** Make the next `switch_session` ack report an extension veto. */ readonly vetoNextSwitch: () => void; + /** Data returned by the next `get_state` acks, consumed in order. */ + readonly queueState: (data: unknown) => void; readonly lastSpawn: () => { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -91,6 +93,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); const entriesQueue: Array = []; + const stateQueue: Array = []; let vetoSwitch = false; let stdinBuffer = ""; @@ -111,7 +114,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { case "get_state": return { ...base, - data: { + data: stateQueue.shift() ?? { model: null, thinkingLevel: "medium", isStreaming: false, @@ -194,6 +197,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { vetoNextSwitch: () => { vetoSwitch = true; }, + queueState: (data) => stateQueue.push(data), lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -1015,6 +1019,265 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("clears a stashed model error when compaction recovers the turn", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + // Overflow surfaces as a model error, then pi compacts and retries. + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "400 too long", + }, + }); + yield* fake.emit({ + type: "compaction_end", + reason: "overflow", + result: { summary: "compacted", tokensBefore: 520000, estimatedTokensAfter: 3400 }, + aborted: false, + willRetry: true, + }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "done" }], + stopReason: "stop", + }, + }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("stops with restart by aborting and then terminating the process", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + requestRuntimeRestart: true, + }); + yield* fake.takeRequest("abort"); + // The fake process cannot die; pi settling still closes the turn as + // interrupted rather than failed. + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.live("defers session-start dialogs to the next turn instead of cancelling", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + // Project-trust style prompt before any turn exists. + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-trust", + method: "confirm", + title: "Run project extensions?", + message: "This project has .pi/extensions.", + }); + // Give the pump real time to buffer the dialog before the turn opens. + yield* Effect.sleep("60 millis"); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const pending = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + const requestId = + pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; + yield* runtime.respondToRuntimeRequest({ requestId: requestId!, decision: "accept" }); + const uiResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(uiResponse["id"], "ui-trust"); + assert.equal(uiResponse["confirmed"], true); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("projects setStatus as a keyed live row and closes it on settle", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-s1", + method: "setStatus", + statusKey: "tps", + statusText: "42 tok/s", + }); + const row = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "status", + ); + assert.isTrue( + row.type === "turn_item.updated" && + row.turnItem.type === "dynamic_tool" && + row.turnItem.status === "running" && + (row.turnItem.input as { status?: string }).status === "42 tok/s", + ); + yield* fake.emit({ type: "agent_settled" }); + const closed = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "status" && + event.turnItem.status === "completed", + ); + assert.equal(closed.type, "turn_item.updated"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("refuses to open a subagent child session while its task is running", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const childFile = "/fake/.pi/agent/sessions/children/child-1.jsonl"; + yield* fake.emit({ + type: "tool_execution_update", + toolCallId: "call_child", + toolName: "subagent", + partialResult: { + content: [{ type: "text", text: "(running...)" }], + details: { + mode: "single", + results: [ + { + agent: "worker", + task: "long task", + exitCode: 0, + stderr: "", + messages: [], + sessionFile: childFile, + }, + ], + }, + }, + }); + yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "running", + ); + const childThread: OrchestrationV2ProviderThread = { + ...providerThread, + nativeThreadRef: { driver: PI_PROVIDER, nativeId: childFile, strength: "strong" }, + }; + const error = yield* runtime.resumeThread({ providerThread: childThread }).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("forks a thread by cloning pi's session and switching back", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const cloneFile = "/fake/.pi/agent/sessions/--workspace--/0002_clone.jsonl"; + fake.queueState({ sessionFile: cloneFile, sessionId: "clone" }); + const forked = yield* runtime.forkThread({ + sourceProviderThread: providerThread, + targetThreadId: ThreadId.make("thread-pi-fork-target"), + }); + assert.equal(forked.nativeThreadRef?.nativeId, cloneFile); + assert.isNull(forked.providerSessionId); + yield* fake.takeRequest("clone"); + const switchBack = yield* fake.takeRequest("switch_session"); + assert.equal(switchBack["sessionPath"], FAKE_SESSION_FILE); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("reads a thread snapshot from pi's session entries", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + fake.queueEntries({ + entries: [ + { + type: "message", + id: "e1", + message: { role: "user", content: "hello pi", timestamp: 1700000000000 }, + }, + { + type: "message", + id: "e2", + message: { + role: "assistant", + content: [{ type: "text", text: "hello back" }], + timestamp: 1700000001000, + }, + }, + { type: "message", id: "e3", message: { role: "toolResult", content: [] } }, + ], + leafId: "e2", + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + assert.equal(snapshot.messages.length, 2); + assert.equal(snapshot.messages[0]!.role, "user"); + assert.equal(snapshot.messages[0]!.text, "hello pi"); + assert.equal(snapshot.messages[1]!.role, "assistant"); + assert.equal(snapshot.messages[1]!.text, "hello back"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("cancels unanswered extension dialogs when the turn settles", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 4227a0eb088d..ee8f4ee9ec83 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -44,6 +44,7 @@ import { type ProviderInstanceId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -104,6 +105,7 @@ import { } from "./PiRpc.ts"; import { buildPiRpcLaunch, + discoverPiUserExtensions, materializePiT3McpExtension, materializePiT3SubagentExtension, } from "./piT3McpInjection.ts"; @@ -134,9 +136,11 @@ export const PiProviderCapabilitiesV2 = { }, threads: { canCreateEmptyThread: true, - canReadThreadSnapshot: false, + canReadThreadSnapshot: true, canRollbackThread: true, - canForkThread: false, + // Forks clone pi's active branch into a new session file. Fork-from-a- + // specific-turn stays off until clone-then-rewind lands. + canForkThread: true, canForkFromTurn: false, canForkFromSubagentThread: false, exposesNativeThreadId: true, @@ -212,7 +216,7 @@ export const PiProviderCapabilitiesV2 = { // CommandPolicy.ensureRollback requires the snapshot whenever provider // rollback is enabled; rollbackThread returns the updated provider thread. providerRollbackReturnsSnapshot: true, - providerCanReadConversationSnapshot: false, + providerCanReadConversationSnapshot: true, }, identity: { nativeThreadIds: "strong", @@ -306,6 +310,12 @@ interface ActivePiTurn { * the turn instead. */ sawAgentActivity: boolean; + /** + * Open keyed status/widget items from extension `setStatus`/`setWidget` + * calls, by native item id. Updated in place as the extension re-keys + * them; whatever is still open is completed when the turn settles. + */ + readonly liveStatus: Map; failure: ReturnType | null; } @@ -337,6 +347,13 @@ interface PiThreadState { export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2Shape { const { idAllocator } = options; + /** + * Session files of subagent children whose task is still running, shared + * across this instance's sessions. A send into such a child would open a + * second pi process on a session file the child process is actively + * writing; refuse it with a readable error until the task finishes. + */ + const liveChildSessions = new Set(); const protocolError = (detail: string, payload?: unknown) => new ProviderAdapterProtocolError({ @@ -377,12 +394,16 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const subagentExtensionPath = yield* provideCacheFs( materializePiT3SubagentExtension(options.serverConfig.providerStatusCacheDir), ); + const discoveredExtensionPaths = yield* provideCacheFs( + discoverPiUserExtensions({ environment: options.environment, cwd }), + ); const launch = buildPiRpcLaunch({ launchArgs: options.settings.launchArgs, environment: options.environment, mcpSession, extensionPath, subagentExtensionPath, + discoveredExtensionPaths, }); const hasT3Mcp = launch.hasT3Mcp; const connection: PiRpcConnection = yield* makePiRpcConnection({ @@ -427,6 +448,15 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S let appliedThinking: string | null = null; /** Last thread title synced into pi's session name (`/resume` listing). */ let appliedSessionName: string | null = null; + /** + * Extension dialogs raised while no turn was active (project trust and + * login prompts at session start). Cancelling them would mean + * trust-gated extensions silently never load, so they are buffered and + * attached to the next turn. Flushing happens only inside the event + * pump, triggered by an order-preserving `t3.flush_dialogs` record, so + * dialog bookkeeping stays single-threaded. Cancelled on session close. + */ + const outOfTurnDialogs: Array = []; /** * Leaf entry id of the pi session tree as of the last turn boundary. * Turn-start user entries are located relative to it, giving each @@ -808,6 +838,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const outputText = piSubagentOutput(result); const title = agent; const sessionFile = recordString(result, "sessionFile"); + if (sessionFile !== undefined) { + if (finished) liveChildSessions.delete(sessionFile); + else liveChildSessions.add(sessionFile); + } let child = turn.childSubagents.get(nativeTaskId); if (sessionFile !== undefined && child === undefined) { const childThreadId = idAllocator.derive.threadFromProviderThread({ @@ -1060,6 +1094,40 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } }); + /** One keyed status/widget row, updated in place while running. */ + const emitLiveStatusItem = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + nativeItemId: string, + title: string, + input: unknown, + done: boolean, + ) { + const emittedAt = yield* DateTime.now; + const startedAt = turn.toolStartedAt.get(nativeItemId) ?? emittedAt; + turn.toolStartedAt.set(nativeItemId, startedAt); + yield* emitItemNode( + turn, + nativeItemId, + "system", + done ? "completed" : "running", + startedAt, + done ? emittedAt : null, + ); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, startedAt, emittedAt), + status: done ? "completed" : "running", + title, + completedAt: done ? emittedAt : null, + type: "dynamic_tool", + toolName: nativeItemId.startsWith("status:") ? "status" : "widget", + input, + }, + }); + }); + // ── extension UI prompts ────────────────────────────── const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => @@ -1138,14 +1206,47 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); return; } + if (method === "setStatus" || method === "setWidget") { + // Keyed live progress from extensions (e.g. a tps tracker). Each + // key becomes one work-log item updated in place: running while + // the extension keeps it set, completed when cleared or on settle. + const state = threadState; + const turn = state?.activeTurn ?? null; + if (turn === null) return; + const key = + method === "setStatus" + ? recordString(event, "statusKey") + : recordString(event, "widgetKey"); + if (key === undefined) return; + const nativeItemId = `${method === "setStatus" ? "status" : "widget"}:${key}`; + const statusText = recordString(event, "statusText"); + const widgetLines = Array.isArray(event["widgetLines"]) + ? event["widgetLines"].filter((line): line is string => typeof line === "string") + : undefined; + const cleared = + method === "setStatus" ? statusText === undefined : widgetLines === undefined; + const payload = + method === "setStatus" + ? { title: key, input: { status: statusText ?? "" } } + : { title: key, input: { lines: widgetLines ?? [] } }; + if (cleared) { + const open = turn.liveStatus.get(nativeItemId); + if (open === undefined) return; + turn.liveStatus.delete(nativeItemId); + yield* emitLiveStatusItem(turn, nativeItemId, open.title, open.input, true); + return; + } + turn.liveStatus.set(nativeItemId, payload); + yield* emitLiveStatusItem(turn, nativeItemId, payload.title, payload.input, false); + return; + } if ( method !== "select" && method !== "confirm" && method !== "input" && method !== "editor" ) { - // setStatus / setWidget / setTitle / set_editor_text: no Pi panel - // yet, so these fire-and-forget surfaces are dropped. + // setTitle / set_editor_text have no matching T3 surface yet. yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); return; } @@ -1153,10 +1254,8 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const state = threadState; const turn = state?.activeTurn ?? null; if (state === null || turn === null) { - // No turn to attach UI to; cancel so the extension is not stuck. - yield* connection - .send({ type: "extension_ui_response", id: nativeRequestId, cancelled: true }) - .pipe(Effect.ignore); + outOfTurnDialogs.push(event); + yield* Effect.logDebug("Buffered out-of-turn pi extension dialog.", { method }); return; } const createdAt = yield* DateTime.now; @@ -1298,6 +1397,14 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const completedAt = yield* DateTime.now; yield* completeOpenStreamItems(turn); yield* cancelPendingPrompts(completedAt); + // Close any status/widget rows the extension left open. + yield* Effect.forEach( + Array.from(turn.liveStatus.entries()), + ([nativeItemId, open]) => + emitLiveStatusItem(turn, nativeItemId, open.title, open.input, true), + { discard: true }, + ); + turn.liveStatus.clear(); const treeRefs = yield* captureTurnTreeRefs(); const failure = turn.interrupted ? null : turn.failure; yield* emit({ @@ -1457,6 +1564,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); return; } + // An overflow can surface as a model error (`message_end` with + // stopReason error) before pi compacts and retries the turn. A + // successful compaction means the turn is recovering, so the + // stashed failure must not terminalize it, mirroring how + // auto_retry_end success already clears it. + turn.failure = null; const nativeItemId = `compaction:${turn.nextItemOrdinal}`; yield* emit({ type: "turn_item.updated", @@ -1570,6 +1683,16 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } return; } + case "t3.flush_dialogs": { + // Synthetic record queued by startTurn: attach buffered + // session-start dialogs to the now-active turn, in order, from + // inside the pump so bookkeeping stays single-threaded. The + // handler re-buffers any dialog whose turn vanished mid-drain. + for (const dialog of outOfTurnDialogs.splice(0)) { + yield* handleExtensionUiRequest(dialog); + } + return; + } case "t3.settle_probe": { // Synthetic idle probe queued after a command-only prompt ack. // Any agent activity Pi emitted before answering get_state has @@ -1606,16 +1729,24 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S Effect.catchCause((cause) => Effect.gen(function* () { // Transport death: fail any live turn, then surface the error. + // A death caused by Stop-with-restart is an interrupt, not a + // failure; finalizeTurn already prefers `interrupted`. const state = threadState; + const interrupted = state?.activeTurn?.interrupted === true; if (state?.activeTurn != null) { - state.activeTurn.failure = makeProviderFailure({ - cause, - message: "Pi process exited unexpectedly.", - class: "transport_error", - }); + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "Pi process exited unexpectedly.", + class: "transport_error", + }); yield* finalizeTurn(state); } - yield* updateProviderSession("error", "Pi process exited unexpectedly."); + yield* updateProviderSession( + "error", + interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", + ); yield* Queue.fail( events, new ProviderAdapterEventStreamError({ @@ -1636,6 +1767,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ) { const existing = threadInput.existingProviderThread; if (existing?.nativeThreadRef?.nativeId != null) { + if (liveChildSessions.has(existing.nativeThreadRef.nativeId)) { + return yield* protocolError( + "This subagent is still running. Wait for it to finish before sending messages to its thread.", + ); + } const switchData = yield* request({ type: "switch_session", sessionPath: existing.nativeThreadRef.nativeId, @@ -1810,6 +1946,23 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S return { message, images }; }); + // Buffered dialogs must not strand their extensions when the session + // closes before another turn ever starts. + yield* Effect.addFinalizer(() => + Effect.forEach( + outOfTurnDialogs, + (dialog) => { + const dialogId = recordString(dialog, "id"); + return dialogId === undefined + ? Effect.void + : connection + .send({ type: "extension_ui_response", id: dialogId, cancelled: true }) + .pipe(Effect.ignore); + }, + { discard: true }, + ).pipe(Effect.ignore), + ); + const runtime: ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver: PI_PROVIDER, @@ -1911,6 +2064,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S childSubagents: new Map(), interrupted: false, sawAgentActivity: false, + liveStatus: new Map(), failure: null, }; yield* emit({ @@ -1925,6 +2079,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S lastRunOrdinal: turnInput.runOrdinal, }); yield* updateProviderSession("running", null); + // Attach any dialogs that arrived before this turn existed + // (project trust, session-start login prompts). The flush runs + // inside the event pump, behind everything pi already emitted. + if (outOfTurnDialogs.length > 0) { + yield* Queue.offer(connection.events, { type: "t3.flush_dialogs" }); + } // Fire-and-forget: pi acks `prompt` only after slash-command // expansion completes, and extension commands may block on user // dialogs indefinitely, so turn start must never await the ack. @@ -2007,6 +2167,16 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); } turn.interrupted = true; + if (interruptInput.requestRuntimeRestart === true) { + // User Stop with restart: the process may be wedged, so give + // abort one short chance and then kill the process group. The + // transport failure finalizes the turn as interrupted and the + // session manager respawns a fresh process on the next turn, + // resuming the same session file. + yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); + yield* connection.terminate; + return; + } yield* request({ type: "abort" }).pipe( Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), ); @@ -2077,11 +2247,66 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ), ), readThreadSnapshot: (snapshotInput) => - Effect.fail( - new ProviderAdapterReadThreadSnapshotError({ - driver: PI_PROVIDER, - providerThreadId: snapshotInput.providerThread.id, - }), + Effect.gen(function* () { + const state = threadState; + const boundNativeId = state?.providerThread.nativeThreadRef?.nativeId; + const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; + if (state === null || wantedNativeId == null || boundNativeId !== wantedNativeId) { + return yield* protocolError( + "Pi snapshot requested for a thread this session does not host", + ); + } + const entriesData = yield* request({ type: "get_entries" }); + const entries = recordField(entriesData, "entries"); + const threadId = state.providerThread.appThreadId ?? input.threadId; + const messages = (Array.isArray(entries) ? entries : []).flatMap((entry) => { + if (recordField(entry, "type") !== "message") return []; + const entryId = recordString(entry, "id"); + const message = recordField(entry, "message"); + const role = recordString(message, "role"); + if (entryId === undefined || (role !== "user" && role !== "assistant")) return []; + const text = contentText(recordField(message, "content")); + if (text.length === 0) return []; + const timestamp = recordNumber(message, "timestamp"); + const at = Option.getOrElse( + DateTime.make(timestamp ?? Number.NaN), + () => state.providerThread.createdAt, + ); + return [ + { + id: idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: entryId, + }), + threadId, + runId: null, + nodeId: null, + role: role as "user" | "assistant", + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + createdAt: at, + updatedAt: at, + }, + ]; + }); + return { + providerThread: state.providerThread, + providerTurns: [], + messages, + runtimeRequests: [], + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: PI_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), ), rollbackThread: (rollbackInput) => Effect.gen(function* () { @@ -2131,11 +2356,66 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ), ), forkThread: (forkInput) => - Effect.fail( - new ProviderAdapterForkThreadError({ + Effect.gen(function* () { + const state = threadState; + if (state === null || state.activeTurn !== null) { + return yield* protocolError( + "Pi can only fork an idle thread; wait for the current run to finish.", + ); + } + if (forkInput.providerTurnId !== undefined) { + return yield* protocolError("Pi cannot fork from a specific earlier turn yet"); + } + const sourceNativeId = forkInput.sourceProviderThread.nativeThreadRef?.nativeId; + if (sourceNativeId == null) { + return yield* protocolError("Pi fork source has no native session file"); + } + // `clone` duplicates the active branch into a new session file + // and moves this process onto it; capture the clone's identity, + // then switch back so this runtime keeps serving the source. + const cloneData = yield* request({ type: "clone" }); + if (recordField(cloneData, "cancelled") === true) { + return yield* protocolError("A Pi extension cancelled the session clone"); + } + const cloneState = yield* request({ type: "get_state" }); + const cloneNativeId = + recordString(cloneState, "sessionFile") ?? recordString(cloneState, "sessionId"); + if (cloneNativeId === undefined || cloneNativeId === sourceNativeId) { + return yield* protocolError("Pi clone did not produce a new session", cloneState); + } + yield* request({ type: "switch_session", sessionPath: sourceNativeId }); + const createdAt = yield* DateTime.now; + return { + id: idAllocator.derive.providerThread({ + driver: PI_PROVIDER, + nativeThreadId: cloneNativeId, + }), driver: PI_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - }), + providerInstanceId: options.instanceId, + // Null so the fork's first send opens its own pi process. + providerSessionId: null, + appThreadId: forkInput.targetThreadId, + ownerNodeId: forkInput.ownerNodeId ?? null, + nativeThreadRef: providerRef(cloneNativeId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: { providerThreadId: forkInput.sourceProviderThread.id }, + pendingBackgroundTasks: [], + createdAt, + updatedAt: createdAt, + } satisfies OrchestrationV2ProviderThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: PI_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), ), }; return runtime; diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index f92e8089538a..19eb576d6b02 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -74,6 +74,13 @@ export interface PiRpcConnection { readonly events: Queue.Queue; /** Resolves when the process has exited, with its exit code. */ readonly exited: Effect.Effect; + /** + * Kill the pi process group immediately (SIGTERM, grace, SIGKILL). Used by + * Stop-with-restart when the process may be wedged and `abort` cannot be + * trusted to land. The transport fails and the session manager respawns a + * fresh process on the next turn. + */ + readonly terminate: Effect.Effect; } const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -375,5 +382,9 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp request, events, exited: Deferred.await(exitDeferred), + terminate: terminatePiProcess(killProcessGroup, hasExited).pipe( + Effect.ignore, + Effect.uninterruptible, + ), } satisfies PiRpcConnection; }); From c659435ae3f27cee2990a2fe2988b856157ad98d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:09:29 +0200 Subject: [PATCH 24/41] fix(pi): stop wrapping the first prompt so slash commands expand again Prepending T3 orchestration instructions to the first user message meant the message no longer started with "/", so pi never expanded slash commands (extension commands, prompt templates, skills) on a thread's first turn. Found live: /t3-demo reached the model as plain text. The T3 MCP extension now delivers the same instructions through pi's real system-prompt channel (a before_agent_start hook), and the adapter sends the user's text untouched. Co-Authored-By: Claude Fable 5 --- .../src/orchestration-v2/Adapters/PiAdapterV2.ts | 12 +++++------- .../Adapters/piT3McpExtensionSource.ts | 10 ++++++++++ .../Adapters/piT3McpInjection.test.ts | 4 ++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index ee8f4ee9ec83..8168d852162c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -58,7 +58,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { IdAllocatorV2 } from "../IdAllocator.ts"; import { @@ -405,7 +404,6 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S subagentExtensionPath, discoveredExtensionPaths, }); - const hasT3Mcp = launch.hasT3Mcp; const connection: PiRpcConnection = yield* makePiRpcConnection({ command: options.settings.binaryPath || "pi", args: launch.args, @@ -2027,12 +2025,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // Resolved before the turn is installed: a failure here (an // unreadable attachment) must not leave `activeTurn` set, which // would reject every later turn as already active. + // Orchestration instructions reach pi through the T3 MCP + // extension's before_agent_start system-prompt hook, never by + // wrapping the user text: a wrapped first message would no + // longer start with "/" and slash commands would stop expanding. const payload = yield* resolvePromptPayload( - t3OrchestrationPromptForFirstRun({ - prompt: turnInput.message.text, - runOrdinal: turnInput.runOrdinal, - hasT3Mcp, - }), + turnInput.message.text, turnInput.message.attachments, ); const startedAt = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts index 8fc657dc09f3..32c05379c28d 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -8,6 +8,8 @@ * Do not import t3code modules from the string body. The Pi process resolves * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. */ +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; + export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; export const T3_MCP_URL_ENV = "T3_MCP_URL"; @@ -19,6 +21,7 @@ import { Type } from "typebox"; const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; +const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; const PROTOCOL = "2025-06-18"; type JsonRpcResponse = { @@ -235,5 +238,12 @@ export default async function t3McpExtension(pi: ExtensionAPI) { ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); } }); + + // Deliver orchestration guidance through pi's real system-prompt channel. + // Wrapping the first user message instead would stop it from starting + // with "/" and silently break slash-command expansion. + pi.on("before_agent_start", (event) => ({ + systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS, + })); } `; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 039daf4cd935..4dca228e0877 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -143,6 +143,10 @@ describe("pi T3 MCP injection", () => { assert.isTrue(dest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); const source = yield* fs.readFileString(dest); assert.include(source, "export default async function t3McpExtension"); + // Orchestration guidance rides the system-prompt hook, not the user + // message, so first-turn slash commands still expand. + assert.include(source, "before_agent_start"); + assert.include(source, "T3 Code orchestration"); assert.include(source, T3_MCP_URL_ENV); assert.include(source, '"mcp-protocol-version"'); assert.include(source, '"tools/call"'); From 4022c2ea52d2afccc55352bbcefde4caed545064 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:13:42 +0200 Subject: [PATCH 25/41] fix(providers): harden Pi lifecycle and context usage --- .../Adapters/PiAdapterV2.test.ts | 142 ++++++---- .../orchestration-v2/Adapters/PiAdapterV2.ts | 243 ++++++++++++------ .../src/orchestration-v2/Adapters/PiRpc.ts | 36 +-- .../Adapters/piT3McpExtensionSource.ts | 31 ++- .../Adapters/piT3McpInjection.test.ts | 143 ++--------- .../Adapters/piT3McpInjection.ts | 46 +++- .../Adapters/piT3SubagentExtensionSource.ts | 55 ++-- .../src/orchestration-v2/UserFacingErrors.ts | 11 +- apps/server/src/provider/Layers/PiProvider.ts | 16 +- .../Layers/piThinkingCapabilities.test.ts | 53 +--- .../provider/Layers/piThinkingCapabilities.ts | 6 +- apps/web/src/components/ChatView.tsx | 10 + apps/web/src/components/chat/ChatComposer.tsx | 13 +- apps/web/src/lib/contextWindow.test.ts | 23 ++ apps/web/src/lib/contextWindow.ts | 68 +++-- docs/README.md | 2 +- docs/internals/providers.md | 11 +- .../orchestrator-mcp-server.md | 11 +- docs/user/install.md | 3 +- docs/user/providers-pi.md | 34 +++ .../contracts/src/orchestrationV2.test.ts | 4 +- packages/contracts/src/orchestrationV2.ts | 6 + 22 files changed, 523 insertions(+), 444 deletions(-) create mode 100644 docs/user/providers-pi.md diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 32fa1fd3365f..9e8435836480 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -34,12 +34,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; -import { - makePiAdapterV2, - piRollbackForkEntry, - PiProviderCapabilitiesV2, - PI_PROVIDER, -} from "./PiAdapterV2.ts"; +import { makePiAdapterV2, PI_PROVIDER } from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -79,6 +74,8 @@ interface FakePi { readonly vetoNextSwitch: () => void; /** Data returned by the next `get_state` acks, consumed in order. */ readonly queueState: (data: unknown) => void; + /** Data returned by the next `get_session_stats` acks, consumed in order. */ + readonly queueStats: (data: unknown) => void; readonly lastSpawn: () => { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -94,6 +91,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const requests = yield* Queue.unbounded(); const entriesQueue: Array = []; const stateQueue: Array = []; + const statsQueue: Array = []; let vetoSwitch = false; let stdinBuffer = ""; @@ -119,6 +117,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { thinkingLevel: "medium", isStreaming: false, isCompacting: false, + autoCompactionEnabled: true, sessionFile: FAKE_SESSION_FILE, sessionId: "abc", }, @@ -130,6 +129,8 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } case "get_entries": return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; + case "get_session_stats": + return { ...base, data: statsQueue.shift() ?? {} }; case "fork": return { ...base, data: { cancelled: false, message: "forked" } }; default: @@ -198,6 +199,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { vetoSwitch = true; }, queueState: (data) => stateQueue.push(data), + queueStats: (data) => statsQueue.push(data), lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -296,24 +298,6 @@ const startTurn = Effect.fnUntraced(function* ( }); describe("PiAdapterV2", () => { - it("keeps the rollback capability triple consistent with CommandPolicy", () => { - // ensureRollback rejects rollback commands unless all three hold, so a - // partially-enabled combination is user-visibly broken, not conservative. - assert.isTrue(PiProviderCapabilitiesV2.threads.canRollbackThread); - assert.isTrue(PiProviderCapabilitiesV2.checkpointing.providerCanRollbackConversation); - assert.isTrue(PiProviderCapabilitiesV2.checkpointing.providerRollbackReturnsSnapshot); - }); - - it("declares Pi-honest capabilities", () => { - assert.isTrue(PiProviderCapabilitiesV2.turns.supportsActiveSteering); - assert.isFalse(PiProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); - assert.equal(PiProviderCapabilitiesV2.turns.terminalStatusQuality, "strong"); - assert.isFalse(PiProviderCapabilitiesV2.approvals.supportsCommandApproval); - assert.isTrue(PiProviderCapabilitiesV2.tools.supportsMcpTools); - assert.isTrue(PiProviderCapabilitiesV2.subagents.exposesSubagentThreadIds); - assert.equal(PiProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); - }); - it.effect("injects the T3 MCP extension and bearer when a session exists", () => Effect.gen(function* () { McpProviderSession.setMcpProviderSession({ @@ -433,6 +417,11 @@ describe("PiAdapterV2", () => { }, }); yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + fake.queueStats({ + tokens: { input: 12_000, output: 500, cacheRead: 8_000, cacheWrite: 0, total: 20_500 }, + toolCalls: 3, + contextUsage: { tokens: 20_500, contextWindow: 200_000, percent: 10.25 }, + }); yield* fake.emit({ type: "agent_settled" }); const assistantItem = yield* takeEvent( @@ -446,6 +435,23 @@ describe("PiAdapterV2", () => { assistantItem.turnItem.type === "assistant_message" && assistantItem.turnItem.text === "Hello", ); + const usage = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.contextUsage !== null, + ); + assert.deepEqual( + usage.type === "provider_thread.updated" ? usage.providerThread.contextUsage : null, + { + usedTokens: 20_500, + totalProcessedTokens: 20_500, + maxTokens: 200_000, + inputTokens: 12_000, + cachedInputTokens: 8_000, + outputTokens: 500, + toolUses: 3, + compactsAutomatically: true, + }, + ); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); }).pipe(Effect.scoped, Effect.provide(testLayer)), @@ -525,37 +531,6 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - it("resolves rollback fork entries from captured turn refs", () => { - const turn = (ordinal: number, ref: OrchestrationV2ProviderTurn["nativeTurnRef"]) => - ({ ordinal, nativeTurnRef: ref }) as OrchestrationV2ProviderTurn; - const strong = (nativeId: string) => - ({ driver: PI_PROVIDER, nativeId, strength: "strong" }) as const; - const weak = (nativeId: string) => - ({ driver: PI_PROVIDER, nativeId, strength: "weak" }) as const; - // No turns after the target: nothing to discard. - assert.isNull( - piRollbackForkEntry({ - target: { type: "provider_turn", providerTurn: turn(2, strong("u2")) }, - providerThreadTurns: [turn(1, strong("u1")), turn(2, strong("u2"))], - }), - ); - // Boundary turn without a captured (strong) entry ref cannot roll back. - assert.isUndefined( - piRollbackForkEntry({ - target: { type: "provider_turn", providerTurn: turn(1, strong("u1")) }, - providerThreadTurns: [turn(1, strong("u1")), turn(2, weak("synthetic"))], - }), - ); - // thread_start discards everything from the first captured turn. - assert.equal( - piRollbackForkEntry({ - target: { type: "thread_start" }, - providerThreadTurns: [turn(2, strong("u2")), turn(1, strong("u1"))], - }), - "u1", - ); - }); - it.effect("fails a resume when an extension vetoes the session switch", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -728,13 +703,23 @@ describe("PiAdapterV2", () => { { agent: "scout", task: "map the repo", + finished: false, exitCode: 0, + stopReason: "toolUse", stderr: "", sessionFile: "/tmp/pi-children/scout.jsonl", messages: [ { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, ], }, + { + agent: "worker", + task: "broken task", + finished: false, + exitCode: -1, + stderr: "", + messages: [], + }, ], }, }, @@ -766,6 +751,12 @@ describe("PiAdapterV2", () => { running.subagent.progress === "scanning files" && running.subagent.childThreadId === childThreadId, ); + const queuedSibling = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.title === "worker", + ); + assert.isTrue( + queuedSibling.type === "subagent.updated" && queuedSibling.subagent.status === "running", + ); yield* fake.emit({ type: "tool_execution_end", toolCallId: "call_sub", @@ -779,6 +770,7 @@ describe("PiAdapterV2", () => { { agent: "scout", task: "map the repo", + finished: true, exitCode: 0, stopReason: "stop", stderr: "", @@ -790,6 +782,7 @@ describe("PiAdapterV2", () => { { agent: "worker", task: "broken task", + finished: true, exitCode: 1, stderr: "boom", messages: [], @@ -1056,7 +1049,21 @@ describe("PiAdapterV2", () => { stopReason: "stop", }, }); + fake.queueStats({ + tokens: { input: 12_000, output: 500, cacheRead: 8_000, cacheWrite: 0, total: 20_500 }, + contextUsage: { tokens: null, contextWindow: 200_000, percent: null }, + }); yield* fake.emit({ type: "agent_settled" }); + const usage = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.contextUsage !== null, + ); + assert.equal( + usage.type === "provider_thread.updated" + ? usage.providerThread.contextUsage?.usedTokens + : null, + 3_400, + ); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); }).pipe(Effect.scoped, Effect.provide(testLayer)), @@ -1196,7 +1203,9 @@ describe("PiAdapterV2", () => { { agent: "worker", task: "long task", - exitCode: 0, + finished: false, + exitCode: -1, + stopReason: "toolUse", stderr: "", messages: [], sessionFile: childFile, @@ -1214,6 +1223,14 @@ describe("PiAdapterV2", () => { }; const error = yield* runtime.resumeThread({ providerThread: childThread }).pipe(Effect.flip); assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + + // A parent teardown releases every child lock, even when the tool never + // emitted a final result (for example after provider transport loss). + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + fake.queueState({ sessionFile: childFile, sessionId: "child-1" }); + const resumed = yield* runtime.resumeThread({ providerThread: childThread }); + assert.equal(resumed.nativeThreadRef?.nativeId, childFile); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); @@ -1237,6 +1254,19 @@ describe("PiAdapterV2", () => { yield* fake.takeRequest("clone"); const switchBack = yield* fake.takeRequest("switch_session"); assert.equal(switchBack["sessionPath"], FAKE_SESSION_FILE); + + fake.queueState({ + sessionFile: "/fake/.pi/agent/sessions/--workspace--/0003_clone.jsonl", + sessionId: "clone-2", + }); + fake.vetoNextSwitch(); + const error = yield* runtime + .forkThread({ + sourceProviderThread: providerThread, + targetThreadId: ThreadId.make("thread-pi-fork-vetoed"), + }) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterForkThreadError"); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 8168d852162c..40a01f82e948 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -19,8 +19,9 @@ * Dialog methods become v2 runtime requests (`confirm` → approval_request, * `select`/`input`/`editor` → user_input_request); answers travel back as * `extension_ui_response`. `notify` becomes a completed activity item; - * remaining fire-and-forget surfaces (`setStatus`/`setWidget`/`setTitle`) - * are dropped until a dedicated Pi panel exists. + * keyed `setStatus`/`setWidget` updates become live work-log rows. Remaining + * fire-and-forget surfaces such as `setTitle` are ignored until T3 has a + * matching surface. */ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -42,6 +43,7 @@ import { type OrchestrationV2UserInputQuestion, type ProviderApprovalDecision, type ProviderInstanceId, + type ThreadTokenUsageSnapshot, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -99,6 +101,9 @@ import { import { makePiRpcConnection, parsePiModelSlug, + piRecordField as recordField, + piRecordNumber as recordNumber, + piRecordString as recordString, type PiRpcConnection, type PiRpcRecord, } from "./PiRpc.ts"; @@ -235,23 +240,6 @@ export interface PiAdapterV2Options { readonly serverConfig: ServerConfig["Service"]; } -// ── record helpers ──────────────────────────────────────────── - -function recordField(input: unknown, key: string): unknown { - if (typeof input !== "object" || input === null) return undefined; - return (input as Record)[key]; -} - -function recordString(input: unknown, key: string): string | undefined { - const value = recordField(input, key); - return typeof value === "string" ? value : undefined; -} - -function recordNumber(input: unknown, key: string): number | undefined { - const value = recordField(input, key); - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - /** Concatenate the `text` fields of a Pi content-block array. */ function contentText(content: unknown): string { if (!Array.isArray(content)) { @@ -315,6 +303,8 @@ interface ActivePiTurn { * them; whatever is still open is completed when the turn settles. */ readonly liveStatus: Map; + /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ + latestCompactionAfterTokens: number | null; failure: ReturnType | null; } @@ -472,6 +462,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // "unset model" command, so the baseline has to be replayed explicitly. let baselineModel: { provider: string; modelId: string } | null = null; let baselineThinking: string | null = null; + let autoCompactionEnabled: boolean | undefined; const emit = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); @@ -515,6 +506,49 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const request = (record: PiRpcRecord, timeoutMs = PI_REQUEST_TIMEOUT_MS) => connection.request(record, timeoutMs); + const nonNegativeInteger = (input: unknown, key: string): number | undefined => { + const value = recordNumber(input, key); + return value === undefined ? undefined : Math.max(0, Math.trunc(value)); + }; + + const contextUsageFromStats = ( + stats: unknown, + fallbackUsedTokens: number | null, + ): ThreadTokenUsageSnapshot | null => { + const contextUsage = recordField(stats, "contextUsage"); + const maxTokens = nonNegativeInteger(contextUsage, "contextWindow"); + const usedTokens = + nonNegativeInteger(contextUsage, "tokens") ?? fallbackUsedTokens ?? undefined; + if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) return null; + + const totals = recordField(stats, "tokens"); + const totalProcessedTokens = nonNegativeInteger(totals, "total"); + const inputTokens = nonNegativeInteger(totals, "input"); + const cachedInputTokens = nonNegativeInteger(totals, "cacheRead"); + const outputTokens = nonNegativeInteger(totals, "output"); + const toolUses = nonNegativeInteger(stats, "toolCalls"); + return { + usedTokens, + maxTokens, + ...(totalProcessedTokens === undefined ? {} : { totalProcessedTokens }), + ...(inputTokens === undefined ? {} : { inputTokens }), + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(outputTokens === undefined ? {} : { outputTokens }), + ...(toolUses === undefined ? {} : { toolUses }), + ...(autoCompactionEnabled === undefined + ? {} + : { compactsAutomatically: autoCompactionEnabled }), + }; + }; + + const readContextUsage = (turn: ActivePiTurn) => + request({ type: "get_session_stats" }, 2_000).pipe( + Effect.map((stats) => contextUsageFromStats(stats, turn.latestCompactionAfterTokens)), + // Usage is secondary telemetry. Bound the request and never fail + // turn terminalization for a provider version without stats. + Effect.orElseSucceed(() => undefined), + ); + const baseItemFields = ( turn: ActivePiTurn, nativeItemId: string, @@ -821,11 +855,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); const startedAt = turn.toolStartedAt.get(nativeTaskId) ?? emittedAt; turn.toolStartedAt.set(nativeTaskId, startedAt); + const finished = completed || recordField(result, "finished") === true; const stopReason = recordString(result, "stopReason"); - const exitCode = recordNumber(result, "exitCode") ?? 0; - const interrupted = stopReason === "aborted"; - const failed = !interrupted && (exitCode !== 0 || stopReason === "error"); - const finished = completed || interrupted || failed || stopReason !== undefined; + const interrupted = finished && stopReason === "aborted"; + const failed = + finished && + !interrupted && + ((recordNumber(result, "exitCode") ?? 0) !== 0 || stopReason === "error"); const status = interrupted ? "interrupted" : failed @@ -1388,10 +1424,20 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }; }); - const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState) { + const releaseLiveChildSessions = (turn: ActivePiTurn): void => { + for (const child of turn.childSubagents.values()) { + liveChildSessions.delete(child.sessionFile); + } + }; + + const finalizeTurn = Effect.fnUntraced(function* ( + state: PiThreadState, + refreshContextUsage = true, + ) { const turn = state.activeTurn; if (turn === null) return; state.activeTurn = null; + releaseLiveChildSessions(turn); const completedAt = yield* DateTime.now; yield* completeOpenStreamItems(turn); yield* cancelPendingPrompts(completedAt); @@ -1404,6 +1450,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ); turn.liveStatus.clear(); const treeRefs = yield* captureTurnTreeRefs(); + const contextUsage = refreshContextUsage ? yield* readContextUsage(turn) : undefined; const failure = turn.interrupted ? null : turn.failure; yield* emit({ type: "provider_turn.updated", @@ -1423,6 +1470,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ...(treeRefs?.leafId == null ? {} : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), + ...(contextUsage === undefined ? {} : { contextUsage }), }); yield* updateProviderSession(failure !== null ? "error" : "ready"); if (failure !== null) { @@ -1569,6 +1617,8 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // auto_retry_end success already clears it. turn.failure = null; const nativeItemId = `compaction:${turn.nextItemOrdinal}`; + turn.latestCompactionAfterTokens = + nonNegativeInteger(result, "estimatedTokensAfter") ?? null; yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, @@ -1725,35 +1775,37 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S } }).pipe( Effect.catchCause((cause) => - Effect.gen(function* () { - // Transport death: fail any live turn, then surface the error. - // A death caused by Stop-with-restart is an interrupt, not a - // failure; finalizeTurn already prefers `interrupted`. - const state = threadState; - const interrupted = state?.activeTurn?.interrupted === true; - if (state?.activeTurn != null) { - state.activeTurn.failure = interrupted - ? null - : makeProviderFailure({ - cause, - message: "Pi process exited unexpectedly.", - class: "transport_error", - }); - yield* finalizeTurn(state); - } - yield* updateProviderSession( - "error", - interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", - ); - yield* Queue.fail( - events, - new ProviderAdapterEventStreamError({ - driver: PI_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ); - }), + sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + // Transport death: fail any live turn, then surface the error. + // A death caused by Stop-with-restart is an interrupt, not a + // failure; finalizeTurn already prefers `interrupted`. + const state = threadState; + const interrupted = state?.activeTurn?.interrupted === true; + if (state?.activeTurn != null) { + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "Pi process exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state, false); + } + yield* updateProviderSession( + "error", + interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", + ); + yield* Queue.fail( + events, + new ProviderAdapterEventStreamError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + }), + ), ), Effect.forkIn(scope), ); @@ -1793,6 +1845,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S baselineThinking = null; } const stateData = yield* request({ type: "get_state" }); + const reportedAutoCompaction = recordField(stateData, "autoCompactionEnabled"); + autoCompactionEnabled = + typeof reportedAutoCompaction === "boolean" ? reportedAutoCompaction : undefined; // Each baseline is captured independently, and only while nothing has // been applied yet, so a `get_state` that omits one field still lets // the other be picked up later without recording our own selection. @@ -1946,6 +2001,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // Buffered dialogs must not strand their extensions when the session // closes before another turn ever starts. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + const turn = threadState?.activeTurn; + if (turn !== null && turn !== undefined) releaseLiveChildSessions(turn); + }), + ); yield* Effect.addFinalizer(() => Effect.forEach( outOfTurnDialogs, @@ -2049,7 +2110,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S startedAt, completedAt: null, }; - state.activeTurn = { + const activeTurn: ActivePiTurn = { turnInput, providerTurn, startedAt, @@ -2063,8 +2124,26 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S interrupted: false, sawAgentActivity: false, liveStatus: new Map(), + latestCompactionAfterTokens: null, failure: null, }; + state.activeTurn = activeTurn; + // Install the turn before enqueueing the prompt so Pi events have + // an owner, but publish the start only after the enqueue succeeds. + // The shared permit keeps the event pump behind this boundary. + yield* connection + .send({ + type: "prompt", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }) + .pipe( + Effect.tapError(() => + Effect.sync(() => { + if (state.activeTurn === activeTurn) state.activeTurn = null; + }), + ), + ); yield* emit({ type: "provider_turn.updated", driver: PI_PROVIDER, @@ -2083,34 +2162,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (outOfTurnDialogs.length > 0) { yield* Queue.offer(connection.events, { type: "t3.flush_dialogs" }); } - // Fire-and-forget: pi acks `prompt` only after slash-command - // expansion completes, and extension commands may block on user - // dialogs indefinitely, so turn start must never await the ack. - // Rejections come back as an id-less response record and are + // Pi acks `prompt` only after slash-command expansion completes, + // and extension commands may block on user dialogs indefinitely. + // Rejections therefore return later as id-less response records // handled by the event pump. - yield* connection - .send({ - type: "prompt", - message: payload.message, - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }) - .pipe( - // The turn is already installed and published, so a failed send - // has to finalize it here. Otherwise `activeTurn` stays set and - // every later turn is rejected as already active. - Effect.tapError(() => - Effect.gen(function* () { - const current = threadState; - if (current?.activeTurn?.providerTurn.id === providerTurn.id) { - current.activeTurn.failure = makeProviderFailure({ - message: "Pi rejected the prompt.", - class: "provider_error", - }); - yield* finalizeTurn(current); - } - }), - ), - ); }).pipe( sessionEventPermit.withPermits(1), Effect.mapError( @@ -2312,6 +2367,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (state === null) { return yield* protocolError("Pi session has no registered thread"); } + if (state.providerThread.id !== rollbackInput.providerThread.id) { + return yield* protocolError( + "Pi rollback requested for a thread this session does not host", + ); + } if (state.activeTurn !== null) { return yield* protocolError("Cannot roll back while a Pi turn is active"); } @@ -2368,6 +2428,11 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (sourceNativeId == null) { return yield* protocolError("Pi fork source has no native session file"); } + if (state.providerThread.nativeThreadRef?.nativeId !== sourceNativeId) { + return yield* protocolError( + "Pi fork requested for a thread this session does not host", + ); + } // `clone` duplicates the active branch into a new session file // and moves this process onto it; capture the clone's identity, // then switch back so this runtime keeps serving the source. @@ -2375,13 +2440,23 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (recordField(cloneData, "cancelled") === true) { return yield* protocolError("A Pi extension cancelled the session clone"); } - const cloneState = yield* request({ type: "get_state" }); + const cloneState = yield* request({ type: "get_state" }).pipe( + Effect.tapError(() => connection.terminate), + ); const cloneNativeId = recordString(cloneState, "sessionFile") ?? recordString(cloneState, "sessionId"); if (cloneNativeId === undefined || cloneNativeId === sourceNativeId) { + yield* connection.terminate; return yield* protocolError("Pi clone did not produce a new session", cloneState); } - yield* request({ type: "switch_session", sessionPath: sourceNativeId }); + const switchData = yield* request({ + type: "switch_session", + sessionPath: sourceNativeId, + }).pipe(Effect.tapError(() => connection.terminate)); + if (recordField(switchData, "cancelled") === true) { + yield* connection.terminate; + return yield* protocolError("A Pi extension cancelled the session switch"); + } const createdAt = yield* DateTime.now; return { id: idAllocator.derive.providerThread({ @@ -2427,7 +2502,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S * `undefined` when the boundary turn has no captured entry ref (only * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). */ -export function piRollbackForkEntry(input: { +function piRollbackForkEntry(input: { readonly target: | { readonly type: "thread_start" } | { readonly type: "provider_turn"; readonly providerTurn: OrchestrationV2ProviderTurn }; diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts index 19eb576d6b02..4c2a2382e281 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -19,6 +19,7 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; @@ -39,6 +40,20 @@ export class PiRpcError extends Schema.TaggedErrorClass()("PiRpcErro export type PiRpcRecord = Record; +export function piRecordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +export function piRecordString(input: unknown, key: string): string | undefined { + const value = piRecordField(input, key); + return Predicate.isString(value) ? value : undefined; +} + +export function piRecordNumber(input: unknown, key: string): number | undefined { + const value = piRecordField(input, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +} + /** * Splits a `provider/model` slug into the two fields `set_model` expects. * Returns null for slugs without a usable separator so callers can reject the @@ -122,10 +137,7 @@ function summarizePiError(error: unknown): string { function parsePiRecord(line: string): PiRpcRecord | undefined { try { const parsed: unknown = decodeJsonLine(line); - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed as PiRpcRecord; - } - return undefined; + return Predicate.isObject(parsed) ? parsed : undefined; } catch { return undefined; } @@ -206,16 +218,13 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp yield* taskkill.exitCode; }).pipe(Effect.scoped, Effect.ignore); + const terminateProcess = + platform === "win32" ? terminateWindowsTree : terminatePiProcess(killProcessGroup, hasExited); + // Registered before any further setup: an interrupt or failure between the // spawn and the rest of this constructor would otherwise leak a detached // pi process with no finalizer to reap it. - yield* Scope.addFinalizer( - scope, - (platform === "win32" - ? terminateWindowsTree - : terminatePiProcess(killProcessGroup, hasExited) - ).pipe(Effect.ignore, Effect.uninterruptible), - ); + yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); const pendingRequests = new Map(); const events = yield* Queue.unbounded(); @@ -382,9 +391,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp request, events, exited: Deferred.await(exitDeferred), - terminate: terminatePiProcess(killProcessGroup, hasExited).pipe( - Effect.ignore, - Effect.uninterruptible, - ), + terminate: terminateProcess.pipe(Effect.ignore, Effect.uninterruptible), } satisfies PiRpcConnection; }); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts index 32c05379c28d..4a18e196705a 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -85,6 +85,15 @@ function formatMcpContent(result: unknown): string { return JSON.stringify(result); } +function isMcpToolError(result: unknown): boolean { + return ( + typeof result === "object" && + result !== null && + "isError" in result && + result.isError === true + ); +} + function createMcpClient(endpoint: string, token: string) { let nextId = 1; let sessionId: string | undefined; @@ -184,7 +193,8 @@ export default async function t3McpExtension(pi: ExtensionAPI) { let started: Promise | undefined; const ensureStarted = () => { - started ??= (async () => { + if (started !== undefined) return started; + const attempt = (async () => { const signal = AbortSignal.timeout(10_000); await client.connect(signal); const tools = await client.listTools(signal); @@ -210,25 +220,24 @@ export default async function t3McpExtension(pi: ExtensionAPI) { return { content: [{ type: "text", text }], details: { server: "t3-code", tool: name }, + ...(isMcpToolError(result) ? { isError: true } : {}), }; }, }); } })(); - return started; + started = attempt; + void attempt.catch(() => { + if (started === attempt) started = undefined; + }); + return attempt; }; // Await here so tools exist before session_start and the first prompt. // session_start is a retry if the process later reloads the extension. - try { - await ensureStarted(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - pi.on("session_start", async (_event, ctx) => { - ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); - }); - return; - } + // Best effort during extension load. A failed first connection is retried + // below on session_start instead of pinning this process to the failure. + await ensureStarted().catch(() => undefined); pi.on("session_start", async (_event, ctx) => { try { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 4dca228e0877..12e8d6f1ed09 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -14,16 +14,11 @@ import { T3_PI_CHILD_SESSION_ROOT_ENV, } from "./piT3SubagentExtensionSource.ts"; import { - bearerTokenFromAuthorizationHeader, buildPiRpcLaunch, discoverPiUserExtensions, - isConflictingPiSubagentExtensionPath, T3_PI_MCP_EXTENSION_PATH_ENV, materializePiT3McpExtension, materializePiT3SubagentExtension, - piChildSessionRootFromLaunchArgs, - piT3McpExtensionDestPath, - piT3SubagentExtensionDestPath, } from "./piT3McpInjection.ts"; const threadId = ThreadId.make("thread-pi-t3-mcp"); @@ -38,11 +33,6 @@ const mcpSession = { }; describe("pi T3 MCP injection", () => { - it("strips the Bearer prefix for the child env", () => { - assert.equal(bearerTokenFromAuthorizationHeader("Bearer secret-pi-token"), "secret-pi-token"); - assert.equal(bearerTokenFromAuthorizationHeader("secret-pi-token"), "secret-pi-token"); - }); - it("leaves spawn args unchanged when no MCP session exists", () => { const launch = buildPiRpcLaunch({ launchArgs: "--session-dir /tmp/pi-sessions", @@ -56,24 +46,15 @@ describe("pi T3 MCP injection", () => { assert.isUndefined(launch.env[T3_MCP_URL_ENV]); }); - it("identifies official subagent paths and keeps the T3 override", () => { - assert.isTrue( - isConflictingPiSubagentExtensionPath("/opt/pi/examples/extensions/subagent/index.ts"), - ); - assert.isTrue( - isConflictingPiSubagentExtensionPath("/home/user/.pi/agent/extensions/subagent/index.ts"), - ); - assert.isFalse(isConflictingPiSubagentExtensionPath("/tmp/cache/pi-t3-subagent-extension.ts")); - }); - - it("prepends the subagent override and drops the official tool", () => { + it("builds one deduplicated extension launch with scoped T3 credentials", () => { const launch = buildPiRpcLaunch({ launchArgs: - "--session-dir /tmp/pi-sessions --extension /opt/pi/examples/extensions/subagent/index.ts", + "--session-dir /tmp/pi-sessions --extension /opt/pi/examples/extensions/subagent/index.ts --extension /tmp/cache/pi-t3-subagent-extension.ts --extension /home/user/.pi/agent/extensions/demo.ts", environment: { PATH: "/usr/bin" }, mcpSession, extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", subagentExtensionPath: "/tmp/cache/pi-t3-subagent-extension.ts", + discoveredExtensionPaths: ["/home/user/.pi/agent/extensions/demo.ts"], }); assert.deepEqual(launch.args, [ "--mode", @@ -81,123 +62,49 @@ describe("pi T3 MCP injection", () => { "--no-extensions", "--extension", "/tmp/cache/pi-t3-subagent-extension.ts", - "--session-dir", - "/tmp/pi-sessions", "--extension", - "/tmp/cache/pi-t3-mcp-extension.ts", - ]); - assert.equal(launch.env[T3_PI_CHILD_SESSION_ROOT_ENV], "/tmp/pi-sessions/children"); - assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); - assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); - }); - - it("appends --extension and scoped env when a session exists", () => { - const launch = buildPiRpcLaunch({ - launchArgs: "--session-dir /tmp/pi-sessions", - environment: { PATH: "/usr/bin" }, - mcpSession, - extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", - }); - assert.isTrue(launch.hasT3Mcp); - assert.deepEqual(launch.args, [ - "--mode", - "rpc", + "/home/user/.pi/agent/extensions/demo.ts", "--session-dir", "/tmp/pi-sessions", "--extension", "/tmp/cache/pi-t3-mcp-extension.ts", ]); + assert.equal(launch.env[T3_PI_CHILD_SESSION_ROOT_ENV], "/tmp/pi-sessions/children"); assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); - }); - - it("does not duplicate an already-present extension path", () => { - const launch = buildPiRpcLaunch({ - launchArgs: "--extension /tmp/cache/pi-t3-mcp-extension.ts", - environment: {}, - mcpSession, - extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", - }); - assert.deepEqual(launch.args, [ - "--mode", - "rpc", - "--extension", - "/tmp/cache/pi-t3-mcp-extension.ts", - ]); - }); - - it("derives the child session root from --session-dir", () => { + assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts"); assert.equal( - piChildSessionRootFromLaunchArgs("--session-dir /tmp/pi-sessions --extension x.ts"), - "/tmp/pi-sessions/children", + launch.args.filter((arg) => arg === "/tmp/cache/pi-t3-subagent-extension.ts").length, + 1, ); - assert.isUndefined(piChildSessionRootFromLaunchArgs("")); }); - it.effect("writes the extension source to the cache directory", () => + it.effect("materializes both runtime extensions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-t3-mcp-" }); - const dest = yield* materializePiT3McpExtension(cacheDir); - assert.equal(dest, piT3McpExtensionDestPath(cacheDir)); - assert.isTrue(dest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); - const source = yield* fs.readFileString(dest); - assert.include(source, "export default async function t3McpExtension"); + const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-extensions-" }); + const mcpDest = yield* materializePiT3McpExtension(cacheDir); + const subagentDest = yield* materializePiT3SubagentExtension(cacheDir); + assert.isTrue(mcpDest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); + assert.isTrue(subagentDest.endsWith(PI_T3_SUBAGENT_EXTENSION_FILENAME)); + const mcpSource = yield* fs.readFileString(mcpDest); + const subagentSource = yield* fs.readFileString(subagentDest); + assert.include(mcpSource, "export default async function t3McpExtension"); // Orchestration guidance rides the system-prompt hook, not the user // message, so first-turn slash commands still expand. - assert.include(source, "before_agent_start"); - assert.include(source, "T3 Code orchestration"); - assert.include(source, T3_MCP_URL_ENV); - assert.include(source, '"mcp-protocol-version"'); - assert.include(source, '"tools/call"'); - const again = yield* materializePiT3McpExtension(cacheDir); - assert.equal(again, dest); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("writes the subagent override source to the cache directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-t3-subagent-" }); - const dest = yield* materializePiT3SubagentExtension(cacheDir); - assert.equal(dest, piT3SubagentExtensionDestPath(cacheDir)); - assert.isTrue(dest.endsWith(PI_T3_SUBAGENT_EXTENSION_FILENAME)); - const source = yield* fs.readFileString(dest); - assert.include(source, "export default function t3SubagentExtension"); - assert.include(source, "--session"); - assert.include(source, T3_PI_CHILD_SESSION_ROOT_ENV); + assert.include(mcpSource, "before_agent_start"); + assert.include(mcpSource, '"mcp-protocol-version"'); + assert.include(mcpSource, '"tools/call"'); + assert.include(subagentSource, "export default function t3SubagentExtension"); + assert.include(subagentSource, "--session"); + assert.include(subagentSource, T3_PI_CHILD_SESSION_ROOT_ENV); // Children re-attach the T3 MCP extension so t3_thread_* tools survive // the nested spawn (T3_MCP_* env is inherited from the parent). - assert.include(source, T3_PI_MCP_EXTENSION_PATH_ENV); - assert.isFalse(source.includes("--no-session")); + assert.include(subagentSource, T3_PI_MCP_EXTENSION_PATH_ENV); + assert.isFalse(subagentSource.includes("--no-session")); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it("re-adds discovered user extensions around the --no-extensions spawn", () => { - const launch = buildPiRpcLaunch({ - launchArgs: "--session-dir /tmp/pi-sessions", - environment: { PATH: "/usr/bin" }, - mcpSession, - extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", - subagentExtensionPath: "/tmp/cache/pi-t3-subagent-extension.ts", - discoveredExtensionPaths: ["/home/user/.pi/agent/extensions/demo.ts"], - }); - assert.deepEqual(launch.args, [ - "--mode", - "rpc", - "--no-extensions", - "--extension", - "/tmp/cache/pi-t3-subagent-extension.ts", - "--extension", - "/home/user/.pi/agent/extensions/demo.ts", - "--session-dir", - "/tmp/pi-sessions", - "--extension", - "/tmp/cache/pi-t3-mcp-extension.ts", - ]); - assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts"); - }); - it.effect("discovers user extensions from the agent dir and skips the subagent", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index b29840991cab..78d6aa172f2a 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; @@ -27,7 +28,7 @@ export { /** Env var telling the T3 subagent override where the MCP extension lives. */ export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH"; -export function bearerTokenFromAuthorizationHeader(header: string): string { +function bearerTokenFromAuthorizationHeader(header: string): string { return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; } @@ -36,9 +37,9 @@ const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); function piDefaultProjectTrust(settingsRaw: string): string | undefined { try { const parsed = decodeJson(settingsRaw); - if (typeof parsed !== "object" || parsed === null) return undefined; - const value = (parsed as Record)["defaultProjectTrust"]; - return typeof value === "string" ? value : undefined; + if (!Predicate.isObject(parsed)) return undefined; + const value = parsed["defaultProjectTrust"]; + return Predicate.isString(value) ? value : undefined; } catch { return undefined; } @@ -94,15 +95,15 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu return found; }); -export function piT3McpExtensionDestPath(cacheDir: string): string { +function piT3McpExtensionDestPath(cacheDir: string): string { return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; } -export function piT3SubagentExtensionDestPath(cacheDir: string): string { +function piT3SubagentExtensionDestPath(cacheDir: string): string { return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_SUBAGENT_EXTENSION_FILENAME}`; } -export function piChildSessionRootFromLaunchArgs(launchArgs: string): string | undefined { +function piChildSessionRootFromLaunchArgs(launchArgs: string): string | undefined { const args = tokenizeCliArgs(launchArgs); const index = args.indexOf("--session-dir"); const sessionDir = index >= 0 ? args[index + 1] : undefined; @@ -140,11 +141,7 @@ function appendExtensionArg( args: ReadonlyArray, extensionPath: string | undefined, ): string[] { - if (extensionPath === undefined) return [...args]; - const alreadyHas = args.some( - (arg, index) => (arg === "--extension" || arg === "-e") && args[index + 1] === extensionPath, - ); - return alreadyHas ? [...args] : [...args, "--extension", extensionPath]; + return extensionPath === undefined ? [...args] : [...args, "--extension", extensionPath]; } function normalizePiPath(value: string): string { @@ -152,7 +149,7 @@ function normalizePiPath(value: string): string { } /** Official / user-installed `subagent` tool. Not the T3 override file. */ -export function isConflictingPiSubagentExtensionPath(extensionPath: string): boolean { +function isConflictingPiSubagentExtensionPath(extensionPath: string): boolean { const normalized = normalizePiPath(extensionPath); if (normalized.endsWith(`/${PI_T3_SUBAGENT_EXTENSION_FILENAME}`)) return false; return ( @@ -163,7 +160,7 @@ export function isConflictingPiSubagentExtensionPath(extensionPath: string): boo ); } -export function stripConflictingPiSubagentExtensionArgs(args: ReadonlyArray): string[] { +function stripConflictingPiSubagentExtensionArgs(args: ReadonlyArray): string[] { const stripped: string[] = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -182,6 +179,26 @@ export function stripConflictingPiSubagentExtensionArgs(args: ReadonlyArray): string[] { + const seen = new Set(); + const deduplicated: Array = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + const extensionPath = args[index + 1]; + if ((arg === "--extension" || arg === "-e") && extensionPath !== undefined) { + index += 1; + const normalized = normalizePiPath(extensionPath); + if (seen.has(normalized)) continue; + seen.add(normalized); + deduplicated.push(arg, extensionPath); + continue; + } + deduplicated.push(arg); + } + return deduplicated; +} + export function buildPiRpcLaunch(input: { readonly launchArgs: string; readonly environment: NodeJS.ProcessEnv; @@ -215,6 +232,7 @@ export function buildPiRpcLaunch(input: { if (hasT3Mcp && input.extensionPath !== undefined) { args = appendExtensionArg(args, input.extensionPath); } + args = deduplicatePiExtensionArgs(args); const childSessionRoot = piChildSessionRootFromLaunchArgs(input.launchArgs); return { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts index d2a2ae57f1a2..0c49f7c07115 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts @@ -46,18 +46,10 @@ type SingleResult = { agent: string; agentSource: "user" | "project" | "unknown"; task: string; + finished: boolean; exitCode: number; messages: unknown[]; stderr: string; - usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - cost: number; - contextTokens: number; - turns: number; - }; model?: string; stopReason?: string; errorMessage?: string; @@ -213,10 +205,10 @@ async function runSingleAgent( agent: agentName, agentSource: "unknown", task, + finished: true, exitCode: 1, messages: [], stderr: \`Unknown agent: "\${agentName}". Available agents: \${available}.\`, - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, step, }; } @@ -245,10 +237,10 @@ async function runSingleAgent( agent: agentName, agentSource: agent.source, task, + finished: false, exitCode: 0, messages: [], stderr: "", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, model, step, sessionFile, @@ -291,23 +283,11 @@ async function runSingleAgent( currentResult.messages.push(event.message); const message = event.message as { role?: string; - usage?: Record; model?: string; stopReason?: string; errorMessage?: string; }; if (message.role === "assistant") { - currentResult.usage.turns += 1; - if (message.usage) { - currentResult.usage.input += message.usage.input || 0; - currentResult.usage.output += message.usage.output || 0; - currentResult.usage.cacheRead += message.usage.cacheRead || 0; - currentResult.usage.cacheWrite += message.usage.cacheWrite || 0; - const cost = message.usage.cost as number | { total?: number } | undefined; - currentResult.usage.cost += - typeof cost === "number" ? cost : typeof cost?.total === "number" ? cost.total : 0; - currentResult.usage.contextTokens = message.usage.totalTokens || 0; - } if (!currentResult.model && message.model) currentResult.model = message.model; if (message.stopReason) currentResult.stopReason = message.stopReason; if (message.errorMessage) currentResult.errorMessage = message.errorMessage; @@ -326,22 +306,33 @@ async function runSingleAgent( proc.stderr?.on("data", (chunk: string) => { currentResult.stderr += chunk; }); + let killTimer: ReturnType | undefined; + let settled = false; + const settle = (code: number) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + if (killTimer !== undefined) clearTimeout(killTimer); + if (buffer.trim()) processLine(buffer); + resolve(code); + }; const onAbort = () => { currentResult.stopReason = "aborted"; - proc.kill("SIGTERM"); + if (proc.kill("SIGTERM")) { + killTimer = setTimeout(() => proc.kill("SIGKILL"), 1_000); + killTimer.unref(); + } }; - signal?.addEventListener("abort", onAbort, { once: true }); - proc.on("close", (code) => { - signal?.removeEventListener("abort", onAbort); - if (buffer.trim()) processLine(buffer); - resolve(code ?? 1); - }); + proc.on("close", (code) => settle(code ?? 1)); proc.on("error", (error) => { currentResult.stderr += error.message; - resolve(1); + settle(1); }); + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); }); currentResult.exitCode = exitCode; + currentResult.finished = true; return currentResult; } finally { if (tmpPromptDir) { @@ -482,10 +473,10 @@ export default function t3SubagentExtension(pi: ExtensionAPI) { agent: item.agent, agentSource: "unknown", task: item.task, + finished: false, exitCode: -1, messages: [], stderr: "", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, })); const emitParallel = () => { onUpdate?.({ diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.ts b/apps/server/src/orchestration-v2/UserFacingErrors.ts index 535dd05babaa..81f12b05dab5 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.ts @@ -1,4 +1,5 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderDriverKind } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; const GENERIC_ERROR_PREFIXES = [ "Failed to dispatch orchestration V2 command", @@ -39,7 +40,7 @@ function providerDisplayName(instanceId: string): string { /** Friendly translation for command-policy rejections; undefined otherwise. */ function policyRejectionMessage(value: unknown): string | undefined { - if (!isRecord(value) || typeof value.providerInstanceId !== "string") return undefined; + if (!Predicate.isObject(value) || typeof value.providerInstanceId !== "string") return undefined; const provider = providerDisplayName(value.providerInstanceId); if (value._tag === "CommandPolicyCapabilityUnsupportedError") { const capability = typeof value.capability === "string" ? value.capability : ""; @@ -51,10 +52,6 @@ function policyRejectionMessage(value: unknown): string | undefined { return undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function textValue(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } @@ -70,7 +67,7 @@ function messageFrom(value: unknown): string | undefined { if (value instanceof Error) { return textValue(value.message); } - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return undefined; } return textValue(value.detail) ?? textValue(value.message); @@ -91,7 +88,7 @@ function collectErrorMessages(value: unknown, seen: Set): ReadonlyArray } const message = messageFrom(value); - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return message === undefined ? [] : [message]; } diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 19cd59d0ac4c..438cbbcf0792 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -26,7 +26,11 @@ import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { makePiRpcConnection } from "../../orchestration-v2/Adapters/PiRpc.ts"; +import { + makePiRpcConnection, + piRecordField as recordField, + piRecordString as recordString, +} from "../../orchestration-v2/Adapters/PiRpc.ts"; import { buildServerProvider, isCommandMissingCause, @@ -80,16 +84,6 @@ function piModelsFromSettings( ); } -function recordField(input: unknown, key: string): unknown { - if (typeof input !== "object" || input === null) return undefined; - return (input as Record)[key]; -} - -function recordString(input: unknown, key: string): string | undefined { - const value = recordField(input, key); - return typeof value === "string" ? value : undefined; -} - function parseDiscoveredModels(data: unknown): ReadonlyArray { const models = recordField(data, "models"); if (!Array.isArray(models)) return []; diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts index a89138d5506c..fae173064e39 100644 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts @@ -2,57 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import { EMPTY_PI_MODEL_CAPABILITIES, - supportedPiThinkingLevelsFromModel, thinkingCapabilitiesForPiModel, } from "./piThinkingCapabilities.ts"; -describe("supportedPiThinkingLevelsFromModel", () => { - it("returns no levels when the model does not advertise reasoning", () => { - assert.deepEqual(supportedPiThinkingLevelsFromModel({ reasoning: false }), []); - assert.deepEqual(supportedPiThinkingLevelsFromModel({}), []); - }); - - it("advertises off through high without Extra High or Max when the map is absent", () => { - assert.deepEqual(supportedPiThinkingLevelsFromModel({ reasoning: true }), [ - "off", - "minimal", - "low", - "medium", - "high", - ]); - }); - - it("adds Extra High and Max only when the map has a non-null entry", () => { - assert.deepEqual( - supportedPiThinkingLevelsFromModel({ - reasoning: true, - thinkingLevelMap: { xhigh: "xhigh", max: "max" }, - }), - ["off", "minimal", "low", "medium", "high", "xhigh", "max"], - ); - }); - - it("hides a mapped-null level and keeps Extra High when only that entry exists", () => { - assert.deepEqual( - supportedPiThinkingLevelsFromModel({ - reasoning: true, - thinkingLevelMap: { off: null, xhigh: "extra_high" }, - }), - ["minimal", "low", "medium", "high", "xhigh"], - ); - }); - - it("does not treat a null Extra High or Max entry as supported", () => { - assert.deepEqual( - supportedPiThinkingLevelsFromModel({ - reasoning: true, - thinkingLevelMap: { xhigh: null, max: null }, - }), - ["off", "minimal", "low", "medium", "high"], - ); - }); -}); - describe("thinkingCapabilitiesForPiModel", () => { it("returns empty capabilities for a non-reasoning model", () => { assert.deepEqual( @@ -61,10 +13,10 @@ describe("thinkingCapabilitiesForPiModel", () => { ); }); - it("prepends inherit and labels Extra High for grok-4.6-shaped maps", () => { + it("maps Pi's per-model thinking levels into the picker", () => { const capabilities = thinkingCapabilitiesForPiModel({ reasoning: true, - thinkingLevelMap: { xhigh: "xhigh" }, + thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, }); const descriptors = capabilities.optionDescriptors ?? []; const thinking = descriptors[0]; @@ -75,7 +27,6 @@ describe("thinkingCapabilitiesForPiModel", () => { thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), [ ["inherit", "Pi default", true], - ["off", "Off", false], ["minimal", "Minimal", false], ["low", "Low", false], ["medium", "Medium", false], diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.ts index f6ffed21ca90..5bdf2dc5b6b6 100644 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.ts +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.ts @@ -1,5 +1,6 @@ import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Predicate from "effect/Predicate"; /** * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per @@ -65,7 +66,7 @@ export function thinkingCapabilitiesForPiModel(model: unknown): ModelCapabilitie * A reasoning model always exposes off through high unless a map entry is * `null`. Extra High and Max appear only when the map has a non-null entry. */ -export function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { +function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { if (recordField(model, "reasoning") !== true) return []; const thinkingLevelMap = thinkingLevelMapFromModel(model); return PI_THINKING_LEVELS.filter((level) => { @@ -83,6 +84,5 @@ function thinkingLevelMapFromModel(model: unknown): Record | un } function recordField(input: unknown, key: string): unknown { - if (typeof input !== "object" || input === null) return undefined; - return (input as Record)[key]; + return Predicate.isObject(input) ? input[key] : undefined; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 01c3f56adf1d..7aceb46d23c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1556,6 +1556,15 @@ function ChatViewContent(props: ChatViewProps) { () => (serverProjection === null ? null : resolveThreadProviderSession(serverProjection)), [serverProjection], ); + const activeProviderThread = useMemo(() => { + if (serverProjection === null) return null; + const activeProviderThreadId = serverProjection.thread.activeProviderThreadId; + if (activeProviderThreadId == null) return null; + return ( + serverProjection.providerThreads.find((thread) => thread.id === activeProviderThreadId) ?? + null + ); + }, [serverProjection]); const supportsProviderSwitchingViaHandoff = activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true; const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null); @@ -6586,6 +6595,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeThreadVisibleTurnItems={serverVisibleTurnItems} + activeProviderThread={activeProviderThread} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8552c0975946..54ff702719bc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2,6 +2,7 @@ import type { EnvironmentId, ModelSelection, OrchestrationV2ProjectedTurnItem, + OrchestrationV2ProviderThread, PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, @@ -234,10 +235,7 @@ import type { PendingUserInput, } from "../../session-logic"; import { resolveComposerDispatchMode, type ComposerDispatchMode } from "./composerDispatch"; -import { - deriveLatestContextWindowSnapshot, - formatProviderDisplayName, -} from "../../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -575,6 +573,7 @@ export interface ChatComposerProps { // Context window activeThreadVisibleTurnItems: ReadonlyArray | undefined; + activeProviderThread: OrchestrationV2ProviderThread | null; // Misc resolvedTheme: "light" | "dark"; @@ -667,6 +666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadVisibleTurnItems, + activeProviderThread, resolvedTheme, settings, keybindings, @@ -963,8 +963,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Context window // ------------------------------------------------------------------ const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(activeThreadVisibleTurnItems ?? []), - [activeThreadVisibleTurnItems], + () => + deriveLatestContextWindowSnapshot(activeThreadVisibleTurnItems ?? [], activeProviderThread), + [activeProviderThread, activeThreadVisibleTurnItems], ); const activeThreadModelDisplayName = useMemo( () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index 471cfe3db3f1..e222315e95d5 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -32,6 +32,29 @@ describe("V2 context window presentation", () => { expect(snapshot?.totalProcessedTokens).toBe(10_000); }); + it("prefers the provider thread's live context snapshot", () => { + const snapshot = deriveLatestContextWindowSnapshot([], { + contextUsage: { + usedTokens: 20_000, + totalProcessedTokens: 42_000, + maxTokens: 200_000, + inputTokens: 18_000, + outputTokens: 2_000, + compactsAutomatically: true, + }, + updatedAt: DateTime.makeUnsafe("2026-08-17T12:00:00.000Z"), + }); + + expect(snapshot).toMatchObject({ + usedTokens: 20_000, + maxTokens: 200_000, + remainingTokens: 180_000, + usedPercentage: 10, + remainingPercentage: 90, + totalProcessedTokens: 42_000, + }); + }); + it("formats compact token values", () => { expect(formatContextWindowTokens(1_500)).toBe("1.5k"); }); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 9ad131f0bc99..5f54d5d258be 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,4 +1,8 @@ -import type { OrchestrationV2TurnItem, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; +import type { + OrchestrationV2ProviderThread, + OrchestrationV2TurnItem, + ThreadTokenUsageSnapshot, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; function asFiniteNumber(value: unknown): number | null { @@ -18,35 +22,49 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { readonly updatedAt: string; }; -/** Map a provider driver kind to a user-facing display name. */ -export function formatProviderDisplayName(provider: string | null | undefined): string { - if (!provider) return "This agent"; - switch (provider) { - case "claudeAgent": - case "claude": - return "Claude"; - case "codex": - return "Codex"; - case "cursor": - return "Cursor"; - case "opencode": - return "OpenCode"; - case "pi": - return "Pi"; - default: { - // Title-case unknown driver kinds so they read reasonably. - const trimmed = provider.replace(/Agent$/i, "").trim(); - if (trimmed.length === 0) return provider; - return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); - } - } -} - export function deriveLatestContextWindowSnapshot( entries: ReadonlyArray<{ readonly item: OrchestrationV2TurnItem; }>, + providerThread?: Pick | null, ): ContextWindowSnapshot | null { + const providerUsage = providerThread?.contextUsage; + const providerUpdatedAt = providerThread?.updatedAt; + if (providerUsage !== null && providerUsage !== undefined && providerUpdatedAt !== undefined) { + const usedTokens = asFiniteNumber(providerUsage.usedTokens); + const maxTokens = asFiniteNumber(providerUsage.maxTokens); + if (usedTokens !== null && usedTokens >= 0) { + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = + usedPercentage === null ? null : Math.max(0, 100 - usedPercentage); + + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens), + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: asFiniteNumber(providerUsage.inputTokens), + cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens), + outputTokens: asFiniteNumber(providerUsage.outputTokens), + reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens), + lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens), + lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens), + lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens), + lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens), + lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens), + toolUses: asFiniteNumber(providerUsage.toolUses), + durationMs: asFiniteNumber(providerUsage.durationMs), + compactsAutomatically: providerUsage.compactsAutomatically ?? null, + updatedAt: DateTime.formatIso(providerUpdatedAt), + }; + } + } + for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; if (!entry || entry.item.type !== "compaction") { diff --git a/docs/README.md b/docs/README.md index 295ff082fefe..2ee66980f5ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Pi](./user/providers-pi.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a52a3e602a68..44d0f9e37a3f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -18,10 +18,13 @@ orchestration layer does not know which one is behind a thread. | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | | `pi` | [`Drivers/PiDriver.ts`][pi] | -The Pi driver speaks Pi's stdio JSONL RPC mode (`pi --mode rpc`) and deliberately spawns the user's -own `pi` install with no `--no-*` flags, so extensions, skills, context files, custom models, and -sessions behave exactly as they do in the Pi TUI. Extension UI dialogs surface as orchestration -runtime requests. +The Pi driver speaks Pi's stdio JSONL RPC mode (`pi --mode rpc`) and spawns the user's own `pi` +install, preserving its skills, context files, custom models, auth, and sessions. T3 replaces Pi's +official `subagent` extension with a session-persisting variant. That launch uses +`--no-extensions`, then explicitly re-adds the T3 extensions and the user's discovered extensions; +project-local extensions are only re-added when Pi has standing project trust. Extension UI dialogs +surface as orchestration runtime requests, and Pi's session statistics feed the shared context +window meter. Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index dc7ba1c1abf0..c63822c05e97 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -145,14 +145,17 @@ The extension connects to that HTTP endpoint, lists tools, and registers each one with `pi.registerTool` under its original name (`delegate_task`, `t3_thread_start`, and the rest). Follow-up HTTP requests send `mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400 -without it. User `launchArgs` are preserved. The first turn of a session -also receives the shared T3 orchestration instructions. +without it. The first turn of a session also receives the shared T3 +orchestration instructions. A second T3-owned extension overrides the official `subagent` tool to persist `--session` and report `sessionFile`. Duplicate `subagent` registrations abort Pi, so the launcher disables extension discovery and -drops the official tool from `launchArgs`. The adapter binds each result -as a child thread that later sends resume through `switch_session`. +drops the official tool from `launchArgs`. It then explicitly re-adds the +T3 extensions and deduplicated user extensions, including project-local +extensions only under standing project trust. Other user `launchArgs` are +preserved. The adapter binds each result as a child thread that later sends +resume through `switch_session`. ### Initial Provider Support diff --git a/docs/user/install.md b/docs/user/install.md index 96776c7ea1f1..2d9cfb6d1ae3 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -82,7 +82,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For provider-specific setup, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and +[Pi](./providers-pi.md). ## Next Steps diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md new file mode 100644 index 000000000000..f3cdb6ec5197 --- /dev/null +++ b/docs/user/providers-pi.md @@ -0,0 +1,34 @@ +# Pi + +T3 Code can use your existing Pi coding agent installation while keeping Pi's models, auth, +extensions, skills, context files, and native session history. + +## Set Up Pi + +1. Install Pi on the machine running the T3 Code server. +2. Run Pi once in a terminal and finish the provider login or API-key setup you normally use. +3. Open T3 Code Settings, enable Pi, and refresh the provider. + +If `pi` is not on the server's `PATH`, set Pi's binary path to the executable. Provider environment +variables and launch arguments are also available for installations that need a custom agent +directory, endpoint, or model configuration. + +## What Carries Over + +T3 Code discovers the models reported by Pi and exposes their supported thinking levels. Threads +use Pi's native session files, so resume, rollback, and current-branch forks keep Pi's conversation +state. Extension dialogs appear in the T3 Code composer, and the composer context meter updates from +Pi's own context-window statistics after a response settles. + +Pi's normal user extensions continue to load. T3 Code supplies its own session-aware `subagent` +tool so each child can be opened and continued as a T3 Code thread. Project-local extensions are +only loaded automatically when Pi is configured to always trust that project. + +## Troubleshooting + +- If Pi is unavailable, confirm that the configured binary runs on the server machine, then refresh + the provider in Settings. +- If no models appear, open Pi directly and confirm its authentication and model configuration. +- If a project extension is missing, approve the project in Pi or configure standing project trust, + then start a fresh provider session. +- The context meter appears after Pi returns its first usable token snapshot for the thread. diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 21ec7e75e214..08b8dd054a92 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -682,7 +682,7 @@ describe("orchestration V2 contracts", () => { expect(ContextTransferId.make("context-transfer-1")).toBe("context-transfer-1"); }); - it("decodes historical provider-thread JSON without pendingBackgroundTasks as empty roster", () => { + it("applies defaults when decoding historical provider threads", () => { const providerThread = decodeOrchestrationV2ProviderThreadJson({ id: "provider-thread-1", driver: "claude", @@ -706,6 +706,7 @@ describe("orchestration V2 contracts", () => { }); expect(providerThread.pendingBackgroundTasks).toEqual([]); + expect(providerThread.contextUsage).toBeNull(); const runtimeThread = decodeOrchestrationV2ProviderThread({ id: "provider-thread-2", @@ -725,6 +726,7 @@ describe("orchestration V2 contracts", () => { updatedAt: now, }); expect(runtimeThread.pendingBackgroundTasks).toEqual([]); + expect(runtimeThread.contextUsage).toBeNull(); }); it("decodes historical thread shell JSON without pendingBackgroundTasks as empty roster", () => { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d58739aa997e..85dd9119ef3f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -43,6 +43,7 @@ import { RuntimeMode, } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { ThreadTokenUsageSnapshot } from "./providerRuntime.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; export const OrchestrationV2Actor = Schema.Literals(["user", "agent", "system"]); @@ -596,6 +597,11 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestrationV2PendingBackgroundTask)).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + // Latest provider-reported context window snapshot. Optional on the Type so + // adapters without usage telemetry and historical projections can omit it. + contextUsage: Schema.optional(Schema.NullOr(ThreadTokenUsageSnapshot)).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), createdAt: Schema.DateTimeUtc, updatedAt: Schema.DateTimeUtc, }); From 55f925f8038c7618c339d18ef7271e87e1d8f2db Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:20:30 +0200 Subject: [PATCH 26/41] fix(providers): scope Pi status rows to each turn --- apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts | 1 + apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 9e8435836480..2c88afe40a11 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1164,6 +1164,7 @@ describe("PiAdapterV2", () => { row.type === "turn_item.updated" && row.turnItem.type === "dynamic_tool" && row.turnItem.status === "running" && + row.turnItem.nativeItemRef?.nativeId === `${row.turnItem.providerTurnId}:status:tps` && (row.turnItem.input as { status?: string }).status === "42 tok/s", ); yield* fake.emit({ type: "agent_settled" }); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 40a01f82e948..fd7504e26f70 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1252,7 +1252,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ? recordString(event, "statusKey") : recordString(event, "widgetKey"); if (key === undefined) return; - const nativeItemId = `${method === "setStatus" ? "status" : "widget"}:${key}`; + const nativeItemId = `${turn.providerTurn.id}:${method === "setStatus" ? "status" : "widget"}:${key}`; const statusText = recordString(event, "statusText"); const widgetLines = Array.isArray(event["widgetLines"]) ? event["widgetLines"].filter((line): line is string => typeof line === "string") From 336853cbb4196ab365aa53c17f0a0825b4fcd465 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:35:33 +0200 Subject: [PATCH 27/41] fix(providers): retain Pi context usage on stats gaps --- .../Adapters/PiAdapterV2.test.ts | 27 ++++++++++++++++--- .../orchestration-v2/Adapters/PiAdapterV2.ts | 7 ++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 2c88afe40a11..38e3b9fb0300 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -437,7 +437,8 @@ describe("PiAdapterV2", () => { ); const usage = yield* takeEvent( (event) => - event.type === "provider_thread.updated" && event.providerThread.contextUsage !== null, + event.type === "provider_thread.updated" && + event.providerThread.contextUsage?.usedTokens === 20_500, ); assert.deepEqual( usage.type === "provider_thread.updated" ? usage.providerThread.contextUsage : null, @@ -454,6 +455,25 @@ describe("PiAdapterV2", () => { ); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + + // An acknowledged stats request can still omit usable window values. + // Keep the last good snapshot instead of making the meter disappear. + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + fake.queueStats({ contextUsage: { tokens: null, contextWindow: 200_000 } }); + yield* fake.emit({ type: "agent_settled" }); + const preservedUsage = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.status === "idle" && + event.providerThread.contextUsage?.totalProcessedTokens === 20_500, + ); + assert.equal( + preservedUsage.type === "provider_thread.updated" + ? preservedUsage.providerThread.contextUsage?.usedTokens + : null, + 20_500, + ); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); @@ -1056,7 +1076,8 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); const usage = yield* takeEvent( (event) => - event.type === "provider_thread.updated" && event.providerThread.contextUsage !== null, + event.type === "provider_thread.updated" && + event.providerThread.contextUsage?.usedTokens === 3_400, ); assert.equal( usage.type === "provider_thread.updated" @@ -1164,7 +1185,7 @@ describe("PiAdapterV2", () => { row.type === "turn_item.updated" && row.turnItem.type === "dynamic_tool" && row.turnItem.status === "running" && - row.turnItem.nativeItemRef?.nativeId === `${row.turnItem.providerTurnId}:status:tps` && + row.turnItem.nativeItemRef?.nativeId === `status:${row.turnItem.providerTurnId}:tps` && (row.turnItem.input as { status?: string }).status === "42 tok/s", ); yield* fake.emit({ type: "agent_settled" }); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index fd7504e26f70..f2346319cb6c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -514,12 +514,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const contextUsageFromStats = ( stats: unknown, fallbackUsedTokens: number | null, - ): ThreadTokenUsageSnapshot | null => { + ): ThreadTokenUsageSnapshot | undefined => { const contextUsage = recordField(stats, "contextUsage"); const maxTokens = nonNegativeInteger(contextUsage, "contextWindow"); const usedTokens = nonNegativeInteger(contextUsage, "tokens") ?? fallbackUsedTokens ?? undefined; - if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) return null; + if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) + return undefined; const totals = recordField(stats, "tokens"); const totalProcessedTokens = nonNegativeInteger(totals, "total"); @@ -1252,7 +1253,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ? recordString(event, "statusKey") : recordString(event, "widgetKey"); if (key === undefined) return; - const nativeItemId = `${turn.providerTurn.id}:${method === "setStatus" ? "status" : "widget"}:${key}`; + const nativeItemId = `${method === "setStatus" ? "status" : "widget"}:${turn.providerTurn.id}:${key}`; const statusText = recordString(event, "statusText"); const widgetLines = Array.isArray(event["widgetLines"]) ? event["widgetLines"].filter((line): line is string => typeof line === "string") From eb3d3c78cd3b4149cf9050f40afe36f5c8edc3d1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:41:01 +0200 Subject: [PATCH 28/41] fix(providers): block Pi thread switches during turns --- .../src/orchestration-v2/Adapters/PiAdapterV2.test.ts | 6 ++++++ apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 38e3b9fb0300..8c06bd6439c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -348,6 +348,12 @@ describe("PiAdapterV2", () => { yield* runtime.resumeThread({ providerThread }); const switchRequest = yield* fake.takeRequest("switch_session"); assert.equal(switchRequest["sessionPath"], FAKE_SESSION_FILE); + + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const error = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.match(String(error.cause), /while a turn is active/); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index f2346319cb6c..10ee90485430 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1816,6 +1816,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S const registerThread = Effect.fnUntraced(function* ( threadInput: ProviderAdapterV2EnsureThreadInput, ) { + if (threadState !== null && threadState.activeTurn !== null) { + return yield* protocolError("Cannot register a Pi thread while a turn is active"); + } const existing = threadInput.existingProviderThread; if (existing?.nativeThreadRef?.nativeId != null) { if (liveChildSessions.has(existing.nativeThreadRef.nativeId)) { From a0ddbf7f0b2ca039263ea6eb2dbeaa9689c4638c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:54:55 +0200 Subject: [PATCH 29/41] fix(providers): serialize Pi thread registration --- apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 10ee90485430..7e884183332d 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -2034,6 +2034,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S events: Stream.fromQueue(events), ensureThread: (threadInput) => registerThread(threadInput).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterEnsureThreadError({ @@ -2051,6 +2052,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, existingProviderThread: threadInput.providerThread, }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterResumeThreadError({ From 4a6d708f54e3277350f249b3bbb3db8b721c233d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:12:01 +0200 Subject: [PATCH 30/41] fix(pi): show configured thinking level as default --- .../Adapters/PiAdapterV2.test.ts | 1 + .../orchestration-v2/Adapters/PiAdapterV2.ts | 4 +- .../Adapters/piT3McpInjection.test.ts | 1 + apps/server/src/provider/Layers/PiProvider.ts | 13 ++++- .../Layers/piThinkingCapabilities.test.ts | 40 +++++++++++--- .../provider/Layers/piThinkingCapabilities.ts | 55 ++++++++++++++----- docs/user/providers-pi.md | 3 +- 7 files changed, 89 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 8c06bd6439c7..5850c329ecfa 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -307,6 +307,7 @@ describe("PiAdapterV2", () => { providerInstanceId: PI_INSTANCE_ID, endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer secret-pi-token", + browserToolsAvailable: true, }); const fake = yield* makeFakePi; yield* openRuntime(fake); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 7e884183332d..5aeff3077ac2 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -458,7 +458,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S */ let leafCursorStale = false; // Pi's own configured defaults, captured from the first `get_state` so - // that selecting "Pi default"/"inherit" again can restore them. Pi has no + // that selecting the displayed default again can restore them. Pi has no // "unset model" command, so the baseline has to be replayed explicitly. let baselineModel: { provider: string; modelId: string } | null = null; let baselineThinking: string | null = null; @@ -1844,7 +1844,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S appliedSessionName = null; // The baselines describe the session we just left too. Dropping them // lets the `get_state` below re-capture this session's own defaults, - // so "Pi default"/"inherit" cannot replay the previous session's. + // so the inherited choice cannot replay the previous session's. baselineModel = null; baselineThinking = null; } diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 12e8d6f1ed09..8378296791c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -30,6 +30,7 @@ const mcpSession = { providerInstanceId: ProviderInstanceId.make("pi"), endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer secret-pi-token", + browserToolsAvailable: true, }; describe("pi T3 MCP injection", () => { diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 438cbbcf0792..ecef43212eda 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -84,7 +84,10 @@ function piModelsFromSettings( ); } -function parseDiscoveredModels(data: unknown): ReadonlyArray { +function parseDiscoveredModels( + data: unknown, + defaultThinkingLevel: unknown, +): ReadonlyArray { const models = recordField(data, "models"); if (!Array.isArray(models)) return []; const seen = new Set(); @@ -100,7 +103,7 @@ function parseDiscoveredModels(data: unknown): ReadonlyArray undefined)); - const discoveredModels = parseDiscoveredModels(modelsData); + const discoveredModels = parseDiscoveredModels( + modelsData, + recordString(stateData, "thinkingLevel"), + ); const { slashCommands, skills } = parseDiscoveredCommands(commandsData); return { models: discoveredModels, diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts index fae173064e39..b26a56884aed 100644 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts @@ -8,16 +8,19 @@ import { describe("thinkingCapabilitiesForPiModel", () => { it("returns empty capabilities for a non-reasoning model", () => { assert.deepEqual( - thinkingCapabilitiesForPiModel({ reasoning: false }), + thinkingCapabilitiesForPiModel({ reasoning: false }, "xhigh"), EMPTY_PI_MODEL_CAPABILITIES, ); }); - it("maps Pi's per-model thinking levels into the picker", () => { - const capabilities = thinkingCapabilitiesForPiModel({ - reasoning: true, - thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, - }); + it("shows Pi's resolved default as the default choice while keeping it inherited", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, + }, + "xhigh", + ); const descriptors = capabilities.optionDescriptors ?? []; const thinking = descriptors[0]; assert.equal(thinking?.id, "thinking"); @@ -26,13 +29,34 @@ describe("thinkingCapabilitiesForPiModel", () => { assert.deepEqual( thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), [ - ["inherit", "Pi default", true], ["minimal", "Minimal", false], ["low", "Low", false], ["medium", "Medium", false], ["high", "High", false], - ["xhigh", "Extra High", false], + ["inherit", "Extra High", true], ], ); }); + + it("clamps Pi's default to each model's supported levels", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { xhigh: "extra_high", max: null }, + }, + "max", + ); + const thinking = capabilities.optionDescriptors?.[0]; + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepInclude(thinking.options, { + id: "inherit", + label: "Extra High", + isDefault: true, + }); + assert.notInclude( + thinking.options.map((option) => option.id), + "xhigh", + ); + }); }); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.ts index 5bdf2dc5b6b6..186bcb587d01 100644 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.ts +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.ts @@ -29,37 +29,64 @@ const PI_THINKING_LEVEL_LABELS: Record = { max: "Max", }; -const INHERIT_CHOICE: ProviderOptionChoice = { - id: "inherit", - label: "Pi default", - isDefault: true, -}; - export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -export function thinkingCapabilitiesForPiModel(model: unknown): ModelCapabilities { +export function thinkingCapabilitiesForPiModel( + model: unknown, + defaultThinkingLevel: unknown, +): ModelCapabilities { const levels = supportedPiThinkingLevelsFromModel(model); if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; + const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); return createModelCapabilities({ optionDescriptors: [ { id: "thinking", label: "Thinking", type: "select", - options: [ - INHERIT_CHOICE, - ...levels.map((level) => ({ - id: level, - label: PI_THINKING_LEVEL_LABELS[level], - })), - ], + options: levels.map( + (level): ProviderOptionChoice => + level === defaultLevel + ? { + // Keep Pi's default as an inherited value internally so T3 + // does not turn the displayed default into an override. + id: "inherit", + label: PI_THINKING_LEVEL_LABELS[level], + isDefault: true, + } + : { + id: level, + label: PI_THINKING_LEVEL_LABELS[level], + }, + ), }, ], }); } +/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ +function clampPiThinkingLevel( + input: unknown, + availableLevels: ReadonlyArray, +): PiThinkingLevel | undefined { + if (typeof input !== "string") return undefined; + const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); + if (requestedIndex === -1) return undefined; + const exact = availableLevels.find((level) => level === input); + if (exact !== undefined) return exact; + for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { + const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (higher !== undefined) return higher; + } + for (let index = requestedIndex - 1; index >= 0; index -= 1) { + const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (lower !== undefined) return lower; + } + return availableLevels[0]; +} + /** * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. * diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index f3cdb6ec5197..1aa54f482ba7 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -15,7 +15,8 @@ directory, endpoint, or model configuration. ## What Carries Over -T3 Code discovers the models reported by Pi and exposes their supported thinking levels. Threads +T3 Code discovers the models reported by Pi and exposes their supported thinking levels. The +thinking picker marks Pi's current configured level as the default without overriding it. Threads use Pi's native session files, so resume, rollback, and current-branch forks keep Pi's conversation state. Extension dialogs appear in the T3 Code composer, and the composer context meter updates from Pi's own context-window statistics after a response settles. From 0ea4e01ece7544fa52b0cdcbe8ec84034b884058 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:48:25 +0200 Subject: [PATCH 31/41] feat(pi): support dollar skill references --- .../Adapters/PiAdapterV2.test.ts | 44 +++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 12 +++- apps/server/src/provider/Drivers/PiDriver.ts | 3 +- apps/server/src/provider/Layers/PiProvider.ts | 56 +++------------ apps/server/src/provider/PiCommands.test.ts | 53 ++++++++++++++ apps/server/src/provider/PiCommands.ts | 71 +++++++++++++++++++ docs/user/providers-pi.md | 4 ++ 7 files changed, 192 insertions(+), 51 deletions(-) create mode 100644 apps/server/src/provider/PiCommands.test.ts create mode 100644 apps/server/src/provider/PiCommands.ts diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 5850c329ecfa..270806a14e78 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -76,6 +76,8 @@ interface FakePi { readonly queueState: (data: unknown) => void; /** Data returned by the next `get_session_stats` acks, consumed in order. */ readonly queueStats: (data: unknown) => void; + /** Data returned by the next `get_commands` acks, consumed in order. */ + readonly queueCommands: (data: unknown) => void; readonly lastSpawn: () => { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -92,6 +94,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const entriesQueue: Array = []; const stateQueue: Array = []; const statsQueue: Array = []; + const commandsQueue: Array = []; let vetoSwitch = false; let stdinBuffer = ""; @@ -131,6 +134,8 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; + case "get_commands": + return { ...base, data: commandsQueue.shift() ?? { commands: [] } }; case "fork": return { ...base, data: { cancelled: false, message: "forked" } }; default: @@ -200,6 +205,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, queueState: (data) => stateQueue.push(data), queueStats: (data) => statsQueue.push(data), + queueCommands: (data) => commandsQueue.push(data), lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -274,6 +280,7 @@ const startTurn = Effect.fnUntraced(function* ( providerThread: OrchestrationV2ProviderThread, model = "default", attachments: ReadonlyArray = [], + text = "Hello pi", ) { const appThread = yield* makeAppThread(model); yield* runtime.startTurn({ @@ -287,7 +294,7 @@ const startTurn = Effect.fnUntraced(function* ( providerThread, message: { messageId: "message:thread-pi-test:1" as never, - text: "Hello pi", + text, attachments, createdBy: "user", creationSource: "web", @@ -385,6 +392,41 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("expands a selected $ skill through Pi's native skill command", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueCommands({ + commands: [ + { + name: "skill:repo-review", + description: "Review this repository.", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/repo-review/SKILL.md", + scope: "project", + }, + }, + ], + }); + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Review this change please $repo-review", + ); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "/skill:repo-review Review this change please"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("streams assistant text and settles a completed turn on agent_settled", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 5aeff3077ac2..1627b2ee7891 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -60,6 +60,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { expandPiSkillReference, parsePiDiscoveredCommands } from "../../provider/PiCommands.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { IdAllocatorV2 } from "../IdAllocator.ts"; import { @@ -129,6 +130,7 @@ export const PI_INHERIT_THINKING_VALUE = "inherit"; const STREAM_FLUSH_MS = 50; const PI_REQUEST_TIMEOUT_MS = 15_000; +const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; export const PiProviderCapabilitiesV2 = { sessions: { @@ -410,6 +412,12 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }), ), ); + const commandData = yield* connection + .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) + .pipe(Effect.orElseSucceed(() => undefined)); + const skillNames = new Set( + parsePiDiscoveredCommands(commandData).skills.map((skill) => skill.name), + ); const now = yield* DateTime.now; let sessionEntity: OrchestrationV2ProviderSession = { @@ -1980,6 +1988,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S text: string, attachments: ReadonlyArray, ) { + const expandedText = expandPiSkillReference(text, skillNames); const images: Array<{ type: "image"; data: string; mimeType: string }> = []; const extraLines: Array = []; for (const attachment of attachments) { @@ -1999,7 +2008,8 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S extraLines.push(`[Attachment saved at ${path}]`); } } - const message = extraLines.length === 0 ? text : `${text}\n\n${extraLines.join("\n")}`; + const message = + extraLines.length === 0 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; return { message, images }; }); diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index b8252b8b1979..07e84af97f9d 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -94,6 +94,7 @@ export const PiDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const { cwd } = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -132,7 +133,7 @@ export const PiDriver: ProviderDriver = { ); const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index ecef43212eda..f76e51d48691 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -8,13 +8,7 @@ * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — * shows up in T3 without any hardcoded catalog. */ -import { - type PiSettings, - type ServerProvider, - type ServerProviderModel, - type ServerProviderSkill, - type ServerProviderSlashCommand, -} from "@t3tools/contracts"; +import { type PiSettings, type ServerProvider, type ServerProviderModel } from "@t3tools/contracts"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; @@ -47,6 +41,7 @@ import { EMPTY_PI_MODEL_CAPABILITIES, thinkingCapabilitiesForPiModel, } from "./piThinkingCapabilities.ts"; +import { parsePiDiscoveredCommands, type PiDiscoveredCommands } from "../PiCommands.ts"; const PI_PRESENTATION = { displayName: "Pi", @@ -66,10 +61,8 @@ const PI_DEFAULT_MODEL: ServerProviderModel = { capabilities: EMPTY_PI_MODEL_CAPABILITIES, }; -interface PiDiscovery { +interface PiDiscovery extends PiDiscoveredCommands { readonly models: ReadonlyArray; - readonly slashCommands: ReadonlyArray; - readonly skills: ReadonlyArray; readonly authenticated: boolean; } @@ -109,46 +102,12 @@ function parseDiscoveredModels( return parsed; } -function parseDiscoveredCommands(data: unknown): { - readonly slashCommands: ReadonlyArray; - readonly skills: ReadonlyArray; -} { - const commands = recordField(data, "commands"); - if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; - const slashCommands: Array = []; - const skills: Array = []; - for (const command of commands) { - const name = recordString(command, "name"); - if (name === undefined || name.length === 0) continue; - const description = recordString(command, "description"); - if (recordString(command, "source") === "skill") { - const path = recordString(command, "path"); - if (path === undefined) continue; - skills.push({ - name, - ...(description === undefined ? {} : { description }), - path, - ...(recordString(command, "location") === undefined - ? {} - : { scope: recordString(command, "location") }), - enabled: true, - }); - continue; - } - slashCommands.push({ - name, - ...(description === undefined ? {} : { description }), - }); - } - return { slashCommands, skills }; -} - -const discoverPiViaRpc = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => +const discoverPiViaRpc = (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd?: string) => Effect.gen(function* () { const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", args: ["--mode", "rpc", "--no-session", ...tokenizeCliArgs(piSettings.launchArgs)], - cwd: undefined, + cwd, env: environment, }); const stateData = yield* connection.request({ type: "get_state" }); @@ -160,7 +119,7 @@ const discoverPiViaRpc = (piSettings: PiSettings, environment: NodeJS.ProcessEnv modelsData, recordString(stateData, "thinkingLevel"), ); - const { slashCommands, skills } = parseDiscoveredCommands(commandsData); + const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); return { models: discoveredModels, slashCommands, @@ -224,6 +183,7 @@ export function buildInitialPiProviderSnapshot( export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = piModelsFromSettings(piSettings.customModels); @@ -303,7 +263,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const discoveryExit = yield* discoverPiViaRpc(piSettings, environment).pipe( + const discoveryExit = yield* discoverPiViaRpc(piSettings, environment, cwd).pipe( Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit, ); diff --git a/apps/server/src/provider/PiCommands.test.ts b/apps/server/src/provider/PiCommands.test.ts new file mode 100644 index 000000000000..eb14bbd5780b --- /dev/null +++ b/apps/server/src/provider/PiCommands.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "@effect/vitest"; + +import { expandPiSkillReference, parsePiDiscoveredCommands } from "./PiCommands.ts"; + +it("maps current Pi skill metadata to T3's user and project skill scopes", () => { + expect( + parsePiDiscoveredCommands({ + commands: [ + { + name: "skill:global-review", + description: "Review changes.", + source: "skill", + sourceInfo: { + path: "/home/test/.agents/skills/global-review/SKILL.md", + scope: "user", + }, + }, + { + name: "skill:project-deploy", + description: "Deploy this project.", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/project-deploy/SKILL.md", + scope: "project", + }, + }, + { name: "hello", description: "Say hello.", source: "extension" }, + ], + }), + ).toEqual({ + skills: [ + { + name: "global-review", + description: "Review changes.", + path: "/home/test/.agents/skills/global-review/SKILL.md", + scope: "user", + enabled: true, + }, + { + name: "project-deploy", + description: "Deploy this project.", + path: "/workspace/.agents/skills/project-deploy/SKILL.md", + scope: "project", + enabled: true, + }, + ], + slashCommands: [{ name: "hello", description: "Say hello." }], + }); +}); + +it("leaves unrelated dollar-prefixed text unchanged", () => { + expect(expandPiSkillReference("Explain $HOME", new Set(["global-review"]))).toBe("Explain $HOME"); +}); diff --git a/apps/server/src/provider/PiCommands.ts b/apps/server/src/provider/PiCommands.ts new file mode 100644 index 000000000000..2ac8f42f2f96 --- /dev/null +++ b/apps/server/src/provider/PiCommands.ts @@ -0,0 +1,71 @@ +import { type ServerProviderSkill, type ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +export interface PiDiscoveredCommands { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} + +/** Maps Pi's `get_commands` payload to T3's shared command and skill surfaces. */ +export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { + const commands = recordField(data, "commands"); + if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; + const slashCommands: Array = []; + const skills: Array = []; + for (const command of commands) { + const commandName = recordString(command, "name"); + if (commandName === undefined || commandName.length === 0) continue; + const description = recordString(command, "description"); + if (recordString(command, "source") === "skill") { + const name = commandName.startsWith("skill:") + ? commandName.slice("skill:".length) + : commandName; + const sourceInfo = recordField(command, "sourceInfo"); + const path = recordString(sourceInfo, "path") ?? recordString(command, "path"); + if (name.length === 0 || path === undefined) continue; + const scope = recordString(sourceInfo, "scope") ?? recordString(command, "location"); + skills.push({ + name, + ...(description === undefined ? {} : { description }), + path, + ...(scope === undefined ? {} : { scope }), + enabled: true, + }); + continue; + } + slashCommands.push({ + name: commandName, + ...(description === undefined ? {} : { description }), + }); + } + return { slashCommands, skills }; +} + +/** + * Pi expands skills only through a leading `/skill:name` command. T3 stores + * skill chips as `$name`, so move the first known skill reference to that + * native command position while preserving the rest of the user's prompt. + */ +export function expandPiSkillReference(text: string, skillNames: ReadonlySet): string { + const references = /(^|\s)\$([^\s]+)(?=\s|$)/g; + for (const match of text.matchAll(references)) { + const name = match[2]; + if (name === undefined || !skillNames.has(name) || match.index === undefined) continue; + const tokenStart = match.index + (match[1]?.length ?? 0); + const tokenEnd = tokenStart + name.length + 1; + const prompt = [text.slice(0, tokenStart).trimEnd(), text.slice(tokenEnd).trimStart()] + .filter((part) => part.length > 0) + .join(" "); + return prompt.length === 0 ? `/skill:${name}` : `/skill:${name} ${prompt}`; + } + return text; +} + +function recordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +function recordString(input: unknown, key: string): string | undefined { + const value = recordField(input, key); + return typeof value === "string" ? value : undefined; +} diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 1aa54f482ba7..367f87c0804c 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -21,6 +21,9 @@ use Pi's native session files, so resume, rollback, and current-branch forks kee state. Extension dialogs appear in the T3 Code composer, and the composer context meter updates from Pi's own context-window statistics after a response settles. +Pi skills appear in the composer's `$` menu. This includes user skills and project skills that Pi +loads for the current workspace; selecting one uses Pi's native skill expansion. + Pi's normal user extensions continue to load. T3 Code supplies its own session-aware `subagent` tool so each child can be opened and continued as a T3 Code thread. Project-local extensions are only loaded automatically when Pi is configured to always trust that project. @@ -32,4 +35,5 @@ only loaded automatically when Pi is configured to always trust that project. - If no models appear, open Pi directly and confirm its authentication and model configuration. - If a project extension is missing, approve the project in Pi or configure standing project trust, then start a fresh provider session. +- If a project skill is missing from the `$` menu, approve the project in Pi and refresh the provider. - The context meter appears after Pi returns its first usable token snapshot for the thread. From 90806b94ff34ebbea7fe6d6a26cb256e2472005a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:54:50 +0200 Subject: [PATCH 32/41] fix(pi): fail closed after session switch --- .../Adapters/PiAdapterV2.test.ts | 25 +++++++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 4 +++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 270806a14e78..073d5d78b7f6 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -365,6 +365,31 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("drops the old thread binding when switched-session state is invalid", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + fake.queueState({ thinkingLevel: "medium" }); + const resumeError = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + assert.equal(resumeError._tag, "ProviderAdapterResumeThreadError"); + assert.match(String(resumeError.cause), /neither sessionFile nor sessionId/); + + const turnError = yield* startTurn(runtime, providerThread).pipe(Effect.flip); + assert.equal(turnError._tag, "ProviderAdapterTurnStartError"); + assert.match(String(turnError.cause), /no registered thread/); + + fake.queueState({ sessionFile: FAKE_SESSION_FILE, sessionId: "abc" }); + const resumed = yield* runtime.resumeThread({ providerThread }); + assert.equal(resumed.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("keeps the thread usable when an attachment cannot be read", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 1627b2ee7891..ac671d1116d9 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1844,6 +1844,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (recordField(switchData, "cancelled") === true) { return yield* protocolError("A Pi extension cancelled the session switch"); } + // Pi is now attached to the target session. Drop the previous + // binding before reading its state so a failed refresh cannot let a + // later turn run against the old T3 thread and the new Pi session. + threadState = null; // These caches describe the session we just left. Clearing them // stops the next turn from treating this session as already // configured and skipping set_model or set_session_name. From 069e28a4c68742687e1f8499c782602bffb2736a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:08:22 +0200 Subject: [PATCH 33/41] fix(pi): retry live skill discovery --- .../Adapters/PiAdapterV2.test.ts | 38 +++++++++++++++++-- .../orchestration-v2/Adapters/PiAdapterV2.ts | 21 +++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 073d5d78b7f6..b779a9e9e40b 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -78,6 +78,8 @@ interface FakePi { readonly queueStats: (data: unknown) => void; /** Data returned by the next `get_commands` acks, consumed in order. */ readonly queueCommands: (data: unknown) => void; + /** Make the next `get_commands` ack fail. */ + readonly failNextCommands: () => void; readonly lastSpawn: () => { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -94,7 +96,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const entriesQueue: Array = []; const stateQueue: Array = []; const statsQueue: Array = []; - const commandsQueue: Array = []; + const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; let vetoSwitch = false; let stdinBuffer = ""; @@ -135,7 +137,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; case "get_commands": - return { ...base, data: commandsQueue.shift() ?? { commands: [] } }; + return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; case "fork": return { ...base, data: { cancelled: false, message: "forked" } }; default: @@ -205,7 +207,8 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, queueState: (data) => stateQueue.push(data), queueStats: (data) => statsQueue.push(data), - queueCommands: (data) => commandsQueue.push(data), + queueCommands: (data) => commandsQueue.push({ success: true, data }), + failNextCommands: () => commandsQueue.push({ success: false }), lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -452,6 +455,35 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("retries skill discovery after a transient session-open failure", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.failNextCommands(); + const { runtime } = yield* openRuntime(fake); + fake.queueCommands({ + commands: [ + { + name: "skill:repo-review", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/repo-review/SKILL.md", + scope: "project", + }, + }, + ], + }); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn(runtime, providerThread, "default", [], "$repo-review check this"); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "/skill:repo-review check this"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("streams assistant text and settles a completed turn on agent_settled", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index ac671d1116d9..0409f193e132 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -412,12 +412,15 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }), ), ); - const commandData = yield* connection + const discoverSkillNames = connection .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) - .pipe(Effect.orElseSucceed(() => undefined)); - const skillNames = new Set( - parsePiDiscoveredCommands(commandData).skills.map((skill) => skill.name), - ); + .pipe( + Effect.map( + (data) => new Set(parsePiDiscoveredCommands(data).skills.map((skill) => skill.name)), + ), + ); + const discoveredSkillNames = yield* discoverSkillNames.pipe(Effect.option); + let skillNames = Option.getOrNull(discoveredSkillNames); const now = yield* DateTime.now; let sessionEntity: OrchestrationV2ProviderSession = { @@ -1992,7 +1995,13 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S text: string, attachments: ReadonlyArray, ) { - const expandedText = expandPiSkillReference(text, skillNames); + // Provider discovery and the live session are separate Pi processes. + // Retry a failed session-local lookup once at first use so a transient + // startup failure cannot leave a visible $ skill inert for this session. + if (skillNames === null && text.includes("$")) { + skillNames = yield* discoverSkillNames.pipe(Effect.orElseSucceed(() => new Set())); + } + const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); const images: Array<{ type: "image"; data: string; mimeType: string }> = []; const extraLines: Array = []; for (const attachment of attachments) { From aaf4bf2a406e7047871f6c6ead6a778a9f715d79 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:12:06 +0200 Subject: [PATCH 34/41] fix(pi): type empty skill fallback --- apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 0409f193e132..53f790a6410e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -1999,7 +1999,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // Retry a failed session-local lookup once at first use so a transient // startup failure cannot leave a visible $ skill inert for this session. if (skillNames === null && text.includes("$")) { - skillNames = yield* discoverSkillNames.pipe(Effect.orElseSucceed(() => new Set())); + skillNames = yield* discoverSkillNames.pipe( + Effect.orElseSucceed(() => new Set()), + ); } const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); const images: Array<{ type: "image"; data: string; mimeType: string }> = []; From 4b22b92c84409e112b152564bd4d2777a6179d5e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:37:33 +0200 Subject: [PATCH 35/41] fix(pi): stop without provider stream error --- .../Adapters/PiAdapterV2.test.ts | 15 ++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 48 ++++++++++------ .../ProviderSessionManager.test.ts | 57 ++++++++++++++++++- .../ProviderSessionManager.ts | 44 +++++++++++--- 4 files changed, 137 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index b779a9e9e40b..e0016c4adf68 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -16,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -80,6 +81,8 @@ interface FakePi { readonly queueCommands: (data: unknown) => void; /** Make the next `get_commands` ack fail. */ readonly failNextCommands: () => void; + /** Close the fake process stdout stream. */ + readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; @@ -91,7 +94,7 @@ interface FakePi { * requests with canned data, and lets tests push protocol events to stdout. */ const makeFakePi: Effect.Effect = Effect.gen(function* () { - const stdout = yield* Queue.unbounded(); + const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); const entriesQueue: Array = []; const stateQueue: Array = []; @@ -209,6 +212,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { queueStats: (data) => statsQueue.push(data), queueCommands: (data) => commandsQueue.push({ success: true, data }), failNextCommands: () => commandsQueue.push({ success: false }), + closeStdout: Queue.end(stdout), lastSpawn: () => lastSpawn, } satisfies FakePi; }); @@ -1225,6 +1229,15 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + yield* fake.closeStdout; + const stopped = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "stopped", + ); + assert.equal( + stopped.type === "provider_session.updated" ? stopped.providerSession.lastError : undefined, + null, + ); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 53f790a6410e..c2db1b042bd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -45,6 +45,7 @@ import { type ProviderInstanceId, type ThreadTokenUsageSnapshot, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; @@ -435,7 +436,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S updatedAt: now, lastError: null, }; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded< + ProviderAdapterV2Event, + ProviderAdapterV2Error | Cause.Done + >(); const pendingPrompts = new Map(); // Answering a dialog and terminalizing a turn both publish lifecycle // events. Pi can settle immediately after `extension_ui_response`, so @@ -443,6 +447,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S // dialog's own resolution updates. const sessionEventPermit = yield* Semaphore.make(1); let threadState: PiThreadState | null = null; + // User Stop intentionally tears down this RPC process after aborting. + // Keep that intent beyond turn finalization so the later stdout close is + // not mistaken for an unexpected transport failure. + let stopRequested = false; let appliedModel: string | null = null; let appliedThinking: string | null = null; /** Last thread title synced into pi's session name (`/resume` listing). */ @@ -1789,9 +1797,9 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S Effect.catchCause((cause) => sessionEventPermit.withPermits(1)( Effect.gen(function* () { - // Transport death: fail any live turn, then surface the error. - // A death caused by Stop-with-restart is an interrupt, not a - // failure; finalizeTurn already prefers `interrupted`. + // Transport death finalizes any live turn. Stop-with-restart + // closes the provider stream cleanly; only an unexpected death + // is surfaced as an event-stream failure. const state = threadState; const interrupted = state?.activeTurn?.interrupted === true; if (state?.activeTurn != null) { @@ -1804,18 +1812,23 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); yield* finalizeTurn(state, false); } - yield* updateProviderSession( - "error", - interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", - ); - yield* Queue.fail( - events, - new ProviderAdapterEventStreamError({ - driver: PI_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ); + if (stopRequested) { + yield* updateProviderSession("stopped", null); + yield* Queue.end(events); + } else { + yield* updateProviderSession( + "error", + interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", + ); + yield* Queue.fail( + events, + new ProviderAdapterEventStreamError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + } }), ), ), @@ -2254,9 +2267,10 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S if (interruptInput.requestRuntimeRestart === true) { // User Stop with restart: the process may be wedged, so give // abort one short chance and then kill the process group. The - // transport failure finalizes the turn as interrupted and the + // transport closure finalizes the turn as interrupted and the // session manager respawns a fresh process on the next turn, // resuming the same session file. + stopRequested = true; yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); yield* connection.terminate; return; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index e0221099862e..b8240aea3109 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -13,6 +13,7 @@ import { type ProviderSessionId, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -92,7 +93,7 @@ interface TestProviderRuntimeState { readonly closeCount: number; readonly interruptCount: number; readonly resumeCount: number; - readonly eventQueues: ReadonlyMap>; + readonly eventQueues: ReadonlyMap>; } const emptyState: TestProviderRuntimeState = { @@ -257,7 +258,7 @@ function makeProviderAdapter( ]); } const now = yield* DateTime.now; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const session = makeProviderSession({ providerSessionId: input.providerSessionId, now, @@ -1913,6 +1914,58 @@ it.effect("ProviderSessionManagerV2 releases sessions when provider event stream }), ); +it.effect("ProviderSessionManagerV2 ends subscribers cleanly after a provider-announced Stop", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-stopped-stream"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const subscription = yield* runtime.subscribeEvents!; + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offer(adapterEvents!, { + type: "provider_session.updated", + driver: CODEX_DRIVER, + providerSession: { + ...runtime.providerSession, + status: "stopped", + updatedAt: now, + lastError: null, + }, + }); + yield* Queue.end(adapterEvents!); + + const events = yield* subscription.events.pipe(Stream.runCollect); + const projection = yield* projectionStore.getThreadProjection(threadId); + assert.equal(events.length, 1); + assert.equal(events[0]?.type, "provider_session.updated"); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal(projection.providerSessions.at(-1)?.status, "stopped"); + assert.equal(projection.providerSessions.at(-1)?.lastError, null); + }); + + yield* effect.pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1000 }))); + }), +); + it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on release", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 7b3c93169bf9..3ed3faeac34d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -455,6 +455,17 @@ export const layerWithOptions = ( ); }); + // Preserve already-published terminal events while ending subscriptions. + // Server shutdown intentionally clears them; a provider-announced Stop + // must let consumers drain them before the stream completes. + const endSubscribers = (entry: LiveSessionEntry) => + Effect.gen(function* () { + const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); + yield* Effect.forEach(subscribers.values(), (queue) => Queue.end(queue), { + discard: true, + }); + }); + const cancelIdleFiber = (fiber: Fiber.Fiber | null) => fiber === null ? Effect.void : Fiber.interrupt(fiber).pipe(Effect.ignore); @@ -614,6 +625,7 @@ export const layerWithOptions = ( readonly detail?: string; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; + readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( Ref.modify(sessions, (current) => { @@ -640,7 +652,9 @@ export const layerWithOptions = ( if (input.cancelIdleFiber !== false) { yield* cancelIdleFiber(entry.idleFiber); } - if (input.reason === "server_shutdown") { + if (input.gracefulSubscribers === true) { + yield* endSubscribers(entry); + } else if (input.reason === "server_shutdown") { yield* closeSubscribers(entry); } else { yield* failSubscribers( @@ -1316,10 +1330,17 @@ export const layerWithOptions = ( ), ); - const startEventPump = (entry: LiveSessionEntry) => - entry.runtime.events.pipe( - Stream.runForEach((event) => - observeActivity( + const startEventPump = (entry: LiveSessionEntry) => { + let stoppedByProvider = false; + return entry.runtime.events.pipe( + Stream.runForEach((event) => { + if ( + event.type === "provider_session.updated" && + event.providerSession.status === "stopped" + ) { + stoppedByProvider = true; + } + return observeActivity( entry.runtime.providerSessionId, event.type === "turn.terminal" ? markIdle(entry.runtime.providerSessionId) @@ -1333,8 +1354,8 @@ export const layerWithOptions = ( Effect.andThen( publishToSubscribers(entry.eventSubscribers, { type: "event", event }), ), - ), - ), + ); + }), Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { @@ -1344,6 +1365,14 @@ export const layerWithOptions = ( if (current?.runtime !== entry.runtime) { return; } + if (stoppedByProvider && Exit.isSuccess(exit)) { + yield* releaseEntry({ + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + gracefulSubscribers: true, + }).pipe(Effect.ignore); + return; + } const cause = Exit.isFailure(exit) ? exit.cause : Cause.fail( @@ -1367,6 +1396,7 @@ export const layerWithOptions = ( ), Effect.forkIn(layerScope), ); + }; const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; From 8f926b093ba8d5c063cfffdabc2b47c3e6606752 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:44:16 +0200 Subject: [PATCH 36/41] fix(pi): serialize session-bound operations --- apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index c2db1b042bd2..562995837f4c 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -2247,6 +2247,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S ...(payload.images.length === 0 ? {} : { images: payload.images }), }); }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterSteerRunError({ @@ -2279,6 +2280,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), ); }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterInterruptError({ @@ -2397,6 +2399,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S runtimeRequests: [], }; }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterReadThreadSnapshotError({ @@ -2448,6 +2451,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S }); return piThreadSnapshot(state.providerThread); }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterRollbackThreadError({ @@ -2526,6 +2530,7 @@ export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2S updatedAt: createdAt, } satisfies OrchestrationV2ProviderThread; }).pipe( + sessionEventPermit.withPermits(1), Effect.mapError( (cause) => new ProviderAdapterForkThreadError({ From f015a6c2aef344b04bcf785dfdc9df6141badaea Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:45:06 +0200 Subject: [PATCH 37/41] fix(pi): restore npm package extensions --- .../Adapters/piT3McpInjection.test.ts | 39 +++++- .../Adapters/piT3McpInjection.ts | 131 ++++++++++++++---- 2 files changed, 140 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 8378296791c5..baec83986e25 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -106,22 +106,57 @@ describe("pi T3 MCP injection", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("discovers user extensions from the agent dir and skips the subagent", () => + it.effect("discovers user and npm package extensions while skipping subagent overrides", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-home-" }); const extensionsDir = `${home}/.pi/agent/extensions`; + const lensDir = `${home}/.pi/agent/npm/node_modules/pi-lens`; + const authDir = `${home}/.pi/agent/npm/node_modules/@gotgenes/pi-anthropic-auth`; + const packageSubagentDir = `${home}/.pi/agent/npm/node_modules/pi-subagents`; yield* fs.makeDirectory(`${extensionsDir}/todos`, { recursive: true }); yield* fs.makeDirectory(`${extensionsDir}/subagent`, { recursive: true }); + yield* fs.makeDirectory(`${lensDir}/src`, { recursive: true }); + yield* fs.makeDirectory(`${authDir}/src`, { recursive: true }); + yield* fs.makeDirectory(packageSubagentDir, { recursive: true }); yield* fs.writeFileString(`${extensionsDir}/demo.ts`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/subagent.ts`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/todos/index.ts`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/subagent/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${lensDir}/src/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${authDir}/src/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${packageSubagentDir}/index.ts`, "export default () => {}"); + yield* fs.writeFileString( + `${lensDir}/package.json`, + '{ "pi": { "extensions": ["./src/index.ts"] } }', + ); + yield* fs.writeFileString( + `${authDir}/package.json`, + '{ "pi": { "extensions": ["./src/index.ts"] } }', + ); + yield* fs.writeFileString( + `${packageSubagentDir}/package.json`, + '{ "pi": { "extensions": ["./index.ts"] } }', + ); + yield* fs.writeFileString( + `${home}/.pi/agent/settings.json`, + `{ "packages": [ + "npm:pi-lens", + "npm:@gotgenes/pi-anthropic-auth@1.2.3", + "npm:pi-subagents", + { "source": "npm:disabled-extension", "extensions": [] } + ] }`, + ); const found = yield* discoverPiUserExtensions({ environment: { HOME: home }, cwd: undefined, }); - assert.deepEqual(found, [`${extensionsDir}/demo.ts`, `${extensionsDir}/todos/index.ts`]); + assert.deepEqual(found, [ + `${extensionsDir}/demo.ts`, + `${extensionsDir}/todos/index.ts`, + `${lensDir}/src/index.ts`, + `${authDir}/src/index.ts`, + ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 78d6aa172f2a..085209b797f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Predicate from "effect/Predicate"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; @@ -32,29 +32,72 @@ function bearerTokenFromAuthorizationHeader(header: string): string { return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; } -const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const PiPackageSource = Schema.Union([ + Schema.String, + Schema.Struct({ + source: Schema.String, + autoload: Schema.optional(Schema.Boolean), + extensions: Schema.optional(Schema.Array(Schema.String)), + }), +]); -function piDefaultProjectTrust(settingsRaw: string): string | undefined { - try { - const parsed = decodeJson(settingsRaw); - if (!Predicate.isObject(parsed)) return undefined; - const value = parsed["defaultProjectTrust"]; - return Predicate.isString(value) ? value : undefined; - } catch { - return undefined; +const PiSettingsFile = Schema.Struct({ + defaultProjectTrust: Schema.optional(Schema.String), + packages: Schema.optional(Schema.Array(PiPackageSource)), +}); + +const PiPackageJson = Schema.Struct({ + pi: Schema.optional( + Schema.Struct({ + extensions: Schema.optional(Schema.Array(Schema.String)), + }), + ), +}); + +const decodePiSettings = Schema.decodeUnknownOption(Schema.fromJsonString(PiSettingsFile)); +const decodePiPackageJson = Schema.decodeUnknownOption(Schema.fromJsonString(PiPackageJson)); + +function piNpmPackageName(source: string): string | undefined { + if (!source.startsWith("npm:")) return undefined; + const spec = source.slice("npm:".length).trim(); + const name = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/)?.[1]; + return name !== undefined && + /^(?:@[A-Za-z0-9._~-]+\/[A-Za-z0-9._~-]+|[A-Za-z0-9._~-]+)$/.test(name) + ? name + : undefined; +} + +function piUnfilteredPackageSource(pkg: typeof PiPackageSource.Type): string | undefined { + if (typeof pkg === "string") return pkg; + // Pi's object form can narrow or disable individual resources. Re-adding + // the whole manifest would bypass that choice, so only restore packages + // whose extension set is inherited unchanged. + return pkg.autoload === false || pkg.extensions !== undefined ? undefined : pkg.source; +} + +function piPackageExtensionPath(packageRoot: string, entry: string): string | undefined { + const segments: Array = []; + for (const segment of entry.replace(/\\/g, "/").split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.pop() === undefined) return undefined; + continue; + } + segments.push(segment); } + return segments.length === 0 ? undefined : `${packageRoot}/${segments.join("/")}`; } /** * Re-discover the user's pi extensions for a `--no-extensions` spawn: the * subagent override forces discovery off (a second `subagent` registration * aborts pi), which must not cost the user every other extension they have. - * Mirrors pi's own discovery roots: `/extensions/*.ts` and - * `/extensions//index.ts`, plus the project-local - * `.pi/extensions` only when the user's `defaultProjectTrust` is `always` — + * Mirrors pi's own discovery roots and user npm package manifests: + * `/extensions`, `settings.json` package `pi.extensions`, and the + * project-local `.pi/extensions` only when the user's `defaultProjectTrust` is `always` — * explicit `--extension` paths bypass pi's trust prompt, so anything short - * of standing trust must not be silently loaded. Entries named `subagent` - * are skipped in favor of the T3 override. + * of standing trust must not be silently loaded. Pi's `pi-subagents` package + * and conventional `subagent` entries are skipped in favor of the T3 override. */ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(function* (input: { readonly environment: NodeJS.ProcessEnv; @@ -65,17 +108,27 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu const agentDir = input.environment["PI_CODING_AGENT_DIR"] ?? (home === undefined ? undefined : `${normalizePiPath(home)}/.pi/agent`); + const normalizedAgentDir = agentDir === undefined ? undefined : normalizePiPath(agentDir); + const settingsRaw = + normalizedAgentDir === undefined + ? "" + : yield* fs + .readFileString(`${normalizedAgentDir}/settings.json`) + .pipe(Effect.orElseSucceed(() => "")); + const settings = Option.getOrUndefined(decodePiSettings(settingsRaw)); const roots: Array = []; - if (agentDir !== undefined) roots.push(`${normalizePiPath(agentDir)}/extensions`); - if (agentDir !== undefined && input.cwd !== undefined) { - const settingsRaw = yield* fs - .readFileString(`${normalizePiPath(agentDir)}/settings.json`) - .pipe(Effect.orElseSucceed(() => "")); - if (piDefaultProjectTrust(settingsRaw) === "always") { - roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); - } + if (normalizedAgentDir !== undefined) roots.push(`${normalizedAgentDir}/extensions`); + if ( + normalizedAgentDir !== undefined && + input.cwd !== undefined && + settings?.defaultProjectTrust === "always" + ) { + roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); } const found: Array = []; + const addFound = (path: string) => { + if (!isConflictingPiSubagentExtensionPath(path) && !found.includes(path)) found.push(path); + }; for (const root of roots) { const entries = yield* fs .readDirectory(root) @@ -83,13 +136,35 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu for (const entry of entries.toSorted()) { if (entry === "subagent" || entry === "subagent.ts") continue; const path = `${root}/${entry}`; - if (entry.endsWith(".ts")) { - found.push(path); + if (entry.endsWith(".ts") || entry.endsWith(".js")) { + addFound(path); continue; } - const indexPath = `${path}/index.ts`; - const hasIndex = yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false)); - if (hasIndex) found.push(indexPath); + for (const indexPath of [`${path}/index.ts`, `${path}/index.js`]) { + const hasIndex = yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) { + addFound(indexPath); + break; + } + } + } + } + if (normalizedAgentDir !== undefined) { + for (const pkg of settings?.packages ?? []) { + const source = piUnfilteredPackageSource(pkg); + const packageName = source === undefined ? undefined : piNpmPackageName(source); + if (packageName === undefined || packageName === "pi-subagents") continue; + const packageRoot = `${normalizedAgentDir}/npm/node_modules/${packageName}`; + const packageJsonRaw = yield* fs + .readFileString(`${packageRoot}/package.json`) + .pipe(Effect.orElseSucceed(() => "")); + const packageJson = Option.getOrUndefined(decodePiPackageJson(packageJsonRaw)); + for (const entry of packageJson?.pi?.extensions ?? []) { + const extensionPath = piPackageExtensionPath(packageRoot, entry); + if (extensionPath === undefined) continue; + const exists = yield* fs.exists(extensionPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) addFound(extensionPath); + } } } return found; From bdfd4eca2e1bc2c3717291931cd51e32a0092088 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:01:43 +0200 Subject: [PATCH 38/41] fix(pi): preserve extension package filters --- .../Adapters/piT3McpInjection.test.ts | 26 +++++ .../Adapters/piT3McpInjection.ts | 106 ++++++++++++++++-- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index baec83986e25..4a166ff801de 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -113,18 +113,23 @@ describe("pi T3 MCP injection", () => { const extensionsDir = `${home}/.pi/agent/extensions`; const lensDir = `${home}/.pi/agent/npm/node_modules/pi-lens`; const authDir = `${home}/.pi/agent/npm/node_modules/@gotgenes/pi-anthropic-auth`; + const filteredDir = `${home}/.pi/agent/npm/node_modules/filtered-extension`; const packageSubagentDir = `${home}/.pi/agent/npm/node_modules/pi-subagents`; yield* fs.makeDirectory(`${extensionsDir}/todos`, { recursive: true }); yield* fs.makeDirectory(`${extensionsDir}/subagent`, { recursive: true }); yield* fs.makeDirectory(`${lensDir}/src`, { recursive: true }); yield* fs.makeDirectory(`${authDir}/src`, { recursive: true }); + yield* fs.makeDirectory(`${filteredDir}/src`, { recursive: true }); yield* fs.makeDirectory(packageSubagentDir, { recursive: true }); yield* fs.writeFileString(`${extensionsDir}/demo.ts`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/subagent.ts`, "export default () => {}"); + yield* fs.writeFileString(`${extensionsDir}/subagent.js`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/todos/index.ts`, "export default () => {}"); yield* fs.writeFileString(`${extensionsDir}/subagent/index.ts`, "export default () => {}"); yield* fs.writeFileString(`${lensDir}/src/index.ts`, "export default () => {}"); yield* fs.writeFileString(`${authDir}/src/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${filteredDir}/src/index.ts`, "export default () => {}"); + yield* fs.writeFileString(`${filteredDir}/src/legacy.ts`, "export default () => {}"); yield* fs.writeFileString(`${packageSubagentDir}/index.ts`, "export default () => {}"); yield* fs.writeFileString( `${lensDir}/package.json`, @@ -134,6 +139,10 @@ describe("pi T3 MCP injection", () => { `${authDir}/package.json`, '{ "pi": { "extensions": ["./src/index.ts"] } }', ); + yield* fs.writeFileString( + `${filteredDir}/package.json`, + '{ "pi": { "extensions": ["./src/index.ts", "./src/legacy.ts"] } }', + ); yield* fs.writeFileString( `${packageSubagentDir}/package.json`, '{ "pi": { "extensions": ["./index.ts"] } }', @@ -143,6 +152,7 @@ describe("pi T3 MCP injection", () => { `{ "packages": [ "npm:pi-lens", "npm:@gotgenes/pi-anthropic-auth@1.2.3", + { "source": "npm:filtered-extension", "extensions": ["./src/index.ts"] }, "npm:pi-subagents", { "source": "npm:disabled-extension", "extensions": [] } ] }`, @@ -156,6 +166,22 @@ describe("pi T3 MCP injection", () => { `${extensionsDir}/todos/index.ts`, `${lensDir}/src/index.ts`, `${authDir}/src/index.ts`, + `${filteredDir}/src/index.ts`, + ]); + yield* fs.writeFileString( + `${home}/.pi/agent/settings.json`, + `{ "packages": [ + { "source": "npm:filtered-extension", "autoload": false, "extensions": ["./src/index.ts"] } + ] }`, + ); + const autoloadDisabled = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: undefined, + }); + assert.deepEqual(autoloadDisabled, [ + `${extensionsDir}/demo.ts`, + `${extensionsDir}/todos/index.ts`, + `${filteredDir}/src/index.ts`, ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 085209b797f0..4ffa33ab8af6 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -67,12 +67,8 @@ function piNpmPackageName(source: string): string | undefined { : undefined; } -function piUnfilteredPackageSource(pkg: typeof PiPackageSource.Type): string | undefined { - if (typeof pkg === "string") return pkg; - // Pi's object form can narrow or disable individual resources. Re-adding - // the whole manifest would bypass that choice, so only restore packages - // whose extension set is inherited unchanged. - return pkg.autoload === false || pkg.extensions !== undefined ? undefined : pkg.source; +function piPackageSource(pkg: typeof PiPackageSource.Type): string { + return typeof pkg === "string" ? pkg : pkg.source; } function piPackageExtensionPath(packageRoot: string, entry: string): string | undefined { @@ -88,6 +84,87 @@ function piPackageExtensionPath(packageRoot: string, entry: string): string | un return segments.length === 0 ? undefined : `${packageRoot}/${segments.join("/")}`; } +function piPackagePatternPaths( + fs: FileSystem.FileSystem, + packageRoot: string, + pattern: string, + exact: boolean, +): Effect.Effect> { + const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, ""); + if ( + normalizedPattern.length === 0 || + normalizedPattern.startsWith("/") || + /^[A-Za-z]:\//.test(normalizedPattern) || + normalizedPattern.split("/").includes("..") + ) { + return Effect.succeed(new Set()); + } + const patterns = + exact || normalizedPattern.includes("/") + ? [normalizedPattern] + : [normalizedPattern, `**/${normalizedPattern}`]; + return Effect.forEach(patterns, (candidate) => + exact + ? Effect.succeed([candidate]) + : fs.glob(candidate, { root: packageRoot }).pipe(Effect.orElseSucceed(() => [])), + ).pipe( + Effect.map( + (groups) => + new Set( + groups.flatMap((matches) => + matches.flatMap((match) => { + const normalizedMatch = normalizePiPath(match); + const path = normalizedMatch.startsWith(`${normalizePiPath(packageRoot)}/`) + ? normalizedMatch + : piPackageExtensionPath(packageRoot, normalizedMatch); + return path === undefined ? [] : [path]; + }), + ), + ), + ), + ); +} + +function piEnabledPackageExtensions( + fs: FileSystem.FileSystem, + packageRoot: string, + extensionPaths: ReadonlyArray, + pkg: typeof PiPackageSource.Type, +): Effect.Effect> { + return Effect.gen(function* () { + if (typeof pkg === "string") return [...extensionPaths]; + if (pkg.extensions === undefined) return pkg.autoload === false ? [] : [...extensionPaths]; + if (pkg.extensions.length === 0) return []; + const includes = pkg.extensions.filter((pattern) => !/^[!+-]/.test(pattern)); + const patterns = + pkg.autoload === false + ? pkg.extensions + : [ + ...includes, + ...pkg.extensions.filter((pattern) => pattern.startsWith("!")), + ...pkg.extensions.filter((pattern) => pattern.startsWith("+")), + ...pkg.extensions.filter((pattern) => pattern.startsWith("-")), + ]; + const enabled = new Set(pkg.autoload === false || includes.length > 0 ? [] : extensionPaths); + for (const pattern of patterns) { + const prefix = pattern[0]; + const target = /^[!+-]/.test(prefix ?? "") ? pattern.slice(1) : pattern; + const matches = yield* piPackagePatternPaths( + fs, + packageRoot, + target, + prefix === "+" || prefix === "-", + ); + for (const path of extensionPaths) { + if (!matches.has(path)) continue; + if (prefix === "!" || prefix === "-") enabled.delete(path); + else enabled.add(path); + } + } + return extensionPaths.filter((path) => enabled.has(path)); + }); +} + /** * Re-discover the user's pi extensions for a `--no-extensions` spawn: the * subagent override forces discovery off (a second `subagent` registration @@ -134,7 +211,7 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu .readDirectory(root) .pipe(Effect.orElseSucceed(() => [] as Array)); for (const entry of entries.toSorted()) { - if (entry === "subagent" || entry === "subagent.ts") continue; + if (entry === "subagent" || entry === "subagent.ts" || entry === "subagent.js") continue; const path = `${root}/${entry}`; if (entry.endsWith(".ts") || entry.endsWith(".js")) { addFound(path); @@ -151,19 +228,28 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu } if (normalizedAgentDir !== undefined) { for (const pkg of settings?.packages ?? []) { - const source = piUnfilteredPackageSource(pkg); - const packageName = source === undefined ? undefined : piNpmPackageName(source); + const packageName = piNpmPackageName(piPackageSource(pkg)); if (packageName === undefined || packageName === "pi-subagents") continue; const packageRoot = `${normalizedAgentDir}/npm/node_modules/${packageName}`; const packageJsonRaw = yield* fs .readFileString(`${packageRoot}/package.json`) .pipe(Effect.orElseSucceed(() => "")); const packageJson = Option.getOrUndefined(decodePiPackageJson(packageJsonRaw)); + const extensionPaths: Array = []; for (const entry of packageJson?.pi?.extensions ?? []) { const extensionPath = piPackageExtensionPath(packageRoot, entry); if (extensionPath === undefined) continue; const exists = yield* fs.exists(extensionPath).pipe(Effect.orElseSucceed(() => false)); - if (exists) addFound(extensionPath); + if (exists) extensionPaths.push(extensionPath); + } + const enabledExtensions = yield* piEnabledPackageExtensions( + fs, + packageRoot, + extensionPaths, + pkg, + ); + for (const extensionPath of enabledExtensions) { + addFound(extensionPath); } } } From 18f773833f93f97bd8321b3ca7088bf61017d966 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:33:55 +0200 Subject: [PATCH 39/41] fix(pi): honor project trust decisions --- .../Adapters/piT3McpInjection.test.ts | 23 +++++++-- .../Adapters/piT3McpInjection.ts | 47 ++++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index 4a166ff801de..bc974cac3526 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -190,7 +190,8 @@ describe("pi T3 MCP injection", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-home-" }); - const project = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-project-" }); + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-parent-" }); + const project = `${parent}/project`; yield* fs.makeDirectory(`${home}/.pi/agent`, { recursive: true }); yield* fs.makeDirectory(`${project}/.pi/extensions`, { recursive: true }); yield* fs.writeFileString(`${project}/.pi/extensions/local.ts`, "export default () => {}"); @@ -199,15 +200,31 @@ describe("pi T3 MCP injection", () => { cwd: project, }); assert.deepEqual(untrusted, []); + yield* fs.writeFileString(`${home}/.pi/agent/trust.json`, `{ "${parent}": true }`); + const trustedViaAncestor = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: project, + }); + assert.deepEqual(trustedViaAncestor, [`${project}/.pi/extensions/local.ts`]); yield* fs.writeFileString( `${home}/.pi/agent/settings.json`, '{ "defaultProjectTrust": "always" }', ); - const trusted = yield* discoverPiUserExtensions({ + yield* fs.writeFileString( + `${home}/.pi/agent/trust.json`, + `{ "${parent}": true, "${project}": false }`, + ); + const explicitlyUntrusted = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: project, + }); + assert.deepEqual(explicitlyUntrusted, []); + yield* fs.writeFileString(`${home}/.pi/agent/trust.json`, "{}"); + const trustedByDefault = yield* discoverPiUserExtensions({ environment: { HOME: home }, cwd: project, }); - assert.deepEqual(trusted, [`${project}/.pi/extensions/local.ts`]); + assert.deepEqual(trustedByDefault, [`${project}/.pi/extensions/local.ts`]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 4ffa33ab8af6..997a7bfe6db3 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -1,3 +1,5 @@ +import * as NodePath from "node:path"; + import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -54,8 +56,26 @@ const PiPackageJson = Schema.Struct({ ), }); +const PiTrustStore = Schema.Record(Schema.String, Schema.NullOr(Schema.Boolean)); + const decodePiSettings = Schema.decodeUnknownOption(Schema.fromJsonString(PiSettingsFile)); const decodePiPackageJson = Schema.decodeUnknownOption(Schema.fromJsonString(PiPackageJson)); +const decodePiTrustStore = Schema.decodeUnknownOption(Schema.fromJsonString(PiTrustStore)); + +/** Mirrors Pi's canonical nearest-ancestor lookup over `trust.json`. */ +function piNearestProjectTrustDecision( + trust: typeof PiTrustStore.Type, + cwd: string, +): boolean | undefined { + let current = cwd; + while (true) { + const decision = trust[current]; + if (decision === true || decision === false) return decision; + const parent = NodePath.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} function piNpmPackageName(source: string): string | undefined { if (!source.startsWith("npm:")) return undefined; @@ -171,7 +191,8 @@ function piEnabledPackageExtensions( * aborts pi), which must not cost the user every other extension they have. * Mirrors pi's own discovery roots and user npm package manifests: * `/extensions`, `settings.json` package `pi.extensions`, and the - * project-local `.pi/extensions` only when the user's `defaultProjectTrust` is `always` — + * project-local `.pi/extensions` only under Pi's recorded per-project trust + * or, when no decision exists, `defaultProjectTrust: "always"` — * explicit `--extension` paths bypass pi's trust prompt, so anything short * of standing trust must not be silently loaded. Pi's `pi-subagents` package * and conventional `subagent` entries are skipped in favor of the T3 override. @@ -195,12 +216,24 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu const settings = Option.getOrUndefined(decodePiSettings(settingsRaw)); const roots: Array = []; if (normalizedAgentDir !== undefined) roots.push(`${normalizedAgentDir}/extensions`); - if ( - normalizedAgentDir !== undefined && - input.cwd !== undefined && - settings?.defaultProjectTrust === "always" - ) { - roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); + if (normalizedAgentDir !== undefined && input.cwd !== undefined) { + const trustPath = `${normalizedAgentDir}/trust.json`; + const trustExists = yield* fs.exists(trustPath).pipe(Effect.orElseSucceed(() => false)); + const trustRaw = trustExists + ? yield* fs.readFileString(trustPath).pipe(Effect.orElseSucceed(() => "")) + : undefined; + const trustStore = + trustRaw === undefined ? {} : Option.getOrUndefined(decodePiTrustStore(trustRaw)); + const resolvedCwd = NodePath.resolve(input.cwd); + const canonicalCwd = yield* fs + .realPath(resolvedCwd) + .pipe(Effect.orElseSucceed(() => resolvedCwd)); + const decision = + trustStore === undefined ? false : piNearestProjectTrustDecision(trustStore, canonicalCwd); + const projectTrusted = decision ?? settings?.defaultProjectTrust === "always"; + if (projectTrusted) { + roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); + } } const found: Array = []; const addFound = (path: string) => { From 8243dd9e2663c3112ef6bac9e40fcc45302262e0 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:37:48 +0200 Subject: [PATCH 40/41] fix(pi): avoid native path dependency --- .../Adapters/piT3McpInjection.ts | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 997a7bfe6db3..753b17a13748 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -1,5 +1,3 @@ -import * as NodePath from "node:path"; - import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -62,19 +60,39 @@ const decodePiSettings = Schema.decodeUnknownOption(Schema.fromJsonString(PiSett const decodePiPackageJson = Schema.decodeUnknownOption(Schema.fromJsonString(PiPackageJson)); const decodePiTrustStore = Schema.decodeUnknownOption(Schema.fromJsonString(PiTrustStore)); +function normalizePiTrustPath(value: string): string { + const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); + if (normalized.length === 0) return "/"; + return /^[A-Za-z]:$/.test(normalized) ? `${normalized}/` : normalized; +} + +function piTrustParentPath(value: string): string | undefined { + if (value === "/" || /^[A-Za-z]:\/$/.test(value)) return undefined; + const uncRoot = value.match(/^\/\/[^/]+\/[^/]+/)?.[0]; + if (uncRoot === value) return undefined; + const separatorIndex = value.lastIndexOf("/"); + if (separatorIndex < 0) return undefined; + const parent = separatorIndex === 0 ? "/" : value.slice(0, separatorIndex); + return uncRoot !== undefined && parent.length < uncRoot.length + ? uncRoot + : normalizePiTrustPath(parent); +} + /** Mirrors Pi's canonical nearest-ancestor lookup over `trust.json`. */ function piNearestProjectTrustDecision( trust: typeof PiTrustStore.Type, cwd: string, ): boolean | undefined { - let current = cwd; - while (true) { - const decision = trust[current]; + const decisions = new Map( + Object.entries(trust).map(([path, decision]) => [normalizePiTrustPath(path), decision]), + ); + let current: string | undefined = normalizePiTrustPath(cwd); + while (current !== undefined) { + const decision = decisions.get(current); if (decision === true || decision === false) return decision; - const parent = NodePath.dirname(current); - if (parent === current) return undefined; - current = parent; + current = piTrustParentPath(current); } + return undefined; } function piNpmPackageName(source: string): string | undefined { @@ -224,10 +242,7 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu : undefined; const trustStore = trustRaw === undefined ? {} : Option.getOrUndefined(decodePiTrustStore(trustRaw)); - const resolvedCwd = NodePath.resolve(input.cwd); - const canonicalCwd = yield* fs - .realPath(resolvedCwd) - .pipe(Effect.orElseSucceed(() => resolvedCwd)); + const canonicalCwd = yield* fs.realPath(input.cwd).pipe(Effect.orElseSucceed(() => input.cwd)); const decision = trustStore === undefined ? false : piNearestProjectTrustDecision(trustStore, canonicalCwd); const projectTrusted = decision ?? settings?.defaultProjectTrust === "always"; From d7a20282bf0a8c8946beba20083a1636ea0b80f1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:41:09 +0200 Subject: [PATCH 41/41] fix(pi): retain validated project cwd --- .../server/src/orchestration-v2/Adapters/piT3McpInjection.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 753b17a13748..b2ff8b1a0e41 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -235,6 +235,7 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu const roots: Array = []; if (normalizedAgentDir !== undefined) roots.push(`${normalizedAgentDir}/extensions`); if (normalizedAgentDir !== undefined && input.cwd !== undefined) { + const cwd = input.cwd; const trustPath = `${normalizedAgentDir}/trust.json`; const trustExists = yield* fs.exists(trustPath).pipe(Effect.orElseSucceed(() => false)); const trustRaw = trustExists @@ -242,12 +243,12 @@ export const discoverPiUserExtensions = Effect.fn("discoverPiUserExtensions")(fu : undefined; const trustStore = trustRaw === undefined ? {} : Option.getOrUndefined(decodePiTrustStore(trustRaw)); - const canonicalCwd = yield* fs.realPath(input.cwd).pipe(Effect.orElseSucceed(() => input.cwd)); + const canonicalCwd = yield* fs.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); const decision = trustStore === undefined ? false : piNearestProjectTrustDecision(trustStore, canonicalCwd); const projectTrusted = decision ?? settings?.defaultProjectTrust === "always"; if (projectTrusted) { - roots.push(`${normalizePiPath(input.cwd)}/.pi/extensions`); + roots.push(`${normalizePiPath(cwd)}/.pi/extensions`); } } const found: Array = [];