@vanillagreen/pi-claude-bridge 1.8.0 → 1.9.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.
package/src/index.ts CHANGED
@@ -1,27 +1,60 @@
1
- import { calculateCost, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
1
+ import { type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
- import { createSdkMcpServer, query, type EffortLevel, type HookCallback, type SDKMessage, type SDKUserMessage, type SettingSource, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
4
+ import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
6
- import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
7
- import { spawn as spawnProcess } from "child_process";
8
- import { createHash } from "crypto";
9
- import { accessSync, appendFileSync, chmodSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
10
- import { resolve as pathResolve } from "path";
11
- import { delimiter, dirname, join } from "path";
12
- import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
13
- import { FABLE_FALLBACK_MODEL_ID, FABLE_MODEL_ID, buildModels, fallbackModelForPrimaryModel } from "./models.js";
6
+ import { PROVIDER_ID, messageContentToText } from "./convert.js";
7
+ import { buildModels, fallbackModelForPrimaryModel, modelDisplayName } from "./models.js";
14
8
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
15
- import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
16
9
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
17
- import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
18
- import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
19
- import { isolatedFromEnv, loadConfig, normalizeConnectorWriteMode, normalizeEffortLevel, piUserDir, recordProjectTrust, type Config, type ConnectorWriteMode } from "./config.js";
10
+ import { QueryContext, ctx, drainPendingToolCalls, stackDepth, pushContext, popContext, toolCallDrainCause } from "./query-state.js";
11
+ import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
20
12
  import { decideRegistration, hasClaudeCredentials } from "./auth-presence.js";
21
13
  import { extractAgentsAppend } from "./agents-md.js";
22
14
  import { buildPromptContextAppend } from "./prompt-context.js";
23
15
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
16
+ import { readFileSync as nodeReadFileSync } from "node:fs";
24
17
  import { resolveGetModels } from "./pi-ai-compat.js";
18
+ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
19
+ // Re-exported from the extension entry point ON PURPOSE. Consuming apps
20
+ // regenerate their vendored package.json with a CLOSED exports map
21
+ // ({".": "./bundle/index.js"}), which makes Node reject BOTH a subpath import
22
+ // and a deep path into the package (ERR_PACKAGE_PATH_NOT_EXPORTED) — verified.
23
+ // So the ./connector-inventory entry point alone does not reach them. Naming
24
+ // these here puts them in bundle/index.js's own export list, which is the one
25
+ // path their existing manifest already allows, and incidentally keeps esbuild
26
+ // from tree-shaking helpers index.ts never calls itself.
27
+ export {
28
+ connectorServerNamespace,
29
+ connectorsListUrl,
30
+ credentialCandidatePaths,
31
+ listAccountConnectors,
32
+ resolveClaudeOAuth,
33
+ type ClaudeOAuthCredentials,
34
+ type ConnectorEntry,
35
+ type ConnectorInventory,
36
+ } from "./connector-inventory.js";
37
+ import { debug, diagDump, makeCliDebugOptions, moduleInstanceId } from "./debug.js";
38
+ import { preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
39
+ import { argKeys, extensionApi, piUI, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, sharedSession } from "./bridge-state.js";
40
+ import { connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor } from "./connectors.js";
41
+ import { restoreSharedSessionFromPi, schedulePersistSharedSession, syncSharedSession } from "./session-persistence.js";
42
+ import { STREAM_IDLE_BACKOFF_HINT_MS, activeStreamIdleWatchdogs, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, formatDurationShort, streamIdleTimeoutMsFromEnv } from "./stream-idle-watchdog.js";
43
+ import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, uniqueNonEmptyLines } from "./rate-limit.js";
44
+ import { mapToolArgs } from "./tool-mapping.js";
45
+ import { ensureTurnStarted, finalizeCurrentStream, parsePartialJson, processAssistantMessage, processStreamEvent, updateTurnOutputModel } from "./assistant-stream.js";
46
+
47
+ // Re-exports: the module decomposition must not change the bundle entry's
48
+ // public surface — unit tests and downstream consumers import these from
49
+ // bundle/index.js.
50
+ export { classifyClaudeExecutableBytes, preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics, wrapClaudeSpawnErrorForSdk, type ClaudeExecutableFileType, type ClaudeExecutablePreflightResult } from "./claude-executable.js";
51
+ export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, reportToolResultMismatch } from "./bridge-state.js";
52
+ export { CLAUDE_AI_CONNECTOR_TOOL_PATTERNS, CLAUDE_BRIDGE_TOOL_ISOLATION, CONNECTOR_DISCOVERY_TOOLS, CONNECTOR_WRITE_TOOLS, DISALLOWED_BUILTIN_TOOLS, connectorQueryOptions, connectorWriteDenyHook, connectorWriteModeFor, connectorWriteModeFromEnv, connectorsEnabledFor, connectorsEnabledFromEnv, isConnectorWriteTool, toolIsolationForQuery } from "./connectors.js";
53
+ export { restoreSharedSessionFromPi, shouldRestorePersistedBridgeEntry } from "./session-persistence.js";
54
+ export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
55
+ export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, normalizeRateLimitUtilization, uniqueNonEmptyLines } from "./rate-limit.js";
56
+ export { mapToolName } from "./tool-mapping.js";
57
+ export { processAssistantMessage, processStreamEvent } from "./assistant-stream.js";
25
58
 
26
59
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
27
60
  const _piAi = piAi as any;
@@ -31,433 +64,6 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
31
64
  ? _piAi.createAssistantMessageEventStream
32
65
  : () => new _piAi.AssistantMessageEventStream();
33
66
 
34
- // --- Debug logging ---
35
- // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to <piUserDir>/claude-bridge.log
36
- // (~/.pi/agent/claude-bridge.log unless PI_CODING_AGENT_DIR points elsewhere).
37
-
38
- const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
39
- const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(piUserDir(), "claude-bridge.log");
40
-
41
- function diagLogPath(): string {
42
- return process.env.CLAUDE_BRIDGE_DIAG_PATH || join(piUserDir(), "claude-bridge-diag.log");
43
- }
44
-
45
- // Ensure log directories exist when debug is enabled
46
- if (DEBUG) {
47
- try {
48
- mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
49
- mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
50
- } catch {
51
- // If directory creation fails, debug functions will throw on first use
52
- }
53
- }
54
-
55
- // Unique per module evaluation — confirms whether subagents share module state
56
- const moduleInstanceId = Math.random().toString(36).slice(2, 8);
57
-
58
- function debug(...args: unknown[]) {
59
- if (!DEBUG) return;
60
- const ts = new Date().toISOString();
61
- const fmt = (a: unknown): string => {
62
- if (typeof a === "string") return a;
63
- if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
64
- return JSON.stringify(a);
65
- };
66
- const msg = args.map(fmt).join(" ");
67
- try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
68
- }
69
-
70
- function executableFromPath(name: string): string | undefined {
71
- const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
72
- for (const dir of paths) {
73
- const candidate = join(dir, name);
74
- try {
75
- accessSync(candidate, fsConstants.X_OK);
76
- return candidate;
77
- } catch {
78
- // keep searching
79
- }
80
- }
81
- return undefined;
82
- }
83
-
84
- export function resolveClaudeExecutable(configured?: string): string | undefined {
85
- const trimmed = configured?.trim();
86
- if (trimmed) return trimmed;
87
- // Isolated mode: never run whatever `claude` happens to be on $PATH — the
88
- // host app either pins an executable in config or gets the SDK's bundled
89
- // default, which ships inside the host bundle.
90
- if (isolatedFromEnv()) return undefined;
91
- return executableFromPath("claude") ?? executableFromPath("claude-code");
92
- }
93
-
94
- export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
95
-
96
- export interface ClaudeExecutablePreflightResult {
97
- path: string;
98
- realPath: string;
99
- cwd: string;
100
- realCwd: string;
101
- fileType: ClaudeExecutableFileType;
102
- }
103
-
104
- function errnoValue(err: unknown): string | number | undefined {
105
- return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
106
- }
107
-
108
- function syscallValue(err: unknown): string | undefined {
109
- return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
110
- }
111
-
112
- function pathValue(err: unknown): string | undefined {
113
- const value = (err as NodeJS.ErrnoException)?.path;
114
- return typeof value === "string" ? value : undefined;
115
- }
116
-
117
- function codeValue(err: unknown, fallback: string): string {
118
- const value = (err as NodeJS.ErrnoException)?.code;
119
- return typeof value === "string" ? value : fallback;
120
- }
121
-
122
- function displayValue(value: unknown): string {
123
- return value === undefined || value === null || value === "" ? "<none>" : String(value);
124
- }
125
-
126
- function makeClaudePreflightError(
127
- summary: string,
128
- details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
129
- ): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
130
- const detail = [
131
- `code=${details.code}`,
132
- `errno=${displayValue(details.errno)}`,
133
- `syscall=${displayValue(details.syscall)}`,
134
- `path=${details.path}`,
135
- `cwd=${details.cwd}`,
136
- ...(details.fileType ? [`fileType=${details.fileType}`] : []),
137
- ...(details.realPath ? [`realPath=${details.realPath}`] : []),
138
- ].join(" ");
139
- const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
140
- error.name = "ClaudeExecutablePreflightError";
141
- error.code = details.code;
142
- if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
143
- if (details.syscall) error.syscall = details.syscall;
144
- error.path = details.path;
145
- error.cwd = details.cwd;
146
- if (details.fileType) error.fileType = details.fileType;
147
- if (details.realPath) error.realPath = details.realPath;
148
- if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
149
- return error;
150
- }
151
-
152
- export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
153
- if (bytes.length === 0) return "empty";
154
- if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
155
- if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
156
- if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
157
- if (bytes.length >= 4) {
158
- const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
159
- if (
160
- magic === 0xfeedface ||
161
- magic === 0xfeedfacf ||
162
- magic === 0xcefaedfe ||
163
- magic === 0xcffaedfe ||
164
- magic === 0xcafebabe ||
165
- magic === 0xbebafeca
166
- ) return "mach-o";
167
- }
168
- return "unknown";
169
- }
170
-
171
- export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
172
- let realCwd = cwd;
173
- try {
174
- const cwdStat = statSync(cwd);
175
- if (!cwdStat.isDirectory()) {
176
- throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
177
- code: "ENOTDIR",
178
- syscall: "chdir",
179
- path: cwd,
180
- cwd,
181
- });
182
- }
183
- accessSync(cwd, fsConstants.X_OK);
184
- realCwd = realpathSync(cwd);
185
- } catch (err) {
186
- if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
187
- throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
188
- code: codeValue(err, "EACCES"),
189
- errno: errnoValue(err),
190
- syscall: syscallValue(err),
191
- path: pathValue(err) ?? cwd,
192
- cwd,
193
- cause: err,
194
- });
195
- }
196
-
197
- let realPath = path;
198
- try {
199
- const stat = statSync(path);
200
- if (!stat.isFile()) {
201
- throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
202
- code: "EACCES",
203
- syscall: "exec",
204
- path,
205
- cwd,
206
- });
207
- }
208
- accessSync(path, fsConstants.X_OK);
209
- realPath = realpathSync(path);
210
- } catch (err) {
211
- if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
212
- throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
213
- code: codeValue(err, "ENOENT"),
214
- errno: errnoValue(err),
215
- syscall: syscallValue(err),
216
- path: pathValue(err) ?? path,
217
- cwd,
218
- cause: err,
219
- });
220
- }
221
-
222
- let fileType: ClaudeExecutableFileType;
223
- try {
224
- fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
225
- } catch (err) {
226
- throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
227
- code: codeValue(err, "EACCES"),
228
- errno: errnoValue(err),
229
- syscall: syscallValue(err),
230
- path: pathValue(err) ?? realPath,
231
- cwd,
232
- realPath,
233
- cause: err,
234
- });
235
- }
236
-
237
- if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
238
- throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
239
- code: "ENOEXEC",
240
- syscall: "exec",
241
- path,
242
- cwd,
243
- fileType,
244
- realPath,
245
- });
246
- }
247
-
248
- return { path, realPath, cwd, realCwd, fileType };
249
- }
250
-
251
- function envFlagEnabled(value: string | undefined): boolean {
252
- return value === "1" || value?.toLowerCase() === "true";
253
- }
254
-
255
- export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
256
- const originalCode = codeValue(err, "SPAWN_ERROR");
257
- const originalMessage = err.message;
258
- const spawnPath = pathValue(err) ?? options.command;
259
- const cwd = options.cwd ?? process.cwd();
260
- const detail = [
261
- `code=${originalCode}`,
262
- `errno=${displayValue(errnoValue(err))}`,
263
- `syscall=${displayValue(syscallValue(err))}`,
264
- `path=${spawnPath}`,
265
- `cwd=${cwd}`,
266
- `command=${options.command}`,
267
- ].join(" ");
268
- const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
269
- wrapped.name = "ClaudeSpawnDiagnosticError";
270
- // The SDK special-cases code === ENOENT and replaces the message with its
271
- // generic "native binary not found" text. Preserve the original code in the
272
- // message/originalCode while using a bridge code so the SDK surfaces context.
273
- wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
274
- wrapped.originalCode = originalCode;
275
- wrapped.originalMessage = originalMessage;
276
- const errno = errnoValue(err);
277
- if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
278
- const syscall = syscallValue(err);
279
- if (syscall) wrapped.syscall = syscall;
280
- wrapped.path = spawnPath;
281
- wrapped.cwd = cwd;
282
- // Do not set `cause` here: the listener copies these structured fields back
283
- // onto the original Error. A cause reference to that same object would become
284
- // `err.cause === err`, making JSON.stringify throw on a circular structure.
285
- // originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
286
- return wrapped;
287
- }
288
-
289
- export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
290
- const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
291
- const child = spawnProcess(options.command, options.args, {
292
- cwd: options.cwd,
293
- env: options.env,
294
- signal: options.signal,
295
- stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
296
- windowsHide: true,
297
- });
298
- if (pipeStderr) {
299
- child.stderr?.on("data", (data) => {
300
- for (const line of data.toString().split(/\r?\n/)) {
301
- if (line) debug(`[cli-stderr spawn] ${line}`);
302
- }
303
- });
304
- }
305
- child.prependListener("error", (err) => {
306
- const originalStack = err.stack;
307
- const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
308
- Object.assign(err, wrapped);
309
- err.name = wrapped.name;
310
- err.message = wrapped.message;
311
- // Keep V8's stack from the actual Node spawn failure, not the wrapper
312
- // construction site. Diagnostic fields above remain enumerable and
313
- // JSON-serializable; stack stays the spawn-time breadcrumb for operators.
314
- if (originalStack) err.stack = originalStack;
315
- });
316
- return {
317
- stdin: child.stdin,
318
- stdout: child.stdout,
319
- get killed() { return child.killed; },
320
- get exitCode() { return child.exitCode; },
321
- kill: child.kill.bind(child),
322
- on: child.on.bind(child),
323
- once: child.once.bind(child),
324
- off: child.off.bind(child),
325
- };
326
- }
327
-
328
- // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
329
- // CLI subprocess to write its own debug log to a file we choose, and also
330
- // forward its stderr into our debug stream. Drops straight into the real SDK's
331
- // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
332
- // stderr). Without this, CC's internal view of the world is invisible to us
333
- // and "No conversation found" / empty-error reports are unactionable.
334
- let nextCliDebugSeq = 1;
335
- function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
336
- if (!DEBUG) return {};
337
- const seq = nextCliDebugSeq++;
338
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
339
- const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
340
- try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
341
- const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
342
- debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
343
- return {
344
- debug: true,
345
- debugFile,
346
- stderr: (data: string) => {
347
- for (const line of data.split(/\r?\n/)) {
348
- if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
349
- }
350
- },
351
- };
352
- }
353
-
354
- /** Unconditional diagnostic dump — for "should never happen" paths */
355
- function diagDump(label: string, data: Record<string, unknown>) {
356
- try {
357
- const ts = new Date().toISOString();
358
- const entry = { ts, moduleInstanceId, label, ...data };
359
- const path = diagLogPath();
360
- try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
361
- appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
362
- try { chmodSync(path, 0o600); } catch { /* best effort */ }
363
- debug(`DIAG: ${label} (see ${path})`);
364
- } catch (error) {
365
- debug(`DIAG FAILED: ${label}`, error);
366
- }
367
- }
368
-
369
- function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
370
- try { piUI?.notify(message, level); }
371
- catch (error) { debug("notify failed:", error); }
372
- }
373
-
374
- function argKeys(args: Record<string, unknown> | undefined): string[] {
375
- return Object.keys(args ?? {}).sort();
376
- }
377
-
378
- function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
379
- return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
380
- }
381
-
382
- function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
383
- const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
384
- if (names.length > limit) shown.push(`+${names.length - limit} more`);
385
- return shown;
386
- }
387
-
388
- function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
389
- try {
390
- if (missing.length === 0) return;
391
- const toolNames = summarizeMissingToolNames(missing);
392
- const toolNameSummary = compactToolNameSummary(toolNames);
393
- const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
394
- diagDump("repair_tool_pairing_synthetic_results", {
395
- count: missing.length,
396
- toolNames,
397
- sampledToolCallIds,
398
- missing: missing.slice(0, 50),
399
- ...context,
400
- });
401
- safeNotify(
402
- `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
403
- `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
404
- `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
405
- "error",
406
- );
407
- } catch (error) {
408
- debug("reportSyntheticToolResultRepair failed:", error);
409
- }
410
- }
411
-
412
- export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
413
- try {
414
- if (queryCtx.reportedToolResultMismatch) return false;
415
- const progress = queryCtx.toolResultProgress();
416
- const hasMismatch = progress.expectedCount > 0
417
- ? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
418
- : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
419
- if (!hasMismatch) return false;
420
- queryCtx.reportedToolResultMismatch = true;
421
- if (sharedSession) {
422
- sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
423
- }
424
- const toolNameSummary = compactToolNameSummary(progress.toolNames);
425
- diagDump("tool_result_delivery_mismatch", {
426
- reason,
427
- cwd,
428
- progress,
429
- activeQueryExists: queryCtx.activeQuery !== null,
430
- sharedSession: sharedSession ? {
431
- sessionId: sharedSession.sessionId.slice(0, 8),
432
- cursor: sharedSession.cursor,
433
- needsRebuild: sharedSession.needsRebuild === true,
434
- forceRotate: sharedSession.forceRotate === true,
435
- } : null,
436
- });
437
- safeNotify(
438
- `Claude bridge: tool result delivery interrupted during ${reason}; ` +
439
- `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
440
- `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
441
- `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
442
- `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
443
- "error",
444
- );
445
- return true;
446
- } catch (error) {
447
- debug("reportToolResultMismatch failed:", error);
448
- return false;
449
- }
450
- }
451
-
452
- export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
453
- if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
454
- if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
455
- }
456
-
457
- export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
458
- return { sharedSession };
459
- }
460
-
461
67
  // --- Constants ---
462
68
 
463
69
  // Two process-global tokens govern provider registration across module reloads.
@@ -488,461 +94,11 @@ const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
488
94
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
489
95
  const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
490
96
 
491
- const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
492
- read: "read", write: "write", edit: "edit", bash: "bash",
493
- };
494
-
495
97
  // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
496
98
  const MODELS = buildModels(getModels("anthropic"));
497
99
 
498
- // Disable Claude Code built-ins in the provider path. Pi owns tool execution;
499
- // Claude reaches Pi tools through the bridged MCP server instead.
500
- //
501
- // `allowedTools` is a permission auto-allow list in the Claude Agent SDK, not a
502
- // visibility allowlist. Use `tools: []` to remove the built-in tool set, and keep
503
- // this disallow list as a belt-and-suspenders guard for SDK/CLI built-ins that may
504
- // otherwise leak into the model context (e.g. TodoWrite, CronList, SendMessage).
505
- export const DISALLOWED_BUILTIN_TOOLS = [
506
- "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash", "Agent", "Task",
507
- "NotebookEdit", "EnterWorktree", "ExitWorktree",
508
- "CronList", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
509
- "TaskOutput", "TaskStop", "SendMessage", "Skill",
510
- "TodoRead", "TodoWrite",
511
- "ListMcpResources", "ReadMcpResource",
512
- "WebFetch", "WebSearch",
513
- "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
514
- "ToolSearch", "ScheduleWakeup",
515
- ];
516
-
517
- export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
518
- tools: [] as string[],
519
- disallowedTools: DISALLOWED_BUILTIN_TOOLS,
520
- allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
521
- } satisfies Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">;
522
-
523
- // --- Claude account cloud MCP connectors (Gmail / Calendar / Drive) ---
524
- //
525
- // By default the bridge suppresses claude.ai cloud MCP servers (see the
526
- // ENABLE_CLAUDEAI_MCP_SERVERS="0" note near the query builder) so Pi owns tool
527
- // execution and tokens stay lean. This opt-in flag lets the authenticated
528
- // Claude account's authorized Google connectors flow through to the model,
529
- // exposing Gmail/Calendar/Drive tools the account has connected. Gated so the
530
- // default behavior is unchanged. See
531
- // docs/plans/claude-bridge-google-connectors.md.
532
- export function connectorsEnabledFromEnv(): boolean {
533
- const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
534
- return v === "1" || v === "true" || v === "yes" || v === "on";
535
- }
536
-
537
- // Connectors are enabled if EITHER the env var is truthy OR the resolved bridge
538
- // config sets `provider.enableConnectors`. Env is the simplest per-process knob
539
- // (one sidecar per Claude account sets it in its child env); config lets a host
540
- // app enable it declaratively via its written settings.json.
541
- export function connectorsEnabledFor(config?: Config): boolean {
542
- return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
543
- }
544
-
545
- // Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
546
- // Names match Claude Code's claude.ai connector servers.
547
- export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
548
- "mcp__claude_ai_Gmail__*",
549
- "mcp__claude_ai_Google_Calendar__*",
550
- "mcp__claude_ai_Google_Drive__*",
551
- ];
552
-
553
- // Claude Code registers a Claude account's cloud connectors as DEFERRED tools
554
- // that the model must load via ToolSearch (and enumerate via the MCP-resource
555
- // tools). The default bridge isolation disallows all three so Pi owns tool
556
- // discovery — but that hides the connectors from the model entirely. When
557
- // connectors are enabled we must let these through so Gmail/Calendar/Drive are
558
- // discoverable. Verified: disallowing ToolSearch reliably yields NO_CONNECTORS.
559
- export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
560
-
561
- // --- Connector WRITE tool control (read-inline / write-by-approval) ---
562
- //
563
- // Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
564
- // ConsentGate never sees them. To keep every connector WRITE explicit + gated,
565
- // connector chat sessions run read-only (writes denied); the model performs a
566
- // write only through a gated Pi custom-tool whose app-side dispatcher runs a
567
- // ONE-SHOT write-enabled bridge query. This block is the bridge lever for that:
568
- // deny connector write tools by default, allow them only for that executor.
569
- //
570
- // Cloud connector server namespaces (the `mcp__<server>__` prefix).
571
- const CONNECTOR_NS_GMAIL = "mcp__claude_ai_Gmail__";
572
- const CONNECTOR_NS_CALENDAR = "mcp__claude_ai_Google_Calendar__";
573
- const CONNECTOR_NS_DRIVE = "mcp__claude_ai_Google_Drive__";
574
- const CONNECTOR_NAMESPACES = [CONNECTOR_NS_GMAIL, CONNECTOR_NS_CALENDAR, CONNECTOR_NS_DRIVE];
575
-
576
- // Read-verb prefixes: a connector tool whose name (the segment after its
577
- // namespace) starts with one of these is a non-mutating READ and always stays
578
- // available. Everything else on a connector namespace is treated as a WRITE.
579
- // Observed reads (POC): list_labels, search_threads, get_message, list_calendars,
580
- // list_events, get_event — i.e. list_/search_/get_; the rest are common Google
581
- // read verbs. Keep this list tight: mis-classifying a read as a write only
582
- // blocks a read (safe, easily fixed), whereas mis-classifying a write as a read
583
- // would open an ungated mutation.
584
- const CONNECTOR_READ_PREFIXES = [
585
- "list_", "search_", "get_", "read_", "fetch_", "find_",
586
- "download_", "describe_", "query_", "count_", "view_",
587
- ];
588
-
589
- // Explicit known write tool names (current claude.ai connectors). Passed to the
590
- // SDK disallowedTools so today's writes are removed from the model's context by
591
- // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
592
- export const CONNECTOR_WRITE_TOOLS = [
593
- `${CONNECTOR_NS_GMAIL}create_draft`,
594
- `${CONNECTOR_NS_GMAIL}create_label`,
595
- `${CONNECTOR_NS_GMAIL}label_message`,
596
- `${CONNECTOR_NS_GMAIL}label_thread`,
597
- `${CONNECTOR_NS_GMAIL}unlabel_message`,
598
- `${CONNECTOR_NS_GMAIL}unlabel_thread`,
599
- `${CONNECTOR_NS_GMAIL}apply_sensitive_label`,
600
- `${CONNECTOR_NS_GMAIL}remove_sensitive_label`,
601
- `${CONNECTOR_NS_CALENDAR}create_event`,
602
- `${CONNECTOR_NS_CALENDAR}update_event`,
603
- `${CONNECTOR_NS_CALENDAR}delete_event`,
604
- `${CONNECTOR_NS_CALENDAR}respond_to_event`,
605
- `${CONNECTOR_NS_DRIVE}create_file`,
606
- `${CONNECTOR_NS_DRIVE}copy_file`,
607
- ];
608
-
609
- // Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED: a tool
610
- // on a connector namespace is a write UNLESS its verb is a known read prefix, so
611
- // not-yet-known future write tools (e.g. Gmail send_message, Drive delete_file,
612
- // Calendar add_attendee) are classified as writes and blocked in a read-only
613
- // session. Non-connector tools (Pi custom-tools, ToolSearch, MCP-resource tools)
614
- // are never connector writes → false. Used by connectorWriteDenyHook and by
615
- // callers (e.g. the one-shot write executor) that enumerate live connector tools.
616
- export function isConnectorWriteTool(name: string): boolean {
617
- const ns = CONNECTOR_NAMESPACES.find((n) => name.startsWith(n));
618
- if (!ns) return false;
619
- const tool = name.slice(ns.length);
620
- return !CONNECTOR_READ_PREFIXES.some((prefix) => tool.startsWith(prefix));
621
- }
622
-
623
- // Connector write mode from the env override. `allow` exposes connector write
624
- // tools; `deny` hides them. Returns undefined when unset so config can decide.
625
- export function connectorWriteModeFromEnv(): ConnectorWriteMode | undefined {
626
- const v = (process.env.CLAUDE_BRIDGE_CONNECTOR_WRITE ?? "").trim().toLowerCase();
627
- if (v === "allow") return "allow";
628
- if (v === "deny") return "deny";
629
- return undefined;
630
- }
631
-
632
- // Resolve the connector write mode: env wins over config, default `deny`
633
- // (mirrors connectorsEnabledFor's env-first precedence). Only meaningful when
634
- // connectors are enabled; connector chat sessions keep the default deny and the
635
- // one-shot approved-write executor sets allow (env or config).
636
- //
637
- // FAIL CLOSED: writes are enabled ONLY by an explicit, validated `allow`. The
638
- // config value is re-normalized here (defense in depth over normalizeProviderConfig)
639
- // so a raw legacy-config value like "Deny"/"read-only"/true can never be treated
640
- // as a truthy non-deny and silently open writes — anything but exact allow → deny.
641
- export function connectorWriteModeFor(config?: Config): ConnectorWriteMode {
642
- const resolved = connectorWriteModeFromEnv() ?? normalizeConnectorWriteMode(config?.provider?.connectorWriteMode);
643
- return resolved === "allow" ? "allow" : "deny";
644
- }
645
-
646
- // PreToolUse hook that hard-blocks connector WRITE tools at call time. Hooks run
647
- // regardless of permissionMode (we use bypassPermissions), so this — not the
648
- // static deny lists — is the real prefix-based runtime enforcement of
649
- // isConnectorWriteTool. disallowedTools removes today's KNOWN writes from model
650
- // context, but the CLI matcher can't glob the tool segment, so a future write
651
- // tool (e.g. mcp__claude_ai_Gmail__send_message, ..._Drive__delete_file) would
652
- // otherwise be callable in a read-only session; this hook denies it by prefix.
653
- export function connectorWriteDenyHook(): HookCallback {
654
- return async (input) => {
655
- // The CLI treats a hook error/timeout as an EMPTY hook output and lets
656
- // the tool call proceed (fail OPEN) — so any exception in this body
657
- // must convert to a deny, never an allow. Today's body is pure string
658
- // checks on schema-validated input; the catch pins that invariant for
659
- // whatever gets added here later.
660
- try {
661
- if (input.hook_event_name !== "PreToolUse") return { continue: true };
662
- if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
663
- return connectorWriteDenyOutput(String(input.tool_name));
664
- } catch {
665
- const toolName = typeof (input as { tool_name?: unknown })?.tool_name === "string"
666
- ? (input as { tool_name: string }).tool_name
667
- : "<unknown>";
668
- return connectorWriteDenyOutput(toolName);
669
- }
670
- };
671
- }
672
-
673
- function connectorWriteDenyOutput(toolName: string) {
674
- return {
675
- hookSpecificOutput: {
676
- hookEventName: "PreToolUse" as const,
677
- permissionDecision: "deny" as const,
678
- permissionDecisionReason:
679
- `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
680
- `Connector writes must go through Memsira's gated approval flow.`,
681
- },
682
- };
683
- }
684
-
685
- // Connector query-option fragment: tool isolation (allow/deny lists) plus, when
686
- // connectors are enabled and writes are denied, the runtime PreToolUse write
687
- // hook. Spread into the SDK query options; continuation queries inherit it via
688
- // `{ ...queryOptions }`. Exported so the wiring is unit-testable end to end.
689
- export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
690
- const isolation = toolIsolationForQuery(connectorsEnabled, writeMode);
691
- // Only enforce (and only meaningful) when connectors are on and writes denied.
692
- if (!connectorsEnabled || writeMode === "allow") return isolation;
693
- return { ...isolation, hooks: { PreToolUse: [{ hooks: [connectorWriteDenyHook()] }] } };
694
- }
695
-
696
- // Tool isolation for a query. When connectors are enabled we still remove
697
- // Claude Code's filesystem/shell built-ins (via disallowedTools; Pi owns those)
698
- // and auto-allow the cloud connector tool namespaces so the model can call
699
- // Gmail/Calendar/Drive.
700
- //
701
- // Critically, we must OMIT `tools: []` in the connector path: an empty --tools
702
- // allowlist strips the claude.ai cloud MCP connector tools from the model's
703
- // view (verified — Pi's SDK-injected custom-tools survive it, but connectors do
704
- // not). Dropping `tools` leaves the connectors visible; disallowedTools still
705
- // hard-denies the built-ins so Pi keeps ownership of file/shell/web tools.
706
- export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">> {
707
- if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
708
- // Keep ToolSearch + MCP-resource tools available so the model can discover the
709
- // deferred cloud connector tools; still block file/shell/web built-ins.
710
- const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOLS.includes(t));
711
- // Deny connector WRITE tools unless writes are explicitly allowed (fail
712
- // closed: any mode but exact "allow" is treated as read-only). This removes
713
- // today's KNOWN writes from the model's context by exact id; deny rules take
714
- // precedence over the CLAUDE_AI_CONNECTOR_TOOL_PATTERNS allow rules below, so
715
- // reads stay available. Runtime enforcement covering unknown/future write
716
- // tools is done by connectorWriteDenyHook — see connectorQueryOptions.
717
- if (writeMode !== "allow") disallowedTools.push(...CONNECTOR_WRITE_TOOLS);
718
- return {
719
- disallowedTools,
720
- allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
721
- };
722
- }
723
-
724
- // --- Session persistence ---
725
-
726
- interface SessionState {
727
- sessionId: string;
728
- cursor: number;
729
- cwd: string;
730
- // Force the next syncSharedSession call down the REBUILD path. Set when
731
- // pi has mutated its messages array out from under us (compact, tree
732
- // navigation) or after an abort left the JSONL in an indeterminate state.
733
- // REBUILD wipes and rewrites the file to match pi's current history.
734
- needsRebuild?: boolean;
735
- // Set ONLY after an abort. The killed CC subprocess may still be flushing
736
- // a late "[Request interrupted by user]" record to the session JSONL.
737
- // Reusing the same sessionId/path would race that orphan write into our
738
- // fresh file and break CC's parent-uuid chain on the next resume. When
739
- // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
740
- // so the orphan writes land on a dead inode. Compact/tree do NOT set
741
- // this — there's no concurrent CC writer during those events, so
742
- // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
743
- forceRotate?: boolean;
744
- }
745
-
746
- let sharedSession: SessionState | null = null;
747
- let extensionApi: ExtensionAPI | undefined;
748
- let piUI: ExtensionUIContext | undefined;
749
100
  let extraUsageHelperInFlight: Promise<string> | null = null;
