diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..73c8b8a9ecff 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..e0016c4adf68 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -0,0 +1,1566 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CheckpointId, + EnvironmentId, + NodeId, + ProviderInstanceId, + ProviderSessionId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2AppThread, + 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"; +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 { 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, + type ProviderAdapterV2Event, + type ProviderAdapterV2SessionRuntime, +} from "../ProviderAdapter.ts"; +import { makePiAdapterV2, 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; + /** 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; + /** 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; + /** 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; + /** Close the fake process stdout stream. */ + readonly closeStdout: Effect.Effect; + readonly lastSpawn: () => { + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + }; +} + +/** + * 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(); + const entriesQueue: Array = []; + const stateQueue: Array = []; + const statsQueue: Array = []; + const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; + let vetoSwitch = false; + 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: stateQueue.shift() ?? { + model: null, + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + autoCompactionEnabled: true, + sessionFile: FAKE_SESSION_FILE, + sessionId: "abc", + }, + }; + case "switch_session": { + const cancelled = vetoSwitch; + vetoSwitch = false; + return { ...base, data: { cancelled } }; + } + case "get_entries": + return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; + case "get_session_stats": + return { ...base, data: statsQueue.shift() ?? {} }; + case "get_commands": + return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; + case "fork": + return { ...base, data: { cancelled: false, message: "forked" } }; + 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); + } + }); + + 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), + 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, + queueEntries: (data) => entriesQueue.push(data), + vetoNextSwitch: () => { + vetoSwitch = true; + }, + queueState: (data) => stateQueue.push(data), + 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; +}); + +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", + attachments: ReadonlyArray = [], + text = "Hello pi", +) { + 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, + attachments, + createdBy: "user", + creationSource: "web", + }, + modelSelection: modelSelection(model), + runtimePolicy, + }); +}); + +describe("PiAdapterV2", () => { + 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", + browserToolsAvailable: true, + }); + const fake = yield* makeFakePi; + yield* openRuntime(fake); + const spawn = fake.lastSpawn(); + assert.isTrue(spawn.args.includes("--extension")); + 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( + 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; + 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); + 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"); + 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)), + ); + + 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; + 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("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("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; + 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"); + // 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" } }); + 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 }); + 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( + (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 usage = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.contextUsage?.usedTokens === 20_500, + ); + 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"); + + // 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)), + ); + + 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.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; + 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; + 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", + 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: [], + }, + ], + }, + }, + }); + 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", + ); + assert.isTrue( + running.type === "subagent.updated" && + running.subagent.title === "scout" && + running.subagent.prompt === "map the repo" && + 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", + toolName: "subagent", + isError: false, + result: { + content: [{ type: "text", text: "done" }], + details: { + mode: "parallel", + results: [ + { + agent: "scout", + task: "map the repo", + finished: true, + exitCode: 0, + stopReason: "stop", + stderr: "", + sessionFile: "/tmp/pi-children/scout.jsonl", + messages: [ + { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, + ], + }, + { + agent: "worker", + task: "broken task", + finished: true, + 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" && + 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", + ); + assert.isTrue( + failedCard.type === "subagent.updated" && + failedCard.subagent.title === "worker" && + failedCard.subagent.result === "boom" && + failedCard.subagent.childThreadId === null, + ); + }).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; + 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; + 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("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", + }, + }); + 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?.usedTokens === 3_400, + ); + 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)), + ); + + 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"); + 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)), + ); + + 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.nativeItemRef?.nativeId === `status:${row.turnItem.providerTurnId}:tps` && + (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", + finished: false, + exitCode: -1, + stopReason: "toolUse", + 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"); + + // 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)), + ); + + 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); + + 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)), + ); + + 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; + 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..562995837f4c --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -0,0 +1,2713 @@ +/** + * 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; + * 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"; +import { + defaultInstanceIdForDriver, + PiSettings, + ProviderDriverKind, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type ThreadId, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ProviderApprovalDecision, + 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"; +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 Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +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 { + 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 ProviderAdapterV2ThreadSnapshot, + 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 { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "../SubagentProjection.ts"; +import { + makePiRpcConnection, + parsePiModelSlug, + piRecordField as recordField, + piRecordNumber as recordNumber, + piRecordString as recordString, + type PiRpcConnection, + type PiRpcRecord, +} from "./PiRpc.ts"; +import { + buildPiRpcLaunch, + discoverPiUserExtensions, + materializePiT3McpExtension, + materializePiT3SubagentExtension, +} from "./piT3McpInjection.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; +const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; + +export const PiProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + // 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, + }, + 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: true, + 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: { + // 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: true, + emitsSubagentLifecycle: true, + 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: true, + // CommandPolicy.ensureRollback requires the snapshot whenever provider + // rollback is enabled; rollbackThread returns the updated provider thread. + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + 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"]; +} + +/** 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 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; + /** + * 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; + readonly childSubagents: 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; + /** + * 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; + /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ + latestCompactionAfterTokens: number | null; + 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"; + 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; + /** + * 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({ + 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 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* provideCacheFs( + materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir), + ); + 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 connection: PiRpcConnection = yield* makePiRpcConnection({ + command: options.settings.binaryPath || "pi", + args: launch.args, + cwd, + env: launch.env, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const discoverSkillNames = connection + .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) + .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 = { + 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< + 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 + // 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; + // 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). */ + 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 + * 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 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; + let autoCompactionEnabled: boolean | undefined; + + 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 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 | 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 undefined; + + 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, + 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 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")); + // 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, + toolCallId, + "tool_call", + status, + startedAt, + 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 } : {}), + }, + }); + if (toolName === "subagent") { + yield* emitSubagentTasks(turn, toolCallId, resultRecord, completed); + } + }); + + /** + * 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* ( + 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; + 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"); + 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 finished = completed || recordField(result, "finished") === true; + const stopReason = recordString(result, "stopReason"); + const interrupted = finished && stopReason === "aborted"; + const failed = + finished && + !interrupted && + ((recordNumber(result, "exitCode") ?? 0) !== 0 || stopReason === "error"); + const status = interrupted + ? "interrupted" + : failed + ? "failed" + : finished + ? "completed" + : "running"; + 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({ + 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, + subagent: { + id: subagentId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId, + origin: "provider_native", + createdBy: "agent", + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: turn.turnInput.providerThread.id, + childThreadId, + 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, + prompt: task, + ...(finished || outputText.length === 0 + ? {} + : { progress: outputText.slice(0, 200) }), + result: finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null, + }, + }); + } + }); + + /** 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) => + 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 === "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"}:${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") + : 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" + ) { + // setTitle / set_editor_text have no matching T3 surface yet. + 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) { + outOfTurnDialogs.push(event); + yield* Effect.logDebug("Buffered out-of-turn pi extension dialog.", { method }); + 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 ──────────────────────────────────── + + /** + * 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 cursorWasStale = leafCursorStale; + const cursor = cursorWasStale ? null : lastKnownLeaf; + const data = yield* request({ + type: "get_entries", + ...(cursor === null ? {} : { since: cursor }), + }).pipe(Effect.orElseSucceed(() => undefined)); + 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; + // 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, + }; + }); + + 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); + // 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 contextUsage = refreshContextUsage ? yield* readContextUsage(turn) : undefined; + const failure = turn.interrupted ? null : turn.failure; + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + 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", + ...(treeRefs?.leafId == null + ? {} + : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), + ...(contextUsage === undefined ? {} : { contextUsage }), + }); + 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 "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; + 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) { + turn.sawAgentActivity = true; + 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) { + // 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; + } + // 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}`; + turn.latestCompactionAfterTokens = + nonNegativeInteger(result, "estimatedTokensAfter") ?? null; + 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) { + // 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", + retryable: false, + }); + return; + } + case "extension_ui_request": + 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"), + errorLength: recordString(event, "error")?.length, + }); + return; + } + case "agent_settled": { + 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 (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.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), + ); + } + 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.", { + errorLength: recordString(event, "error")?.length, + }); + return; + } + if (turn !== null && (command === "prompt" || command === "parse")) { + turn.failure = makeProviderFailure({ + message: recordString(event, "error") ?? "Pi rejected the prompt.", + class: "provider_error", + }); + if (state !== null) yield* finalizeTurn(state); + } + 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 + // 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 && + (probeFailed || + (recordField(data, "isStreaming") !== true && + (recordNumber(data, "pendingMessageCount") ?? 0) === 0)) + ) { + if (state !== null) yield* finalizeTurn(state); + } + return; + } + default: + return; + } + }); + + yield* Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(connection.events); + yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); + } + }).pipe( + Effect.catchCause((cause) => + sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + // 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) { + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "Pi process exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state, false); + } + 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, + }), + ); + } + }), + ), + ), + Effect.forkIn(scope), + ); + + // ── session runtime ─────────────────────────────────── + + 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)) { + 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, + }); + // 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"); + } + // 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. + 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 the inherited choice cannot replay the previous session's. + baselineModel = null; + 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. + 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 = + 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 }; + // 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. + 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, + providerThread, + }); + return providerThread; + }); + + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { + 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( + `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 === PI_INHERIT_THINKING_VALUE) { + if (appliedThinking !== null && baselineThinking !== null) { + yield* request({ type: "set_thinking_level", level: baselineThinking }); + appliedThinking = null; + } + } else if ( + thinking !== undefined && + 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, + ) { + // 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) { + 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 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; + return { message, images }; + }); + + // 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, + (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, + providerSessionId: input.providerSessionId, + providerSession: sessionEntity, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + registerThread(threadInput).pipe( + sessionEventPermit.withPermits(1), + 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( + sessionEventPermit.withPermits(1), + 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); + // 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. + // 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( + turnInput.message.text, + turnInput.message.attachments, + ); + 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, + }; + const activeTurn: ActivePiTurn = { + turnInput, + providerTurn, + startedAt, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + messageOrdinal: 0, + streamItems: new Map(), + toolArgs: new Map(), + toolStartedAt: new Map(), + childSubagents: new Map(), + 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, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + 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" }); + } + // 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. + }).pipe( + sessionEventPermit.withPermits(1), + 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, + ); + // 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({ + type: "steer", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }); + }).pipe( + sessionEventPermit.withPermits(1), + 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; + 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 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; + } + yield* request({ type: "abort" }).pipe( + Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), + ); + }).pipe( + sessionEventPermit.withPermits(1), + 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}`, + ); + } + 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, + 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( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterRuntimeRequestResponseError({ + driver: PI_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + 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( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: PI_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + const state = threadState; + 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"); + } + // `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; + // 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), + }); + return piThreadSnapshot(state.providerThread); + }).pipe( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterRollbackThreadError({ + driver: PI_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: (forkInput) => + 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"); + } + 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. + 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" }).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); + } + 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({ + driver: PI_PROVIDER, + nativeThreadId: cloneNativeId, + }), + driver: PI_PROVIDER, + 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( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: PI_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + return runtime; + }), + }); +} + +/** + * 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). + */ +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; +} + +/** + * 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) { + // 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"); + 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 { + return { providerThread, providerTurns: [], messages: [], runtimeRequests: [] }; +} + +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 })) + : []; + // 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: + prefill === undefined || prefill.length === 0 + ? question + : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, + 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]; + // 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 }; +} + +// ── 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..4c2a2382e281 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -0,0 +1,396 @@ +/** + * 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 Predicate from "effect/Predicate"; +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 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 + * 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; + 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. 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; + /** + * 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; +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); + +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); + return Predicate.isObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * 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"); + }); + +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 }))); + + let childExited = false; + + 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; + } + }; + + /** 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); + + 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, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); + + 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); + } + // 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); + }); + + // 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: summarizePiError(record["error"]), + ...(record["error"] === undefined ? {} : { cause: record["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 + : // 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), + ); + + yield* child.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => + Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), + onSuccess: (code) => + Effect.suspend(() => { + childExited = true; + return Deferred.succeed(exitDeferred, Number(code)); + }), + }), + Effect.forkIn(scope), + ); + + const send = (record: PiRpcRecord): Effect.Effect => + Effect.gen(function* () { + 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); + } + }); + + 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))), + ); + // 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: () => + 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), + 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 new file mode 100644 index 000000000000..4a18e196705a --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -0,0 +1,258 @@ +/** + * 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. + */ +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"; +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 ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; +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 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; + + 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 = () => { + if (started !== undefined) return started; + const attempt = (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 }, + ...(isMcpToolError(result) ? { isError: true } : {}), + }; + }, + }); + } + })(); + 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. + // 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 { + await ensureStarted(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + 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 new file mode 100644 index 000000000000..bc974cac3526 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -0,0 +1,230 @@ +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 { + PI_T3_SUBAGENT_EXTENSION_FILENAME, + T3_PI_CHILD_SESSION_ROOT_ENV, +} from "./piT3SubagentExtensionSource.ts"; +import { + buildPiRpcLaunch, + discoverPiUserExtensions, + T3_PI_MCP_EXTENSION_PATH_ENV, + materializePiT3McpExtension, + materializePiT3SubagentExtension, +} 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", + browserToolsAvailable: true, +}; + +describe("pi T3 MCP injection", () => { + 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("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 --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", + "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_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"); + assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts"); + assert.equal( + launch.args.filter((arg) => arg === "/tmp/cache/pi-t3-subagent-extension.ts").length, + 1, + ); + }); + + it.effect("materializes both runtime extensions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + 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(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(subagentSource, T3_PI_MCP_EXTENSION_PATH_ENV); + assert.isFalse(subagentSource.includes("--no-session")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + 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 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`, + '{ "pi": { "extensions": ["./src/index.ts"] } }', + ); + yield* fs.writeFileString( + `${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"] } }', + ); + yield* fs.writeFileString( + `${home}/.pi/agent/settings.json`, + `{ "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": [] } + ] }`, + ); + const found = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + cwd: undefined, + }); + assert.deepEqual(found, [ + `${extensionsDir}/demo.ts`, + `${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)), + ); + + 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 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 () => {}"); + const untrusted = yield* discoverPiUserExtensions({ + environment: { HOME: home }, + 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" }', + ); + 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(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 new file mode 100644 index 000000000000..b2ff8b1a0e41 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -0,0 +1,471 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +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"; +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, + PI_T3_SUBAGENT_EXTENSION_FILENAME, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, + 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"; + +function bearerTokenFromAuthorizationHeader(header: string): string { + return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; +} + +const PiPackageSource = Schema.Union([ + Schema.String, + Schema.Struct({ + source: Schema.String, + autoload: Schema.optional(Schema.Boolean), + extensions: Schema.optional(Schema.Array(Schema.String)), + }), +]); + +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 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)); + +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 { + 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; + current = piTrustParentPath(current); + } + return undefined; +} + +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 piPackageSource(pkg: typeof PiPackageSource.Type): string { + return typeof pkg === "string" ? pkg : 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("/")}`; +} + +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 + * 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 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. + */ +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 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 (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 + ? yield* fs.readFileString(trustPath).pipe(Effect.orElseSucceed(() => "")) + : undefined; + const trustStore = + trustRaw === undefined ? {} : Option.getOrUndefined(decodePiTrustStore(trustRaw)); + 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(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) + .pipe(Effect.orElseSucceed(() => [] as Array)); + for (const entry of entries.toSorted()) { + if (entry === "subagent" || entry === "subagent.ts" || entry === "subagent.js") continue; + const path = `${root}/${entry}`; + if (entry.endsWith(".ts") || entry.endsWith(".js")) { + addFound(path); + continue; + } + 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 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) extensionPaths.push(extensionPath); + } + const enabledExtensions = yield* piEnabledPackageExtensions( + fs, + packageRoot, + extensionPaths, + pkg, + ); + for (const extensionPath of enabledExtensions) { + addFound(extensionPath); + } + } + } + return found; +}); + +function piT3McpExtensionDestPath(cacheDir: string): string { + return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; +} + +function piT3SubagentExtensionDestPath(cacheDir: string): string { + return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_SUBAGENT_EXTENSION_FILENAME}`; +} + +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, +) { + 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 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[] { + return extensionPath === undefined ? [...args] : [...args, "--extension", extensionPath]; +} + +function normalizePiPath(value: string): string { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +/** Official / user-installed `subagent` tool. Not the T3 override file. */ +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") + ); +} + +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; +} + +function deduplicatePiExtensionArgs(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; + 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; + readonly hasT3Mcp: boolean; +} { + const userArgs = tokenizeCliArgs(input.launchArgs); + const hasT3Mcp = input.mcpSession !== undefined && input.extensionPath !== undefined; + // 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); + for (const discovered of input.discoveredExtensionPaths ?? []) { + args = appendExtensionArg(args, discovered); + } + args = [...args, ...stripConflictingPiSubagentExtensionArgs(userArgs)]; + } else { + args = [...args, ...userArgs]; + } + if (hasT3Mcp && input.extensionPath !== undefined) { + args = appendExtensionArg(args, input.extensionPath); + } + args = deduplicatePiExtensionArgs(args); + + const childSessionRoot = piChildSessionRootFromLaunchArgs(input.launchArgs); + return { + args, + env: { + ...input.environment, + ...(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, + ), + // 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 }), + } + : {}), + }, + 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..0c49f7c07115 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3SubagentExtensionSource.ts @@ -0,0 +1,556 @@ +/** + * 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; + finished: boolean; + exitCode: number; + messages: unknown[]; + stderr: string; + 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, + finished: true, + exitCode: 1, + messages: [], + stderr: \`Unknown agent: "\${agentName}". Available agents: \${available}.\`, + step, + }; + } + + const sessionFile = childSessionFile(); + const args: string[] = [ + "--mode", + "json", + "-p", + "--session", + sessionFile, + "--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); + 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, + finished: false, + exitCode: 0, + messages: [], + stderr: "", + 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; + model?: string; + stopReason?: string; + errorMessage?: string; + }; + if (message.role === "assistant") { + 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; + }); + 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"; + if (proc.kill("SIGTERM")) { + killTimer = setTimeout(() => proc.kill("SIGKILL"), 1_000); + killTimer.unref(); + } + }; + proc.on("close", (code) => settle(code ?? 1)); + proc.on("error", (error) => { + currentResult.stderr += error.message; + settle(1); + }); + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + currentResult.exitCode = exitCode; + currentResult.finished = true; + 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, + finished: false, + exitCode: -1, + messages: [], + stderr: "", + })); + 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/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()]; 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..81f12b05dab5 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.ts @@ -1,11 +1,55 @@ +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", "Failed to dispatch orchestration command ", "Provider adapter failed while dispatching orchestration command ", ]; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; +/** + * 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 (!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 : ""; + return CAPABILITY_REJECTION_MESSAGES[capability]?.(provider); + } + if (value._tag === "CommandPolicyUnsupportedError") { + return `${provider} cannot deliver a message that way right now.`; + } + return undefined; } function textValue(value: unknown): string | undefined { @@ -16,10 +60,14 @@ 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); } - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return undefined; } return textValue(value.detail) ?? textValue(value.message); @@ -40,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/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..07e84af97f9d --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -0,0 +1,182 @@ +/** + * 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 { cwd } = yield* ServerConfig; + 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, cwd).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, + }), + ), + ); + + 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..f76e51d48691 --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -0,0 +1,348 @@ +/** + * 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 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"; +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, + piRecordField as recordField, + piRecordString as recordString, +} from "../../orchestration-v2/Adapters/PiRpc.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./piThinkingCapabilities.ts"; +import { parsePiDiscoveredCommands, type PiDiscoveredCommands } from "../PiCommands.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; + +/** Deferring to the user's own settings.json default model. */ +const PI_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Pi default", + isCustom: false, + capabilities: EMPTY_PI_MODEL_CAPABILITIES, +}; + +interface PiDiscovery extends PiDiscoveredCommands { + readonly models: ReadonlyArray; + readonly authenticated: boolean; +} + +function piModelsFromSettings( + customModels: ReadonlyArray | undefined, + discovered: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + [PI_DEFAULT_MODEL, ...discovered], + customModels ?? [], + EMPTY_PI_MODEL_CAPABILITIES, + ); +} + +function parseDiscoveredModels( + data: unknown, + defaultThinkingLevel: 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: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), + }); + } + return parsed; +} + +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, + env: environment, + }); + const stateData = yield* connection.request({ type: "get_state" }); + 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, + recordString(stateData, "thinkingLevel"), + ); + const { slashCommands, skills } = parsePiDiscoveredCommands(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, + cwd?: string, +): 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, cwd).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/Layers/piThinkingCapabilities.test.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts new file mode 100644 index 000000000000..b26a56884aed --- /dev/null +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts @@ -0,0 +1,62 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./piThinkingCapabilities.ts"; + +describe("thinkingCapabilitiesForPiModel", () => { + it("returns empty capabilities for a non-reasoning model", () => { + assert.deepEqual( + thinkingCapabilitiesForPiModel({ reasoning: false }, "xhigh"), + EMPTY_PI_MODEL_CAPABILITIES, + ); + }); + + 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"); + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepEqual( + thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), + [ + ["minimal", "Minimal", false], + ["low", "Low", false], + ["medium", "Medium", false], + ["high", "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 new file mode 100644 index 000000000000..186bcb587d01 --- /dev/null +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.ts @@ -0,0 +1,115 @@ +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 + * 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", +}; + +export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +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: 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`. + * + * 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. + */ +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 { + return Predicate.isObject(input) ? input[key] : undefined; +} 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/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", + // --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)); + + if (modelSelection.model !== "default") { + // `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 }); + 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/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/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..b1642036b3e8 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 }) => ( - - + - + ); 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/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 { 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 8d60f6267178..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,33 +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"; - 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/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6d874ab59329..15cbbc42e20c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -44,6 +44,12 @@ export const PROVIDER_OPTIONS: Array<{ pickerSidebarBadge: "new", }, { value: ProviderDriverKind.make("grok"), label: "Grok", available: true }, + { + value: ProviderDriverKind.make("pi"), + label: "Pi", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = 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 a309d70f03de..44d0f9e37a3f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with these entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -16,6 +16,15 @@ orchestration layer does not know which one is behind a thread. | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `pi` | [`Drivers/PiDriver.ts`][pi] | + +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 @@ -81,6 +90,7 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[pi]: ../../apps/server/src/provider/Drivers/PiDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401a..c63822c05e97 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -130,10 +130,37 @@ 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. 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`. 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 -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. 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..367f87c0804c --- /dev/null +++ b/docs/user/providers-pi.md @@ -0,0 +1,39 @@ +# 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. 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. + +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. + +## 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. +- 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. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1c8c16a0638b..635e75b0d5a8 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -154,6 +155,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: 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/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, }); 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), }), ),