hob-bot 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +76 -6
  2. package/README.zh-CN.md +76 -6
  3. package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
  4. package/dist/web/assets/index-Di5lyTHO.css +1 -0
  5. package/dist/web/assets/index-y09i1RGd.js +267 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/package.json +7 -1
  13. package/server/src/actions.ts +16 -56
  14. package/server/src/assistant-skills.ts +215 -0
  15. package/server/src/boot.ts +1 -0
  16. package/server/src/claude-status.ts +47 -0
  17. package/server/src/config.ts +3 -1
  18. package/server/src/delegations.ts +21 -11
  19. package/server/src/desktop-control.ts +152 -0
  20. package/server/src/desktop-events.ts +63 -0
  21. package/server/src/desktop-public.ts +43 -0
  22. package/server/src/desktop-tools.ts +161 -0
  23. package/server/src/desktop.ts +76 -0
  24. package/server/src/embedding.ts +52 -0
  25. package/server/src/files.ts +63 -43
  26. package/server/src/http.ts +325 -13
  27. package/server/src/job-view.ts +52 -0
  28. package/server/src/jobs.ts +326 -44
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +4 -4
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +51 -156
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +39 -5
  43. package/server/src/router-activity.ts +6 -0
  44. package/server/src/router-tools.ts +184 -39
  45. package/server/src/router.ts +154 -17
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/transcript.ts +57 -4
  51. package/server/src/worker-tools.ts +14 -12
  52. package/shared/knowledge.ts +92 -0
  53. package/shared/mcp.ts +91 -0
  54. package/shared/skills.ts +18 -0
  55. package/shared/types.ts +104 -20
  56. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  57. package/vendor/claude-bridge/LICENSE +21 -0
  58. package/vendor/claude-bridge/package.json +62 -0
  59. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  60. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  61. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  62. package/vendor/claude-bridge/src/attachments.ts +135 -0
  63. package/vendor/claude-bridge/src/config.ts +105 -0
  64. package/vendor/claude-bridge/src/convert.ts +222 -0
  65. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  66. package/vendor/claude-bridge/src/index.ts +2503 -0
  67. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  68. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  69. package/vendor/claude-bridge/src/models.ts +164 -0
  70. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  71. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  72. package/vendor/claude-bridge/src/query-state.ts +118 -0
  73. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  74. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  75. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  76. package/vendor/claude-bridge/src/skills.ts +20 -0
  77. package/vendor/claude-bridge/src/transcript.ts +86 -0
  78. package/vendor/claude-bridge/src/usage.ts +53 -0
  79. package/vendor/lore-pi-extension/package.json +1 -1
  80. package/vendor/lore-pi-extension/tools.ts +8 -5
  81. package/dist/web/assets/index-BCGEZvgg.css +0 -1
  82. package/dist/web/assets/index-BqkIAlk9.js +0 -258