750
101
 
751
- const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
752
- const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
753
- export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
754
- export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
755
- export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
756
-
757
- type TimerHandle = ReturnType<typeof setTimeout>;
758
-
759
- export interface StreamIdleWatchdogState {
760
- activeQuery: unknown | null;
761
- currentPiStream: AssistantMessageEventStream | null;
762
- turnOutput: AssistantMessage | null;
763
- turnSawStreamEvent: boolean;
764
- turnStarted: boolean;
765
- }
766
-
767
- export interface StreamIdleTimeoutInfo {
768
- idleMs: number;
769
- timeoutMs: number;
770
- }
771
-
772
- export interface StreamIdleWatchdog {
773
- dispose: () => void;
774
- noteChunk: () => void;
775
- refresh: () => void;
776
- timedOut: () => boolean;
777
- }
778
-
779
- const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
780
-
781
- function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
782
- const text = value.trim().toLowerCase();
783
- if (!text) return undefined;
784
- if (["off", "false", "disabled", "disable"].includes(text)) return 0;
785
- const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
786
- if (!match) return undefined;
787
- const amount = Number(match[1]);
788
- if (!Number.isFinite(amount) || amount < 0) return undefined;
789
- const unit = (match[2] ?? defaultUnit).toLowerCase();
790
- const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
791
- ? 1
792
- : ["s", "sec", "secs", "second", "seconds"].includes(unit)
793
- ? 1000
794
- : ["m", "min", "mins", "minute", "minutes"].includes(unit)
795
- ? 60_000
796
- : undefined;
797
- if (multiplier === undefined) return undefined;
798
- const ms = Math.round(amount * multiplier);
799
- return Number.isFinite(ms) ? ms : undefined;
800
- }
801
-
802
- export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
803
- const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
804
- if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
805
- return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
806
- }
807
-
808
- function formatDurationShort(ms: number): string {
809
- if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
810
- if (ms % 60_000 === 0) return `${ms / 60_000}m`;
811
- if (ms % 1000 === 0) return `${ms / 1000}s`;
812
- return `${ms}ms`;
813
- }
814
-
815
- export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
816
- return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
817
- }
818
-
819
- export function createStreamIdleWatchdog({
820
- clearTimer = (timer: TimerHandle) => clearTimeout(timer),
821
- getState,
822
- now = () => Date.now(),
823
- onTimeout,
824
- setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
825
- timeoutMs,
826
- }: {
827
- clearTimer?: (timer: TimerHandle) => void;
828
- getState: () => StreamIdleWatchdogState;
829
- now?: () => number;
830
- onTimeout: (info: StreamIdleTimeoutInfo) => void;
831
- setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
832
- timeoutMs: number;
833
- }): StreamIdleWatchdog {
834
- let disposed = false;
835
- let lastChunkAt = now();
836
- let timer: TimerHandle | null = null;
837
- let didTimeout = false;
838
-
839
- const clear = () => {
840
- if (!timer) return;
841
- try { clearTimer(timer); } catch { /* best effort */ }
842
- timer = null;
843
- };
844
-
845
- const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
846
- timeoutMs > 0
847
- && state.activeQuery
848
- && state.currentPiStream
849
- && state.turnOutput
850
- && !state.turnStarted
851
- && !state.turnSawStreamEvent,
852
- );
853
-
854
- const schedule = () => {
855
- clear();
856
- if (disposed || didTimeout || timeoutMs <= 0) return;
857
- const state = getState();
858
- if (!shouldMonitor(state)) return;
859
- const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
860
- const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
861
- const idleMs = Math.max(0, now() - idleStartedAt);
862
- if (idleMs >= timeoutMs) {
863
- didTimeout = true;
864
- onTimeout({ idleMs, timeoutMs });
865
- return;
866
- }
867
- timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
868
- (timer as { unref?: () => void }).unref?.();
869
- };
870
-
871
- return {
872
- dispose: () => {
873
- disposed = true;
874
- clear();
875
- },
876
- noteChunk: () => {
877
- lastChunkAt = now();
878
- schedule();
879
- },
880
- refresh: schedule,
881
- timedOut: () => didTimeout,
882
- };
883
- }
884
-
885
- export function isExtraUsageRequiredMessage(value: unknown): boolean {
886
- let text: string;
887
- if (typeof value === "string") text = value;
888
- else if (value instanceof Error) text = value.message;
889
- else {
890
- try { text = JSON.stringify(value ?? ""); }
891
- catch { text = String(value); }
892
- }
893
- return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
894
- }
895
-
896
- export function uniqueNonEmptyLines(values: unknown[]): string[] {
897
- const seen = new Set<string>();
898
- const out: string[] = [];
899
- for (const value of values) {
900
- const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
901
- if (!text || seen.has(text)) continue;
902
- seen.add(text);
903
- out.push(text);
904
- }
905
- return out;
906
- }
907
-
908
- export function formatResetTimestamp(value: unknown): string {
909
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
910
- if (!Number.isFinite(parsed)) return "unknown";
911
- return new Date(parsed).toLocaleString(undefined, {
912
- day: "numeric",
913
- hour: "numeric",
914
- minute: "2-digit",
915
- month: "short",
916
- second: "2-digit",
917
- timeZoneName: "short",
918
- year: "numeric",
919
- });
920
- }
921
-
922
- export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
923
-
924
- export function normalizeRateLimitUtilization(value: unknown): number | undefined {
925
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
926
- if (value === 0) return 0;
927
- // Claude SDK payloads have appeared as both fractions and percentages.
928
- // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
929
- if (value > 0 && value < 1) return value * 100;
930
- if (value > 1 && value <= 100) return value;
931
- return undefined;
932
- }
933
-
934
- function rateLimitTypeLabel(value: unknown): string {
935
- const text = typeof value === "string" ? value.trim() : "";
936
- return text || "unknown";
937
- }
938
-
939
- export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
940
- if (info?.status !== "allowed_warning") return undefined;
941
- const utilization = normalizeRateLimitUtilization(info.utilization);
942
- if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
943
- return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
944
- }
945
-
946
102
  function emitRateLimitEvent(payload: Record<string, unknown>): void {
947
103
  try {
948
104
  extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
@@ -1014,180 +170,6 @@ function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: st
1014
170
  return true;
1015
171
  }
1016
172
 
1017
- const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
1018
-
1019
- interface PersistedBridgeSessionState extends SessionState {
1020
- fingerprint: string;
1021
- piSessionId?: string;
1022
- updatedAt: string;
1023
- }
1024
-
1025
- function fingerprintMessages(messages: Context["messages"]): string {
1026
- const normalized = messages.map((message) => {
1027
- if (message.role === "assistant") {
1028
- return {
1029
- role: message.role,
1030
- provider: (message as AssistantMessage).provider,
1031
- model: (message as AssistantMessage).model,
1032
- content: (message as AssistantMessage).content,
1033
- };
1034
- }
1035
- return message;
1036
- });
1037
- return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
1038
- }
1039
-
1040
- function readBuiltSessionContext(sessionManager: unknown): { messages: Context["messages"] } | undefined {
1041
- const built = typeof (sessionManager as any)?.buildSessionContext === "function" ? (sessionManager as any).buildSessionContext() : undefined;
1042
- return Array.isArray(built?.messages) ? built as { messages: Context["messages"] } : undefined;
1043
- }
1044
-
1045
- function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeSessionState | undefined {
1046
- const entries = typeof (sessionManager as any)?.getEntries === "function" ? (sessionManager as any).getEntries() : [];
1047
- if (!Array.isArray(entries)) return undefined;
1048
- for (let i = entries.length - 1; i >= 0; i--) {
1049
- const entry = entries[i];
1050
- if (entry?.type !== "custom" || entry.customType !== BRIDGE_SESSION_CUSTOM_TYPE) continue;
1051
- const data = entry.data as Partial<PersistedBridgeSessionState> | undefined;
1052
- if (!data || typeof data.sessionId !== "string" || typeof data.cursor !== "number" || typeof data.cwd !== "string" || typeof data.fingerprint !== "string") continue;
1053
- return data as PersistedBridgeSessionState;
1054
- }
1055
- return undefined;
1056
- }
1057
-
1058
- function claudeSessionExists(sessionId: string, cwd: string): boolean {
1059
- try {
1060
- const session = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
1061
- statSync(session.jsonlPath);
1062
- return true;
1063
- } catch {
1064
- return false;
1065
- }
1066
- }
1067
-
1068
- function canonicalize(p: string | undefined): string | undefined {
1069
- if (!p) return undefined;
1070
- try { return realpathSync.native(p); } catch { return pathResolve(p); }
1071
- }
1072
-
1073
- // Decides whether a persisted bridge-session marker is safe to restore.
1074
- //
1075
- // The fork case is the load-bearing one: pi/core's createBranchedSession copies
1076
- // every non-label entry from root→leaf into the new session file. That includes
1077
- // our claude-bridge-session markers from the parent. Restoring from them would
1078
- // --resume parent's Claude jsonl on the fork's first turn, leaking conversation
1079
- // past the fork point.
1080
- //
1081
- // Returns undefined when the entry is safe to use, or a short rejection reason
1082
- // for diagnostic logging. Old entries without piSessionId always reject, which
1083
- // degrades safely to the rebuild path.
1084
- export function shouldRestorePersistedBridgeEntry(
1085
- persisted: { piSessionId?: string; cwd: string },
1086
- currentPiSessionId: string | undefined,
1087
- currentCwd: string | undefined,
1088
- ): string | undefined {
1089
- if (!persisted.piSessionId) return "missing piSessionId";
1090
- if (currentPiSessionId && persisted.piSessionId !== currentPiSessionId) {
1091
- return `piSessionId mismatch (persisted=${persisted.piSessionId} current=${currentPiSessionId})`;
1092
- }
1093
- if (currentCwd && canonicalize(persisted.cwd) !== canonicalize(currentCwd)) {
1094
- return `cwd mismatch (persisted=${persisted.cwd} current=${currentCwd})`;
1095
- }
1096
- return undefined;
1097
- }
1098
-
1099
- export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?: string }): void {
1100
- const persisted = latestPersistedBridgeSession(ctx.sessionManager);
1101
- if (!persisted) return;
1102
- const currentPiSessionId = typeof (ctx.sessionManager as any)?.getSessionId === "function" ? (ctx.sessionManager as any).getSessionId() : undefined;
1103
- const currentCwd = typeof (ctx.sessionManager as any)?.getCwd === "function" ? (ctx.sessionManager as any).getCwd() : ctx.cwd;
1104
- const rejection = shouldRestorePersistedBridgeEntry(persisted, currentPiSessionId, currentCwd);
1105
- if (rejection) {
1106
- debug(`restoreSharedSession: ${rejection} — forcing rebuild`);
1107
- return;
1108
- }
1109
- const built = readBuiltSessionContext(ctx.sessionManager);
1110
- if (!built) return;
1111
- const cursor = Math.max(0, Math.min(persisted.cursor, built.messages.length));
1112
- const fingerprint = fingerprintMessages(built.messages.slice(0, cursor));
1113
- if (fingerprint !== persisted.fingerprint) {
1114
- debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
1115
- return;
1116
- }
1117
- if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
1118
- debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
1119
- return;
1120
- }
1121
- sharedSession = { sessionId: persisted.sessionId, cursor, cwd: persisted.cwd };
1122
- debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
1123
- }
1124
-
1125
- function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
1126
- if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
1127
- const snapshot = { ...sharedSession };
1128
- const timer = setTimeout(() => {
1129
- try {
1130
- const built = readBuiltSessionContext(ctxLike.sessionManager);
1131
- if (!built) return;
1132
- const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
1133
- const data: PersistedBridgeSessionState = {
1134
- ...snapshot,
1135
- cursor,
1136
- fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
1137
- piSessionId: typeof (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
1138
- updatedAt: new Date().toISOString(),
1139
- };
1140
- extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
1141
- debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
1142
- } catch (error) {
1143
- debug("persistSharedSession failed:", error);
1144
- }
1145
- }, 0);
1146
- timer.unref?.();
1147
- }
1148
-
1149
- // Convert pi messages to Anthropic API format for session import.
1150
- // Lossy: non-Anthropic thinking blocks are dropped (no valid signature). User and
1151
- // tool-result image blocks are preserved when possible. If assistant blocks are
1152
- // otherwise incompatible, convertPiMessages emits a text placeholder so the record
1153
- // sequence stays valid before repairToolPairing runs.
1154
- function convertAndImportMessages(
1155
- session: ReturnType<typeof createSession>,
1156
- messages: Context["messages"],
1157
- customToolNameToSdk?: Map<string, string>,
1158
- cwd?: string,
1159
- ): void {
1160
- const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
1161
-
1162
- debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
1163
- debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
1164
- const c = m.content;
1165
- if (typeof c === "string") return `[${i}]${m.role}:text`;
1166
- if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
1167
- return `[${i}]${m.role}:?`;
1168
- }).join(" "));
1169
- if (sanitizedIds.size > 0) {
1170
- debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
1171
- [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
1172
- }
1173
- // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
1174
- const missingToolResults = findUnpairedToolUses(anthropicMessages);
1175
- const repaired = repairToolPairing(anthropicMessages);
1176
- if (missingToolResults.length > 0) {
1177
- reportSyntheticToolResultRepair(missingToolResults, {
1178
- cwd,
1179
- messageCount: messages.length,
1180
- anthropicMessageCount: anthropicMessages.length,
1181
- sessionId: session.sessionId,
1182
- jsonlPath: session.jsonlPath,
1183
- });
1184
- }
1185
- if (repaired.length !== anthropicMessages.length) {
1186
- debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
1187
- }
1188
- if (repaired.length) session.importMessages(repaired);
1189
- }
1190
-
1191
173
  // Pi doesn't pass tool results directly — it appends them to the context and calls
1192
174
  // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
1193
175
  // debug logging at the extraction boundary.
@@ -1252,198 +234,6 @@ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDK
1252
234
  };
1253
235
  }
1254
236
 
1255
-
1256
- interface SyncResult {
1257
- sessionId: string | null;
1258
- }
1259
-
1260
- /**
1261
- * Ensure the shared session has all messages up to (but not including) the last user message.
1262
- * Returns session ID to resume from, or null if no resume needed.
1263
- */
1264
- // Read the session file we just wrote and sanity-check it. Warns instead of
1265
- // throwing — CC may be more tolerant than our checks, so a false positive
1266
- // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
1267
- // fans each warning out to debug log + piUI notify + diagDump.
1268
- function verifyWrittenSession(
1269
- jsonlPath: string,
1270
- expectedSessionId: string,
1271
- expectedRecordCount: number,
1272
- cwd: string,
1273
- ): void {
1274
- const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
1275
- for (const msg of warnings) {
1276
- debug(`WARNING session verify: ${msg}`);
1277
- piUI?.notify(
1278
- `Session file issue: ${msg}\n` +
1279
- `cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
1280
- `Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
1281
- (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
1282
- "warning",
1283
- );
1284
- diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
1285
- }
1286
- }
1287
-
1288
- function safeRealpath(p: string): string {
1289
- try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
1290
- }
1291
-
1292
- // Diagnostic snapshot of where a session file was just written. Catches the
1293
- // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
1294
- // from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
1295
- function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
1296
- const realCwd = safeRealpath(cwd);
1297
- let fileSize: number | null = null;
1298
- let fileExists = false;
1299
- try {
1300
- const st = statSync(jsonlPath);
1301
- fileExists = true;
1302
- fileSize = st.size;
1303
- } catch { /* file may not exist yet */ }
1304
- debug(`${label}: cwd=${cwd}`);
1305
- if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
1306
- debug(`${label}: jsonlPath=${jsonlPath}`);
1307
- debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
1308
- debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
1309
- }
1310
-
1311
- // Two semantic paths:
1312
- // REUSE — pi's history is in sync with the existing sharedSession (or drifted
1313
- // only by the trailing final-assistant message that pi appends after
1314
- // streamSimple returns, which CC's own persisted session already has).
1315
- // Returns the existing sessionId. Keeps CC's prompt cache warm.
1316
- // REBUILD — no session yet, or pi's history has diverged (non-trailing
1317
- // missed messages, e.g. another provider took a turn). Wipes the existing
1318
- // session file (if any) and writes a fresh one containing all prior
1319
- // messages, reusing the same sessionId across rebuilds so UUIDs stay
1320
- // stable for the lifetime of pi's session.
1321
- //
1322
- // Why a full rebuild rather than patching:
1323
- // Injecting deltas into an existing session creates a branch that CC's
1324
- // --resume doesn't follow (documented attempt prior to this). A complete
1325
- // overwrite at the same path is simpler and correct.
1326
- //
1327
- // Why reuse the sessionId across rebuilds:
1328
- // CC re-reads the JSONL on every --resume call — no in-process UUID
1329
- // caching. Validated in tests/exp-session-clear.mjs, including the case
1330
- // where CC had appended its own tool_use/tool_result records between
1331
- // rebuilds. Preserving the UUID means stable log correlation across
1332
- // provider switches and no orphaned session files.
1333
- //
1334
- // Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
1335
- // int-session-resume.mjs) keep grepping the same anchors.
1336
- function syncSharedSession(
1337
- messages: Context["messages"],
1338
- cwd: string,
1339
- customToolNameToSdk?: Map<string, string>,
1340
- modelId?: string,
1341
- ): SyncResult {
1342
- const priorMessages = messages.slice(0, -1); // everything before the new user prompt
1343
-
1344
- // REUSE path
1345
- if (sharedSession && !sharedSession.needsRebuild) {
1346
- const missed = priorMessages.slice(sharedSession.cursor);
1347
- const trailingAssistantOnly =
1348
- missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
1349
- if (missed.length === 0 || trailingAssistantOnly) {
1350
- if (trailingAssistantOnly) {
1351
- sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
1352
- }
1353
- debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
1354
- debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
1355
- return { sessionId: sharedSession.sessionId };
1356
- }
1357
- }
1358
-
1359
- // REBUILD path
1360
- if (priorMessages.length === 0) {
1361
- debug(`Case 1: clean start, ${messages.length} total messages`);
1362
- debug(`syncResult: path=clean-start`);
1363
- return { sessionId: null };
1364
- }
1365
- const previousSessionId = sharedSession?.sessionId;
1366
- const previousCursor = sharedSession?.cursor ?? 0;
1367
- // preserveId: rebuild in place (deleteSession + createSession with the
1368
- // existing UUID), so prompt-cache UUIDs stay stable for log correlation
1369
- // and for any tools that key off them. Skipped only when there's a
1370
- // concurrent writer we shouldn't race — see forceRotate docs above.
1371
- const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
1372
- if (preserveId) {
1373
- // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
1374
- deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
1375
- }
1376
- const session = createSession({
1377
- projectPath: cwd,
1378
- claudeDir: process.env.CLAUDE_CONFIG_DIR,
1379
- ...(preserveId ? { sessionId: previousSessionId } : {}),
1380
- ...(modelId ? { model: modelId } : {}),
1381
- });
1382
- convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
1383
- session.save();
1384
- verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
1385
- sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
1386
- if (previousSessionId === undefined) {
1387
- debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
1388
- } else if (preserveId) {
1389
- const missedCount = priorMessages.length - previousCursor;
1390
- debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
1391
- } else {
1392
- debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
1393
- }
1394
- debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
1395
- debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
1396
- return { sessionId: session.sessionId };
1397
- }
1398
-
1399
- // --- Provider helpers: tool name mapping ---
1400
-
1401
- export function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
1402
- const normalized = name.toLowerCase();
1403
- const builtin = SDK_TO_PI_TOOL_NAME[normalized];
1404
- if (builtin) return builtin;
1405
- if (customToolNameToPi) {
1406
- const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
1407
- if (mapped) return mapped;
1408
- }
1409
- for (const prefix of [
1410
- MCP_TOOL_PREFIX,
1411
- `mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
1412
- `mcp/${MCP_SERVER_NAME}/`,
1413
- `mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
1414
- ]) {
1415
- if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
1416
- }
1417
- return name;
1418
- }
1419
-
1420
- // Renames for Claude Code SDK param names that differ from pi's native names.
1421
- // Keys not listed here pass through unchanged, so new pi params work automatically.
1422
- const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
1423
- read: { file_path: "path" },
1424
- write: { file_path: "path" },
1425
- edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
1426
- };
1427
-
1428
- // Maps SDK tool args to pi tool args via key renaming + pass-through.
1429
- // Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
1430
- function mapToolArgs(
1431
- toolName: string, args: Record<string, unknown> | undefined,
1432
- ): Record<string, unknown> {
1433
- const input = args ?? {};
1434
- const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
1435
- const result: Record<string, unknown> = {};
1436
- for (const [key, value] of Object.entries(input)) {
1437
- const piKey = renames?.[key] ?? key;
1438
- if (!(piKey in result)) result[piKey] = value; // first alias wins
1439
- }
1440
- // Pi bash has no default timeout; add a safety default
1441
- if (toolName.toLowerCase() === "bash" && result.timeout == null) {
1442
- result.timeout = 120;
1443
- }
1444
- return result;
1445
- }
1446
-
1447
237
  // --- Provider helpers: tool resolution ---