@@ -0,0 +1,2503 @@
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
2
+ import { getModels } from "@earendil-works/pi-ai/compat";
3
+ import { buildSessionContext, keyHint, type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
+ import { query, type EffortLevel, type SDKMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
5
+ import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";
6
+ import { Text } from "@earendil-works/pi-tui";
7
+ import { appendFileSync, mkdirSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { PROVIDER_ID, messageContentToText } from "./convert.js";
10
+ import { debugLogPath, diagLogPath } from "./log-paths.js";
11
+ import { applyLongContext, buildModels, claudeCodeModelId, type LongContextSettings, resolveModel as _resolveModel } from "./models.js";
12
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, renderSkillsBlock } from "./skills.js";
13
+ import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
14
+ import { QueryContext, ctx } from "./query-state.js";
15
+ import { makePromptStream, userMessage, type PromptStream } from "./prompt-stream.js";
16
+ import { claudeCodeSettings, loadConfig, markAskClaudeDeprecationNoticeShown, markStartupNoticeShown, type Config } from "./config.js";
17
+ import {
18
+ collectPromptSkills,
19
+ projectPromptCapture,
20
+ sharedPromptCaptures,
21
+ type PromptCapture,
22
+ } from "./prompt-capture.js";
23
+ import { createToolServer } from "./mcp-server.js";
24
+ import { buildActionSummary, type ToolCallState } from "./askclaude-ui.js";
25
+ import { askClaudeCallTags, askClaudeToolDescription, buildAskClaudeParams, resolveAskClaudeDefaults, resolveAskClaudeMode, type AskClaudeMode } from "./askclaude-schema.js";
26
+ import { nonSystemMessages, toBridgeContext } from "./transcript.js";
27
+ import { forgetSessionCwd, rememberSessionCwd, rememberSummarySession, sessionCwdFor, summarySessionFor } from "./session-cwd.js";
28
+ import { updateUsage, type SdkUsage } from "./usage.js";
29
+ import { rateLimitResponse } from "./rate-limit.js";
30
+
31
+ // --- Debug logging ---
32
+ // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to the bridge log in pi's agent
33
+ // dir (log-paths.ts), not a fixed ~/.pi/agent.
34
+
35
+ const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
36
+
37
+ // Applied to every Claude Code subprocess the bridge spawns — provider, AskClaude
38
+ // and the compact summary. One place, so a guard is added once rather than three
39
+ // times, and so a missing one is visible.
40
+ //
41
+ // - ENABLE_CLAUDEAI_MCP_SERVERS=0: keep the user's claude.ai-connected MCP servers
42
+ // out of a pi session, which serves its own tools.
43
+ // - DISABLE_AUTO_COMPACT=1: pi owns compaction; CC compacting its own copy would
44
+ // diverge from pi's history, which is the source of truth for every rebuild.
45
+ const CC_CHILD_ENV = {
46
+ ENABLE_CLAUDEAI_MCP_SERVERS: "0",
47
+ DISABLE_AUTO_COMPACT: "1",
48
+ } as const;
49
+
50
+ // Pi owns context files on the provider path, so Claude Code must not load its
51
+ // own on top: otherwise project CLAUDE.md/AGENTS.md files arrive twice, and
52
+ // ~/.claude/CLAUDE.md — a user persona written for a harness that is not the
53
+ // one running — arrives at all, stamped "These instructions OVERRIDE any default
54
+ // behavior" and outranking Pi's own AGENTS.md.
55
+ //
56
+ // Excludes rather than settingSources: the source gate that suppresses CLAUDE.md
57
+ // is the same one that reads settings.json, where Bedrock/Vertex users keep
58
+ // `env` and `apiKeyHelper`. Patterns are matched with picomatch against absolute
59
+ // paths; the filename globs cover user, ancestor, project and .claude/ copies,
60
+ // while rules need their own. Managed/policy memory is not excludable by design.
61
+ const CLAUDE_MD_EXCLUDES = ["**/CLAUDE.md", "**/AGENTS.md", "**/.claude/rules/**"];
62
+
63
+ // Unique per module evaluation — confirms whether subagents share module state
64
+ const moduleInstanceId = Math.random().toString(36).slice(2, 8);
65
+
66
+ function debug(...args: unknown[]) {
67
+ if (!DEBUG) return;
68
+ const logPath = debugLogPath();
69
+ try { mkdirSync(dirname(logPath), { recursive: true }); } catch { /* ignore */ }
70
+ const ts = new Date().toISOString();
71
+ const fmt = (a: unknown): string => {
72
+ if (typeof a === "string") return a;
73
+ if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
74
+ return JSON.stringify(a);
75
+ };
76
+ const msg = args.map(fmt).join(" ");
77
+ appendFileSync(logPath, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
78
+ }
79
+
80
+ // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
81
+ // CLI subprocess to write its own debug log to a file we choose, and also
82
+ // forward its stderr into our debug stream. Drops straight into the real SDK's
83
+ // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
84
+ // stderr). Without this, CC's internal view of the world is invisible to us
85
+ // and "No conversation found" / empty-error reports are unactionable.
86
+ let nextCliDebugSeq = 1;
87
+ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
88
+ if (!DEBUG) return {};
89
+ const seq = nextCliDebugSeq++;
90
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
91
+ const logDir = join(dirname(debugLogPath()), "cc-cli-logs");
92
+ try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
93
+ const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
94
+ debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
95
+ return {
96
+ debug: true,
97
+ debugFile,
98
+ stderr: (data: string) => {
99
+ for (const line of data.split(/\r?\n/)) {
100
+ if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
101
+ }
102
+ },
103
+ };
104
+ }
105
+
106
+ /** Unconditional diagnostic dump — for "should never happen" paths. Creates pi's agent
107
+ * dir itself: callers run inside streamSimple, where a missing dir must not throw. */
108
+ function diagDump(label: string, data: Record<string, unknown>) {
109
+ const ts = new Date().toISOString();
110
+ const entry = { ts, moduleInstanceId, label, ...data };
111
+ const logPath = diagLogPath();
112
+ mkdirSync(dirname(logPath), { recursive: true });
113
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
114
+ debug(`DIAG: ${label} (see ${logPath})`);
115
+ }
116
+
117
+ // --- Constants ---
118
+
119
+ // Marks which bridge module instance owns the registered provider's stream fn.
120
+ // Full registration policy (first vs later instances, shared vs own registry):
121
+ // see the "--- Provider ---" block in activate() below.
122
+
123
+ // Claude Code's own builtin tools, for the AskClaude path where CC really runs
124
+ // them. The provider path never sees these — it starts CC with `tools: []`.
125
+ const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
126
+ read: "read", write: "write", edit: "edit", bash: "bash",
127
+ };
128
+
129
+ // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
130
+ const MODELS = buildModels(getModels("anthropic"));
131
+ let providerSettings: NonNullable<Config["provider"]> = {};
132
+ let longContextSettings: LongContextSettings = { plan: "pro", longContextExtraUsage: false };
133
+
134
+ function resolveModel(input: string) {
135
+ return _resolveModel(MODELS, input);
136
+ }
137
+
138
+ // --- Error handling ---
139
+
140
+ function errorMessage(err: unknown): string {
141
+ if (err instanceof Error) return err.message;
142
+ if (err && typeof err === "object") {
143
+ const obj = err as Record<string, unknown>;
144
+ if (typeof obj.message === "string") return obj.message;
145
+ if (typeof obj.error === "string") return obj.error;
146
+ try { return JSON.stringify(err); } catch {}
147
+ }
148
+ return String(err);
149
+ }
150
+
151
+ // AskClaude mode presets — controls which CC tools are blocked per mode.
152
+ // Only block tools that can't work (no pi TUI for user interaction).
153
+ // Other CC tools (Agent, SendMessage, RemoteTrigger, Tasks, etc.) are intentionally not blocked.
154
+ const ASKCLAUDE_ALWAYS_BLOCKED = [
155
+ "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
156
+ "ToolSearch", // probes for blocked tools, wastes tokens
157
+ "ScheduleWakeup", // no harness to fire wakeup from inside a delegated subagent
158
+ ];
159
+ const MODE_DISALLOWED_TOOLS: Record<AskClaudeMode, string[]> = {
160
+ full: ASKCLAUDE_ALWAYS_BLOCKED,
161
+ read: [
162
+ ...ASKCLAUDE_ALWAYS_BLOCKED,
163
+ "Write", "Edit", "Bash", "NotebookEdit",
164
+ "EnterWorktree", "ExitWorktree", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
165
+ ],
166
+ none: [
167
+ ...ASKCLAUDE_ALWAYS_BLOCKED,
168
+ "Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent",
169
+ "NotebookEdit", "EnterWorktree", "ExitWorktree",
170
+ "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
171
+ "WebFetch", "WebSearch",
172
+ ],
173
+ };
174
+
175
+ // --- Session persistence ---
176
+
177
+ interface SessionState {
178
+ sessionId: string;
179
+ cursor: number;
180
+ cwd: string;
181
+ // The pi session this CC conversation serves, from the provider call's
182
+ // options.sessionId. Attribution for history rewrites: a subagent's
183
+ // session_compact must not force a rebuild of a conversation that belongs
184
+ // to a different pi session. Null until a provider call recorded it.
185
+ piSessionId?: string;
186
+ // Context rewrites and interrupted queries require a fresh SDK replay.
187
+ needsRebuild?: boolean;
188
+ forceRotate?: boolean;
189
+ }
190
+
191
+ /** The session key a pi session's provider calls address. Unattributed calls
192
+ * (no options.sessionId — AskClaude's direct sync, or a host that omits it)
193
+ * share the "(none)" bucket: they cannot be told apart, so they share the
194
+ * pre-existing single-slot semantics. */
195
+ function sessionKey(piSessionId: string | null | undefined): string {
196
+ return piSessionId ?? "(none)";
197
+ }
198
+
199
+ /** Mirror of the CC conversation one pi session's turns are running on. One
200
+ * entry per pi session: a bridge process serves several sessions at once
201
+ * (pi-subagents children run their own AgentSessions), and a single shared
202
+ * slot forced them to fight over it — a length-matching foreign sync could
203
+ * REUSE or rebuild another session's CC file, and the completion capture was
204
+ * last-writer-wins (a foreground child in the parent's first turn permanently
205
+ * reassigned the parent's conversation). Keyed lookup removes the fight: each
206
+ * session's reads, writes and teardown marks touch only its own mirror. */
207
+ const sharedSessions = new Map<string, SessionState>();
208
+
209
+ /** The mirror for `piSessionId`, or null when this session has none yet. */
210
+ function sessionStateFor(piSessionId: string | null | undefined): SessionState | null {
211
+ return sharedSessions.get(sessionKey(piSessionId)) ?? null;
212
+ }
213
+
214
+ /** Replace (or plant) the mirror for `piSessionId`. */
215
+ function setSessionStateFor(piSessionId: string | null | undefined, state: SessionState | null): void {
216
+ if (state === null) sharedSessions.delete(sessionKey(piSessionId));
217
+ else sharedSessions.set(sessionKey(piSessionId), state);
218
+ }
219
+
220
+ // pi replaced one of its sessions' history (compact, tree) rather than appending
221
+ // to it. Read on the tool-result path — the one provider call that never reaches
222
+ // syncSharedSession — so a query parked at a tool boundary is discarded rather
223
+ // than resumed (issue #101). Keyed by pi session id, not process-global: a bridge
224
+ // process serves several pi sessions at once (subagents run their own
225
+ // AgentSessions and can compact mid-run while the parent is parked), and
226
+ // marking across that boundary kills healthy queries (or, worse, rebuilds the
227
+ // parent around a compaction that never touched it).
228
+ //
229
+ // Holds real pi session ids only, never the "(none)" key: no production caller
230
+ // marks with null (the rewrite events attribute via ctx.sessionManager), and
231
+ // every reader guards on a non-null piSessionId — so a "(none)" entry could
232
+ // never be matched or consumed, only leaked.
233
+ const historyRewrittenBySession = new Set<string>();
234
+
235
+ /** Handlers that arm rewrite staleness, one per module instance. Worktree-
236
+ * spawned subagents can load this module fresh (pi's loader cache is keyed on
237
+ * cwd, and a worktree cwd clears it), while the serving instance — whose
238
+ * streamSimple the pi sessions actually call — is whoever registered first.
239
+ * A fresh instance must forward its session's rewrites to the serving one.
240
+ * Symbol.for: one registry per process, like SHARED_CAPTURES_KEY below. */
241
+ const MARK_REBUILD_HOOKS_KEY = Symbol.for("claude-bridge:markRebuildHooks");
242
+ type MarkRebuildHook = (piSession: string | null, event: string) => void;
243
+ const markRebuildHooks: Set<MarkRebuildHook> =
244
+ ((globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] as Set<MarkRebuildHook> | undefined) ?? new Set();
245
+ (globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] = markRebuildHooks;
246
+
247
+ /** pi mutated its messages array out from under us: force the next
248
+ * syncSharedSession down REBUILD, and arm the discard above. `piSession`
249
+ * is never null from an event handler (each pi session has its own runner).
250
+ * The "(none)" key still covers the mirror for a hypothetical direct caller
251
+ * with no session id, so such a rewrite forces the REBUILD side; the discard
252
+ * set holds real ids only — see the comment on historyRewrittenBySession. */
253
+ function markRebuildForSession(piSession: string | null, event: string): void {
254
+ // The rewriting session's own mirror: the rewrite changed the history it was
255
+ // built from, so its next sync must REBUILD rather than REUSE. Every other
256
+ // session's mirror stays untouched — its conversation was never rewritten.
257
+ const key = sessionKey(piSession);
258
+ const state = sharedSessions.get(key);
259
+ if (!state) {
260
+ debug(`${event}: history rewritten, no session to mark yet`);
261
+ } else {
262
+ sharedSessions.set(key, { ...state, needsRebuild: true });
263
+ debug(`${event}: marking needsRebuild on session ${state.sessionId.slice(0, 8)}`);
264
+ }
265
+ // Arming parked contexts cannot wait for delivery: the entry checks
266
+ // `resultCtx.historyStale`, and a rewrite usually lands *while* the query is
267
+ // parked (compaction runs inside pi's turn loop, not between provider calls).
268
+ if (piSession) historyRewrittenBySession.add(piSession);
269
+ armStaleContexts();
270
+ }
271
+
272
+ /** Copy each armed session's mark onto the parked queries built from it. */
273
+ function armStaleContexts(): void {
274
+ for (const c of activeQueryContexts) {
275
+ if (c.piSessionId && historyRewrittenBySession.has(c.piSessionId)) c.historyStale = true;
276
+ }
277
+ }
278
+
279
+ // This instance enlists. Session ids reaching any hook equal options.sessionId
280
+ // on the serving instance's provider calls, so forwarding is safe: only the
281
+ // owning instance's contexts and SessionState match the key.
282
+ markRebuildHooks.add(markRebuildForSession);
283
+
284
+ /** Event handlers call this: it fans the rewrite out to every module instance
285
+ * in the process, of which exactly one is serving provider traffic for any
286
+ * given pi session. */
287
+ function sponsorMarkRebuildForSession(piSession: string | null, event: string): void {
288
+ for (const hook of markRebuildHooks) hook(piSession, event);
289
+ }
290
+
291
+ // Pi doesn't pass tool results directly — it appends them to the context and calls
292
+ // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
293
+ // debug logging at the extraction boundary.
294
+ function extractAllToolResults(context: Context): McpResult[] {
295
+ const { results, stopIdx } = _extractAllToolResults(context.messages as unknown as Array<{ role: string; [key: string]: unknown }>);
296
+ debug(`extractAllToolResults: ${results.length} results from ${context.messages.length} msgs, stopped at index ${stopIdx}`);
297
+ debug(`extractAllToolResults: all msg roles:`, context.messages.map((m, i) => `[${i}]${m.role}`).join(" "));
298
+ for (let r = 0; r < results.length; r++) {
299
+ debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} preview:`, JSON.stringify(results[r].content).slice(0, 150));
300
+ }
301
+ return results;
302
+ }
303
+
304
+ /** Index of the first message of the current user turn — the trailing run of
305
+ * user messages that has not been written into the Claude Code session yet.
306
+ * Equals messages.length when the last message is not a user message.
307
+ *
308
+ * Single source of truth for the history/prompt split: everything before this
309
+ * index is replayed as session history, everything from it onward becomes the
310
+ * prompt. Deriving both halves from one index is what keeps a message from
311
+ * landing in both — an extension appending a display-only user message after
312
+ * the real one (see issue #34) makes the turn longer than one message. */
313
+ function turnStart(messages: Context["messages"]): number {
314
+ let i = messages.length;
315
+ while (i > 0 && messages[i - 1].role === "user") i--;
316
+ return i;
317
+ }
318
+
319
+ /** Extract the current user turn as a prompt string. Returns null if the last message is not a user message. */
320
+ function extractUserPrompt(messages: Context["messages"]): string | null {
321
+ const turn = messages.slice(turnStart(messages)) as UserMessage[];
322
+ if (turn.length === 0) return null;
323
+ // Drop empties before joining so an all-empty turn still yields "" and trips
324
+ // the caller's empty-prompt guard rather than sending bare newlines.
325
+ return turn
326
+ .map((m) => (typeof m.content === "string" ? m.content : messageContentToText(m.content)))
327
+ .filter((text) => text)
328
+ .join("\n");
329
+ }
330
+
331
+ /** Extract the current user turn as ContentBlockParam[] (preserving images).
332
+ * Returns null if no images — caller should fall back to string prompt. */
333
+ function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
334
+ const turn = messages.slice(turnStart(messages)) as UserMessage[];
335
+ if (turn.length === 0) return null;
336
+
337
+ let hasImage = false;
338
+ const blocks: ContentBlockParam[] = [];
339
+ for (const message of turn) {
340
+ const content: (TextContent | ImageContent)[] = typeof message.content === "string"
341
+ ? [{ type: "text", text: message.content }]
342
+ : message.content;
343
+ // Off-type content violates UserMessage's contract, so fail rather than
344
+ // degrade — but name the shape, since the cause is almost always another
345
+ // extension appending a malformed message, not this file.
346
+ if (!Array.isArray(content)) {
347
+ throw new Error(
348
+ `extractUserPromptBlocks: user message content must be a string or block array, got ${typeof content} — likely a malformed message from another extension`,
349
+ );
350
+ }
351
+ for (const block of content) {
352
+ if (block.type === "text" && block.text) {
353
+ blocks.push({ type: "text", text: block.text });
354
+ } else if (block.type === "image") {
355
+ // Guard before logging: data-less image blocks do occur, and reading
356
+ // .length off the missing field in the debug template would throw
357
+ // before this check ever runs (template args evaluate unconditionally).
358
+ if (!block.data || !block.mimeType) {
359
+ debug(`image block missing data or mimeType, skipping: keys=${Object.keys(block).join(",")}`);
360
+ continue;
361
+ }
362
+ debug(`image block: mimeType=${block.mimeType}, data length=${block.data.length}`);
363
+ hasImage = true;
364
+ blocks.push({
365
+ type: "image",
366
+ source: {
367
+ type: "base64",
368
+ media_type: block.mimeType as Base64ImageSource["media_type"],
369
+ data: block.data,
370
+ },
371
+ });
372
+ }
373
+ }
374
+ }
375
+ debug(`extractUserPromptBlocks: ${turn.length} msgs in turn, ${blocks.length} blocks, types=${blocks.map((b) => b.type).join(",")}`);
376
+ return hasImage ? blocks : null;
377
+ }
378
+
379
+ // A session cannot be resumed after a restart or context rewrite. Replay only
380
+ // Hob's own history as SDK input; Claude Code alone manages its session files.
381
+ // Historical images are never re-sent: the whole history travels inside ONE
382
+ // SDK user message, so an inline image reads as an attachment of the current
383
+ // turn (the model answered a text-only message by describing "the three images
384
+ // attached here" — issue #71), and every replay re-uploads all of them. Each
385
+ // becomes a text placeholder instead; the current turn's own images still
386
+ // travel via extractUserPromptBlocks after the 「以下是当前消息:」 marker.
387
+ function replayBlocks(messages: Context["messages"]): ContentBlockParam[] {
388
+ const blocks: ContentBlockParam[] = [{ type: "text", text: "以下是此前的对话与工具结果,按顺序作为历史参考;历史里的图片不重发,只以占位文字标注;最后一段是当前消息。" }];
389
+ for (const message of messages) {
390
+ const m = message as unknown as Record<string, unknown>;
391
+ const text = JSON.stringify({ role: m.role, content: m.content, toolCallId: m.toolCallId, toolName: m.toolName, isError: m.isError }, (_key, value) => {
392
+ if (value && typeof value === "object" && value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string") {
393
+ return "[图片:这条历史消息当时带了一张图片,不在当前消息里]";
394
+ }
395
+ return value;
396
+ });
397
+ blocks.push({ type: "text", text: `历史消息:${text}` });
398
+ }
399
+ blocks.push({ type: "text", text: "以下是当前消息:" });
400
+ return blocks;
401
+ }
402
+
403
+ function newAssistantOutput(model: Model<any>, text: string, stopReason: AssistantMessage["stopReason"], errorMessage?: string): AssistantMessage {
404
+ return {
405
+ role: "assistant",
406
+ content: text ? [{ type: "text", text }] : [],
407
+ api: model.api,
408
+ provider: model.provider,
409
+ model: model.id,
410
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
411
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
412
+ stopReason,
413
+ ...(errorMessage ? { errorMessage } : {}),
414
+ timestamp: Date.now(),
415
+ };
416
+ }
417
+
418
+ function extractIsolatedSummaryPrompt(messages: Context["messages"]): string {
419
+ if (messages.length !== 1 || messages[0].role !== "user") {
420
+ throw new Error(
421
+ `isolatedStreamFn: expected exactly 1 user message, got ${messages.length} ` +
422
+ `(${messages.map((m) => m.role).join(",")})`,
423
+ );
424
+ }
425
+ const promptText = extractUserPrompt(messages);
426
+ if (!promptText) throw new Error("isolatedStreamFn: summarization prompt is empty");
427
+ return promptText;
428
+ }
429
+
430
+ /** Failure text for an SDK result, or undefined when it succeeded. CC reports API failures
431
+ * (429 capacity, overload, prompt-too-long) with `is_error` on an otherwise success-shaped
432
+ * result; the dedicated error subtypes carry `errors` instead. */
433
+ function resultErrorText(message: SDKMessage): string | undefined {
434
+ const result = message as SDKMessage & { subtype?: string; is_error?: boolean; result?: string; errors?: unknown; error?: unknown };
435
+ if (result.subtype === "success") return result.is_error ? result.result || "Claude Code reported an error" : undefined;
436
+ if (Array.isArray(result.errors) && result.errors.length) return result.errors.map(String).join("\n");
437
+ if (typeof result.error === "string") return result.error;
438
+ return `Claude Code failed: ${result.subtype ?? "unknown result"}`;
439
+ }
440
+
441
+ /** Name a failure as a rate limit when a rejection preceded it.
442
+ *
443
+ * pi has no typed rate-limit error — `stopReason` is only ever `"error"` and the sole carrier
444
+ * is `errorMessage` — so everything that reacts to a rate limit pattern-matches that string:
445
+ * pi-subagents gates `fallbackModels` on its own pattern list, and key-rotating extensions use
446
+ * their own. Claude Code words a subscription limit as "You're out of extra usage · resets
447
+ * 6:30pm", which matches none of them, so an exhausted quota reads as a fatal error and the
448
+ * fallback chain never runs (issue #58).
449
+ *
450
+ * Leading with "Claude quota exceeded — rate limit" rather than appending keeps both phrases
451
+ * in any truncated render, and avoids the `<tool> failed (exit N):` shape that pi-subagents
452
+ * treats as a tool failure and refuses to retry. "quota exceeded" is on pi-ai's non-retryable
453
+ * list, which wins over its retryable "rate limit" pattern, so pi hands the rejected window
454
+ * straight to fallback instead of retrying a limit that resets in hours (issue #183). */
455
+ function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?: number }, failure: string): string {
456
+ const kind = rejection.rateLimitType ? ` (${rejection.rateLimitType})` : "";
457
+ const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : ""; // resetsAt: Unix seconds (unit undocumented in the SDK; observed)
458
+ return `Claude quota exceeded — rate limit${kind}${resets}: ${failure}`;
459
+ }
460
+
461
+ /** Reports to the call that owns the open stream, before its next event, as pi's own
462
+ * providers do. A failing consumer must not end the turn it is observing. */
463
+ async function reportHeldRateLimit(c: QueryContext, model: Model<any>): Promise<void> {
464
+ const info = c.heldRateLimit;
465
+ if (!info || !c.currentPiStream || !c.currentOnResponse) return;
466
+ c.heldRateLimit = undefined;
467
+ try {
468
+ await c.currentOnResponse(rateLimitResponse(info), model);
469
+ } catch (error) {
470
+ debug("rate limit: onResponse failed", error);
471
+ }
472
+ }
473
+
474
+ function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
475
+ const stream = createAssistantMessageEventStream();
476
+ void runIsolatedSummary(model, context, options, stream);
477
+ return stream;
478
+ }
479
+
480
+ async function runIsolatedSummary(
481
+ model: Model<any>,
482
+ context: Context,
483
+ options: SimpleStreamOptions | undefined,
484
+ stream: AssistantMessageEventStream,
485
+ ): Promise<void> {
486
+ // pi delivers compaction/branch-summary requests as a transcript: the summarization
487
+ // prompt folded into a leading system message ahead of the lone user message
488
+ // (issue #106). toBridgeContext restores the prompt/tools fields the extraction
489
+ // assertion below assumes; the summarization prompt still reaches CC as its systemPrompt.
490
+ context = toBridgeContext(context);
491
+ let sdkQuery: ReturnType<typeof query> | undefined;
492
+ let wasAborted = false;
493
+ const onAbort = () => {
494
+ wasAborted = true;
495
+ void sdkQuery?.interrupt().catch(() => {});
496
+ try { sdkQuery?.close(); } catch {}
497
+ };
498
+
499
+ try {
500
+ // One-off summarizer calls (compaction, branch summary, turn prefix, bug report —
501
+ // anything routed through pi's completeSummarization) are marked cacheRetention:
502
+ // "none". Any of them may appear in a future pi release without a bridge change,
503
+ // so route on the marker, not on which summarizer is calling. Non-summarizer calls
504
+ // must still match the [system,user] compaction shape exactly.
505
+ const isOneOffSummary = options?.cacheRetention === "none";
506
+ const promptText = isOneOffSummary
507
+ ? extractUserPrompt(context.messages)
508
+ : extractIsolatedSummaryPrompt(context.messages);
509
+ if (!promptText) throw new Error("runIsolatedSummary: one-off summary without a user prompt (last message is not user?)");
510
+ const cwd = sessionCwdFor(summarySessionFor(options?.signal) ?? options?.sessionId);
511
+ const compactProviderSettings = loadConfig(cwd).provider;
512
+ const claudeExecutable = compactProviderSettings?.pathToClaudeCodeExecutable;
513
+ const cliModel = claudeCodeModelId(model, longContextSettings);
514
+ // Honour the caller's reasoning level, same mapping as the provider path.
515
+ const effort = effortForReasoning(model, options?.reasoning);
516
+ debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} effort=${effort ?? "default"} promptLen=${promptText.length}`);
517
+
518
+ sdkQuery = queryImpl({
519
+ prompt: promptText,
520
+ options: {
521
+ cwd,
522
+ env: { ...process.env, ...CC_CHILD_ENV },
523
+ settings: { autoMemoryEnabled: false },
524
+ tools: [],
525
+ strictMcpConfig: true,
526
+ extraArgs: { "safe-mode": null },
527
+ skills: [],
528
+ persistSession: false,
529
+ systemPrompt: context.systemPrompt,
530
+ model: cliModel,
531
+ maxTurns: 1,
532
+ ...(effort ? { effort } : {}),
533
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
534
+ ...makeCliDebugOptions("compact-summary"),
535
+ },
536
+ });
537
+
538
+ if (options?.signal) {
539
+ if (options.signal.aborted) onAbort();
540
+ else options.signal.addEventListener("abort", onAbort, { once: true });
541
+ }
542
+
543
+ let assistantText = "";
544
+ let finalText = "";
545
+ let errorText: string | undefined;
546
+ let firstEventLogged = false;
547
+
548
+ for await (const message of sdkQuery) {
549
+ if (!firstEventLogged) {
550
+ debug(`compact summary: first event type=${message.type}`);
551
+ firstEventLogged = true;
552
+ }
553
+ if (wasAborted) break;
554
+
555
+ if (message.type === "assistant") {
556
+ for (const block of (message as any).message?.content ?? []) {
557
+ if (block.type === "text" && typeof block.text === "string") assistantText += block.text;
558
+ }
559
+ } else if (message.type === "result") {
560
+ logServedContextWindow("compact summary", message, model);
561
+ errorText = resultErrorText(message);
562
+ if (!errorText && message.subtype === "success") finalText = message.result || assistantText;
563
+ }
564
+ }
565
+
566
+ if (wasAborted) {
567
+ const output = newAssistantOutput(model, "", "aborted", "Operation aborted");
568
+ debug("compact summary: aborted");
569
+ stream.push({ type: "error", reason: "aborted", error: output });
570
+ stream.end();
571
+ return;
572
+ }
573
+
574
+ const text = finalText || assistantText;
575
+ if (errorText || !text.trim()) {
576
+ const msg = errorText ?? "Claude Code summary returned empty text";
577
+ debug(`compact summary: error ${msg}`);
578
+ stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
579
+ stream.end();
580
+ return;
581
+ }
582
+
583
+ debug(`compact summary: done textLen=${text.length}`);
584
+ stream.push({ type: "done", reason: "stop", message: newAssistantOutput(model, text, "stop") });
585
+ stream.end();
586
+ } catch (err) {
587
+ const msg = errorMessage(err);
588
+ debug("runIsolatedSummary threw; pushing terminal error", err);
589
+ stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
590
+ stream.end();
591
+ } finally {
592
+ options?.signal?.removeEventListener("abort", onAbort);
593
+ try { sdkQuery?.close(); } catch {}
594
+ }
595
+ }
596
+
597
+ interface SyncResult {
598
+ sessionId: string | null;
599
+ preserveSharedSession?: boolean;
600
+ replay?: Context["messages"];
601
+ }
602
+
603
+ /**
604
+ * Ensure the shared session has all messages up to (but not including) the last user message.
605
+ * Returns session ID to resume from, or null if no resume needed.
606
+ */
607
+ // Resume an unchanged Claude session or replay Hob history through the SDK.
608
+ function syncSharedSession(
609
+ messages: Context["messages"],
610
+ cwd: string,
611
+ customToolNameToSdk?: Map<string, string>,
612
+ modelId?: string,
613
+ piSessionId?: string | null,
614
+ ): SyncResult {
615
+ // System messages are pi's transcript representation of prompt and tool state, not
616
+ // conversation history — they are never imported into a CC session, so exclude them from
617
+ // the history space (priorMessages, cursor, missed) everywhere below (issue #106).
618
+ // The mirror this sync coordinates belongs to the syncing pi session alone:
619
+ // every read and write below addresses sessionStateFor(piSessionId), so a
620
+ // foreign session's shape-matching context can never REUSE or rebuild another
621
+ // session's CC file.
622
+ const sharedSession = sessionStateFor(piSessionId);
623
+ const history = nonSystemMessages(messages);
624
+ const priorMessages = history.slice(0, turnStart(history)); // everything before the current user turn
625
+
626
+ // REUSE path
627
+ //
628
+ // Guard on priorMessages.length >= cursor: a shorter incoming context cannot
629
+ // be a continuation of the cached session. This is the general invariant for
630
+ // pi-side history rewrites such as /compact and session_tree: without it,
631
+ // missed = [].slice(cursor) can falsely hit REUSE and resume an unrelated
632
+ // longer CC session. See issue #25.
633
+ if (sharedSession && !sharedSession.needsRebuild && priorMessages.length >= sharedSession.cursor) {
634
+ const missed = priorMessages.slice(sharedSession.cursor);
635
+ const trailingAssistantOnly =
636
+ missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
637
+ if (missed.length === 0 || trailingAssistantOnly) {
638
+ if (trailingAssistantOnly) {
639
+ setSessionStateFor(piSessionId, { ...sharedSession, cursor: priorMessages.length, cwd });
640
+ debug(`Case 3: advanced cursor past trailing assistant, resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${priorMessages.length}`);
641
+ } else {
642
+ debug(`Case 3: resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
643
+ }
644
+ debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession?.cursor}`);
645
+ return { sessionId: sharedSession.sessionId };
646
+ }
647
+ }
648
+ // This is what keeps a caller with a pruned or short context from resuming
649
+ // — then overwriting — the bucket's session: shorter-than-cursor means the
650
+ // incoming history cannot be a continuation, so start clean and preserve.
651
+ // Historically this also caught reentrant subagents (a subagent's priors are
652
+ // shorter than the parent's cursor); with per-session mirrors it now catches
653
+ // the pruned-context shapes on a session's own bucket, and the non-isolated
654
+ // AskClaude path on the "(none)" bucket. The captured ephemeral session is
655
+ // deleted once its query completes (see preserveSharedSession in the
656
+ // completion handler).
657
+ //
658
+ // It is NOT, despite an earlier comment here, the isolated compact-summary
659
+ // path: runIsolatedSummary never calls syncSharedSession at all.
660
+ //
661
+ // Only reachable when needsRebuild is false — user-facing history rewrites
662
+ // (/compact, session_tree, /new, fork) always set needsRebuild or clear
663
+ // sharedSession before the next syncSharedSession call.
664
+ if (sharedSession && !sharedSession.needsRebuild && priorMessages.length < sharedSession.cursor) {
665
+ debug(`Case 1 synthetic: clean start for shorter context, preserving shared session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
666
+ debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
667
+ return { sessionId: null, preserveSharedSession: true, replay: priorMessages };
668
+ }
669
+
670
+ // REBUILD path
671
+ if (priorMessages.length === 0) {
672
+ setSessionStateFor(piSessionId, null);
673
+ debug(`Case 1: clean start, ${history.length} total messages`);
674
+ debug(`syncResult: path=clean-start`);
675
+ return { sessionId: null };
676
+ }
677
+ // Replay Hob's own history through the SDK rather than editing Claude Code files.
678
+ setSessionStateFor(piSessionId, null);
679
+ debug(`syncResult: path=replay priors=${priorMessages.length}`);
680
+ return { sessionId: null, replay: priorMessages };
681
+ }
682
+
683
+ // The SDK's query(), or a test double (see setQuery).
684
+ let queryImpl: typeof query = query;
685
+
686
+ // @internal
687
+ export const __test = {
688
+ setQuery(fn: typeof query | null) {
689
+ queryImpl = fn ?? query;
690
+ },
691
+ resetSharedSession(piSessionId?: string | null) {
692
+ // No id: full reset (the pre-map semantics — tests start from a blank slate).
693
+ if (piSessionId === undefined) sharedSessions.clear();
694
+ else setSessionStateFor(piSessionId, null);
695
+ historyRewrittenBySession.clear();
696
+ },
697
+ markRebuildForSession,
698
+ getHistoryRewritten: () => historyRewrittenBySession.size > 0,
699
+ historyRewrittenBySession,
700
+ armStaleContexts,
701
+ discardRewrittenQuery,
702
+ contextForToolResults,
703
+ isQueryAbandoned: (q: object) => abandonedQueries.has(q),
704
+ get activeQueryContexts() {
705
+ return activeQueryContexts;
706
+ },
707
+ setSharedSession(piSessionId: string | null, state: SessionState | null) {
708
+ setSessionStateFor(piSessionId, state);
709
+ },
710
+ getSharedSession(piSessionId: string | null = null) {
711
+ return sessionStateFor(piSessionId);
712
+ },
713
+ setPiUI(ui: ExtensionUIContext | null) {
714
+ piUI = ui;
715
+ },
716
+ toBridgeContext,
717
+ isolatedStreamFn,
718
+ effortForReasoning,
719
+ syncSharedSession,
720
+ extractUserPromptBlocks,
721
+ replayBlocks,
722
+ consumeQuery,
723
+ finalizeCurrentStream,
724
+ resultErrorText,
725
+ deliverToolResults,
726
+ drainForAbort,
727
+ CC_CHILD_ENV,
728
+ buildMcpServers,
729
+ get promptCaptures() {
730
+ return promptCaptures;
731
+ },
732
+ };
733
+
734
+ // --- Provider helpers: tool name mapping ---
735
+
736
+ // AskClaude path: CC runs its own tools, so builtin names are real.
737
+ function mapToolName(name: string): string {
738
+ const normalized = name.toLowerCase();
739
+ const builtin = SDK_TO_PI_TOOL_NAME[normalized];
740
+ if (builtin) return builtin;
741
+ if (normalized.startsWith(MCP_TOOL_PREFIX)) return name.slice(MCP_TOOL_PREFIX.length);
742
+ return name;
743
+ }
744
+
745
+ // Provider path: the query runs with `tools: []`, so the only tools CC can
746
+ // legitimately call are the pi tools we serve over MCP. Any other name is the
747
+ // model hallucinating a builtin (`bash`, `Bash`, `Edit`, an MCP server we don't
748
+ // serve). CC answers those itself with "No such tool available" and retries
749
+ // inside the same query, never dispatching them to our MCP server — so a tool
750
+ // call under such a name must not reach pi. Forwarding one ran a tool CC never
751
+ // dispatched (real side effects) and, because the retry carries a fresh
752
+ // tool_use id, left the handler for the retry with no result to release it:
753
+ // pi's result arrived keyed to the dead id, and both sides deadlocked.
754
+ function piToolNameFor(name: string, customToolNameToPi: Map<string, string>): string | undefined {
755
+ return customToolNameToPi.get(name) ?? customToolNameToPi.get(name.toLowerCase());
756
+ }
757
+
758
+ // Renames for Claude Code SDK param names that differ from pi's native names.
759
+ // Keys not listed here pass through unchanged, so new pi params work automatically.
760
+ const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
761
+ read: { file_path: "path" },
762
+ write: { file_path: "path" },
763
+ edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
764
+ };
765
+
766
+ // Maps SDK tool args to pi tool args via key renaming + pass-through.
767
+ // Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
768
+ function mapToolArgs(
769
+ toolName: string, args: Record<string, unknown> | undefined,
770
+ ): Record<string, unknown> {
771
+ const input = args ?? {};
772
+ const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
773
+ const result: Record<string, unknown> = {};
774
+ for (const [key, value] of Object.entries(input)) {
775
+ const piKey = renames?.[key] ?? key;
776
+ if (!(piKey in result)) result[piKey] = value; // first alias wins
777
+ }
778
+ // Pi bash has no default timeout; add a safety default
779
+ if (toolName.toLowerCase() === "bash" && result.timeout == null) {
780
+ result.timeout = 120;
781
+ }
782
+ return result;
783
+ }
784
+
785
+ // --- Query state ---
786
+ // QueryContext lives in query-state.js so tests can import it without
787
+ // activating the extension.
788
+
789
+ // Global (not query state):
790
+ let piUI: ExtensionUIContext | null = null;
791
+ let piMode: ExtensionContext["mode"] | null = null;
792
+ const activeQueryContexts = new Set<QueryContext>();
793
+
794
+ // Defaults that silently cost the user something (no Opus 1M on Max) are
795
+ // announced once. Deferred to the first bridge query rather
796
+ // than session_start: the notice persists a flag to the global config, and
797
+ // firing it on startup would write that file for every pi session that merely
798
+ // has this extension installed. One message, because consecutive info notifies
799
+ // overwrite each other in the TUI.
800
+ let pendingNotices: string[] = [];
801
+
802
+ function showStartupNoticeOnce(): void {
803
+ // `hasUI` is true in RPC mode too — it means dialogs are possible, not that a
804
+ // human is watching. Only a terminal user can act on this.
805
+ if (pendingNotices.length === 0 || piMode !== "tui") return;
806
+ const notices = pendingNotices;
807
+ pendingNotices = [];
808
+ const path = markStartupNoticeShown();
809
+ // pi wraps the whole notify string in the theme's dim foreground; the inner reset
810
+ // drops back to the terminal default rather than dim, which is fine here.
811
+ const title = `\x1b[33mWelcome to pi-claude-bridge\x1b[39m — settings live in ${path}`;
812
+ const bullets = [...notices, "This message only appears once. See README.md for more."].map((n) => `• ${n}`);
813
+ piUI?.notify([title, ...bullets, "─".repeat(64)].join("\n"), "info");
814
+ }
815
+
816
+ // Captures of what pi assembled per agent; see src/prompt-capture.ts for why this
817
+ // is keyed rather than held in a single slot. One process-wide instance, shared
818
+ // across every extension module instance: isolated subagents re-evaluate this
819
+ // module, and the pinned stream they all route through resolves against it.
820
+ const promptCaptures = sharedPromptCaptures((diagnostic) => {
821
+ const first = diagnostic.matches[0];
822
+ debug(
823
+ `prompt-capture: no match for ${diagnostic.systemPrompt.length}-char system prompt. `
824
+ + (first
825
+ ? `closest known (${first.key.length}-char) shares its first ${first.firstDivergent} chars and diverges at offset ${first.firstDivergent}: `
826
+ + JSON.stringify(diagnostic.systemPrompt.slice(first.firstDivergent - 40, first.firstDivergent + 60))
827
+ : "no known captures to compare against."
828
+ ) + ` known keys=${diagnostic.matches.length}`,
829
+ );
830
+ });
831
+
832
+ /** Whatever a settled session left behind, named in one greppable line.
833
+ *
834
+ * Every one of these should be empty once the last turn ends, and each is a leak
835
+ * that costs something real: a retained context routes a later orphaned tool result
836
+ * into the delivery path and returns a stream nobody ends; a pending tool call is an
837
+ * MCP handler Claude Code is still waiting on; a live prompt stream is an unresolved
838
+ * ack. The activeQueryContexts leak was present on every single happy-path run and
839
+ * no test noticed, because nothing asserted that anything ends clean — so assert it
840
+ * where the real sessions are, and let diag/audit-warnings.mjs scan for it. */
841
+ function reportLeaks(label: string): void {
842
+ const pendingCalls = [...activeQueryContexts].reduce((n, c) => n + c.pendingToolCalls.size, 0);
843
+ const liveStreams = [...activeQueryContexts].filter((c) => c.promptStream !== null).length;
844
+ if (activeQueryContexts.size === 0 && pendingCalls === 0 && liveStreams === 0) return;
845
+ debug(
846
+ `WARNING: ${label} left state behind — contexts=${activeQueryContexts.size} `
847
+ + `pendingToolCalls=${pendingCalls} promptStreams=${liveStreams}`,
848
+ );
849
+ }
850
+
851
+ function contextForToolResults(results: McpResult[]): QueryContext | undefined {
852
+ for (const result of results) {
853
+ const id = result.toolCallId;
854
+ if (!id) continue;
855
+ for (const queryCtx of activeQueryContexts) {
856
+ if (queryCtx.pendingToolCalls.has(id) || queryCtx.pendingResults.has(id) || queryCtx.turnToolCallIds.includes(id)) {
857
+ return queryCtx;
858
+ }
859
+ }
860
+ }
861
+ return undefined;
862
+ }
863
+
864
+ function resolveMcpTools(context: Context, excludeToolName?: string): {
865
+ mcpTools: Tool[];
866
+ customToolNameToSdk: Map<string, string>;
867
+ customToolNameToPi: Map<string, string>;
868
+ } {
869
+ const mcpTools: Tool[] = [];
870
+ const customToolNameToSdk = new Map<string, string>();
871
+ const customToolNameToPi = new Map<string, string>();
872
+
873
+ if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
874
+
875
+ for (const tool of context.tools) {
876
+ if (tool.name === excludeToolName) continue;
877
+ const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
878
+ mcpTools.push(tool);
879
+ customToolNameToSdk.set(tool.name, sdkName);
880
+ customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
881
+ customToolNameToPi.set(sdkName, tool.name);
882
+ customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
883
+ }
884
+
885
+ return { mcpTools, customToolNameToSdk, customToolNameToPi };
886
+ }
887
+
888
+ // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
889
+ // blocks on a Promise until pi delivers the tool result via streamSimple.
890
+ // Handlers receive their toolCallId from Claude's tools/call _meta, so results
891
+ // are matched by ID end to end.
892
+ //
893
+ // The handler and pi's result can arrive in either order, hence the two maps:
894
+ // a result that lands first waits in `pendingResults` for the handler to claim
895
+ // it, and a handler that runs first parks its resolver in `pendingToolCalls`.
896
+ // Handlers close over the captured `queryCtx`, ensuring they operate on the
897
+ // correct query's state while multiple queries run concurrently.
898
+ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createToolServer>> | undefined {
899
+ if (!tools.length) return undefined;
900
+ const mcpTools = tools.map((tool) => ({
901
+ name: tool.name,
902
+ description: tool.description,
903
+ inputSchema: tool.parameters,
904
+ handler: async (toolCallId: string) => {
905
+ if (queryCtx.pendingResults.has(toolCallId)) {
906
+ const result = queryCtx.pendingResults.get(toolCallId)!;
907
+ queryCtx.pendingResults.delete(toolCallId);
908
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
909
+ return result;
910
+ }
911
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
912
+ return new Promise<McpResult>((resolve) => {
913
+ queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
914
+ });
915
+ },
916
+ }));
917
+ return { [MCP_SERVER_NAME]: createToolServer(MCP_SERVER_NAME, mcpTools) };
918
+ }
919
+
920
+ // --- Usage helpers ---
921
+
922
+ // The counter mapping lives in usage.ts; the debug line is this side's job, so every
923
+ // call site logs the same way rather than three times over.
924
+ function recordUsage(output: AssistantMessage, usage: SdkUsage, model: Model<any>): void {
925
+ const { cachePct, reasoning } = updateUsage(output, usage, model);
926
+ const reasoningText = reasoning == null ? "" : ` reasoning=${reasoning}`;
927
+ debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens}${reasoningText} cachePct=${cachePct}% model=${model.id}`);
928
+ }
929
+
930
+ // Log the *served* context window reported by an SDK result message
931
+ // (modelUsage[id].contextWindow), which can differ from the window pi
932
+ // registered (model.contextWindow) when the runtime entitlement doesn't
933
+ // match the docs — e.g. bare Opus served 200K on Pro, or [1m] not honored.
934
+ // The result message's modelUsage is otherwise discarded; this makes the
935
+ // gap observable. See issue #18.
936
+ function logServedContextWindow(label: string, message: SDKMessage, model: Model<any>): void {
937
+ const modelUsage = (message as any).modelUsage as Record<string, { contextWindow?: number; maxOutputTokens?: number }> | undefined;
938
+ if (!modelUsage) return;
939
+ for (const [k, v] of Object.entries(modelUsage)) {
940
+ debug(`${label}: served contextWindow=${v.contextWindow ?? "?"} maxOutputTokens=${v.maxOutputTokens ?? "?"} servedModel=${k} registered=${model.contextWindow}`);
941
+ }
942
+ }
943
+
944
+ // --- Effort level mapping ---
945
+ // Pi reasoning levels → CC SDK effort levels
946
+
947
+ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
948
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
949
+ };
950
+
951
+ const VALID_EFFORTS = new Set<string>(["low", "medium", "high", "xhigh", "max"]);
952
+
953
+ // Prefer the model's own thinkingLevelMap (per-model overrides — e.g. a map can
954
+ // route xhigh→xhigh where the generic table maps xhigh→max). pi-ai's catalog
955
+ // ships a map for most Claude models; the table above covers models without
956
+ // one, and the levels a map leaves unnamed. A null entry means the level is
957
+ // unsupported on that model: no effort argument is sent, so Claude Code's own
958
+ // default applies rather than the generic table's value. Map values are
959
+ // provider-generic strings, so a map value is trusted only when it names a
960
+ // level CC accepts.
961
+ function effortForReasoning(model: Model<any>, reasoning: string | undefined): EffortLevel | undefined {
962
+ if (!reasoning) return undefined;
963
+ const mapped = model.thinkingLevelMap?.[reasoning];
964
+ if (mapped === undefined) return REASONING_TO_EFFORT[reasoning];
965
+ return VALID_EFFORTS.has(mapped as EffortLevel) ? mapped as EffortLevel : undefined;
966
+ }
967
+
968
+ // --- Provider helpers: misc ---
969
+
970
+ function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
971
+ switch (reason) {
972
+ case "tool_use": return "toolUse";
973
+ case "max_tokens": return "length";
974
+ case "end_turn": default: return "stop";
975
+ }
976
+ }
977
+
978
+ function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
979
+ if (!input) return fallback;
980
+ try { return JSON.parse(input); } catch { return fallback; }
981
+ }
982
+
983
+
984
+ // --- Provider: streaming function ---
985
+ //
986
+ // Push-based streaming with MCP tool bridge:
987
+ // 1. streamSimple starts a query() and kicks off consumeQuery() in background
988
+ // 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
989
+ // 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
990
+ // blocks the generator naturally — no events arrive until resolved.
991
+ // 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
992
+ // resolve the MCP handler, and the generator unblocks — events flow to new stream.
993
+ //
994
+ // Note: resetTurnState clears turnSawStreamEvent while the generator may still
995
+ // have queued messages from the previous turn. This is safe because step 3 nulls
996
+ // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
997
+ // in consumeQuery and are skipped before resetTurnState runs.
998
+
999
+ const completedStreams = new WeakSet<object>();
1000
+
1001
+ function markStreamComplete(stream: AssistantMessageEventStream | null): void {
1002
+ if (stream) completedStreams.add(stream as object);
1003
+ }
1004
+
1005
+ function claimCurrentPiStream(stream: AssistantMessageEventStream, label: string, c: QueryContext, options: SimpleStreamOptions | undefined): void {
1006
+ if (c.currentPiStream && !completedStreams.has(c.currentPiStream as object)) {
1007
+ debug(`WARNING: currentPiStream overwritten before terminal event (${label}); activeQuery=${Boolean(c.activeQuery)} pendingHandlers=${c.pendingToolCalls.size}`);
1008
+ }
1009
+ c.currentPiStream = stream;
1010
+ c.currentOnResponse = options?.onResponse;
1011
+ }
1012
+
1013
+ function ensureTurnStarted(c: QueryContext): void {
1014
+ if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
1015
+ c.currentPiStream!.push({ type: "start", partial: c.turnOutput });
1016
+ c.turnStarted = true;
1017
+ }
1018
+ }
1019
+
1020
+ function finalizeCurrentStream(c: QueryContext, stopReason?: string): void {
1021
+ if (!c.currentPiStream || !c.turnOutput) return;
1022
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput!.stopReason, error: c.turnOutput!.errorMessage})}`);
1023
+ if (!c.turnStarted) ensureTurnStarted(c);
1024
+ const stream = c.currentPiStream;
1025
+ if (c.turnOutput.stopReason === "error") {
1026
+ stream!.push({ type: "error", reason: "error", error: c.turnOutput });
1027
+ } else {
1028
+ const reason = stopReason === "length" ? "length" : "stop";
1029
+ stream!.push({ type: "done", reason, message: c.turnOutput });
1030
+ }
1031
+ markStreamComplete(stream);
1032
+ stream!.end();
1033
+ c.currentPiStream = null;
1034
+ }
1035
+
1036
+ /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1037
+ * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1038
+ function processStreamEvent(
1039
+ message: SDKMessage,
1040
+ customToolNameToPi: Map<string, string>,
1041
+ model: Model<any>,
1042
+ c: QueryContext,
1043
+ ): void {
1044
+ if (!c.currentPiStream || !c.turnOutput) return;
1045
+ c.turnSawStreamEvent = true;
1046
+ const event = (message as SDKMessage & { event: any }).event;
1047
+
1048
+ if (event?.type === "message_start") {
1049
+ // Still open from an earlier message_start: Claude Code gave up on that
1050
+ // stream and is retrying it. Its blocks were never completed.
1051
+ if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `restreamed as ${event.message?.id}`);
1052
+ c.turnToolCallIds = [];
1053
+ c.turnStreamMessageId = event.message?.id;
1054
+ c.turnStreamOpen = true;
1055
+ c.turnStreamBlockStart = c.turnBlocks.length;
1056
+ if (event.message?.usage) recordUsage(c.turnOutput, event.message.usage, model);
1057
+ return;
1058
+ }
1059
+
1060
+ if (event?.type === "content_block_start") {
1061
+ ensureTurnStarted(c);
1062
+ if (event.content_block?.type === "text") {
1063
+ c.turnBlocks.push({ type: "text", text: "", index: event.index });
1064
+ c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1065
+ } else if (event.content_block?.type === "thinking") {
1066
+ c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1067
+ c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1068
+ } else if (event.content_block?.type === "tool_use") {
1069
+ const piName = piToolNameFor(event.content_block.name, customToolNameToPi);
1070
+ if (!piName) {
1071
+ debug(`processStreamEvent: skipping tool_use for unserved tool ${event.content_block.name} [${event.content_block.id}] — CC rejects it and retries`);
1072
+ return;
1073
+ }
1074
+ c.turnToolCallIds.push(event.content_block.id);
1075
+ c.turnBlocks.push({
1076
+ type: "toolCall", id: event.content_block.id,
1077
+ name: piName,
1078
+ arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1079
+ partialJson: "", index: event.index,
1080
+ });
1081
+ c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1082
+ } else {
1083
+ debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
1084
+ }
1085
+ return;
1086
+ }
1087
+
1088
+ if (event?.type === "content_block_delta") {
1089
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1090
+ const block = c.turnBlocks[index];
1091
+ if (!block) return;
1092
+ if (event.delta?.type === "text_delta" && block.type === "text") {
1093
+ block.text += event.delta.text;
1094
+ c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
1095
+ } else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
1096
+ block.thinking += event.delta.thinking;
1097
+ c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
1098
+ } else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
1099
+ block.partialJson += event.delta.partial_json;
1100
+ block.arguments = parsePartialJson(block.partialJson, block.arguments);
1101
+ c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
1102
+ } else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
1103
+ block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
1104
+ } else {
1105
+ debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
1106
+ }
1107
+ return;
1108
+ }
1109
+
1110
+ if (event?.type === "content_block_stop") {
1111
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1112
+ const block = c.turnBlocks[index];
1113
+ if (!block) return;
1114
+ delete block.index;
1115
+ if (block.type === "text") {
1116
+ c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
1117
+ } else if (block.type === "thinking") {
1118
+ c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
1119
+ } else if (block.type === "toolCall") {
1120
+ c.turnSawToolCall = true;
1121
+ block.arguments = mapToolArgs(
1122
+ block.name, parsePartialJson(block.partialJson, block.arguments),
1123
+ );
1124
+ delete block.partialJson;
1125
+ c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1126
+ }
1127
+ return;
1128
+ }
1129
+
1130
+ if (event?.type === "message_delta") {
1131
+ c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
1132
+ if (event.usage) recordUsage(c.turnOutput, event.usage, model);
1133
+ return;
1134
+ }
1135
+
1136
+ if (event?.type === "message_stop") {
1137
+ dropUnfinishedStreamBlocks(c, `message_stop for ${c.turnStreamMessageId}`);
1138
+ c.turnStreamOpen = false;
1139
+ }
1140
+
1141
+ if (event?.type === "message_stop" && c.turnSawToolCall) {
1142
+ // Tool call complete — end this pi stream. The SDK will still yield an
1143
+ // assistant message for this turn, but currentPiStream=null causes
1144
+ // consumeQuery to skip it. The MCP handler blocks the generator until
1145
+ // pi delivers the tool result via the next streamSimple call.
1146
+ c.turnOutput.stopReason = "toolUse";
1147
+ const stream = c.currentPiStream;
1148
+ stream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1149
+ markStreamComplete(stream);
1150
+ stream!.end();
1151
+ c.currentPiStream = null;
1152
+
1153
+ // Cursor is updated by the next streamSimple call (tool result delivery path)
1154
+ // which sets cursor = context.messages.length with the post-tool-result context.
1155
+ return;
1156
+ }
1157
+
1158
+ if (event?.type !== "message_stop" && event?.type !== "ping") {
1159
+ debug("processStreamEvent: unhandled event type", event?.type);
1160
+ }
1161
+ }
1162
+
1163
+ /** Remove the blocks of the message that just stopped which never got their own
1164
+ * content_block_stop, because the stream was cut inside them. A tool call left
1165
+ * that way carries whatever JSON survived the cut — `{}` when no delta landed at
1166
+ * all — under an id Claude Code drops from its own conversation before resuming
1167
+ * the turn, so it will never dispatch that id and nothing could answer a result
1168
+ * keyed to it; pi would reject the call, and its result would sit unmatched while
1169
+ * the resumed call waited in a handler. A thinking block left that way has no
1170
+ * signature.
1171
+ *
1172
+ * Per block rather than per message: Claude Code keeps the blocks that did
1173
+ * complete, tool calls included, and one it dispatched while pi was never told is
1174
+ * the same deadlock the other way round. Nothing here ends the pi turn — with no
1175
+ * finished tool call left the resume is more of this same turn, and a turn ended
1176
+ * here would leave the real call with no stream to arrive on. */
1177
+ function dropUnfinishedStreamBlocks(c: QueryContext, why: string): void {
1178
+ const dropped: string[] = [];
1179
+ for (let i = c.turnBlocks.length - 1; i >= c.turnStreamBlockStart; i--) {
1180
+ // `index` is deleted at content_block_stop, so it marks a block that never closed.
1181
+ if (c.turnBlocks[i].index === undefined) continue;
1182
+ dropped.push(`${c.turnBlocks[i].type}${c.turnBlocks[i].id ? `[${c.turnBlocks[i].id}]` : ""}`);
1183
+ c.turnBlocks.splice(i, 1);
1184
+ }
1185
+ if (!dropped.length) return;
1186
+ debug(`dropUnfinishedStreamBlocks: ${why}; dropped ${dropped.reverse().join(",")}`);
1187
+ c.turnToolCallIds = c.turnBlocks.filter((b: any) => b.type === "toolCall").map((b: any) => b.id);
1188
+ c.turnSawToolCall = c.turnBlocks.some((b: any) => b.type === "toolCall");
1189
+ }
1190
+
1191
+ /** Remove the blocks a stream Claude Code abandoned mid-message. Their own
1192
+ * content_block_stop never came, so a thinking block has no signature and a tool
1193
+ * call is one CC will never dispatch; left in, pi would run the tool and the turn
1194
+ * would wait on a handler that never comes, or the next request would replay a
1195
+ * broken block.
1196
+ * The fallback then restarts those indices. pi's normal provider path tolerates that;
1197
+ * pi-agent-core's experimental harness frame encoder keys blocks by contentIndex and
1198
+ * rejects a repeated start, so it would need a change there to drive this provider. */
1199
+ function dropAbandonedStreamBlocks(c: QueryContext, why: string): void {
1200
+ const dropped = c.turnBlocks.splice(c.turnStreamBlockStart);
1201
+ debug(`dropAbandonedStreamBlocks: ${why}; dropped ${dropped.length} blocks from ${c.turnStreamMessageId} types=${dropped.map((b: any) => b.type).join(",")}`);
1202
+ c.turnToolCallIds = [];
1203
+ c.turnSawToolCall = c.turnBlocks.some((b: any) => b.type === "toolCall");
1204
+ c.turnStreamOpen = false;
1205
+ }
1206
+
1207
+ // The SDK always yields `assistant` messages (completed content blocks) after streaming.
1208
+ // When stream_events already delivered the content, this is a no-op. But after
1209
+ // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
1210
+ // arrives before any stream_events, this is the primary content path. Must maintain
1211
+ // the same stream lifecycle as processStreamEvent — including ending the stream on
1212
+ // tool_use to prevent deadlock with the MCP handler.
1213
+ //
1214
+ // It is also the content path when a stream stalls: Claude Code drops it and asks
1215
+ // again without streaming ("Error streaming, falling back to non-streaming mode"),
1216
+ // and the answer arrives as one assistant message, under a new message id, with no
1217
+ // stream_events of its own. turnSawStreamEvent is already set by the dead stream,
1218
+ // so gating on it alone dropped that message: its tool calls never reached pi, CC
1219
+ // sat in the MCP handler waiting for their results, and the turn hung on "Working"
1220
+ // until the user aborted it.
1221
+ function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
1222
+ const assistantMsg = (message as any).message;
1223
+ if (!assistantMsg?.content) return;
1224
+ // Claude Code reports API failures and its own quota/context notices as a
1225
+ // `<synthetic>` assistant message: a report, not model output. Streaming its text
1226
+ // pins the turn's output before the failure it describes, and a consumer that only
1227
+ // fails over before output commits (pi-model-fallback-alias) then cannot reach the
1228
+ // next provider. Keep the wording on the failed turn — the error event carries it —
1229
+ // but emit no events, so the turn still reads as a call that produced no output.
1230
+ // Issue #162.
1231
+ if (assistantMsg.model === "<synthetic>") {
1232
+ // The report can follow a stalled stream whose non-streaming retry also failed;
1233
+ // drop the abandoned partial blocks (unsigned thinking, a tool call CC will never
1234
+ // dispatch) the way the fallback path below would.
1235
+ if (c.turnSawStreamEvent && c.turnStreamOpen) dropAbandonedStreamBlocks(c, "synthetic failure report");
1236
+ debug(`processAssistantMessage: <synthetic> message, keeping ${assistantMsg.content.length} block(s) off the stream`);
1237
+ for (const block of assistantMsg.content) {
1238
+ if (block.type === "text" && block.text) c.turnBlocks.push({ type: "text", text: block.text });
1239
+ else debug("processAssistantMessage: unhandled <synthetic> block type", block.type);
1240
+ }
1241
+ if (assistantMsg.usage && c.turnOutput) recordUsage(c.turnOutput, assistantMsg.usage, model);
1242
+ return;
1243
+ }
1244
+ if (c.turnSawStreamEvent) {
1245
+ // Same id was already delivered; a new id is CC's non-streaming fallback.
1246
+ // Drop the stalled stream's partial blocks if it never stopped. Deliberately
1247
+ // deliver even if it stopped, at the risk of duplication if CC renumbers it.
1248
+ const id = assistantMsg.id;
1249
+ if (!id || !c.turnStreamMessageId || id === c.turnStreamMessageId) return;
1250
+ if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `non-streaming fallback ${id}`);
1251
+ }
1252
+ c.turnToolCallIds = [];
1253
+ debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1254
+ for (const block of assistantMsg.content) {
1255
+ if (block.type === "text" && block.text) {
1256
+ ensureTurnStarted(c);
1257
+ c.turnBlocks.push({ type: "text", text: block.text });
1258
+ const idx = c.turnBlocks.length - 1;
1259
+ c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
1260
+ c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
1261
+ c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
1262
+ } else if (block.type === "thinking") {
1263
+ ensureTurnStarted(c);
1264
+ c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
1265
+ const idx = c.turnBlocks.length - 1;
1266
+ c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
1267
+ if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
1268
+ c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
1269
+ } else if (block.type === "tool_use") {
1270
+ const piName = piToolNameFor(block.name, customToolNameToPi);
1271
+ if (!piName) {
1272
+ debug(`processAssistantMessage: skipping tool_use for unserved tool ${block.name} [${block.id}] — CC rejects it and retries`);
1273
+ continue;
1274
+ }
1275
+ ensureTurnStarted(c);
1276
+ c.turnSawToolCall = true;
1277
+ c.turnToolCallIds.push(block.id);
1278
+ c.turnBlocks.push({
1279
+ type: "toolCall", id: block.id,
1280
+ name: piName,
1281
+ arguments: mapToolArgs(piName, block.input),
1282
+ });
1283
+ const idx = c.turnBlocks.length - 1;
1284
+ const toolBlock = c.turnBlocks[idx];
1285
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1286
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1287
+ } else {
1288
+ debug("processAssistantMessage: unhandled block type", block.type);
1289
+ }
1290
+ }
1291
+ if (assistantMsg.usage && c.turnOutput) recordUsage(c.turnOutput, assistantMsg.usage, model);
1292
+
1293
+ // End the stream on tool_use, same as processStreamEvent's message_stop handler.
1294
+ if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
1295
+ c.turnOutput.stopReason = "toolUse";
1296
+ const stream = c.currentPiStream;
1297
+ stream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1298
+ markStreamComplete(stream);
1299
+ stream.end();
1300
+ c.currentPiStream = null;
1301
+ }
1302
+ }
1303
+
1304
+ /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
1305
+ * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
1306
+ * an assistant message (completed blocks). On tool_use, the stream is ended by
1307
+ * whichever path handles it first (processStreamEvent or processAssistantMessage),
1308
+ * and the MCP handler blocks the generator until pi delivers the tool result. */
1309
+ async function consumeQuery(
1310
+ sdkQuery: ReturnType<typeof query>,
1311
+ customToolNameToPi: Map<string, string>,
1312
+ model: Model<any>,
1313
+ wasAborted: () => boolean,
1314
+ queryCtx: QueryContext,
1315
+ ): Promise<{ capturedSessionId?: string }> {
1316
+ let capturedSessionId: string | undefined;
1317
+
1318
+ for await (const message of sdkQuery) {
1319
+ // CLAUDE_BRIDGE_RECORD_STREAM=<path> appends every SDK message consumeQuery
1320
+ // sees, one JSON object per line. Used by tests/lib/record-sdk-streams.mjs to
1321
+ // capture replay fixtures, so unit tests assert against message shapes Claude
1322
+ // Code really emitted rather than ones we imagined.
1323
+ const recordStreamPath = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
1324
+ if (recordStreamPath) appendFileSync(recordStreamPath, `${JSON.stringify(message)}\n`);
1325
+ if (wasAborted()) break;
1326
+ await reportHeldRateLimit(queryCtx, model);
1327
+ // Everything below the currentPiStream guard is content, which there is
1328
+ // nowhere to put once a turn has ended on a tool call. These three are not
1329
+ // content and must not share that gate:
1330
+ //
1331
+ // - stdin: nothing else closes the CLI's stdin now that the prompt is a
1332
+ // streamed generator (isSingleUserTurn=false), so missing this hangs the query.
1333
+ // - the failure a `result` carries: it is the only record that the turn
1334
+ // failed at all. Behind the guard, a 429 arriving at a tool boundary set
1335
+ // no stopReason, no errorMessage, and logged nothing — the turn simply
1336
+ // ended empty.
1337
+ // - rate-limit events: notifications to the user, which are most likely to
1338
+ // fire during exactly the long tool-using turns the guard was skipping.
1339
+ let resultError: string | undefined;
1340
+ if (message.type === "result") {
1341
+ queryCtx.promptStream?.end();
1342
+ logServedContextWindow("result", message, model);
1343
+ resultError = resultErrorText(message);
1344
+ if (resultError !== undefined) {
1345
+ // Consume the rejection alongside the failure it caused, so a later
1346
+ // unrelated failure on this query doesn't inherit the label.
1347
+ if (queryCtx.rateLimitRejection) {
1348
+ resultError = describeRateLimitFailure(queryCtx.rateLimitRejection, resultError);
1349
+ queryCtx.rateLimitRejection = null;
1350
+ }
1351
+ debug(`consumeQuery: error result, subtype=${message.subtype}, error=${resultError}`);
1352
+ if (queryCtx.turnOutput) {
1353
+ queryCtx.turnOutput.stopReason = "error";
1354
+ queryCtx.turnOutput.errorMessage = resultError;
1355
+ }
1356
+ }
1357
+ }
1358
+ if (message.type === "rate_limit_event") {
1359
+ const info = (message as any).rate_limit_info;
1360
+ debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1361
+ if (info) queryCtx.heldRateLimit = info;
1362
+ await reportHeldRateLimit(queryCtx, model);
1363
+ if (info?.status === "rejected") {
1364
+ // Held so the failure Claude Code sends next can be named as a rate limit.
1365
+ queryCtx.rateLimitRejection = info;
1366
+ // The "rate limit reached" notice below supersedes warnings; re-arm so the next
1367
+ // window's warnings fire even if it opens straight into allowed_warning.
1368
+ queryCtx.lastRateLimitWarnStep = null;
1369
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1370
+ // resetsAt is Unix seconds, not milliseconds.
1371
+ const resetsAt = info.resetsAt ? new Date(info.resetsAt * 1000).toLocaleTimeString() : "unknown";
1372
+ piUI?.notify(`Claude subscription rate limit reached (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1373
+ } else if (info?.status === "allowed") {
1374
+ // Back under the threshold (window reset) — re-arm the warning dedupe.
1375
+ queryCtx.lastRateLimitWarnStep = null;
1376
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1377
+ } else if (info?.status === "allowed_warning") {
1378
+ // utilization is a fraction (0..1); allowed_warning fires once it crosses surpassedThreshold.
1379
+ const percent = Math.round((info.utilization ?? 0) * 100);
1380
+ // The SDK emits one event per request, so only re-notify when the level
1381
+ // rises past a new 5% step or the threshold changes.
1382
+ const step = Math.floor(percent / 5);
1383
+ const rose = queryCtx.lastRateLimitWarnStep === null || step > queryCtx.lastRateLimitWarnStep;
1384
+ if (rose || info.surpassedThreshold !== queryCtx.lastRateLimitWarnThreshold) {
1385
+ queryCtx.lastRateLimitWarnStep = step;
1386
+ queryCtx.lastRateLimitWarnThreshold = info.surpassedThreshold;
1387
+ piUI?.notify(`Claude rate limit warning: ${percent}% used (${info.rateLimitType ?? ""})`, "warning");
1388
+ }
1389
+ }
1390
+ continue;
1391
+ }
1392
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) continue;
1393
+
1394
+ switch (message.type) {
1395
+ case "stream_event":
1396
+ processStreamEvent(message, customToolNameToPi, model, queryCtx);
1397
+ break;
1398
+ case "assistant":
1399
+ processAssistantMessage(message, model, customToolNameToPi, queryCtx);
1400
+ break;
1401
+ case "result": {
1402
+ // The failure itself was recorded above the guard, along with the served
1403
+ // context window. What is left here is the success path: push the result
1404
+ // text when no assistant message already delivered it.
1405
+ if (resultError === undefined && !queryCtx.turnSawStreamEvent && message.subtype === "success") {
1406
+ ensureTurnStarted(queryCtx);
1407
+ const text = message.result || "";
1408
+ queryCtx.turnBlocks.push({ type: "text", text });
1409
+ const idx = queryCtx.turnBlocks.length - 1;
1410
+ queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
1411
+ queryCtx.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: queryCtx.turnOutput });
1412
+ queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
1413
+ }
1414
+ break;
1415
+ }
1416
+ case "system":
1417
+ if ((message as any).subtype === "init" && (message as any).session_id) {
1418
+ capturedSessionId = (message as any).session_id;
1419
+ }
1420
+ break;
1421
+ case "user":
1422
+ // SDK echo of the user prompt — no stream events to emit. Note it
1423
+ // carries only prompts and tool results: a steer CC drained at a
1424
+ // tool boundary is recorded in its session transcript as a
1425
+ // `queued_command` attachment and never reaches this stream, which
1426
+ // is why the mid-turn steering tripwire has to live in the
1427
+ // integration test.
1428
+ break;
1429
+ default:
1430
+ debug("consumeQuery: unhandled SDK message type", message.type);
1431
+ break;
1432
+ }
1433
+ }
1434
+
1435
+ // DEBUG: trace when consumeQuery exits
1436
+ debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
1437
+
1438
+ return { capturedSessionId };
1439
+ }
1440
+
1441
+ /** The trailing user turn as content blocks, or null if there isn't one.
1442
+ * Blocks rather than text so image steers keep their images. */
1443
+ function steerBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
1444
+ const blocks = extractUserPromptBlocks(messages);
1445
+ if (blocks) return blocks;
1446
+ const text = extractUserPrompt(messages);
1447
+ return text ? [{ type: "text", text }] : null;
1448
+ }
1449
+
1450
+ /** A steer that never made it into CC's session. The cursor has already counted
1451
+ * it, so count-based sync would skip it forever — rebuild instead, which
1452
+ * re-imports the message from pi's context. */
1453
+ function steerMissedSession(c: QueryContext, text: string): void {
1454
+ c.missedSteer = true;
1455
+ const state = sessionStateFor(c.piSessionId);
1456
+ if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true });
1457
+ debug(`provider: steer never reached CC, marked query for rebuild: ${text.slice(0, 60)}`);
1458
+ }
1459
+
1460
+ /** Releases this turn's tool results to their MCP handlers, after first pushing
1461
+ * any steer to CC.
1462
+ *
1463
+ * The ordering is mandatory, not an optimization. The steer and the MCP tool
1464
+ * result travel back to CC over the same stdin FIFO. Awaiting the push ack
1465
+ * (which resolves only once the SDK's write to stdin completed) before
1466
+ * resolving any handler guarantees CC enqueues the steer *before* it reads the
1467
+ * tool result, so its post-tool-call drain sees it and acts on it this turn.
1468
+ * Resolve first and the steer misses the drain, silently degrading to
1469
+ * follow-up semantics.
1470
+ *
1471
+ * Both the post-tool-call drain and the FIFO ordering are CC CLI internals,
1472
+ * not SDK contract — tests/int-tool-message.mjs is the tripwire if they move. */
1473
+ async function deliverToolResults(
1474
+ c: QueryContext,
1475
+ results: McpResult[],
1476
+ steer: ContentBlockParam[] | null,
1477
+ contextLength: number,
1478
+ ): Promise<void> {
1479
+ if (steer) {
1480
+ const text = steer.map((b) => (b.type === "text" ? b.text : "[image]")).join("\n");
1481
+ if (!c.promptStream) {
1482
+ debug(`WARNING: steer with no prompt stream, dropping: ${text.slice(0, 60)}`);
1483
+ steerMissedSession(c, text);
1484
+ } else {
1485
+ try {
1486
+ await c.promptStream.push(userMessage(steer, "next"));
1487
+ debug(`provider: steer written to CC stdin before tool result: ${text.slice(0, 60)}`);
1488
+ } catch (error) {
1489
+ // The query is ending — pushing further input would wedge tool-result
1490
+ // delivery, so the steer doesn't reach this query. It is still in
1491
+ // pi's context, and the caller has already advanced the session
1492
+ // cursor past it, so force a rebuild or CC would never see it.
1493
+ debug(`provider: steer push rejected, delivering tool result anyway:`, error);
1494
+ steerMissedSession(c, text);
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ debug(`provider: tool results, ${results.length} results, ${c.pendingToolCalls.size} waiting handlers, ctx.msgs=${contextLength}`);
1500
+ for (const result of results) {
1501
+ const id = result.toolCallId;
1502
+ if (id && c.pendingToolCalls.has(id)) {
1503
+ const pending = c.pendingToolCalls.get(id)!;
1504
+ c.pendingToolCalls.delete(id);
1505
+ debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
1506
+ pending.resolve(result);
1507
+ } else if (id) {
1508
+ c.pendingResults.set(id, result);
1509
+ debug(`provider: queued result [${id}] (${c.pendingResults.size} pending)`);
1510
+ } else {
1511
+ debug(`WARNING: tool result without toolCallId, cannot match`);
1512
+ }
1513
+ if (c.pendingToolCalls.size > 0 && c.pendingResults.size > 0) {
1514
+ debug(`BUG: both maps non-empty! handlers=${c.pendingToolCalls.size} results=${c.pendingResults.size}`);
1515
+ }
1516
+ }
1517
+ if (c.pendingToolCalls.size > 0) {
1518
+ debug(`WARNING: ${c.pendingToolCalls.size} MCP handlers still waiting after delivering ${results.length} results`);
1519
+ piUI?.notify(`Claude bridge: ${c.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1520
+ }
1521
+ }
1522
+
1523
+ /** Abort teardown for one query: settle everything that would otherwise be left
1524
+ * awaiting a subprocess we are about to kill. The pump abandons iteration on
1525
+ * abort, so an in-flight prompt-stream push would hang forever and take
1526
+ * tool-result delivery with it. */
1527
+ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
1528
+ promptStream.fail(new Error("Operation aborted"));
1529
+ c.releasePendingToolCalls("Operation aborted");
1530
+ }
1531
+
1532
+ /** Queries pi's history moved out from under. Their completion must not touch
1533
+ * `sharedSession` or the pi stream: the query that took over the turn has
1534
+ * already rebuilt both from the new history, and this one's session id names the
1535
+ * conversation pi just discarded. */
1536
+ const abandonedQueries = new WeakSet<object>();
1537
+
1538
+ /** The prompt a continuation query is opened with: the pi turn goes on, but its
1539
+ * last message is a tool result rather than a prompt, and query() cannot resume
1540
+ * a session without one. */
1541
+ const CONTINUE_AFTER_REWRITE_PROMPT =
1542
+ "[Your context was compacted. What precedes this is a summary plus the most recent messages, "
1543
+ + "ending with the tool result you were waiting for. Continue the task from there.]";
1544
+
1545
+ /** The prompt a resumed tool result is opened with: no query of this process
1546
+ * was parked on it (a restart, a crash, a stall), so the turn goes on from
1547
+ * history that ends at the result. */
1548
+ const CONTINUE_AFTER_RESUME_PROMPT =
1549
+ "[Your session was interrupted and resumed. What precedes this ends with the tool result "
1550
+ + "you were waiting for. Continue the task from there.]";
1551
+
1552
+ /** Drop a Claude Code query parked at a tool boundary whose conversation pi has
1553
+ * since rewritten (/compact, tree navigation).
1554
+ *
1555
+ * Delivering the turn's tool result into that query hands Claude Code the
1556
+ * context pi just shrank: one pi turn is one CC query, and the query keeps its
1557
+ * own context inside the CLI whatever pi does to its transcript. It answers off
1558
+ * the pre-compaction conversation, reports the pre-compaction usage back, and pi
1559
+ * crosses the same threshold at the next boundary — measured as one compaction
1560
+ * per tool call with usage never dropping (issue #101). `needsRebuild` does not
1561
+ * prevent it: only syncSharedSession reads that flag, and tool-result delivery
1562
+ * is the one call that never syncs.
1563
+ *
1564
+ * The caller then takes the fresh-query path, where REBUILD imports pi's
1565
+ * rewritten history — this tool result included, since it is already in that
1566
+ * history — so the turn continues instead of ending here. Nothing is lost by
1567
+ * killing the subprocess: pi owns the only copy of the conversation that counts. */
1568
+ function discardRewrittenQuery(c: QueryContext): void {
1569
+ const discarded = c.activeQuery as { interrupt?: () => Promise<unknown>; close?: () => void } | null;
1570
+ if (discarded) abandonedQueries.add(discarded);
1571
+ c.activeQuery = null;
1572
+ // Leaving the routing set is what stops this result coming straight back here:
1573
+ // contextForToolResults only matches ids against contexts still in it.
1574
+ activeQueryContexts.delete(c);
1575
+ c.turnToolCallIds = [];
1576
+ c.promptStream?.fail(new Error("conversation rewritten"));
1577
+ c.promptStream = null;
1578
+ // Settle the parked handlers before killing the CLI, for drainForAbort's
1579
+ // reason: one left awaiting a dead subprocess never settles.
1580
+ c.releasePendingToolCalls("Context was compacted; this query was discarded.");
1581
+ void discarded?.interrupt?.().catch(() => {});
1582
+ try { discarded?.close?.(); } catch {}
1583
+ // The CLI we just killed may still flush a record into the session JSONL, and
1584
+ // the rebuild is the next thing that happens — so rotate rather than race it,
1585
+ // exactly as after an abort. Only this session's mirror: the discarding query
1586
+ // proves its own conversation is the one being rebuilt around.
1587
+ const state = sessionStateFor(c.piSessionId);
1588
+ if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1589
+ if (c.piSessionId) historyRewrittenBySession.delete(c.piSessionId);
1590
+ debug("provider: history rewritten under a parked query — discarded it, rebuilding from current history");
1591
+ }
1592
+
1593
+ /** Provider entry point. Pi calls this for each new prompt and each tool result.
1594
+ * Two cases: tool result delivery (active query) or fresh query. */
1595
+ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1596
+ showStartupNoticeOnce();
1597
+ // pi hands providers a transcript (prompt/tools folded into system messages) — fold it
1598
+ // back out to the prompt/tools fields every cursor write, syncSharedSession call and
1599
+ // prompt-capture lookup below assumes (issue #106).
1600
+ context = toBridgeContext(context);
1601
+
1602
+ // Pi's compaction, branch-summary and bug-report calls use cacheRetention:"none"
1603
+ // in completeSummarization. Their prompts bypass the capture boundaries, so route
1604
+ // them to an isolated persistSession:false CC process without session sync.
1605
+ if (options?.cacheRetention === "none") {
1606
+ debug(`provider: one-off summarizer call (cacheRetention none) routed to isolated summary, msgs=${context.messages.length}`);
1607
+ return isolatedStreamFn(model, context, options);
1608
+ }
1609
+
1610
+ const stream = createAssistantMessageEventStream();
1611
+
1612
+ // DEBUG: trace followUp message triggering
1613
+ const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1614
+ debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1615
+
1616
+ let activeQuery = ctx().activeQuery !== null;
1617
+ const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
1618
+ let resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
1619
+
1620
+ // pi rewrote its history while this query sat parked at a tool boundary, so the
1621
+ // query answers about a conversation that no longer exists. Discard it and let
1622
+ // this tool result carry the turn into a fresh query over the rewritten history.
1623
+ // The staleness mark is per pi session: a subagent's compaction (its own
1624
+ // AgentSession, sharing this process) must not discard the parent's parked
1625
+ // query, and vice versa.
1626
+ const rewrittenUnderQuery = Boolean(resultCtx?.historyStale);
1627
+ if (resultCtx && rewrittenUnderQuery) {
1628
+ discardRewrittenQuery(resultCtx);
1629
+ resultCtx = undefined;
1630
+ // Recomputed, not cleared: a reentrant subagent may still hold a query of its own.
1631
+ activeQuery = ctx().activeQuery !== null;
1632
+ }
1633
+
1634
+ const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
1635
+ if (isReentrantUserQuery) {
1636
+ debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
1637
+ }
1638
+
1639
+ // --- Tool result delivery ---
1640
+ // Pi appends tool results to context and calls back. Extract this turn's results
1641
+ // (everything after the last assistant message) and match against waiting MCP
1642
+ // handlers. Results that arrive before their handler get queued in pendingResults.
1643
+ if (resultCtx) {
1644
+ claimCurrentPiStream(stream, "tool-result", resultCtx, options);
1645
+ resultCtx.resetTurnState(model);
1646
+ // A rewrite that armed the mark after this query parked gets copied here,
1647
+ // though markRebuildForSession usually reaches it directly.
1648
+ if (!resultCtx.historyStale && resultCtx.piSessionId && historyRewrittenBySession.has(resultCtx.piSessionId)) {
1649
+ resultCtx.historyStale = true;
1650
+ }
1651
+ // User messages (steer/followUp) pi injected into context during the
1652
+ // active query: a steer sent while a tool was executing, drained by pi at
1653
+ // the turn boundary and appended alongside the tool result.
1654
+ const steer = lastMsgRole === "user" ? steerBlocks(context.messages) : null;
1655
+ // Delivery is async because the steer must reach CC's stdin *before* the
1656
+ // tool result does — see deliverToolResults. Detached so the provider
1657
+ // still returns its stream synchronously.
1658
+ void deliverToolResults(resultCtx, allResults, steer, context.messages.length);
1659
+ // The shared cursor tracks the top-level conversation. A reentrant subagent
1660
+ // delivering its own results would drag it to that subagent's message count
1661
+ // — observed pulling a parent from 5 back to 3, which cost the parent's next
1662
+ // turn a full rebuild and a flushed prompt cache.
1663
+ const state = sessionStateFor(resultCtx.piSessionId);
1664
+ if (state) state.cursor = context.messages.length;
1665
+ resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
1666
+ return stream;
1667
+ }
1668
+
1669
+ // --- Orphaned tool result (e.g. user aborted a tool call) ---
1670
+ // The query is gone but pi still delivered the result. Nothing to do — just
1671
+ // emit end_turn so pi waits for the next real user message. The discard
1672
+ // branch above already siphoned off the stale-query case, which goes on to a
1673
+ // rebuild instead — that one has somewhere to deliver the result to.
1674
+ // Only when this call's own signal is aborted: after an abort during a tool
1675
+ // call, pi calls back with the run's (already aborted) signal. A result that
1676
+ // arrives otherwise -- after a restart (a durable host resuming a run), a
1677
+ // crash, a stall, or an overflow/quota retry -- has no parked query either, and
1678
+ // falls through to the fresh-query path, which resumes the CC session and
1679
+ // delivers it.
1680
+ const lastMsg = context.messages[context.messages.length - 1];
1681
+ const unparkedToolResult = lastMsg?.role === "toolResult" && !rewrittenUnderQuery;
1682
+ if (unparkedToolResult && options?.signal?.aborted) {
1683
+ debug(`provider: orphaned tool result after abort, emitting end_turn`);
1684
+ // With no query in flight anywhere, the top-level session this result
1685
+ // belongs to is the one whose turn just ended: its cursor advances to
1686
+ // count the result (options.sessionId is that session — pi emits the
1687
+ // result event through the same session's streamSimple call).
1688
+ const orphanState = sessionStateFor(options?.sessionId ?? null);
1689
+ if (orphanState && activeQueryContexts.size === 0) orphanState.cursor = context.messages.length;
1690
+ // No query owns this result, so there is no context to reset: resetTurnState
1691
+ // on the top-level ctx() would replace a live parent's turnOutput mid-stream,
1692
+ // stranding the blocks it had already emitted. A throwaway context just
1693
+ // supplies the empty message this turn ends with.
1694
+ const c = new QueryContext();
1695
+ c.resetTurnState(model);
1696
+ queueMicrotask(() => {
1697
+ stream.push({ type: "done", reason: "stop", message: c.turnOutput });
1698
+ markStreamComplete(stream);
1699
+ stream.end();
1700
+ });
1701
+ return stream;
1702
+ }
1703
+
1704
+ // --- Fresh query ---
1705
+
1706
+ // 1. Determine reentrancy. Reentrant queries get their own QueryContext so
1707
+ // background subagents can run concurrently with the parent query.
1708
+ const isReentrant = activeQuery;
1709
+ const queryCtx = isReentrant ? new QueryContext() : ctx();
1710
+ debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1711
+
1712
+ // Resolved first: an unaccountable system prompt fails this query before anything
1713
+ // is claimed or reset, leaving no half-built query behind — in particular no stream
1714
+ // claimed on the shared context that nobody will ever end.
1715
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askClaudeToolName);
1716
+ // Build from what Pi loaded for this run, so `--no-context-files` and
1717
+ // `--no-skills` reach Claude Code by leaving nothing to forward. A sub-agent's
1718
+ // custom override embeds its parent's assembled Pi prompt; recursive projection
1719
+ // replaces that exact inherited prompt with its already-safe portable parts.
1720
+ // Derive the key from the transcript replay (toBridgeContext), NOT from the
1721
+ // recorded keys: under a forced prompt the transcript head is projected via
1722
+ // transformContext after turn_start, so ctx.getSystemPrompt() is not the head.
1723
+ let promptCapture: PromptCapture | undefined;
1724
+ let systemPromptAppend: string | undefined;
1725
+ try {
1726
+ promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
1727
+ systemPromptAppend = promptCapture
1728
+ ? projectPromptCapture(promptCapture, {
1729
+ skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
1730
+ })
1731
+ : undefined;
1732
+ } catch (err) {
1733
+ // resolveOrDerive and projectPromptCapture throw to stop a turn that would lose
1734
+ // its instructions or leak pi's harness text. Report it on the stream, as pi-ai's
1735
+ // provider contract expects, so any caller — not only pi's agent loop, which
1736
+ // catches a throw — sees a failed turn rather than a synchronous exception.
1737
+ const output = newAssistantOutput(model, "", "error", errorMessage(err));
1738
+ queueMicrotask(() => {
1739
+ stream.push({ type: "error", reason: "error", error: output });
1740
+ markStreamComplete(stream);
1741
+ stream.end();
1742
+ });
1743
+ diagDump("prompt_capture_unresolved", {
1744
+ promptChars: context.systemPrompt?.length ?? 0,
1745
+ knownKeys: promptCaptures.size,
1746
+ reentrantUserQuery: isReentrantUserQuery,
1747
+ error: errorMessage(err),
1748
+ });
1749
+ return stream;
1750
+ }
1751
+
1752
+ // 2. Fresh child context — constructor already gave us clean Maps and empty
1753
+ // arrays. For a reused top-level context, clear explicitly.
1754
+ claimCurrentPiStream(stream, "fresh-query", queryCtx, options);
1755
+ // A held event belongs to the query that received it, not to this request.
1756
+ queryCtx.heldRateLimit = undefined;
1757
+ queryCtx.pendingToolCalls.clear();
1758
+ queryCtx.pendingResults.clear();
1759
+ // Stale ids would let a late result from the previous query route here via
1760
+ // contextForToolResults — which now means pushing its steer into this
1761
+ // query's stdin, not just mismatching a map.
1762
+ queryCtx.turnToolCallIds = [];
1763
+ queryCtx.resetTurnState(model);
1764
+ queryCtx.latestCursor = 0;
1765
+ // The served pi session, for rewrite attribution on delivery (issue #101
1766
+ // follow-up) and on SessionState. A fresh instance of this module inside a
1767
+ // worktree-spawned subagent has its own contexts; each records its own.
1768
+ queryCtx.piSessionId = options?.sessionId ?? null;
1769
+ // A discarded query's replacement reuses this context; without the reset its
1770
+ // first tool result would sit on armed staleness again (the mark is consumed
1771
+ // from the set, not from here) and re-discard a healthy query.
1772
+ queryCtx.historyStale = false;
1773
+ queryCtx.missedSteer = false;
1774
+
1775
+ const cwd = sessionCwdFor(options?.sessionId);
1776
+ // cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
1777
+ // pi-registered id. Log cliModel so debug lines reflect what CC actually received.
1778
+ const cliModel = claudeCodeModelId(model, longContextSettings);
1779
+ // Which pi session this query serves — the attribution key for history
1780
+ // rewrites (session_compact / session_tree) and for SessionState above.
1781
+ const piSessionId = options?.sessionId ?? null;
1782
+ // Read before the consume below: with no query parked (an overflow retried
1783
+ // after compaction), the session's mark is the only sign of the rewrite.
1784
+ const historyRewritten = rewrittenUnderQuery || (piSessionId !== null && historyRewrittenBySession.has(piSessionId));
1785
+ const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, cliModel, piSessionId);
1786
+ // This query starts from the history pi has now: consume this session's
1787
+ // armed rewrite — a sibling pi session's stays armed for its own queries.
1788
+ if (piSessionId) historyRewrittenBySession.delete(piSessionId);
1789
+ const { sessionId: resumeSessionId } = syncResult;
1790
+ const promptBlocks = extractUserPromptBlocks(context.messages);
1791
+ let promptText = extractUserPrompt(context.messages) ?? "";
1792
+
1793
+ // A turn continuing past a rewrite ends at its tool result, not at a
1794
+ // prompt, so say what happened rather than falling into the empty-prompt
1795
+ // recovery below — that one is for a shape we do not expect, and this is one
1796
+ // we do. The rebuilt session already ends with the tool result, placed after
1797
+ // the tool call it answers.
1798
+ if (historyRewritten && !promptText && !promptBlocks) {
1799
+ promptText = CONTINUE_AFTER_REWRITE_PROMPT;
1800
+ debug(`provider: continuing the turn after a rewritten history, ${context.messages.length} msgs rebuilt`);
1801
+ }
1802
+ if (unparkedToolResult && !promptText && !promptBlocks) {
1803
+ promptText = CONTINUE_AFTER_RESUME_PROMPT;
1804
+ debug(`provider: resuming the turn at a tool result with no parked query, ${context.messages.length} msgs`);
1805
+ }
1806
+
1807
+ // Guard: empty prompt means the last context message isn't a user message.
1808
+ // This should never happen with per-query state — dump diagnostics if it does.
1809
+ if (!promptText && !promptBlocks) {
1810
+ diagDump("empty_prompt", {
1811
+ contextLength: context.messages.length,
1812
+ lastMsgRole: lastMsg?.role,
1813
+ isReentrant,
1814
+ activeQueryContexts: activeQueryContexts.size,
1815
+ activeQueryExists: queryCtx.activeQuery !== null,
1816
+ sharedSession: sessionStateFor(piSessionId) ? { sessionId: sessionStateFor(piSessionId)!.sessionId.slice(0, 8), cursor: sessionStateFor(piSessionId)!.cursor } : (sessionStateFor(null) ? { sessionId: sessionStateFor(null)!.sessionId.slice(0, 8), cursor: sessionStateFor(null)!.cursor } : null),
1817
+ messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
1818
+ });
1819
+ // Recover: use a continuation prompt so the SDK doesn't send an empty text block
1820
+ promptText = "[continue]";
1821
+ }
1822
+
1823
+ // Always stream the prompt rather than passing a string: a parked input
1824
+ // generator is what lets us write steers to CC's stdin mid-turn. The cost is
1825
+ // that `isSingleUserTurn` is false, so the SDK no longer closes stdin on the
1826
+ // first result — consumeQuery ends the stream explicitly instead, or the
1827
+ // query would never terminate.
1828
+ const promptStream = makePromptStream();
1829
+ const currentBlocks: ContentBlockParam[] = promptBlocks ?? [{ type: "text", text: promptText }];
1830
+ void promptStream.push(userMessage(syncResult.replay?.length ? [...replayBlocks(syncResult.replay), ...currentBlocks] : currentBlocks))
1831
+ .catch((error) => debug(`provider: initial prompt push rejected:`, error));
1832
+ queryCtx.promptStream = promptStream;
1833
+ const mcpServers = buildMcpServers(mcpTools, queryCtx);
1834
+
1835
+ // MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
1836
+ // + per-project) and .mcp.json. Since pi executes tools (not CC), those are pure
1837
+ // token overhead. --strict-mcp-config tells the binary to use ONLY mcpServers passed
1838
+ // programmatically and ignore filesystem MCP entries — applied unconditionally because
1839
+ // settingSources is left at CC's default, which loads all sources.
1840
+ const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1841
+ const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1842
+
1843
+ const effort = effortForReasoning(model, options?.reasoning);
1844
+
1845
+ const extraArgs: Record<string, string | null> = { model: cliModel, "safe-mode": null };
1846
+ if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
1847
+ // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
1848
+ // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
1849
+ if (effort) extraArgs["thinking-display"] = "summarized";
1850
+
1851
+ // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
1852
+ // when the user is logged into Anthropic). These are a separate code path from
1853
+ // filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
1854
+ // The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
1855
+ // to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
1856
+ // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
1857
+ // /compact via session_compact (see handler in default export). Letting CC
1858
+ // also autocompact would double-flush the prompt cache and races pi's
1859
+ // threshold with CC's, including CC's anti-thrashing guard (issue #8).
1860
+ // Manual /compact in CC still works (we never invoke it).
1861
+ debug("provider: system prompt append:", systemPromptAppend ?? "");
1862
+ const childEnv = { ...process.env, ...CC_CHILD_ENV };
1863
+ const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
1864
+ cwd,
1865
+ env: childEnv,
1866
+ tools: [],
1867
+ permissionMode: "bypassPermissions",
1868
+ includePartialMessages: true,
1869
+ // includeGitInstructions:false drops the gitStatus block from the preset.
1870
+ // That block is the trailing suffix of the cached system block, and a
1871
+ // git-state transition (new file, staging, commit) rewrites it — busting
1872
+ // the prompt cache for the whole conversation from there on (see
1873
+ // diag/probe-git-cache.mjs). The bridge re-invokes CC per turn, so this
1874
+ // hit on every transition. Cost here is nil: the setting also strips
1875
+ // CC's git-workflow guidance from its Bash tool prompt, but the provider
1876
+ // path runs CC with `tools: []`, so those definitions never ship.
1877
+ // AskClaude keeps CC's native tools and its guidance — unaffected.
1878
+ settings: {
1879
+ ...claudeCodeSettings(providerSettings),
1880
+ claudeMdExcludes: CLAUDE_MD_EXCLUDES,
1881
+ includeGitInstructions: false,
1882
+ },
1883
+ systemPrompt: {
1884
+ type: "preset", preset: "claude_code",
1885
+ append: systemPromptAppend ? systemPromptAppend : undefined,
1886
+ },
1887
+ extraArgs,
1888
+ ...(effort ? { effort } : {}),
1889
+ ...(mcpServers ? { mcpServers } : {}),
1890
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1891
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
1892
+ ...makeCliDebugOptions("provider"),
1893
+ };
1894
+
1895
+ debug("provider: fresh query",
1896
+ `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1897
+ `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1898
+ `ctxFiles=${promptCapture?.contextFiles.length ?? 0} strictMcp=${strictMcpConfigEnabled}`,
1899
+ `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1900
+
1901
+ // 3. Start SDK query and claim it for this context
1902
+ let wasAborted = false;
1903
+ const sdkQuery = queryImpl({ prompt: promptStream.stream, options: queryOptions });
1904
+ queryCtx.activeQuery = sdkQuery;
1905
+ activeQueryContexts.add(queryCtx);
1906
+
1907
+ // 4. Capture context for abort handling
1908
+ const abortCtx = queryCtx;
1909
+
1910
+ const requestAbort = () => {
1911
+ // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
1912
+ // Both are needed — interrupt alone lets the current API call finish.
1913
+ void sdkQuery.interrupt().catch(() => {});
1914
+ try { sdkQuery.close(); } catch {}
1915
+ };
1916
+ const onAbort = () => {
1917
+ wasAborted = true;
1918
+ drainForAbort(abortCtx, promptStream);
1919
+ requestAbort();
1920
+ };
1921
+ if (options?.signal) {
1922
+ if (options.signal.aborted) onAbort();
1923
+ else options.signal.addEventListener("abort", onAbort, { once: true });
1924
+ }
1925
+
1926
+ // Background consumer — runs until query ends
1927
+ consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx)
1928
+ .then(async ({ capturedSessionId }) => {
1929
+ debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
1930
+
1931
+ // Discarded out from under: the query continuing the turn owns the context,
1932
+ // the session and the stream. Capturing this one's session id here would put
1933
+ // Claude Code back on the conversation it was discarded for.
1934
+ if (abandonedQueries.has(sdkQuery)) {
1935
+ debug("provider: discarded query completed, leaving session and stream to its replacement");
1936
+ return;
1937
+ }
1938
+
1939
+ // --- Abort detection in normal completion path ---
1940
+ if (wasAborted || options?.signal?.aborted) {
1941
+ // The killed subprocess may flush a late record into this session's
1942
+ // JSONL — its own mirror's next sync must rebuild and rotate.
1943
+ const state = sessionStateFor(queryCtx.piSessionId);
1944
+ if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1945
+ debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
1946
+ if (queryCtx.turnOutput) {
1947
+ queryCtx.turnOutput.stopReason = "aborted";
1948
+ queryCtx.turnOutput.errorMessage = "Operation aborted";
1949
+ }
1950
+ const stream = queryCtx.currentPiStream;
1951
+ stream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
1952
+ markStreamComplete(stream);
1953
+ stream?.end();
1954
+ queryCtx.currentPiStream = null;
1955
+ queryCtx.heldRateLimit = undefined;
1956
+ return;
1957
+ }
1958
+
1959
+ // --- Capture session ID ---
1960
+ // This query's own mirror — a reentrant subagent completing does not
1961
+ // reassign the parent's conversation to the child's CC file.
1962
+ if (syncResult.preserveSharedSession) {
1963
+ debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
1964
+ } else {
1965
+ const state = sessionStateFor(queryCtx.piSessionId);
1966
+ const sessionId = capturedSessionId ?? state?.sessionId;
1967
+ if (sessionId) {
1968
+ const cursor = Math.max(context.messages.length, queryCtx.latestCursor, state?.cursor ?? 0);
1969
+ debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
1970
+ // A missed steer may precede the first mirror or arrive while this
1971
+ // query is still able to complete. Preserve both rebuild signals.
1972
+ setSessionStateFor(queryCtx.piSessionId, { ...state, sessionId, cursor, cwd, piSessionId: queryCtx.piSessionId ?? undefined, needsRebuild: queryCtx.missedSteer || state?.needsRebuild });
1973
+ }
1974
+ }
1975
+
1976
+ if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
1977
+ debug("provider: clearing activeQuery before final stream completion");
1978
+ queryCtx.activeQuery = null;
1979
+ }
1980
+ finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
1981
+ })
1982
+ .catch((error) => {
1983
+ debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1984
+ if (abandonedQueries.has(sdkQuery)) {
1985
+ debug("provider: discarded query ended in error, leaving session and stream to its replacement");
1986
+ return;
1987
+ }
1988
+ if ((wasAborted || options?.signal?.aborted)) {
1989
+ const state = sessionStateFor(queryCtx.piSessionId);
1990
+ if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1991
+ queryCtx.heldRateLimit = undefined;
1992
+ } else {
1993
+ // Drop this session's mirror: its conversation is in an unknown
1994
+ // state after the error. Other sessions' mirrors stay — one
1995
+ // session's failure says nothing about another's conversation.
1996
+ setSessionStateFor(queryCtx.piSessionId, null);
1997
+ }
1998
+ promptStream.fail(error instanceof Error ? error : new Error(String(error)));
1999
+ if (queryCtx.turnOutput) {
2000
+ queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
2001
+ // The SDK drops its copy of the result text if any message follows the error
2002
+ // result, so prefer the cause consumeQuery recorded off the result itself.
2003
+ queryCtx.turnOutput.errorMessage ??= error instanceof Error ? error.message : String(error);
2004
+ }
2005
+ if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
2006
+ queryCtx.releasePendingToolCalls("Query ended");
2007
+ debug("provider: clearing activeQuery before error stream completion");
2008
+ queryCtx.activeQuery = null;
2009
+ }
2010
+ const stream = queryCtx.currentPiStream;
2011
+ stream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
2012
+ markStreamComplete(stream);
2013
+ stream?.end();
2014
+ queryCtx.currentPiStream = null;
2015
+ })
2016
+ .finally(() => {
2017
+ if (options?.signal) options.signal.removeEventListener("abort", onAbort);
2018
+ // Settle any ack still parked in the generator — the CLI is gone, so
2019
+ // nothing will resume it. Clear the handle only if a later query
2020
+ // hasn't already claimed the shared context.
2021
+ promptStream.fail(new Error("query ended"));
2022
+ if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
2023
+ // A later query claiming this context sets activeQuery to its own handle;
2024
+ // null means the .then/.catch above cleared ours and nothing replaced it.
2025
+ // Testing only for `=== sdkQuery` would never fire on the non-reentrant
2026
+ // path, leaving the top-level context in the routing set forever — where a
2027
+ // later orphaned tool result matches its stale turnToolCallIds and takes
2028
+ // the delivery branch, returning a stream nothing ends.
2029
+ if (queryCtx.activeQuery === sdkQuery || queryCtx.activeQuery === null) {
2030
+ queryCtx.releasePendingToolCalls("Query ended");
2031
+ queryCtx.activeQuery = null;
2032
+ activeQueryContexts.delete(queryCtx);
2033
+ }
2034
+ sdkQuery.close();
2035
+ });
2036
+
2037
+ return stream;
2038
+ }
2039
+
2040
+ // --- AskClaude: prompt and wait ---
2041
+
2042
+ async function promptAndWait(
2043
+ prompt: string,
2044
+ mode: "full" | "read" | "none",
2045
+ toolCalls: Map<string, ToolCallState>,
2046
+ signal?: AbortSignal,
2047
+ options?: {
2048
+ systemPrompt?: string;
2049
+ appendSkills?: boolean;
2050
+ onStreamUpdate?: (responseText: string) => void;
2051
+ model?: string;
2052
+ thinking?: string;
2053
+ isolated?: boolean;
2054
+ context?: Context["messages"];
2055
+ /** pi session the calling tool ran in — AskClaude's conversation continues
2056
+ * the session that called it, so its sync and capture key that mirror. */
2057
+ piSessionId?: string | null;
2058
+ },
2059
+ ): Promise<{ responseText: string; stopReason: string }> {
2060
+ const cwd = sessionCwdFor(options?.piSessionId);
2061
+ const requestedModel = options?.model ?? "opus";
2062
+ const model = resolveModel(requestedModel);
2063
+ const modelId = model?.id ?? requestedModel;
2064
+ const cliModel = model ? claudeCodeModelId(model, longContextSettings) : modelId;
2065
+
2066
+ // Session resume for shared mode — reuse provider's session if it exists,
2067
+ // otherwise create one from pi's context.
2068
+ // Note: doesn't update the mirror's cursor after completion, so the next
2069
+ // provider call will see missed messages and trigger a Case 4 rebuild.
2070
+ // AskClaude has a random-origin context handed to it, but the session it
2071
+ // belongs to is the one whose tool ran — the extension API's execute ctx
2072
+ // carries it — passed here as piSessionId and used for every map access.
2073
+ const askClaudeSessionId = options?.piSessionId ?? null;
2074
+ let resumeSessionId: string | null = null;
2075
+ if (!options?.isolated && options?.context?.length) {
2076
+ const askClaudeState = sessionStateFor(askClaudeSessionId);
2077
+ if (askClaudeState) {
2078
+ // Provider already has a session — just resume from it
2079
+ // Any missed messages from other providers were already handled by the provider's Case 4
2080
+ resumeSessionId = askClaudeState.sessionId;
2081
+ } else {
2082
+ // No provider session yet — create one from pi's context
2083
+ const contextWithPrompt = [...options.context, { role: "user" as const, content: prompt, timestamp: Date.now() }];
2084
+ const sync = syncSharedSession(contextWithPrompt as Context["messages"], cwd, undefined, cliModel, askClaudeSessionId);
2085
+ resumeSessionId = sync.sessionId;
2086
+ }
2087
+ }
2088
+
2089
+ // Mode → disallowed tools
2090
+ const disallowedTools = MODE_DISALLOWED_TOOLS[mode];
2091
+
2092
+ // AskClaude uses Claude Code's native Read tool rather than Pi's MCP bridge.
2093
+ // Same resolver as the provider path: a prompt neither recorded nor derivable
2094
+ // throws here too, rather than silently sending Claude Code no skills.
2095
+ //
2096
+ // Resolved only when the answer would be used. The throw is justified by what a
2097
+ // miss would cost, so where it costs nothing — skills switched off, or no reader
2098
+ // to open a skill file with — an unrelated miss must not fail the call.
2099
+ const skillReadTool = disallowedTools.includes("Read") ? "none" : "native";
2100
+ const skillCapture = options?.appendSkills !== false && skillReadTool !== "none"
2101
+ ? promptCaptures.resolveOrDerive(options?.systemPrompt)
2102
+ : undefined;
2103
+ const skillsBlock = skillCapture
2104
+ ? renderSkillsBlock(collectPromptSkills(skillCapture), skillReadTool)
2105
+ : undefined;
2106
+
2107
+ // Effort
2108
+ const effort = options?.thinking && options.thinking !== "off"
2109
+ ? REASONING_TO_EFFORT[options.thinking] : undefined;
2110
+
2111
+ const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
2112
+
2113
+ const extraArgs: Record<string, string | null> = {
2114
+ "strict-mcp-config": null,
2115
+ model: cliModel,
2116
+ };
2117
+ if (effort) extraArgs["thinking-display"] = "summarized";
2118
+
2119
+ debug("askClaude:",
2120
+ `mode=${mode} model=${modelId} cliModel=${cliModel} effort=${effort ?? "default"}`,
2121
+ `isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
2122
+ `skills=${Boolean(skillsBlock)} promptLen=${prompt.length}`);
2123
+
2124
+ // skills: [] suppresses Claude Code's own skill listing, a system-reminder naming every
2125
+ // skill under the ~/.claude estate. The provider path gets this for free — `tools: []`
2126
+ // removes the Skill tool and the listing with it — but AskClaude runs on CC's native
2127
+ // tools, so it has to be asked for. Pi-side skills still arrive via skillsBlock below,
2128
+ // which is meant to be the only channel.
2129
+ const sdkQuery = query({
2130
+ prompt,
2131
+ options: {
2132
+ cwd,
2133
+ env: { ...process.env, ...CC_CHILD_ENV },
2134
+ permissionMode: "bypassPermissions",
2135
+ settings: { ...claudeCodeSettings(providerSettings), claudeMdExcludes: CLAUDE_MD_EXCLUDES },
2136
+ skills: [],
2137
+ ...(disallowedTools.length ? { disallowedTools } : {}),
2138
+ ...(effort ? { effort } : {}),
2139
+ // Preset unconditionally: omitting it leaves the child on the SDK's bare default,
2140
+ // without the tool and permission guidance the bridge relies on everywhere else.
2141
+ // Whether pi has skills to append is unrelated to whether the child needs that.
2142
+ systemPrompt: { type: "preset", preset: "claude_code", append: skillsBlock },
2143
+ settingSources: ["user", "project"] as SettingSource[],
2144
+ extraArgs,
2145
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
2146
+ ...(options?.isolated ? { persistSession: false } : {}),
2147
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
2148
+ ...makeCliDebugOptions("askclaude"),
2149
+ },
2150
+ });
2151
+
2152
+ // Abort handling
2153
+ let wasAborted = false;
2154
+ const onAbort = () => {
2155
+ wasAborted = true;
2156
+ sdkQuery.interrupt().catch(() => { try { sdkQuery.close(); } catch {} });
2157
+ };
2158
+ if (signal?.aborted) { onAbort(); throw new Error("Aborted"); }
2159
+ signal?.addEventListener("abort", onAbort, { once: true });
2160
+
2161
+ let responseText = "";
2162
+ let sdkMessageCount = 0;
2163
+ let textDeltaCount = 0;
2164
+ let resultSubtype: string | undefined;
2165
+
2166
+ try {
2167
+ for await (const message of sdkQuery) {
2168
+ if (wasAborted) break;
2169
+ sdkMessageCount++;
2170
+
2171
+ switch (message.type) {
2172
+ case "stream_event": {
2173
+ const event = (message as SDKMessage & { event: any }).event;
2174
+ // Text deltas → accumulate and stream
2175
+ if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
2176
+ responseText += event.delta.text;
2177
+ textDeltaCount++;
2178
+ options?.onStreamUpdate?.(responseText);
2179
+ }
2180
+ // Tool call start → track for action summary progress
2181
+ if (event?.type === "content_block_start" && event.content_block?.type === "tool_use") {
2182
+ debug(`askClaude: tool_use start: ${event.content_block.name}`);
2183
+ toolCalls.set(event.content_block.id, {
2184
+ name: mapToolName(event.content_block.name),
2185
+ status: "running",
2186
+ });
2187
+ }
2188
+ break;
2189
+ }
2190
+ case "assistant": {
2191
+ // Update tool calls with full input for action summary
2192
+ for (const block of (message as any).message?.content ?? []) {
2193
+ if (block.type === "tool_use") {
2194
+ toolCalls.set(block.id, {
2195
+ name: mapToolName(block.name),
2196
+ status: "complete",
2197
+ rawInput: block.input,
2198
+ });
2199
+ }
2200
+ }
2201
+ break;
2202
+ }
2203
+ case "result": {
2204
+ resultSubtype = message.subtype;
2205
+ const r = message as any;
2206
+ if (r.usage) {
2207
+ debug(`askClaude: result usage: in=${r.usage.input_tokens} out=${r.usage.output_tokens} cacheRead=${r.usage.cache_read_input_tokens ?? 0} cacheWrite=${r.usage.cache_creation_input_tokens ?? 0} turns=${r.num_turns ?? "?"}`);
2208
+ }
2209
+ // Claude Code reports an API failure with `is_error` on a result whose
2210
+ // subtype is still "success", so without this the error text was returned
2211
+ // as Claude's answer and pi's model read a 429 as content. Throwing hands
2212
+ // it to the tool's own catch, which renders it as an error result.
2213
+ const failure = wasAborted ? undefined : resultErrorText(message);
2214
+ if (failure) throw new Error(failure);
2215
+ if (!responseText && message.subtype === "success" && message.result) {
2216
+ responseText = message.result;
2217
+ }
2218
+ break;
2219
+ }
2220
+ }
2221
+ }
2222
+
2223
+ const stopReason = wasAborted ? "cancelled" : "stop";
2224
+ debug(`askClaude: done`,
2225
+ `stopReason=${stopReason} resultSubtype=${resultSubtype ?? "none"}`,
2226
+ `sdkMessages=${sdkMessageCount} textDeltas=${textDeltaCount} responseLen=${responseText.length}`,
2227
+ `toolCalls=${toolCalls.size}`);
2228
+ return { responseText, stopReason };
2229
+ } finally {
2230
+ signal?.removeEventListener("abort", onAbort);
2231
+ sdkQuery.close();
2232
+ }
2233
+ }
2234
+
2235
+ // --- Extension registration ---
2236
+
2237
+ const PREVIEW_MAX_CHARS = 1000;
2238
+ const PREVIEW_MAX_LINES = 6;
2239
+
2240
+ let askClaudeToolName = "AskClaude";
2241
+
2242
+ export default function (pi: ExtensionAPI) {
2243
+ const config = loadConfig(process.cwd());
2244
+ let askClaudeDeprecationNoticeShown = Boolean(config.askClaudeDeprecationShown);
2245
+ debug("loadConfig:", JSON.stringify(config));
2246
+ providerSettings = config.provider ?? {};
2247
+ // We need these settings to know if we're eligible for 1M context on certain models
2248
+ // Validate at the boundary: a non-array here would throw inside every
2249
+ // claudeCodeModelId call and brick the extension at activation.
2250
+ const forceTwoHundredK = Array.isArray(providerSettings.forceTwoHundredK)
2251
+ ? providerSettings.forceTwoHundredK.filter((id): id is string => typeof id === "string")
2252
+ : undefined;
2253
+ longContextSettings = {
2254
+ plan: providerSettings.plan ?? "pro",
2255
+ longContextExtraUsage: providerSettings.longContextExtraUsage ?? false,
2256
+ forceTwoHundredK,
2257
+ };
2258
+ const registeredModels = applyLongContext(MODELS, longContextSettings);
2259
+ if (registeredModels.length === 0) {
2260
+ console.error("claude-bridge: no models available from pi-ai's anthropic catalog — update @earendil-works/pi-ai (requires >=0.86.1)");
2261
+ }
2262
+
2263
+ if (!config.startupNoticeShown && config.provider?.plan === undefined) {
2264
+ pendingNotices.push('Are you using a Max plan? You need to set provider.plan to "max" to unlock 1M context in Opus.');
2265
+ }
2266
+
2267
+ // Hob hosts concurrent independent sessions in one process.
2268
+ const clearSession = (id: string) => {
2269
+ sharedSessions.delete(sessionKey(id));
2270
+ historyRewrittenBySession.delete(id);
2271
+ };
2272
+ pi.on("session_start", (event, ctx) => {
2273
+ piUI = ctx.ui;
2274
+ piMode = ctx.mode;
2275
+ rememberSessionCwd(ctx.sessionManager.getSessionId(), ctx.cwd);
2276
+ if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
2277
+ clearSession(ctx.sessionManager.getSessionId());
2278
+ }
2279
+ });
2280
+ pi.on("session_before_compact", (event, ctx) => {
2281
+ rememberSummarySession(event.signal, ctx.sessionManager.getSessionId());
2282
+ });
2283
+ pi.on("session_before_tree", (event, ctx) => {
2284
+ rememberSummarySession(event.signal, ctx.sessionManager.getSessionId());
2285
+ });
2286
+ // `--system-prompt` replaces pi's default rather than adding to it, but Claude
2287
+ // Code's preset carries its own tool and permission guidance that the bridge
2288
+ // still depends on, so both flags are forwarded as an append.
2289
+ //
2290
+ // The options (custom/append/contextFiles/skills/sections) are pi config, stable across a
2291
+ // turn; only the auto-generated tool list in the rendered prompt varies. Stash them
2292
+ // at before_agent_start so the agent_start recording below can reuse them.
2293
+ type RecordOptions = Parameters<typeof recordSystemPrompt>[2];
2294
+ let lastSystemPromptOptions: RecordOptions | undefined;
2295
+ function recordSystemPrompt(source: string, systemPrompt: string | undefined, options: {
2296
+ customPrompt?: string;
2297
+ appendSystemPrompt?: string;
2298
+ contextFiles?: { path: string; content: string }[];
2299
+ skills?: Parameters<typeof promptCaptures.record>[1]["skills"];
2300
+ sections?: Record<string, string>;
2301
+ selectedTools?: string[];
2302
+ } | undefined) {
2303
+ if (!systemPrompt) return;
2304
+ const hasRead = !options?.selectedTools || options.selectedTools.includes("read");
2305
+ promptCaptures.record(systemPrompt, {
2306
+ custom: options?.customPrompt,
2307
+ append: options?.appendSystemPrompt,
2308
+ contextFiles: options?.contextFiles ?? [],
2309
+ skills: hasRead ? options?.skills ?? [] : [],
2310
+ sections: options?.sections,
2311
+ }, source);
2312
+ }
2313
+ pi.on("before_agent_start", (event) => {
2314
+ lastSystemPromptOptions = event.systemPromptOptions;
2315
+ recordSystemPrompt("before_agent_start", event.systemPrompt, event.systemPromptOptions);
2316
+ });
2317
+ // The prompt the provider actually queries with is the fully-widened one: MCP tool
2318
+ // descriptions merge into the system prompt only after their servers connect, which
2319
+ // is after before_agent_start. ctx.getSystemPrompt() returns that widened prompt by
2320
+ // agent_start (verified: before_agent_start=10,988 chars vs agent_start/query=23,479).
2321
+ // A subagent embeds the widened parent prompt verbatim (pi-subagents reads
2322
+ // ctx.getSystemPrompt() at dispatch), so unless the widened prompt is a capture key
2323
+ // too, the child's turn resolves against nothing, falls to a verbatim side request,
2324
+ // and ships pi's harness — tripping the server's third-party plan-eligibility check
2325
+ // ("out of extra usage"). Recording it here, before the query, restores the match.
2326
+ //
2327
+ // agent_start also captures a handler-returned forceSystemPrompt, which
2328
+ // buildSystemPrompt renders verbatim.
2329
+ pi.on("agent_start", (_event, ctx) => {
2330
+ recordSystemPrompt("agent_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
2331
+ });
2332
+
2333
+ // Mid-run re-renders: turn_start fires before every turn (first turn included)
2334
+ // after the turn's prompt is final: prepareNextTurnWithContext has
2335
+ // re-rendered the options (pi's section-based prompt) and any mid-run
2336
+ // setActiveToolsByName rebuild has already landed. Re-keying at each boundary
2337
+ // the prompt can change at keeps exact-match alive mid-run. The stashed options can
2338
+ // lag a mid-run tool-loadout change, which skews the hasRead skills filter until the
2339
+ // next before_agent_start — accepted: a stale skills list beats failing the turn.
2340
+ pi.on("turn_start", (_event, ctx) => {
2341
+ recordSystemPrompt("turn_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
2342
+ });
2343
+ pi.on("session_shutdown", (_event, ctx) => {
2344
+ forgetSessionCwd(ctx.sessionManager.getSessionId());
2345
+ reportLeaks("session_shutdown");
2346
+ clearSession(ctx.sessionManager.getSessionId());
2347
+ });
2348
+
2349
+ // Pi and summary extensions own compaction and branch summaries; native calls use the
2350
+ // isolated provider path. Observe the completed rewrite below, not its owner.
2351
+
2352
+ // pi /compact and session-tree navigation (rewind / fork-at-point /
2353
+ // branch switch) both mutate pi's messages array out from under the
2354
+ // bridge. syncSharedSession's REUSE check would otherwise see
2355
+ // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
2356
+ // session that no longer matches pi's history. /compact in particular
2357
+ // triggers CC's autocompact-thrashing guard (issue #8). Force the next
2358
+ // call down the REBUILD path so CC sees the current history — and, when a
2359
+ // query is parked at a tool boundary while this fires, discard that query
2360
+ // instead of resuming it (markRebuild, discardRewrittenQuery).
2361
+ //
2362
+ // Attributed to the compacting session (ctx.sessionManager belongs to the
2363
+ // session whose runner fired this), so a subagent compacting while its
2364
+ // parent sits parked on the Agent tool result discards nothing — the parent's
2365
+ // query is live and its conversation untouched. Registered by every instance
2366
+ // of this module; instances sponsoring stale marks forward them to the
2367
+ // serving instance via sponsorMarkRebuildForSession.
2368
+ pi.on("session_compact", (event, ctx) =>
2369
+ sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), `session_compact:${event.reason}:willRetry=${event.willRetry}`));
2370
+ pi.on("session_tree", (_event, ctx) => sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), "session_tree"));
2371
+
2372
+ // Hob uses the same module instance for catalog registration and all sessions.
2373
+
2374
+ const providerConfig = {
2375
+ baseUrl: "claude-bridge",
2376
+ apiKey: "not-used",
2377
+ api: "claude-bridge",
2378
+ models: registeredModels,
2379
+ // Cast: the Provider interface passes a TranscriptContext; the bridge takes plain
2380
+ // Context models (toBridgeContext normalizes at the stream entry points).
2381
+ streamSimple: streamClaudeAgentSdk as any,
2382
+ };
2383
+ pi.registerProvider(PROVIDER_ID, providerConfig);
2384
+
2385
+ // --- AskClaude tool ---
2386
+
2387
+ const askConf = config.askClaude;
2388
+ const askDefaults = resolveAskClaudeDefaults(askConf);
2389
+ askClaudeToolName = askConf?.name ?? "AskClaude";
2390
+
2391
+ if (askConf?.enabled) {
2392
+ const askClaudeParams = buildAskClaudeParams(askDefaults);
2393
+ pi.registerTool<typeof askClaudeParams>({
2394
+ name: askConf?.name ?? "AskClaude",
2395
+ label: askConf?.label ?? "Ask Claude Code",
2396
+ description: askClaudeToolDescription(askDefaults, askConf?.description),
2397
+ parameters: askClaudeParams,
2398
+ renderCall(args, theme) {
2399
+ let text = theme.fg("mdLink", theme.bold("AskClaude "));
2400
+ const tags = askClaudeCallTags(args, askDefaults);
2401
+ if (tags.length) text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
2402
+ const truncated = args.prompt.length > PREVIEW_MAX_CHARS ? args.prompt.substring(0, PREVIEW_MAX_CHARS) : args.prompt;
2403
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
2404
+ text += theme.fg("muted", `"${lines.join("\n")}"`);
2405
+ if (args.prompt.length > PREVIEW_MAX_CHARS || args.prompt.split("\n").length > PREVIEW_MAX_LINES) text += theme.fg("dim", " …");
2406
+ return new Text(text, 0, 0);
2407
+ },
2408
+ renderResult(result, { expanded, isPartial }, theme) {
2409
+ if (isPartial) {
2410
+ const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
2411
+ return new Text(theme.fg("mdLink", "◉ Claude Code ") + theme.fg("muted", status), 0, 0);
2412
+ }
2413
+
2414
+ const details = result.details as { prompt?: string; executionTime?: number; actions?: string; error?: boolean } | undefined;
2415
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "";
2416
+
2417
+ let text = details?.error
2418
+ ? theme.fg("error", "✗ Claude Code error")
2419
+ : theme.fg("mdLink", "✓ Claude Code");
2420
+
2421
+ if (details?.executionTime) text += ` ${theme.fg("dim", `${(details.executionTime / 1000).toFixed(1)}s`)}`;
2422
+ if (details?.actions) text += ` ${theme.fg("muted", details.actions)}`;
2423
+
2424
+ if (expanded) {
2425
+ if (details?.prompt) text += `\n${theme.fg("dim", `Prompt: ${details.prompt}`)}`;
2426
+ if (details?.prompt && body) text += `\n${theme.fg("dim", "─".repeat(40))}`;
2427
+ if (body) text += `\n${theme.fg("toolOutput", body)}`;
2428
+ } else {
2429
+ const truncated = body.length > PREVIEW_MAX_CHARS ? body.substring(0, PREVIEW_MAX_CHARS) : body;
2430
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
2431
+ if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
2432
+ if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
2433
+
2434
+ }
2435
+
2436
+ return new Text(text, 0, 0);
2437
+ },
2438
+ async execute(_id, params, signal, onUpdate, ctx) {
2439
+ if (!askClaudeDeprecationNoticeShown && piMode === "tui") {
2440
+ ctx.ui.notify("AskClaude is deprecated and may be removed in a future release. The Claude Code provider remains supported.", "warning");
2441
+ markAskClaudeDeprecationNoticeShown();
2442
+ askClaudeDeprecationNoticeShown = true;
2443
+ }
2444
+
2445
+ // Guard: circular delegation
2446
+ if (ctx.model?.baseUrl === "claude-bridge") {
2447
+ debug("askClaude: blocked circular delegation (active provider is claude-bridge)");
2448
+ return {
2449
+ content: [{ type: "text" as const, text: "Error: AskClaude cannot be used when the active provider is claude-bridge — you're already running through Claude Code." }],
2450
+ details: { error: true },
2451
+ };
2452
+ }
2453
+
2454
+ const mode = resolveAskClaudeMode(params.mode, askDefaults);
2455
+ const isolated = params.isolated ?? askDefaults.isolated;
2456
+ const toolCalls = new Map<string, ToolCallState>();
2457
+ const start = Date.now();
2458
+
2459
+ const progressInterval = setInterval(() => {
2460
+ const elapsed = ((Date.now() - start) / 1000).toFixed(0);
2461
+ const summary = buildActionSummary(toolCalls);
2462
+ const status = summary ? `${elapsed}s — ${summary}` : `${elapsed}s — working...`;
2463
+ onUpdate?.({
2464
+ content: [{ type: "text", text: status }],
2465
+ details: { prompt: params.prompt, executionTime: Date.now() - start },
2466
+ });
2467
+ }, 1000);
2468
+
2469
+ try {
2470
+ const result = await promptAndWait(params.prompt, mode, toolCalls, signal, {
2471
+ systemPrompt: ctx.getSystemPrompt(),
2472
+ appendSkills: askConf?.appendSkills,
2473
+ model: params.model,
2474
+ thinking: params.thinking,
2475
+ isolated,
2476
+ context: isolated ? undefined : buildSessionContext(ctx.sessionManager.getBranch()).messages as Context["messages"],
2477
+ piSessionId: ctx.sessionManager.getSessionId(),
2478
+ });
2479
+ clearInterval(progressInterval);
2480
+ onUpdate?.({ content: [{ type: "text", text: "" }], details: {} });
2481
+ const executionTime = Date.now() - start;
2482
+ const actions = buildActionSummary(toolCalls);
2483
+
2484
+ const text = actions
2485
+ ? `${result.responseText}\n\n[Claude Code actions: ${actions}]`
2486
+ : result.responseText;
2487
+ return {
2488
+ content: [{ type: "text" as const, text }],
2489
+ details: { prompt: params.prompt, executionTime, actions },
2490
+ };
2491
+ } catch (err) {
2492
+ clearInterval(progressInterval);
2493
+ debug(`askClaude error: mode=${mode}, model=${params.model ?? "default"}, isolated=${isolated}, elapsed=${((Date.now() - start) / 1000).toFixed(1)}s, error=`, err);
2494
+ const msg = errorMessage(err);
2495
+ return {
2496
+ content: [{ type: "text" as const, text: `Error: ${msg}` }],
2497
+ details: { prompt: params.prompt, executionTime: Date.now() - start, error: true },
2498
+ };
2499
+ }
2500
+ },
2501
+ });
2502
+ }
2503
+ }