1448
238
 
1449
239
  // --- Provider helpers: tool bridge ---
@@ -1580,25 +370,11 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
1580
370
  return { [MCP_SERVER_NAME]: server };
1581
371
  }
1582
372
 
1583
- // --- Usage helpers ---
1584
-
1585
- function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
1586
- if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
1587
- if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
1588
- if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
1589
- if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
1590
- output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
1591
- calculateCost(model, output.usage);
1592
- const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
1593
- const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
1594
- debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens} cachePct=${cachePct}% model=${model.id}`);
1595
- }
1596
-
1597
373
  // --- Effort level mapping ---
1598
374
  // Pi reasoning levels → CC SDK effort levels
1599
375
 
1600
376
  const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
1601
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
377
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
1602
378
  };
1603
379
 
1604
380
  function normalizeEffortOverrideModelKey(value: string): string {
@@ -1621,22 +397,6 @@ export function resolveConfiguredEffort(
1621
397
  return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
1622
398
  }
1623
399
 
1624
- // --- Provider helpers: misc ---
1625
-
1626
- function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
1627
- switch (reason) {
1628
- case "tool_use": return "toolUse";
1629
- case "max_tokens": return "length";
1630
- case "end_turn": default: return "stop";
1631
- }
1632
- }
1633
-
1634
- function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
1635
- if (!input) return fallback;
1636
- try { return JSON.parse(input); } catch { return fallback; }
1637
- }
1638
-
1639
-
1640
400
  // --- Provider: streaming function ---
1641
401
  //
1642
402
  // Push-based streaming with MCP tool bridge:
@@ -1652,279 +412,6 @@ function parsePartialJson(input: string, fallback: Record<string, unknown>): Rec
1652
412
  // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
1653
413
  // in consumeQuery and are skipped before resetTurnState runs.
1654
414
 
1655
- function ensureTurnStarted(): void {
1656
- if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
1657
- ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
1658
- ctx().turnStarted = true;
1659
- }
1660
- }
1661
-
1662
- function finalizeCurrentStream(stopReason?: string): void {
1663
- if (!ctx().currentPiStream || !ctx().turnOutput) return;
1664
- debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
1665
- if (!ctx().turnStarted) ensureTurnStarted();
1666
- const reason = stopReason === "length" ? "length" : "stop";
1667
- ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
1668
- ctx().currentPiStream!.end();
1669
- ctx().currentPiStream = null;
1670
- }
1671
-
1672
- function updateTurnOutputModel(modelId: unknown): void {
1673
- const c = ctx();
1674
- if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
1675
- if (c.turnOutput.model === modelId) return;
1676
- debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
1677
- c.turnOutput.model = modelId;
1678
- }
1679
-
1680
- /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1681
- * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1682
- export function processStreamEvent(
1683
- message: SDKMessage,
1684
- customToolNameToPi: Map<string, string>,
1685
- model: Model<any>,
1686
- ): void {
1687
- const c = ctx();
1688
- if (!c.currentPiStream || !c.turnOutput) return;
1689
- const event = (message as SDKMessage & { event: any }).event;
1690
- if (event?.type === "ping") return;
1691
- if (event?.type === "message_stop" && !c.turnSawToolCall) {
1692
- debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
1693
- return;
1694
- }
1695
-
1696
- if (event?.type === "message_start") {
1697
- c.resetToolTracking();
1698
- updateTurnOutputModel(event.message?.model);
1699
- if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1700
- return;
1701
- }
1702
-
1703
- if (event?.type === "content_block_start") {
1704
- c.turnSawStreamEvent = true;
1705
- ensureTurnStarted();
1706
- if (event.content_block?.type === "text") {
1707
- c.turnBlocks.push({ type: "text", text: "", index: event.index });
1708
- c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1709
- } else if (event.content_block?.type === "thinking") {
1710
- c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1711
- c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1712
- } else if (event.content_block?.type === "tool_use") {
1713
- c.turnSawToolCall = true;
1714
- const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
1715
- c.recordToolCall(event.content_block.id, mappedName, {});
1716
- c.turnBlocks.push({
1717
- type: "toolCall", id: event.content_block.id,
1718
- name: mappedName,
1719
- arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1720
- partialJson: "", index: event.index,
1721
- });
1722
- c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1723
- } else {
1724
- debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
1725
- }
1726
- return;
1727
- }
1728
-
1729
- if (event?.type === "content_block_delta") {
1730
- const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1731
- const block = c.turnBlocks[index];
1732
- if (!block) {
1733
- debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
1734
- return;
1735
- }
1736
- c.turnSawStreamEvent = true;
1737
- if (event.delta?.type === "text_delta" && block.type === "text") {
1738
- block.text += event.delta.text;
1739
- c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
1740
- } else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
1741
- block.thinking += event.delta.thinking;
1742
- c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
1743
- } else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
1744
- block.partialJson += event.delta.partial_json;
1745
- block.arguments = parsePartialJson(block.partialJson, block.arguments);
1746
- c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
1747
- } else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
1748
- block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
1749
- } else {
1750
- debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
1751
- }
1752
- return;
1753
- }
1754
-
1755
- if (event?.type === "content_block_stop") {
1756
- const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1757
- const block = c.turnBlocks[index];
1758
- if (!block) {
1759
- debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
1760
- return;
1761
- }
1762
- c.turnSawStreamEvent = true;
1763
- delete block.index;
1764
- if (block.type === "text") {
1765
- c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
1766
- } else if (block.type === "thinking") {
1767
- c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
1768
- } else if (block.type === "toolCall") {
1769
- c.turnSawToolCall = true;
1770
- block.arguments = mapToolArgs(
1771
- block.name, parsePartialJson(block.partialJson, block.arguments),
1772
- );
1773
- c.updateToolCallArgs(block.id, block.arguments);
1774
- delete block.partialJson;
1775
- c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1776
- }
1777
- return;
1778
- }
1779
-
1780
- if (event?.type === "message_delta") {
1781
- c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
1782
- if (event.usage) updateUsage(c.turnOutput, event.usage, model);
1783
- return;
1784
- }
1785
-
1786
- if (event?.type === "message_stop" && c.turnSawToolCall) {
1787
- // Tool call complete — end this pi stream. The SDK will still yield an
1788
- // assistant message for this turn, but currentPiStream=null causes
1789
- // consumeQuery to skip it. The MCP handler blocks the generator until
1790
- // pi delivers the tool result via the next streamSimple call.
1791
- c.turnOutput.stopReason = "toolUse";
1792
- c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1793
- c.currentPiStream!.end();
1794
- c.currentPiStream = null;
1795
-
1796
- // Cursor is updated by the next streamSimple call (tool result delivery path)
1797
- // which sets cursor = context.messages.length with the post-tool-result context.
1798
- return;
1799
- }
1800
-
1801
- if (event?.type !== "message_stop" && event?.type !== "ping") {
1802
- debug("processStreamEvent: unhandled event type", event?.type);
1803
- }
1804
- }
1805
-
1806
- // The SDK always yields `assistant` messages (completed content blocks) after streaming.
1807
- // When stream_events already delivered the content, this is a no-op. But after
1808
- // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
1809
- // arrives before any stream_events, this is the primary content path. Must maintain
1810
- // the same stream lifecycle as processStreamEvent — including ending the stream on
1811
- // tool_use to prevent deadlock with the MCP handler.
1812
- function appendMissingToolUsesFromAssistant(
1813
- assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
1814
- model: Model<any>,
1815
- customToolNameToPi: Map<string, string>,
1816
- ): boolean {
1817
- const c = ctx();
1818
- if (!assistantMsg?.content) return false;
1819
- let sawToolUse = false;
1820
- for (const block of assistantMsg.content) {
1821
- if (block.type !== "tool_use") continue;
1822
- sawToolUse = true;
1823
- const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
1824
- const name = mapToolName(block.name, customToolNameToPi);
1825
- const mappedArgs = mapToolArgs(name, block.input);
1826
- c.recordToolCall(block.id, name, mappedArgs);
1827
- if (existingIdx >= 0) {
1828
- const existing = c.turnBlocks[existingIdx] as any;
1829
- existing.name = name;
1830
- existing.arguments = mappedArgs;
1831
- c.updateToolCallArgs(block.id, mappedArgs);
1832
- if ("partialJson" in existing) {
1833
- delete existing.partialJson;
1834
- delete existing.index;
1835
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
1836
- }
1837
- continue;
1838
- }
1839
-
1840
- ensureTurnStarted();
1841
- c.turnBlocks.push({
1842
- type: "toolCall", id: block.id,
1843
- name,
1844
- arguments: mappedArgs,
1845
- });
1846
- const idx = c.turnBlocks.length - 1;
1847
- const toolBlock = c.turnBlocks[idx];
1848
- c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1849
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1850
- }
1851
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1852
- return sawToolUse;
1853
- }
1854
-
1855
- export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1856
- const c = ctx();
1857
- const assistantMsg = (message as any).message;
1858
- if (!assistantMsg?.content) return;
1859
- updateTurnOutputModel(assistantMsg.model);
1860
- if (c.turnSawStreamEvent) {
1861
- // Claude Agent SDK can yield the completed assistant message before (or
1862
- // instead of) a stream_event message_stop for a tool-use turn. Treat that
1863
- // assistant message as a hard turn boundary so Pi executes the tool calls
1864
- // and the MCP handlers stay blocked until real tool results are delivered.
1865
- // Without this fallback, Claude Code can continue internally with empty MCP
1866
- // results and Pi only sees the real outputs one render cycle later.
1867
- if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
1868
- c.turnSawToolCall = true;
1869
- if (c.currentPiStream && c.turnOutput) {
1870
- c.turnOutput.stopReason = "toolUse";
1871
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1872
- c.currentPiStream.end();
1873
- c.currentPiStream = null;
1874
- debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
1875
- }
1876
- }
1877
- return;
1878
- }
1879
- c.resetToolTracking();
1880
- debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1881
- for (const block of assistantMsg.content) {
1882
- if (block.type === "text" && block.text) {
1883
- ensureTurnStarted();
1884
- c.turnBlocks.push({ type: "text", text: block.text });
1885
- const idx = c.turnBlocks.length - 1;
1886
- c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
1887
- c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
1888
- c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
1889
- } else if (block.type === "thinking") {
1890
- ensureTurnStarted();
1891
- c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
1892
- const idx = c.turnBlocks.length - 1;
1893
- c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
1894
- if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
1895
- c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
1896
- } else if (block.type === "tool_use") {
1897
- ensureTurnStarted();
1898
- c.turnSawToolCall = true;
1899
- const mappedName = mapToolName(block.name, customToolNameToPi);
1900
- const mappedArgs = mapToolArgs(mappedName, block.input);
1901
- c.recordToolCall(block.id, mappedName, mappedArgs);
1902
- c.turnBlocks.push({
1903
- type: "toolCall", id: block.id,
1904
- name: mappedName,
1905
- arguments: mappedArgs,
1906
- });
1907
- const idx = c.turnBlocks.length - 1;
1908
- const toolBlock = c.turnBlocks[idx];
1909
- c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1910
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1911
- } else if (block.type === "fallback") {
1912
- updateTurnOutputModel(block.to?.model);
1913
- } else {
1914
- debug("processAssistantMessage: unhandled block type", block.type);
1915
- }
1916
- }
1917
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1918
-
1919
- // End the stream on tool_use, same as processStreamEvent's message_stop handler.
1920
- if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
1921
- c.turnOutput.stopReason = "toolUse";
1922
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1923
- c.currentPiStream.end();
1924
- c.currentPiStream = null;
1925
- }
1926
- }
1927
-
1928
415
  /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
1929
416
  * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
1930
417
  * an assistant message (completed blocks). On tool_use, the stream is ended by
@@ -1983,8 +470,13 @@ async function consumeQuery(
1983
470
  const fallbackModel = (message as any).fallback_model;
1984
471
  updateTurnOutputModel(fallbackModel);
1985
472
  debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
1986
- if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
1987
- safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
473
+ // Notify only for reroutes we configured, so an unexpected pairing from
474
+ // Claude Code is still logged above but not announced as one of ours.
475
+ if (typeof fallbackModel === "string" && typeof originalModel === "string" && fallbackModelForPrimaryModel(originalModel) === fallbackModel) {
476
+ safeNotify(
477
+ `Claude bridge switched ${modelDisplayName(originalModel)} to ${modelDisplayName(fallbackModel)} after Claude Code safety fallback.`,
478
+ "info",
479
+ );
1988
480
  }
1989
481
  }
1990
482
  break;
@@ -2405,7 +897,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2405
897
  streamIdleTimedOut = true;
2406
898
  abortCtx.deferredUserMessages = [];
2407
899
  abortCtx.handledTerminalError = true;
2408
- if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
900
+ if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2409
901
  const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
2410
902
  debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
2411
903
  emitRateLimitEvent({
@@ -2446,8 +938,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2446
938
  // Prevent stale deferred messages from being replayed by parent on pop
2447
939
  abortCtx.deferredUserMessages = [];
2448
940
  reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
2449
- for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
2450
- abortCtx.pendingToolCalls.clear();
941
+ const drained = drainPendingToolCalls(abortCtx, "abort");
942
+ if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
2451
943
  abortCtx.pendingResults.clear();
2452
944
  requestAbort();
2453
945
  };
@@ -2468,7 +960,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2468
960
 
2469
961
  // --- Abort detection in normal completion path ---
2470
962
  if (wasAborted || options?.signal?.aborted) {
2471
- if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
963
+ if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2472
964
  ctx().deferredUserMessages = [];
2473
965
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
2474
966
  if (ctx().turnOutput) {
@@ -2486,7 +978,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2486
978
  if (sessionId) {
2487
979
  const cursor = Math.max(context.messages.length, ctx().latestCursor, sharedSession?.cursor ?? 0);
2488
980
  debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
2489
- sharedSession = { sessionId, cursor, cwd };
981
+ setSharedSession({ sessionId, cursor, cwd });
2490
982
  }
2491
983
 
2492
984
  // --- Replay deferred user messages as continuation queries ---
@@ -2515,7 +1007,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2515
1007
  const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
2516
1008
  const sid = contSid ?? sharedSession?.sessionId;
2517
1009
  if (sid) {
2518
- sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
1010
+ setSharedSession({ sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd });
2519
1011
  }
2520
1012
  } catch (contError) {
2521
1013
  debug(`provider: continuation query error:`, contError);
@@ -2536,9 +1028,9 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2536
1028
  const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
2537
1029
  const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
2538
1030
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
2539
- sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1031
+ setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2540
1032
  } else {
2541
- sharedSession = null;
1033
+ setSharedSession(null);
2542
1034
  }
2543
1035
  ctx().deferredUserMessages = [];
2544
1036
  if (suppressDuplicateError) {
@@ -2558,10 +1050,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2558
1050
  activeStreamIdleWatchdogs.delete(abortCtx);
2559
1051
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
2560
1052
  if (ctx().activeQuery === sdkQuery) {
2561
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
2562
- // Drain pending handlers for this query
2563
- for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
2564
- ctx().pendingToolCalls.clear();
1053
+ const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1054
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: cause !== "query-end" });
1055
+ // Drain pending handlers for this query as errors naming the cause —
1056
+ // their results are never coming.
1057
+ const drained = drainPendingToolCalls(ctx(), cause);
1058
+ if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
2565
1059
  ctx().pendingResults.clear();
2566
1060
 
2567
1061
  if (isReentrant) {
@@ -2602,6 +1096,38 @@ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
2602
1096
  ].join("\n"), "info");
2603
1097
  }
2604
1098
 
1099
+ // Read a credential file, treating any read error as "absent" — a missing or
1100
+ // unreadable candidate must fall through to the next one, not abort resolution.
1101
+ function readCredentialFile(path: string): string | undefined {
1102
+ try {
1103
+ return nodeReadFileSync(path, "utf8");
1104
+ } catch {
1105
+ return undefined;
1106
+ }
1107
+ }
1108
+
1109
+ // Deterministic connector enumeration for the host app (vstack#838). Reports the
1110
+ // failure reason rather than an empty list, so "no connectors" and "could not
1111
+ // check" stay distinguishable.
1112
+ async function reportConnectorInventory(ctx: { ui: ExtensionUIContext }): Promise<void> {
1113
+ const credentials = resolveClaudeOAuth(readCredentialFile);
1114
+ if (!credentials) {
1115
+ ctx.ui.notify("Claude bridge: no Claude OAuth credentials found — cannot enumerate connectors.", "error");
1116
+ return;
1117
+ }
1118
+ const inventory = await listAccountConnectors({ credentials });
1119
+ if (!inventory.ok) {
1120
+ ctx.ui.notify(`Claude bridge: connector enumeration failed — ${inventory.reason}`, "error");
1121
+ return;
1122
+ }
1123
+ if (inventory.connectors.length === 0) {
1124
+ ctx.ui.notify("Claude bridge: this account has no connectors installed.", "info");
1125
+ return;
1126
+ }
1127
+ const names = inventory.connectors.map((c) => c.name).join(", ");
1128
+ ctx.ui.notify(`Claude bridge: ${inventory.connectors.length} connector(s) installed — ${names}`, "info");
1129
+ }
1130
+
2605
1131
  function registerBridgeCommands(pi: ExtensionAPI): void {
2606
1132
  const guard = pi as unknown as Record<PropertyKey, unknown>;
2607
1133
  if (guard[COMMANDS_REGISTERED_KEY]) return;
@@ -2638,12 +1164,16 @@ function registerBridgeCommands(pi: ExtensionAPI): void {
2638
1164
  description: "Run Claude Code /extra-usage through claude-bridge",
2639
1165
  handler: async (_args: string, ctx) => runExtraUsage(ctx),
2640
1166
  });
1167
+ pi.registerCommand("claude-bridge:connectors", {
1168
+ description: "List the Claude account's installed claude.ai connectors",
1169
+ handler: async (_args: string, ctx) => reportConnectorInventory(ctx),
1170
+ });
2641
1171
  }
2642
1172
 
2643
1173
  // --- Extension registration ---
2644
1174
 
2645
1175
  export default function (pi: ExtensionAPI) {
2646
- extensionApi = pi;
1176
+ setExtensionApi(pi);
2647
1177
  // Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
2648
1178
  process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
2649
1179
 
@@ -2661,12 +1191,12 @@ export default function (pi: ExtensionAPI) {
2661
1191
  // a mid-session credential flip is handled while token ownership is intact.
2662
1192
  const clearSession = (event: string) => {
2663
1193
  debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
2664
- sharedSession = null;
1194
+ setSharedSession(null);
2665
1195
  };
2666
1196
 
2667
1197
  pi.on("session_start", (event, ctx) => {
2668
1198
  recordProjectTrust(ctx);
2669
- piUI = ctx.ui;
1199
+ setPiUI(ctx.ui);
2670
1200
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
2671
1201
  clearSession(`session_start:${event.reason}`);
2672
1202
  }
@@ -2701,7 +1231,7 @@ export default function (pi: ExtensionAPI) {
2701
1231
  }
2702
1232
  if (sharedSession) {
2703
1233
  debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
2704
- sharedSession = { ...sharedSession, needsRebuild: true };
1234
+ setSharedSession({ ...sharedSession, needsRebuild: true });
2705
1235
  }
2706
1236
  };
2707
1237
  pi.on("session_compact", () => markRebuild("session_compact"));