@vanillagreen/pi-claude-bridge 1.6.2 → 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 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 { homedir } from "os";
12
- import { delimiter, dirname, join } from "path";
13
- import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
14
- 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";
15
8
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
16
- import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
17
9
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
18
- import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
19
- import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
10
+ import { QueryContext, ctx, drainPendingToolCalls, stackDepth, pushContext, popContext, toolCallDrainCause } from "./query-state.js";
20
11
  import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
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,702 +64,41 @@ 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 ~/.pi/agent/claude-bridge.log
36
-
37
- const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
38
- const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
39
- const DEFAULT_DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
40
-
41
- function diagLogPath(): string {
42
- return process.env.CLAUDE_BRIDGE_DIAG_PATH || DEFAULT_DIAG_LOG_PATH;
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
- function resolveClaudeExecutable(configured?: string): string | undefined {
85
- const trimmed = configured?.trim();
86
- if (trimmed) return trimmed;
87
- return executableFromPath("claude") ?? executableFromPath("claude-code");
88
- }
89
-
90
- export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
91
-
92
- export interface ClaudeExecutablePreflightResult {
93
- path: string;
94
- realPath: string;
95
- cwd: string;
96
- realCwd: string;
97
- fileType: ClaudeExecutableFileType;
98
- }
99
-
100
- function errnoValue(err: unknown): string | number | undefined {
101
- return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
102
- }
103
-
104
- function syscallValue(err: unknown): string | undefined {
105
- return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
106
- }
107
-
108
- function pathValue(err: unknown): string | undefined {
109
- const value = (err as NodeJS.ErrnoException)?.path;
110
- return typeof value === "string" ? value : undefined;
111
- }
112
-
113
- function codeValue(err: unknown, fallback: string): string {
114
- const value = (err as NodeJS.ErrnoException)?.code;
115
- return typeof value === "string" ? value : fallback;
116
- }
117
-
118
- function displayValue(value: unknown): string {
119
- return value === undefined || value === null || value === "" ? "<none>" : String(value);
120
- }
121
-
122
- function makeClaudePreflightError(
123
- summary: string,
124
- details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
125
- ): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
126
- const detail = [
127
- `code=${details.code}`,
128
- `errno=${displayValue(details.errno)}`,
129
- `syscall=${displayValue(details.syscall)}`,
130
- `path=${details.path}`,
131
- `cwd=${details.cwd}`,
132
- ...(details.fileType ? [`fileType=${details.fileType}`] : []),
133
- ...(details.realPath ? [`realPath=${details.realPath}`] : []),
134
- ].join(" ");
135
- const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
136
- error.name = "ClaudeExecutablePreflightError";
137
- error.code = details.code;
138
- if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
139
- if (details.syscall) error.syscall = details.syscall;
140
- error.path = details.path;
141
- error.cwd = details.cwd;
142
- if (details.fileType) error.fileType = details.fileType;
143
- if (details.realPath) error.realPath = details.realPath;
144
- if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
145
- return error;
146
- }
147
-
148
- export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
149
- if (bytes.length === 0) return "empty";
150
- if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
151
- if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
152
- if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
153
- if (bytes.length >= 4) {
154
- const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
155
- if (
156
- magic === 0xfeedface ||
157
- magic === 0xfeedfacf ||
158
- magic === 0xcefaedfe ||
159
- magic === 0xcffaedfe ||
160
- magic === 0xcafebabe ||
161
- magic === 0xbebafeca
162
- ) return "mach-o";
163
- }
164
- return "unknown";
165
- }
166
-
167
- export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
168
- let realCwd = cwd;
169
- try {
170
- const cwdStat = statSync(cwd);
171
- if (!cwdStat.isDirectory()) {
172
- throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
173
- code: "ENOTDIR",
174
- syscall: "chdir",
175
- path: cwd,
176
- cwd,
177
- });
178
- }
179
- accessSync(cwd, fsConstants.X_OK);
180
- realCwd = realpathSync(cwd);
181
- } catch (err) {
182
- if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
183
- throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
184
- code: codeValue(err, "EACCES"),
185
- errno: errnoValue(err),
186
- syscall: syscallValue(err),
187
- path: pathValue(err) ?? cwd,
188
- cwd,
189
- cause: err,
190
- });
191
- }
192
-
193
- let realPath = path;
194
- try {
195
- const stat = statSync(path);
196
- if (!stat.isFile()) {
197
- throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
198
- code: "EACCES",
199
- syscall: "exec",
200
- path,
201
- cwd,
202
- });
203
- }
204
- accessSync(path, fsConstants.X_OK);
205
- realPath = realpathSync(path);
206
- } catch (err) {
207
- if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
208
- throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
209
- code: codeValue(err, "ENOENT"),
210
- errno: errnoValue(err),
211
- syscall: syscallValue(err),
212
- path: pathValue(err) ?? path,
213
- cwd,
214
- cause: err,
215
- });
216
- }
217
-
218
- let fileType: ClaudeExecutableFileType;
219
- try {
220
- fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
221
- } catch (err) {
222
- throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
223
- code: codeValue(err, "EACCES"),
224
- errno: errnoValue(err),
225
- syscall: syscallValue(err),
226
- path: pathValue(err) ?? realPath,
227
- cwd,
228
- realPath,
229
- cause: err,
230
- });
231
- }
232
-
233
- if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
234
- throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
235
- code: "ENOEXEC",
236
- syscall: "exec",
237
- path,
238
- cwd,
239
- fileType,
240
- realPath,
241
- });
242
- }
243
-
244
- return { path, realPath, cwd, realCwd, fileType };
245
- }
246
-
247
- function envFlagEnabled(value: string | undefined): boolean {
248
- return value === "1" || value?.toLowerCase() === "true";
249
- }
250
-
251
- export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
252
- const originalCode = codeValue(err, "SPAWN_ERROR");
253
- const originalMessage = err.message;
254
- const spawnPath = pathValue(err) ?? options.command;
255
- const cwd = options.cwd ?? process.cwd();
256
- const detail = [
257
- `code=${originalCode}`,
258
- `errno=${displayValue(errnoValue(err))}`,
259
- `syscall=${displayValue(syscallValue(err))}`,
260
- `path=${spawnPath}`,
261
- `cwd=${cwd}`,
262
- `command=${options.command}`,
263
- ].join(" ");
264
- const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
265
- wrapped.name = "ClaudeSpawnDiagnosticError";
266
- // The SDK special-cases code === ENOENT and replaces the message with its
267
- // generic "native binary not found" text. Preserve the original code in the
268
- // message/originalCode while using a bridge code so the SDK surfaces context.
269
- wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
270
- wrapped.originalCode = originalCode;
271
- wrapped.originalMessage = originalMessage;
272
- const errno = errnoValue(err);
273
- if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
274
- const syscall = syscallValue(err);
275
- if (syscall) wrapped.syscall = syscall;
276
- wrapped.path = spawnPath;
277
- wrapped.cwd = cwd;
278
- // Do not set `cause` here: the listener copies these structured fields back
279
- // onto the original Error. A cause reference to that same object would become
280
- // `err.cause === err`, making JSON.stringify throw on a circular structure.
281
- // originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
282
- return wrapped;
283
- }
284
-
285
- export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
286
- const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
287
- const child = spawnProcess(options.command, options.args, {
288
- cwd: options.cwd,
289
- env: options.env,
290
- signal: options.signal,
291
- stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
292
- windowsHide: true,
293
- });
294
- if (pipeStderr) {
295
- child.stderr?.on("data", (data) => {
296
- for (const line of data.toString().split(/\r?\n/)) {
297
- if (line) debug(`[cli-stderr spawn] ${line}`);
298
- }
299
- });
300
- }
301
- child.prependListener("error", (err) => {
302
- const originalStack = err.stack;
303
- const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
304
- Object.assign(err, wrapped);
305
- err.name = wrapped.name;
306
- err.message = wrapped.message;
307
- // Keep V8's stack from the actual Node spawn failure, not the wrapper
308
- // construction site. Diagnostic fields above remain enumerable and
309
- // JSON-serializable; stack stays the spawn-time breadcrumb for operators.
310
- if (originalStack) err.stack = originalStack;
311
- });
312
- return {
313
- stdin: child.stdin,
314
- stdout: child.stdout,
315
- get killed() { return child.killed; },
316
- get exitCode() { return child.exitCode; },
317
- kill: child.kill.bind(child),
318
- on: child.on.bind(child),
319
- once: child.once.bind(child),
320
- off: child.off.bind(child),
321
- };
322
- }
323
-
324
- // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
325
- // CLI subprocess to write its own debug log to a file we choose, and also
326
- // forward its stderr into our debug stream. Drops straight into the real SDK's
327
- // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
328
- // stderr). Without this, CC's internal view of the world is invisible to us
329
- // and "No conversation found" / empty-error reports are unactionable.
330
- let nextCliDebugSeq = 1;
331
- function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
332
- if (!DEBUG) return {};
333
- const seq = nextCliDebugSeq++;
334
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
335
- const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
336
- try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
337
- const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
338
- debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
339
- return {
340
- debug: true,
341
- debugFile,
342
- stderr: (data: string) => {
343
- for (const line of data.split(/\r?\n/)) {
344
- if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
345
- }
346
- },
347
- };
348
- }
349
-
350
- /** Unconditional diagnostic dump — for "should never happen" paths */
351
- function diagDump(label: string, data: Record<string, unknown>) {
352
- try {
353
- const ts = new Date().toISOString();
354
- const entry = { ts, moduleInstanceId, label, ...data };
355
- const path = diagLogPath();
356
- try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
357
- appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
358
- try { chmodSync(path, 0o600); } catch { /* best effort */ }
359
- debug(`DIAG: ${label} (see ${path})`);
360
- } catch (error) {
361
- debug(`DIAG FAILED: ${label}`, error);
362
- }
363
- }
364
-
365
- function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
366
- try { piUI?.notify(message, level); }
367
- catch (error) { debug("notify failed:", error); }
368
- }
369
-
370
- function argKeys(args: Record<string, unknown> | undefined): string[] {
371
- return Object.keys(args ?? {}).sort();
372
- }
373
-
374
- function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
375
- return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
376
- }
377
-
378
- function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
379
- const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
380
- if (names.length > limit) shown.push(`+${names.length - limit} more`);
381
- return shown;
382
- }
383
-
384
- function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
385
- try {
386
- if (missing.length === 0) return;
387
- const toolNames = summarizeMissingToolNames(missing);
388
- const toolNameSummary = compactToolNameSummary(toolNames);
389
- const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
390
- diagDump("repair_tool_pairing_synthetic_results", {
391
- count: missing.length,
392
- toolNames,
393
- sampledToolCallIds,
394
- missing: missing.slice(0, 50),
395
- ...context,
396
- });
397
- safeNotify(
398
- `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
399
- `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
400
- `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
401
- "error",
402
- );
403
- } catch (error) {
404
- debug("reportSyntheticToolResultRepair failed:", error);
405
- }
406
- }
407
-
408
- export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
409
- try {
410
- if (queryCtx.reportedToolResultMismatch) return false;
411
- const progress = queryCtx.toolResultProgress();
412
- const hasMismatch = progress.expectedCount > 0
413
- ? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
414
- : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
415
- if (!hasMismatch) return false;
416
- queryCtx.reportedToolResultMismatch = true;
417
- if (sharedSession) {
418
- sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
419
- }
420
- const toolNameSummary = compactToolNameSummary(progress.toolNames);
421
- diagDump("tool_result_delivery_mismatch", {
422
- reason,
423
- cwd,
424
- progress,
425
- activeQueryExists: queryCtx.activeQuery !== null,
426
- sharedSession: sharedSession ? {
427
- sessionId: sharedSession.sessionId.slice(0, 8),
428
- cursor: sharedSession.cursor,
429
- needsRebuild: sharedSession.needsRebuild === true,
430
- forceRotate: sharedSession.forceRotate === true,
431
- } : null,
432
- });
433
- safeNotify(
434
- `Claude bridge: tool result delivery interrupted during ${reason}; ` +
435
- `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
436
- `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
437
- `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
438
- `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
439
- "error",
440
- );
441
- return true;
442
- } catch (error) {
443
- debug("reportToolResultMismatch failed:", error);
444
- return false;
445
- }
446
- }
447
-
448
- export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
449
- if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
450
- if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
451
- }
452
-
453
- export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
454
- return { sharedSession };
455
- }
456
-
457
67
  // --- Constants ---
458
68
 
459
- // Global key to prevent re-registration of the provider across module reloads.
69
+ // Two process-global tokens govern provider registration across module reloads.
70
+ // Extensions like pi-subagents spawn a subagent that loads THIS module again as
71
+ // a fresh (non-primary) instance. Two failure modes must be prevented:
72
+ // (1) a subagent's registerProvider() overwriting the parent's `streamSimple`
73
+ // in the shared ModelRegistry — the parent would then deliver tool results
74
+ // through the subagent's empty-state streamSimple and break tool pairing;
75
+ // (2) a subagent STEALING registration ownership: if the parent loaded
76
+ // uncredentialed and the user logged in mid-session, a later subagent load
77
+ // would see credentialed + no-owner and claim ownership + register ITS
78
+ // streamSimple, split-braining the shared session/ctx.
460
79
  //
461
- // Extensions like pi-subagents spawn a subagent and it loads this module
462
- // again. Without this guard, the subagent's call to registerProvider() would
463
- // overwrite the parent's `streamSimple` function reference in the shared
464
- // ModelRegistry. When the parent later delivers a tool result, it would call
465
- // the subagent's `streamSimple` (which has empty state) instead of its own.
80
+ // PRIMARY_INSTANCE_KEY claimed UNCONDITIONALLY (regardless of credentials) by
81
+ // the first-loaded module instance. ONLY the primary instance may ever
82
+ // register, unregister, or claim the stream guard. Non-primary instances
83
+ // (subagents) always no-op. This is the authority token; it closes (2).
466
84
  //
467
- // By storing the active streamSimple in a Symbol.for() global (shared across all
468
- // module instances), we ensure only the FIRST instance to register takes effect.
469
- // Subsequent instances wrap the stored function instead of overwriting it.
85
+ // ACTIVE_STREAM_SIMPLE_KEY holds the registered instance's `streamSimple`.
86
+ // Only the primary claims it, and only while a registration is live. It doubles
87
+ // as the "already registered" flag (guard === our streamSimple) and the routing
88
+ // target for reentrant subagent calls; it closes (1).
470
89
  //
471
- // On session_shutdown (including /reload), clearSession() resets this so a fresh
472
- // registration can occur for the next session.
90
+ // Both are released on session_shutdown (incl. /reload) by releaseProviderTokens
91
+ // so the next module load starts clean. See applyProviderRegistration for the
92
+ // state machine and auth-presence.ts/decideRegistration for the pure decision.
93
+ const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
473
94
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
474
95
  const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
475
96
 
476
- const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
477
- read: "read", write: "write", edit: "edit", bash: "bash",
478
- };
479
-
480
97
  // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
481
98
  const MODELS = buildModels(getModels("anthropic"));
482
99
 
483
- // Disable Claude Code built-ins in the provider path. Pi owns tool execution;
484
- // Claude reaches Pi tools through the bridged MCP server instead.
485
- //
486
- // `allowedTools` is a permission auto-allow list in the Claude Agent SDK, not a
487
- // visibility allowlist. Use `tools: []` to remove the built-in tool set, and keep
488
- // this disallow list as a belt-and-suspenders guard for SDK/CLI built-ins that may
489
- // otherwise leak into the model context (e.g. TodoWrite, CronList, SendMessage).
490
- export const DISALLOWED_BUILTIN_TOOLS = [
491
- "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash", "Agent", "Task",
492
- "NotebookEdit", "EnterWorktree", "ExitWorktree",
493
- "CronList", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
494
- "TaskOutput", "TaskStop", "SendMessage", "Skill",
495
- "TodoRead", "TodoWrite",
496
- "ListMcpResources", "ReadMcpResource",
497
- "WebFetch", "WebSearch",
498
- "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
499
- "ToolSearch", "ScheduleWakeup",
500
- ];
501
-
502
- export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
503
- tools: [] as string[],
504
- disallowedTools: DISALLOWED_BUILTIN_TOOLS,
505
- allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
506
- } satisfies Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">;
507
-
508
- // --- Session persistence ---
509
-
510
- interface SessionState {
511
- sessionId: string;
512
- cursor: number;
513
- cwd: string;
514
- // Force the next syncSharedSession call down the REBUILD path. Set when
515
- // pi has mutated its messages array out from under us (compact, tree
516
- // navigation) or after an abort left the JSONL in an indeterminate state.
517
- // REBUILD wipes and rewrites the file to match pi's current history.
518
- needsRebuild?: boolean;
519
- // Set ONLY after an abort. The killed CC subprocess may still be flushing
520
- // a late "[Request interrupted by user]" record to the session JSONL.
521
- // Reusing the same sessionId/path would race that orphan write into our
522
- // fresh file and break CC's parent-uuid chain on the next resume. When
523
- // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
524
- // so the orphan writes land on a dead inode. Compact/tree do NOT set
525
- // this — there's no concurrent CC writer during those events, so
526
- // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
527
- forceRotate?: boolean;
528
- }
529
-
530
- let sharedSession: SessionState | null = null;
531
- let extensionApi: ExtensionAPI | undefined;
532
- let piUI: ExtensionUIContext | undefined;
533
100
  let extraUsageHelperInFlight: Promise<string> | null = null;
534
101
 
535
- const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
536
- const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
537
- export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
538
- export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
539
- export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
540
-
541
- type TimerHandle = ReturnType<typeof setTimeout>;
542
-
543
- export interface StreamIdleWatchdogState {
544
- activeQuery: unknown | null;
545
- currentPiStream: AssistantMessageEventStream | null;
546
- turnOutput: AssistantMessage | null;
547
- turnSawStreamEvent: boolean;
548
- turnStarted: boolean;
549
- }
550
-
551
- export interface StreamIdleTimeoutInfo {
552
- idleMs: number;
553
- timeoutMs: number;
554
- }
555
-
556
- export interface StreamIdleWatchdog {
557
- dispose: () => void;
558
- noteChunk: () => void;
559
- refresh: () => void;
560
- timedOut: () => boolean;
561
- }
562
-
563
- const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
564
-
565
- function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
566
- const text = value.trim().toLowerCase();
567
- if (!text) return undefined;
568
- if (["off", "false", "disabled", "disable"].includes(text)) return 0;
569
- const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
570
- if (!match) return undefined;
571
- const amount = Number(match[1]);
572
- if (!Number.isFinite(amount) || amount < 0) return undefined;
573
- const unit = (match[2] ?? defaultUnit).toLowerCase();
574
- const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
575
- ? 1
576
- : ["s", "sec", "secs", "second", "seconds"].includes(unit)
577
- ? 1000
578
- : ["m", "min", "mins", "minute", "minutes"].includes(unit)
579
- ? 60_000
580
- : undefined;
581
- if (multiplier === undefined) return undefined;
582
- const ms = Math.round(amount * multiplier);
583
- return Number.isFinite(ms) ? ms : undefined;
584
- }
585
-
586
- export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
587
- const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
588
- if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
589
- return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
590
- }
591
-
592
- function formatDurationShort(ms: number): string {
593
- if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
594
- if (ms % 60_000 === 0) return `${ms / 60_000}m`;
595
- if (ms % 1000 === 0) return `${ms / 1000}s`;
596
- return `${ms}ms`;
597
- }
598
-
599
- export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
600
- 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)}.`;
601
- }
602
-
603
- export function createStreamIdleWatchdog({
604
- clearTimer = (timer: TimerHandle) => clearTimeout(timer),
605
- getState,
606
- now = () => Date.now(),
607
- onTimeout,
608
- setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
609
- timeoutMs,
610
- }: {
611
- clearTimer?: (timer: TimerHandle) => void;
612
- getState: () => StreamIdleWatchdogState;
613
- now?: () => number;
614
- onTimeout: (info: StreamIdleTimeoutInfo) => void;
615
- setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
616
- timeoutMs: number;
617
- }): StreamIdleWatchdog {
618
- let disposed = false;
619
- let lastChunkAt = now();
620
- let timer: TimerHandle | null = null;
621
- let didTimeout = false;
622
-
623
- const clear = () => {
624
- if (!timer) return;
625
- try { clearTimer(timer); } catch { /* best effort */ }
626
- timer = null;
627
- };
628
-
629
- const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
630
- timeoutMs > 0
631
- && state.activeQuery
632
- && state.currentPiStream
633
- && state.turnOutput
634
- && !state.turnStarted
635
- && !state.turnSawStreamEvent,
636
- );
637
-
638
- const schedule = () => {
639
- clear();
640
- if (disposed || didTimeout || timeoutMs <= 0) return;
641
- const state = getState();
642
- if (!shouldMonitor(state)) return;
643
- const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
644
- const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
645
- const idleMs = Math.max(0, now() - idleStartedAt);
646
- if (idleMs >= timeoutMs) {
647
- didTimeout = true;
648
- onTimeout({ idleMs, timeoutMs });
649
- return;
650
- }
651
- timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
652
- (timer as { unref?: () => void }).unref?.();
653
- };
654
-
655
- return {
656
- dispose: () => {
657
- disposed = true;
658
- clear();
659
- },
660
- noteChunk: () => {
661
- lastChunkAt = now();
662
- schedule();
663
- },
664
- refresh: schedule,
665
- timedOut: () => didTimeout,
666
- };
667
- }
668
-
669
- export function isExtraUsageRequiredMessage(value: unknown): boolean {
670
- let text: string;
671
- if (typeof value === "string") text = value;
672
- else if (value instanceof Error) text = value.message;
673
- else {
674
- try { text = JSON.stringify(value ?? ""); }
675
- catch { text = String(value); }
676
- }
677
- return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
678
- }
679
-
680
- export function uniqueNonEmptyLines(values: unknown[]): string[] {
681
- const seen = new Set<string>();
682
- const out: string[] = [];
683
- for (const value of values) {
684
- const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
685
- if (!text || seen.has(text)) continue;
686
- seen.add(text);
687
- out.push(text);
688
- }
689
- return out;
690
- }
691
-
692
- export function formatResetTimestamp(value: unknown): string {
693
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
694
- if (!Number.isFinite(parsed)) return "unknown";
695
- return new Date(parsed).toLocaleString(undefined, {
696
- day: "numeric",
697
- hour: "numeric",
698
- minute: "2-digit",
699
- month: "short",
700
- second: "2-digit",
701
- timeZoneName: "short",
702
- year: "numeric",
703
- });
704
- }
705
-
706
- export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
707
-
708
- export function normalizeRateLimitUtilization(value: unknown): number | undefined {
709
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
710
- if (value === 0) return 0;
711
- // Claude SDK payloads have appeared as both fractions and percentages.
712
- // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
713
- if (value > 0 && value < 1) return value * 100;
714
- if (value > 1 && value <= 100) return value;
715
- return undefined;
716
- }
717
-
718
- function rateLimitTypeLabel(value: unknown): string {
719
- const text = typeof value === "string" ? value.trim() : "";
720
- return text || "unknown";
721
- }
722
-
723
- export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
724
- if (info?.status !== "allowed_warning") return undefined;
725
- const utilization = normalizeRateLimitUtilization(info.utilization);
726
- if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
727
- return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
728
- }
729
-
730
102
  function emitRateLimitEvent(payload: Record<string, unknown>): void {
731
103
  try {
732
104
  extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
@@ -798,180 +170,6 @@ function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: st
798
170
  return true;
799
171
  }
800
172
 
801
- const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
802
-
803
- interface PersistedBridgeSessionState extends SessionState {
804
- fingerprint: string;
805
- piSessionId?: string;
806
- updatedAt: string;
807
- }
808
-
809
- function fingerprintMessages(messages: Context["messages"]): string {
810
- const normalized = messages.map((message) => {
811
- if (message.role === "assistant") {
812
- return {
813
- role: message.role,
814
- provider: (message as AssistantMessage).provider,
815
- model: (message as AssistantMessage).model,
816
- content: (message as AssistantMessage).content,
817
- };
818
- }
819
- return message;
820
- });
821
- return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
822
- }
823
-
824
- function readBuiltSessionContext(sessionManager: unknown): { messages: Context["messages"] } | undefined {
825
- const built = typeof (sessionManager as any)?.buildSessionContext === "function" ? (sessionManager as any).buildSessionContext() : undefined;
826
- return Array.isArray(built?.messages) ? built as { messages: Context["messages"] } : undefined;
827
- }
828
-
829
- function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeSessionState | undefined {
830
- const entries = typeof (sessionManager as any)?.getEntries === "function" ? (sessionManager as any).getEntries() : [];
831
- if (!Array.isArray(entries)) return undefined;
832
- for (let i = entries.length - 1; i >= 0; i--) {
833
- const entry = entries[i];
834
- if (entry?.type !== "custom" || entry.customType !== BRIDGE_SESSION_CUSTOM_TYPE) continue;
835
- const data = entry.data as Partial<PersistedBridgeSessionState> | undefined;
836
- if (!data || typeof data.sessionId !== "string" || typeof data.cursor !== "number" || typeof data.cwd !== "string" || typeof data.fingerprint !== "string") continue;
837
- return data as PersistedBridgeSessionState;
838
- }
839
- return undefined;
840
- }
841
-
842
- function claudeSessionExists(sessionId: string, cwd: string): boolean {
843
- try {
844
- const session = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
845
- statSync(session.jsonlPath);
846
- return true;
847
- } catch {
848
- return false;
849
- }
850
- }
851
-
852
- function canonicalize(p: string | undefined): string | undefined {
853
- if (!p) return undefined;
854
- try { return realpathSync.native(p); } catch { return pathResolve(p); }
855
- }
856
-
857
- // Decides whether a persisted bridge-session marker is safe to restore.
858
- //
859
- // The fork case is the load-bearing one: pi/core's createBranchedSession copies
860
- // every non-label entry from root→leaf into the new session file. That includes
861
- // our claude-bridge-session markers from the parent. Restoring from them would
862
- // --resume parent's Claude jsonl on the fork's first turn, leaking conversation
863
- // past the fork point.
864
- //
865
- // Returns undefined when the entry is safe to use, or a short rejection reason
866
- // for diagnostic logging. Old entries without piSessionId always reject, which
867
- // degrades safely to the rebuild path.
868
- export function shouldRestorePersistedBridgeEntry(
869
- persisted: { piSessionId?: string; cwd: string },
870
- currentPiSessionId: string | undefined,
871
- currentCwd: string | undefined,
872
- ): string | undefined {
873
- if (!persisted.piSessionId) return "missing piSessionId";
874
- if (currentPiSessionId && persisted.piSessionId !== currentPiSessionId) {
875
- return `piSessionId mismatch (persisted=${persisted.piSessionId} current=${currentPiSessionId})`;
876
- }
877
- if (currentCwd && canonicalize(persisted.cwd) !== canonicalize(currentCwd)) {
878
- return `cwd mismatch (persisted=${persisted.cwd} current=${currentCwd})`;
879
- }
880
- return undefined;
881
- }
882
-
883
- export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?: string }): void {
884
- const persisted = latestPersistedBridgeSession(ctx.sessionManager);
885
- if (!persisted) return;
886
- const currentPiSessionId = typeof (ctx.sessionManager as any)?.getSessionId === "function" ? (ctx.sessionManager as any).getSessionId() : undefined;
887
- const currentCwd = typeof (ctx.sessionManager as any)?.getCwd === "function" ? (ctx.sessionManager as any).getCwd() : ctx.cwd;
888
- const rejection = shouldRestorePersistedBridgeEntry(persisted, currentPiSessionId, currentCwd);
889
- if (rejection) {
890
- debug(`restoreSharedSession: ${rejection} — forcing rebuild`);
891
- return;
892
- }
893
- const built = readBuiltSessionContext(ctx.sessionManager);
894
- if (!built) return;
895
- const cursor = Math.max(0, Math.min(persisted.cursor, built.messages.length));
896
- const fingerprint = fingerprintMessages(built.messages.slice(0, cursor));
897
- if (fingerprint !== persisted.fingerprint) {
898
- debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
899
- return;
900
- }
901
- if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
902
- debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
903
- return;
904
- }
905
- sharedSession = { sessionId: persisted.sessionId, cursor, cwd: persisted.cwd };
906
- debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
907
- }
908
-
909
- function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
910
- if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
911
- const snapshot = { ...sharedSession };
912
- const timer = setTimeout(() => {
913
- try {
914
- const built = readBuiltSessionContext(ctxLike.sessionManager);
915
- if (!built) return;
916
- const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
917
- const data: PersistedBridgeSessionState = {
918
- ...snapshot,
919
- cursor,
920
- fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
921
- piSessionId: typeof (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
922
- updatedAt: new Date().toISOString(),
923
- };
924
- extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
925
- debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
926
- } catch (error) {
927
- debug("persistSharedSession failed:", error);
928
- }
929
- }, 0);
930
- timer.unref?.();
931
- }
932
-
933
- // Convert pi messages to Anthropic API format for session import.
934
- // Lossy: non-Anthropic thinking blocks are dropped (no valid signature). User and
935
- // tool-result image blocks are preserved when possible. If assistant blocks are
936
- // otherwise incompatible, convertPiMessages emits a text placeholder so the record
937
- // sequence stays valid before repairToolPairing runs.
938
- function convertAndImportMessages(
939
- session: ReturnType<typeof createSession>,
940
- messages: Context["messages"],
941
- customToolNameToSdk?: Map<string, string>,
942
- cwd?: string,
943
- ): void {
944
- const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
945
-
946
- debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
947
- debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
948
- const c = m.content;
949
- if (typeof c === "string") return `[${i}]${m.role}:text`;
950
- if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
951
- return `[${i}]${m.role}:?`;
952
- }).join(" "));
953
- if (sanitizedIds.size > 0) {
954
- debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
955
- [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
956
- }
957
- // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
958
- const missingToolResults = findUnpairedToolUses(anthropicMessages);
959
- const repaired = repairToolPairing(anthropicMessages);
960
- if (missingToolResults.length > 0) {
961
- reportSyntheticToolResultRepair(missingToolResults, {
962
- cwd,
963
- messageCount: messages.length,
964
- anthropicMessageCount: anthropicMessages.length,
965
- sessionId: session.sessionId,
966
- jsonlPath: session.jsonlPath,
967
- });
968
- }
969
- if (repaired.length !== anthropicMessages.length) {
970
- debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
971
- }
972
- if (repaired.length) session.importMessages(repaired);
973
- }
974
-
975
173
  // Pi doesn't pass tool results directly — it appends them to the context and calls
976
174
  // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
977
175
  // debug logging at the extraction boundary.
@@ -1036,198 +234,6 @@ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDK
1036
234
  };
1037
235
  }
1038
236
 
1039
-
1040
- interface SyncResult {
1041
- sessionId: string | null;
1042
- }
1043
-
1044
- /**
1045
- * Ensure the shared session has all messages up to (but not including) the last user message.
1046
- * Returns session ID to resume from, or null if no resume needed.
1047
- */
1048
- // Read the session file we just wrote and sanity-check it. Warns instead of
1049
- // throwing — CC may be more tolerant than our checks, so a false positive
1050
- // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
1051
- // fans each warning out to debug log + piUI notify + diagDump.
1052
- function verifyWrittenSession(
1053
- jsonlPath: string,
1054
- expectedSessionId: string,
1055
- expectedRecordCount: number,
1056
- cwd: string,
1057
- ): void {
1058
- const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
1059
- for (const msg of warnings) {
1060
- debug(`WARNING session verify: ${msg}`);
1061
- piUI?.notify(
1062
- `Session file issue: ${msg}\n` +
1063
- `cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
1064
- `Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
1065
- (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
1066
- "warning",
1067
- );
1068
- diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
1069
- }
1070
- }
1071
-
1072
- function safeRealpath(p: string): string {
1073
- try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
1074
- }
1075
-
1076
- // Diagnostic snapshot of where a session file was just written. Catches the
1077
- // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
1078
- // from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
1079
- function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
1080
- const realCwd = safeRealpath(cwd);
1081
- let fileSize: number | null = null;
1082
- let fileExists = false;
1083
- try {
1084
- const st = statSync(jsonlPath);
1085
- fileExists = true;
1086
- fileSize = st.size;
1087
- } catch { /* file may not exist yet */ }
1088
- debug(`${label}: cwd=${cwd}`);
1089
- if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
1090
- debug(`${label}: jsonlPath=${jsonlPath}`);
1091
- debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
1092
- debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
1093
- }
1094
-
1095
- // Two semantic paths:
1096
- // REUSE — pi's history is in sync with the existing sharedSession (or drifted
1097
- // only by the trailing final-assistant message that pi appends after
1098
- // streamSimple returns, which CC's own persisted session already has).
1099
- // Returns the existing sessionId. Keeps CC's prompt cache warm.
1100
- // REBUILD — no session yet, or pi's history has diverged (non-trailing
1101
- // missed messages, e.g. another provider took a turn). Wipes the existing
1102
- // session file (if any) and writes a fresh one containing all prior
1103
- // messages, reusing the same sessionId across rebuilds so UUIDs stay
1104
- // stable for the lifetime of pi's session.
1105
- //
1106
- // Why a full rebuild rather than patching:
1107
- // Injecting deltas into an existing session creates a branch that CC's
1108
- // --resume doesn't follow (documented attempt prior to this). A complete
1109
- // overwrite at the same path is simpler and correct.
1110
- //
1111
- // Why reuse the sessionId across rebuilds:
1112
- // CC re-reads the JSONL on every --resume call — no in-process UUID
1113
- // caching. Validated in tests/exp-session-clear.mjs, including the case
1114
- // where CC had appended its own tool_use/tool_result records between
1115
- // rebuilds. Preserving the UUID means stable log correlation across
1116
- // provider switches and no orphaned session files.
1117
- //
1118
- // Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
1119
- // int-session-resume.mjs) keep grepping the same anchors.
1120
- function syncSharedSession(
1121
- messages: Context["messages"],
1122
- cwd: string,
1123
- customToolNameToSdk?: Map<string, string>,
1124
- modelId?: string,
1125
- ): SyncResult {
1126
- const priorMessages = messages.slice(0, -1); // everything before the new user prompt
1127
-
1128
- // REUSE path
1129
- if (sharedSession && !sharedSession.needsRebuild) {
1130
- const missed = priorMessages.slice(sharedSession.cursor);
1131
- const trailingAssistantOnly =
1132
- missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
1133
- if (missed.length === 0 || trailingAssistantOnly) {
1134
- if (trailingAssistantOnly) {
1135
- sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
1136
- }
1137
- debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
1138
- debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
1139
- return { sessionId: sharedSession.sessionId };
1140
- }
1141
- }
1142
-
1143
- // REBUILD path
1144
- if (priorMessages.length === 0) {
1145
- debug(`Case 1: clean start, ${messages.length} total messages`);
1146
- debug(`syncResult: path=clean-start`);
1147
- return { sessionId: null };
1148
- }
1149
- const previousSessionId = sharedSession?.sessionId;
1150
- const previousCursor = sharedSession?.cursor ?? 0;
1151
- // preserveId: rebuild in place (deleteSession + createSession with the
1152
- // existing UUID), so prompt-cache UUIDs stay stable for log correlation
1153
- // and for any tools that key off them. Skipped only when there's a
1154
- // concurrent writer we shouldn't race — see forceRotate docs above.
1155
- const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
1156
- if (preserveId) {
1157
- // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
1158
- deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
1159
- }
1160
- const session = createSession({
1161
- projectPath: cwd,
1162
- claudeDir: process.env.CLAUDE_CONFIG_DIR,
1163
- ...(preserveId ? { sessionId: previousSessionId } : {}),
1164
- ...(modelId ? { model: modelId } : {}),
1165
- });
1166
- convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
1167
- session.save();
1168
- verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
1169
- sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
1170
- if (previousSessionId === undefined) {
1171
- debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
1172
- } else if (preserveId) {
1173
- const missedCount = priorMessages.length - previousCursor;
1174
- debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
1175
- } else {
1176
- 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`);
1177
- }
1178
- debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
1179
- debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
1180
- return { sessionId: session.sessionId };
1181
- }
1182
-
1183
- // --- Provider helpers: tool name mapping ---
1184
-
1185
- export function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
1186
- const normalized = name.toLowerCase();
1187
- const builtin = SDK_TO_PI_TOOL_NAME[normalized];
1188
- if (builtin) return builtin;
1189
- if (customToolNameToPi) {
1190
- const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
1191
- if (mapped) return mapped;
1192
- }
1193
- for (const prefix of [
1194
- MCP_TOOL_PREFIX,
1195
- `mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
1196
- `mcp/${MCP_SERVER_NAME}/`,
1197
- `mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
1198
- ]) {
1199
- if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
1200
- }
1201
- return name;
1202
- }
1203
-
1204
- // Renames for Claude Code SDK param names that differ from pi's native names.
1205
- // Keys not listed here pass through unchanged, so new pi params work automatically.
1206
- const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
1207
- read: { file_path: "path" },
1208
- write: { file_path: "path" },
1209
- edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
1210
- };
1211
-
1212
- // Maps SDK tool args to pi tool args via key renaming + pass-through.
1213
- // Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
1214
- function mapToolArgs(
1215
- toolName: string, args: Record<string, unknown> | undefined,
1216
- ): Record<string, unknown> {
1217
- const input = args ?? {};
1218
- const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
1219
- const result: Record<string, unknown> = {};
1220
- for (const [key, value] of Object.entries(input)) {
1221
- const piKey = renames?.[key] ?? key;
1222
- if (!(piKey in result)) result[piKey] = value; // first alias wins
1223
- }
1224
- // Pi bash has no default timeout; add a safety default
1225
- if (toolName.toLowerCase() === "bash" && result.timeout == null) {
1226
- result.timeout = 120;
1227
- }
1228
- return result;
1229
- }
1230
-
1231
237
  // --- Provider helpers: tool resolution ---
1232
238
 
1233
239
  // --- Provider helpers: tool bridge ---
@@ -1261,6 +267,55 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
1261
267
  return { mcpTools, customToolNameToSdk, customToolNameToPi };
1262
268
  }
1263
269
 
270
+ /** Finalizes the current pi turn when the SDK invokes an MCP tool handler
271
+ * before emitting `message_stop` or the completed assistant message.
272
+ *
273
+ * Observed with Claude Code under pi 0.80's steer draining (tool result and
274
+ * drained steer arrive in one provider call): the NEXT tool turn's tool_use
275
+ * streams in, the SDK invokes the MCP handler — and neither terminal event
276
+ * ever arrives. The invocation itself proves the assistant turn is committed,
277
+ * so end the pi stream here exactly like the `message_stop` path; otherwise
278
+ * the handler blocks on a result pi will never deliver (deadlock). No-op when
279
+ * the turn already ended (stream null) or the tool call isn't part of the
280
+ * currently streamed turn. */
281
+ function finalizeToolUseTurnFromMcpInvocation(
282
+ queryCtx: QueryContext,
283
+ toolCallId: string,
284
+ toolName: string,
285
+ mappedArgs: Record<string, unknown>,
286
+ ): void {
287
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
288
+ let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
289
+ if (idx >= 0) {
290
+ const block = queryCtx.turnBlocks[idx] as any;
291
+ if ("partialJson" in block) {
292
+ // Stream ended before content_block_stop — settle the args from the
293
+ // partial JSON the same way content_block_stop would have.
294
+ block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
295
+ queryCtx.updateToolCallArgs(block.id, block.arguments);
296
+ delete block.partialJson;
297
+ delete block.index;
298
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
299
+ }
300
+ } else {
301
+ // The invocation can arrive before the tool_use is streamed at all
302
+ // (observed after a tool-result+steer provider call reset the turn):
303
+ // synthesize the toolCall from the claim — the MCP call carries the
304
+ // authoritative id, name, and arguments.
305
+ queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
306
+ idx = queryCtx.turnBlocks.length - 1;
307
+ const block = queryCtx.turnBlocks[idx] as any;
308
+ queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
309
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
310
+ }
311
+ queryCtx.turnSawToolCall = true;
312
+ queryCtx.turnOutput.stopReason = "toolUse";
313
+ debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — SDK invoked the tool before message_stop/assistant message`);
314
+ queryCtx.currentPiStream.push({ type: "done", reason: "toolUse", message: queryCtx.turnOutput });
315
+ queryCtx.currentPiStream.end();
316
+ queryCtx.currentPiStream = null;
317
+ }
318
+
1264
319
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
1265
320
  // blocks on a Promise until pi delivers the tool result via streamSimple.
1266
321
  // Handlers claim their tool_call id by matching the actual MCP call
@@ -1299,6 +354,7 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
1299
354
  return result;
1300
355
  }
1301
356
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
357
+ finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs);
1302
358
  return new Promise<McpResult>((resolve) => {
1303
359
  queryCtx.pendingToolCalls.set(toolCallId, {
1304
360
  toolName: tool.name,
@@ -1314,25 +370,11 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
1314
370
  return { [MCP_SERVER_NAME]: server };
1315
371
  }
1316
372
 
1317
- // --- Usage helpers ---
1318
-
1319
- function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
1320
- if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
1321
- if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
1322
- if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
1323
- if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
1324
- output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
1325
- calculateCost(model, output.usage);
1326
- const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
1327
- const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
1328
- 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}`);
1329
- }
1330
-
1331
373
  // --- Effort level mapping ---
1332
374
  // Pi reasoning levels → CC SDK effort levels
1333
375
 
1334
376
  const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
1335
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
377
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
1336
378
  };
1337
379
 
1338
380
  function normalizeEffortOverrideModelKey(value: string): string {
@@ -1355,22 +397,6 @@ export function resolveConfiguredEffort(
1355
397
  return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
1356
398
  }
1357
399
 
1358
- // --- Provider helpers: misc ---
1359
-
1360
- function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
1361
- switch (reason) {
1362
- case "tool_use": return "toolUse";
1363
- case "max_tokens": return "length";
1364
- case "end_turn": default: return "stop";
1365
- }
1366
- }
1367
-
1368
- function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
1369
- if (!input) return fallback;
1370
- try { return JSON.parse(input); } catch { return fallback; }
1371
- }
1372
-
1373
-
1374
400
  // --- Provider: streaming function ---
1375
401
  //
1376
402
  // Push-based streaming with MCP tool bridge:
@@ -1386,279 +412,6 @@ function parsePartialJson(input: string, fallback: Record<string, unknown>): Rec
1386
412
  // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
1387
413
  // in consumeQuery and are skipped before resetTurnState runs.
1388
414
 
1389
- function ensureTurnStarted(): void {
1390
- if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
1391
- ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
1392
- ctx().turnStarted = true;
1393
- }
1394
- }
1395
-
1396
- function finalizeCurrentStream(stopReason?: string): void {
1397
- if (!ctx().currentPiStream || !ctx().turnOutput) return;
1398
- debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
1399
- if (!ctx().turnStarted) ensureTurnStarted();
1400
- const reason = stopReason === "length" ? "length" : "stop";
1401
- ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
1402
- ctx().currentPiStream!.end();
1403
- ctx().currentPiStream = null;
1404
- }
1405
-
1406
- function updateTurnOutputModel(modelId: unknown): void {
1407
- const c = ctx();
1408
- if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
1409
- if (c.turnOutput.model === modelId) return;
1410
- debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
1411
- c.turnOutput.model = modelId;
1412
- }
1413
-
1414
- /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1415
- * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1416
- export function processStreamEvent(
1417
- message: SDKMessage,
1418
- customToolNameToPi: Map<string, string>,
1419
- model: Model<any>,
1420
- ): void {
1421
- const c = ctx();
1422
- if (!c.currentPiStream || !c.turnOutput) return;
1423
- const event = (message as SDKMessage & { event: any }).event;
1424
- if (event?.type === "ping") return;
1425
- if (event?.type === "message_stop" && !c.turnSawToolCall) {
1426
- debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
1427
- return;
1428
- }
1429
-
1430
- if (event?.type === "message_start") {
1431
- c.resetToolTracking();
1432
- updateTurnOutputModel(event.message?.model);
1433
- if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1434
- return;
1435
- }
1436
-
1437
- if (event?.type === "content_block_start") {
1438
- c.turnSawStreamEvent = true;
1439
- ensureTurnStarted();
1440
- if (event.content_block?.type === "text") {
1441
- c.turnBlocks.push({ type: "text", text: "", index: event.index });
1442
- c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1443
- } else if (event.content_block?.type === "thinking") {
1444
- c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1445
- c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1446
- } else if (event.content_block?.type === "tool_use") {
1447
- c.turnSawToolCall = true;
1448
- const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
1449
- c.recordToolCall(event.content_block.id, mappedName, {});
1450
- c.turnBlocks.push({
1451
- type: "toolCall", id: event.content_block.id,
1452
- name: mappedName,
1453
- arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1454
- partialJson: "", index: event.index,
1455
- });
1456
- c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1457
- } else {
1458
- debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
1459
- }
1460
- return;
1461
- }
1462
-
1463
- if (event?.type === "content_block_delta") {
1464
- const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1465
- const block = c.turnBlocks[index];
1466
- if (!block) {
1467
- debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
1468
- return;
1469
- }
1470
- c.turnSawStreamEvent = true;
1471
- if (event.delta?.type === "text_delta" && block.type === "text") {
1472
- block.text += event.delta.text;
1473
- c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
1474
- } else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
1475
- block.thinking += event.delta.thinking;
1476
- c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
1477
- } else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
1478
- block.partialJson += event.delta.partial_json;
1479
- block.arguments = parsePartialJson(block.partialJson, block.arguments);
1480
- c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
1481
- } else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
1482
- block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
1483
- } else {
1484
- debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
1485
- }
1486
- return;
1487
- }
1488
-
1489
- if (event?.type === "content_block_stop") {
1490
- const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1491
- const block = c.turnBlocks[index];
1492
- if (!block) {
1493
- debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
1494
- return;
1495
- }
1496
- c.turnSawStreamEvent = true;
1497
- delete block.index;
1498
- if (block.type === "text") {
1499
- c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
1500
- } else if (block.type === "thinking") {
1501
- c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
1502
- } else if (block.type === "toolCall") {
1503
- c.turnSawToolCall = true;
1504
- block.arguments = mapToolArgs(
1505
- block.name, parsePartialJson(block.partialJson, block.arguments),
1506
- );
1507
- c.updateToolCallArgs(block.id, block.arguments);
1508
- delete block.partialJson;
1509
- c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1510
- }
1511
- return;
1512
- }
1513
-
1514
- if (event?.type === "message_delta") {
1515
- c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
1516
- if (event.usage) updateUsage(c.turnOutput, event.usage, model);
1517
- return;
1518
- }
1519
-
1520
- if (event?.type === "message_stop" && c.turnSawToolCall) {
1521
- // Tool call complete — end this pi stream. The SDK will still yield an
1522
- // assistant message for this turn, but currentPiStream=null causes
1523
- // consumeQuery to skip it. The MCP handler blocks the generator until
1524
- // pi delivers the tool result via the next streamSimple call.
1525
- c.turnOutput.stopReason = "toolUse";
1526
- c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1527
- c.currentPiStream!.end();
1528
- c.currentPiStream = null;
1529
-
1530
- // Cursor is updated by the next streamSimple call (tool result delivery path)
1531
- // which sets cursor = context.messages.length with the post-tool-result context.
1532
- return;
1533
- }
1534
-
1535
- if (event?.type !== "message_stop" && event?.type !== "ping") {
1536
- debug("processStreamEvent: unhandled event type", event?.type);
1537
- }
1538
- }
1539
-
1540
- // The SDK always yields `assistant` messages (completed content blocks) after streaming.
1541
- // When stream_events already delivered the content, this is a no-op. But after
1542
- // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
1543
- // arrives before any stream_events, this is the primary content path. Must maintain
1544
- // the same stream lifecycle as processStreamEvent — including ending the stream on
1545
- // tool_use to prevent deadlock with the MCP handler.
1546
- function appendMissingToolUsesFromAssistant(
1547
- assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
1548
- model: Model<any>,
1549
- customToolNameToPi: Map<string, string>,
1550
- ): boolean {
1551
- const c = ctx();
1552
- if (!assistantMsg?.content) return false;
1553
- let sawToolUse = false;
1554
- for (const block of assistantMsg.content) {
1555
- if (block.type !== "tool_use") continue;
1556
- sawToolUse = true;
1557
- const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
1558
- const name = mapToolName(block.name, customToolNameToPi);
1559
- const mappedArgs = mapToolArgs(name, block.input);
1560
- c.recordToolCall(block.id, name, mappedArgs);
1561
- if (existingIdx >= 0) {
1562
- const existing = c.turnBlocks[existingIdx] as any;
1563
- existing.name = name;
1564
- existing.arguments = mappedArgs;
1565
- c.updateToolCallArgs(block.id, mappedArgs);
1566
- if ("partialJson" in existing) {
1567
- delete existing.partialJson;
1568
- delete existing.index;
1569
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
1570
- }
1571
- continue;
1572
- }
1573
-
1574
- ensureTurnStarted();
1575
- c.turnBlocks.push({
1576
- type: "toolCall", id: block.id,
1577
- name,
1578
- arguments: mappedArgs,
1579
- });
1580
- const idx = c.turnBlocks.length - 1;
1581
- const toolBlock = c.turnBlocks[idx];
1582
- c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1583
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1584
- }
1585
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1586
- return sawToolUse;
1587
- }
1588
-
1589
- export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1590
- const c = ctx();
1591
- const assistantMsg = (message as any).message;
1592
- if (!assistantMsg?.content) return;
1593
- updateTurnOutputModel(assistantMsg.model);
1594
- if (c.turnSawStreamEvent) {
1595
- // Claude Agent SDK can yield the completed assistant message before (or
1596
- // instead of) a stream_event message_stop for a tool-use turn. Treat that
1597
- // assistant message as a hard turn boundary so Pi executes the tool calls
1598
- // and the MCP handlers stay blocked until real tool results are delivered.
1599
- // Without this fallback, Claude Code can continue internally with empty MCP
1600
- // results and Pi only sees the real outputs one render cycle later.
1601
- if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
1602
- c.turnSawToolCall = true;
1603
- if (c.currentPiStream && c.turnOutput) {
1604
- c.turnOutput.stopReason = "toolUse";
1605
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1606
- c.currentPiStream.end();
1607
- c.currentPiStream = null;
1608
- debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
1609
- }
1610
- }
1611
- return;
1612
- }
1613
- c.resetToolTracking();
1614
- debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1615
- for (const block of assistantMsg.content) {
1616
- if (block.type === "text" && block.text) {
1617
- ensureTurnStarted();
1618
- c.turnBlocks.push({ type: "text", text: block.text });
1619
- const idx = c.turnBlocks.length - 1;
1620
- c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
1621
- c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
1622
- c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
1623
- } else if (block.type === "thinking") {
1624
- ensureTurnStarted();
1625
- c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
1626
- const idx = c.turnBlocks.length - 1;
1627
- c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
1628
- if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
1629
- c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
1630
- } else if (block.type === "tool_use") {
1631
- ensureTurnStarted();
1632
- c.turnSawToolCall = true;
1633
- const mappedName = mapToolName(block.name, customToolNameToPi);
1634
- const mappedArgs = mapToolArgs(mappedName, block.input);
1635
- c.recordToolCall(block.id, mappedName, mappedArgs);
1636
- c.turnBlocks.push({
1637
- type: "toolCall", id: block.id,
1638
- name: mappedName,
1639
- arguments: mappedArgs,
1640
- });
1641
- const idx = c.turnBlocks.length - 1;
1642
- const toolBlock = c.turnBlocks[idx];
1643
- c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1644
- c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1645
- } else if (block.type === "fallback") {
1646
- updateTurnOutputModel(block.to?.model);
1647
- } else {
1648
- debug("processAssistantMessage: unhandled block type", block.type);
1649
- }
1650
- }
1651
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1652
-
1653
- // End the stream on tool_use, same as processStreamEvent's message_stop handler.
1654
- if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
1655
- c.turnOutput.stopReason = "toolUse";
1656
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1657
- c.currentPiStream.end();
1658
- c.currentPiStream = null;
1659
- }
1660
- }
1661
-
1662
415
  /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
1663
416
  * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
1664
417
  * an assistant message (completed blocks). On tool_use, the stream is ended by
@@ -1717,8 +470,13 @@ async function consumeQuery(
1717
470
  const fallbackModel = (message as any).fallback_model;
1718
471
  updateTurnOutputModel(fallbackModel);
1719
472
  debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
1720
- if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
1721
- 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
+ );
1722
480
  }
1723
481
  }
1724
482
  break;
@@ -1762,6 +520,92 @@ async function consumeQuery(
1762
520
  return { capturedSessionId };
1763
521
  }
1764
522
 
523
+ // Claim the primary-instance token for this module instance if unclaimed, and
524
+ // report whether this instance is the primary. First-loaded instance wins,
525
+ // UNCONDITIONALLY (before any credential check), so a later subagent load can
526
+ // never become primary and steal registration ownership.
527
+ function claimPrimaryInstance(): boolean {
528
+ const g = globalThis as Record<symbol, any>;
529
+ if (!g[PRIMARY_INSTANCE_KEY]) g[PRIMARY_INSTANCE_KEY] = streamClaudeAgentSdk;
530
+ return g[PRIMARY_INSTANCE_KEY] === streamClaudeAgentSdk;
531
+ }
532
+
533
+ // Release both process-global tokens this instance owns. Called on
534
+ // session_shutdown (incl. /reload) so the freshly loaded instance starts clean.
535
+ // NOTE: this does NOT unregister the provider — the ModelRegistry's
536
+ // registeredProviders is a process-lifetime Map that survives module reload, so
537
+ // retraction on logout is handled by applyProviderRegistration's defensive
538
+ // unregister on the next load/session_start, not here.
539
+ function releaseProviderTokens(event: string): void {
540
+ const g = globalThis as Record<symbol, any>;
541
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
542
+ debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
543
+ g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
544
+ }
545
+ if (g[PRIMARY_INSTANCE_KEY] === streamClaudeAgentSdk) {
546
+ debug(`${event}: clearing PRIMARY_INSTANCE_KEY`);
547
+ g[PRIMARY_INSTANCE_KEY] = undefined;
548
+ }
549
+ }
550
+
551
+ // Conditional (un)registration driven by real credential presence + instance
552
+ // primacy. Run at extension load, on every session_start, and at pre-spawn
553
+ // (fail-fast) so a `claude login` / logout is reflected without a /reload.
554
+ //
555
+ // decideRegistration encodes the pure state machine; this wrapper performs the
556
+ // matching token mutations so tokens and registration never diverge:
557
+ // - register: claim the stream guard, THEN registerProvider. If register
558
+ // throws/queue-fails, release the stream guard (but keep primacy) so a
559
+ // later re-check can retry cleanly (self-healing); errors are swallowed so
560
+ // a session_start handler can't crash the dispatch.
561
+ // - unregister: pi.unregisterProvider (idempotent), THEN release the stream
562
+ // guard if we own it. Defensive even when we never registered — this is the
563
+ // only retraction path for a stale registration surviving /reload. At LOAD
564
+ // the SDK's unregister only filters the pending-registration queue and can't
565
+ // mutate the persistent registry (loader.js), so the effective retraction
566
+ // lands on the post-load session_start re-check; the load-time call is a
567
+ // harmless idempotent no-op that also cancels any same-pass queued register.
568
+ // Non-primary instances (subagents) always decide noop and touch nothing.
569
+ function applyProviderRegistration(trigger: string): void {
570
+ const pi = extensionApi;
571
+ if (!pi) { debug(`${trigger}: applyProviderRegistration skipped — no extensionApi`); return; }
572
+ const g = globalThis as Record<symbol, any>;
573
+ const isPrimary = claimPrimaryInstance();
574
+ const credentialed = hasClaudeCredentials();
575
+ const registered = g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk;
576
+ const decision = decideRegistration({ credentialed, isPrimary, registered });
577
+ debug(`${trigger}: registration decision=${decision} credentialed=${credentialed} isPrimary=${isPrimary} registered=${registered} (module=${moduleInstanceId})`);
578
+ if (decision === "register") {
579
+ // Claim ordering: stream guard BEFORE registerProvider so a concurrent
580
+ // subagent can never observe a registered provider without an owner.
581
+ g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
582
+ try {
583
+ pi.registerProvider(PROVIDER_ID, {
584
+ baseUrl: "claude-bridge",
585
+ apiKey: "not-used",
586
+ api: "claude-bridge",
587
+ models: MODELS,
588
+ // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
589
+ streamSimple: streamClaudeAgentSdk as any,
590
+ });
591
+ } catch (err) {
592
+ // Self-heal: release ONLY the stream guard we just claimed so a later
593
+ // re-check (primary + credentialed + not-registered → register) retries.
594
+ // Keep PRIMARY_INSTANCE_KEY: releasing it would reopen the subagent
595
+ // ownership-steal window, and retry does not need it released.
596
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
597
+ debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
598
+ }
599
+ } else if (decision === "unregister") {
600
+ try {
601
+ pi.unregisterProvider(PROVIDER_ID);
602
+ } catch (err) {
603
+ debug(`${trigger}: unregisterProvider threw (ignored):`, err);
604
+ }
605
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
606
+ }
607
+ }
608
+
1765
609
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
1766
610
  * Two cases: tool result delivery (active query) or fresh query. */
1767
611
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
@@ -1861,6 +705,34 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1861
705
 
1862
706
  // --- Fresh query ---
1863
707
 
708
+ // Fail-fast credential re-check (only for a fresh query — NEVER for
709
+ // tool-result delivery of an in-flight query, handled above, where creds were
710
+ // valid at start and failing mid-turn would break tool pairing). This bounds
711
+ // the retraction-latency window from "next session boundary" to "first use":
712
+ // if credentials vanished since the last session_start, (a) trigger the same
713
+ // re-evaluation applyProviderRegistration does (primary-only; retracts the
714
+ // stale registration), and (b) fail this request with a clear, actionable
715
+ // message instead of letting the SDK spawn die with a generic error. The
716
+ // check is cheap (existsSync + env reads only, no credential contents).
717
+ if (!hasClaudeCredentials()) {
718
+ try { applyProviderRegistration("pre-spawn"); } catch { /* best effort */ }
719
+ const message = "Claude account not connected — connect an account (or run `claude login`) and retry.";
720
+ debug(`provider: pre-spawn credential check failed; failing fast: ${message}`);
721
+ const errorOutput: AssistantMessage = {
722
+ role: "assistant", content: [],
723
+ api: model.api, provider: model.provider, model: model.id,
724
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
725
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
726
+ stopReason: "error", timestamp: Date.now(),
727
+ errorMessage: message,
728
+ };
729
+ queueMicrotask(() => {
730
+ stream.push({ type: "error", reason: "error", error: errorOutput });
731
+ stream.end();
732
+ });
733
+ return stream;
734
+ }
735
+
1864
736
  // 1. Determine reentrancy and push parent context if needed.
1865
737
  const isReentrant = ctx().activeQuery !== null;
1866
738
  if (isReentrant) pushContext();
@@ -1902,6 +774,13 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1902
774
  const mcpServers = buildMcpServers(mcpTools, ctx());
1903
775
  const bridgeConfig = loadConfig(cwd);
1904
776
  const providerSettings = bridgeConfig.provider ?? {};
777
+ // Whether to expose the Claude account's claude.ai cloud MCP connectors
778
+ // (Gmail/Calendar/Drive). Enabled via env or config; drives setting-sources,
779
+ // tool isolation, and the ENABLE_CLAUDEAI_MCP_SERVERS child-env gate below.
780
+ const enableCloudMcp = connectorsEnabledFor(bridgeConfig);
781
+ // Connector WRITE control: read-only by default (writes denied); the one-shot
782
+ // approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
783
+ const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
1905
784
  const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
1906
785
  const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
1907
786
  const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
@@ -1913,9 +792,16 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1913
792
  // SDK uses isolation mode and avoids filesystem settings. If users turn that
1914
793
  // off, load user/project settings but pass --strict-mcp-config so Claude Code
1915
794
  // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
1916
- const settingSources: SettingSource[] | undefined = appendSystemPrompt
1917
- ? undefined
1918
- : providerSettings.settingSources ?? ["user", "project"];
795
+ // claude.ai cloud MCP connectors only load when Claude Code resolves its
796
+ // filesystem setting sources. The SDK treats settingSources=undefined as
797
+ // isolation (no sources), which drops the connectors even with
798
+ // ENABLE_CLAUDEAI_MCP_SERVERS=1. When connectors are enabled we force the CLI
799
+ // default source set so Gmail/Calendar/Drive surface.
800
+ const settingSources: SettingSource[] | undefined = enableCloudMcp
801
+ ? (providerSettings.settingSources ?? ["user", "project", "local"])
802
+ : appendSystemPrompt
803
+ ? undefined
804
+ : providerSettings.settingSources ?? ["user", "project"];
1919
805
  const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
1920
806
  const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
1921
807
  const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
@@ -1947,12 +833,14 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1947
833
  // also autocompact would double-flush the prompt cache and races pi's
1948
834
  // threshold with CC's, including CC's anti-thrashing guard (issue #8).
1949
835
  // Manual /compact in CC still works (we never invoke it).
1950
- const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
836
+ // When connectors are enabled, allow claude.ai cloud MCP servers so the
837
+ // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
838
+ const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: enableCloudMcp ? "1" : "0", DISABLE_AUTO_COMPACT: "1" };
1951
839
  const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
1952
840
  cwd,
1953
841
  model: model.id,
1954
842
  env: childEnv,
1955
- ...CLAUDE_BRIDGE_TOOL_ISOLATION,
843
+ ...connectorQueryOptions(enableCloudMcp, connectorWriteMode),
1956
844
  permissionMode: "bypassPermissions",
1957
845
  includePartialMessages: true,
1958
846
  ...(fallbackModel ? { fallbackModel } : {}),
@@ -1975,7 +863,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1975
863
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1976
864
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1977
865
  `fallback=${fallbackModel ?? "none"}`,
1978
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
866
+ `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true} connectors=${enableCloudMcp}`,
1979
867
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1980
868
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1981
869
 
@@ -2009,7 +897,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2009
897
  streamIdleTimedOut = true;
2010
898
  abortCtx.deferredUserMessages = [];
2011
899
  abortCtx.handledTerminalError = true;
2012
- if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
900
+ if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2013
901
  const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
2014
902
  debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
2015
903
  emitRateLimitEvent({
@@ -2050,8 +938,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2050
938
  // Prevent stale deferred messages from being replayed by parent on pop
2051
939
  abortCtx.deferredUserMessages = [];
2052
940
  reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
2053
- for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
2054
- abortCtx.pendingToolCalls.clear();
941
+ const drained = drainPendingToolCalls(abortCtx, "abort");
942
+ if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
2055
943
  abortCtx.pendingResults.clear();
2056
944
  requestAbort();
2057
945
  };
@@ -2072,7 +960,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2072
960
 
2073
961
  // --- Abort detection in normal completion path ---
2074
962
  if (wasAborted || options?.signal?.aborted) {
2075
- if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
963
+ if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2076
964
  ctx().deferredUserMessages = [];
2077
965
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
2078
966
  if (ctx().turnOutput) {
@@ -2090,7 +978,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2090
978
  if (sessionId) {
2091
979
  const cursor = Math.max(context.messages.length, ctx().latestCursor, sharedSession?.cursor ?? 0);
2092
980
  debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
2093
- sharedSession = { sessionId, cursor, cwd };
981
+ setSharedSession({ sessionId, cursor, cwd });
2094
982
  }
2095
983
 
2096
984
  // --- Replay deferred user messages as continuation queries ---
@@ -2119,7 +1007,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2119
1007
  const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
2120
1008
  const sid = contSid ?? sharedSession?.sessionId;
2121
1009
  if (sid) {
2122
- sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
1010
+ setSharedSession({ sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd });
2123
1011
  }
2124
1012
  } catch (contError) {
2125
1013
  debug(`provider: continuation query error:`, contError);
@@ -2140,9 +1028,9 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2140
1028
  const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
2141
1029
  const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
2142
1030
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
2143
- sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1031
+ setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
2144
1032
  } else {
2145
- sharedSession = null;
1033
+ setSharedSession(null);
2146
1034
  }
2147
1035
  ctx().deferredUserMessages = [];
2148
1036
  if (suppressDuplicateError) {
@@ -2162,10 +1050,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
2162
1050
  activeStreamIdleWatchdogs.delete(abortCtx);
2163
1051
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
2164
1052
  if (ctx().activeQuery === sdkQuery) {
2165
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
2166
- // Drain pending handlers for this query
2167
- for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
2168
- 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})`);
2169
1059
  ctx().pendingResults.clear();
2170
1060
 
2171
1061
  if (isReentrant) {
@@ -2206,6 +1096,38 @@ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
2206
1096
  ].join("\n"), "info");
2207
1097
  }
2208
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
+
2209
1131
  function registerBridgeCommands(pi: ExtensionAPI): void {
2210
1132
  const guard = pi as unknown as Record<PropertyKey, unknown>;
2211
1133
  if (guard[COMMANDS_REGISTERED_KEY]) return;
@@ -2242,12 +1164,16 @@ function registerBridgeCommands(pi: ExtensionAPI): void {
2242
1164
  description: "Run Claude Code /extra-usage through claude-bridge",
2243
1165
  handler: async (_args: string, ctx) => runExtraUsage(ctx),
2244
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
+ });
2245
1171
  }
2246
1172
 
2247
1173
  // --- Extension registration ---
2248
1174
 
2249
1175
  export default function (pi: ExtensionAPI) {
2250
- extensionApi = pi;
1176
+ setExtensionApi(pi);
2251
1177
  // Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
2252
1178
  process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
2253
1179
 
@@ -2259,23 +1185,18 @@ export default function (pi: ExtensionAPI) {
2259
1185
  return;
2260
1186
  }
2261
1187
 
2262
- // Reset shared session on pi session lifecycle events
1188
+ // Reset shared (Claude) conversation state on pi session lifecycle events.
1189
+ // Registration tokens are managed separately by applyProviderRegistration
1190
+ // (load / session_start / pre-spawn) and releaseProviderTokens (shutdown), so
1191
+ // a mid-session credential flip is handled while token ownership is intact.
2263
1192
  const clearSession = (event: string) => {
2264
1193
  debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
2265
- sharedSession = null;
2266
-
2267
- // Clear the global streamSimple if this instance registered it.
2268
- // This allows /reload to work — the old instance clears the flag so
2269
- // the new instance can register fresh without wrapping stale state.
2270
- const g = globalThis as Record<symbol, any>;
2271
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
2272
- debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
2273
- g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
2274
- }
1194
+ setSharedSession(null);
2275
1195
  };
1196
+
2276
1197
  pi.on("session_start", (event, ctx) => {
2277
1198
  recordProjectTrust(ctx);
2278
- piUI = ctx.ui;
1199
+ setPiUI(ctx.ui);
2279
1200
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
2280
1201
  clearSession(`session_start:${event.reason}`);
2281
1202
  }
@@ -2284,8 +1205,14 @@ export default function (pi: ExtensionAPI) {
2284
1205
  // them would --resume the parent's Claude jsonl and leak conversation past the
2285
1206
  // fork point. Letting the first fork turn rebuild is the correct path.
2286
1207
  if (event.reason === "startup" || event.reason === "resume") restoreSharedSessionFromPi(ctx);
1208
+ // Live availability flip: re-evaluate credential presence every
1209
+ // session_start so login/logout since load is reflected without /reload.
1210
+ applyProviderRegistration(`session_start:${event.reason}`);
1211
+ });
1212
+ pi.on("session_shutdown", () => {
1213
+ clearSession("session_shutdown");
1214
+ releaseProviderTokens("session_shutdown");
2287
1215
  });
2288
- pi.on("session_shutdown", () => clearSession("session_shutdown"));
2289
1216
  pi.on("message_end", (event, ctx) => {
2290
1217
  const message = (event as { message?: AssistantMessage }).message;
2291
1218
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
@@ -2304,7 +1231,7 @@ export default function (pi: ExtensionAPI) {
2304
1231
  }
2305
1232
  if (sharedSession) {
2306
1233
  debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
2307
- sharedSession = { ...sharedSession, needsRebuild: true };
1234
+ setSharedSession({ ...sharedSession, needsRebuild: true });
2308
1235
  }
2309
1236
  };
2310
1237
  pi.on("session_compact", () => markRebuild("session_compact"));
@@ -2312,30 +1239,14 @@ export default function (pi: ExtensionAPI) {
2312
1239
 
2313
1240
  // --- Provider ---
2314
1241
  //
2315
- // Guard against re-registration when the module is loaded multiple times
2316
- // (e.g., when spawning subagents). The shared ModelRegistry would otherwise
2317
- // overwrite the parent's streamSimple, breaking tool result delivery.
2318
- // See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
2319
-
2320
- const g = globalThis as Record<symbol, any>;
2321
- if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
2322
- // First instance: store our streamSimple and register.
2323
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
2324
- pi.registerProvider(PROVIDER_ID, {
2325
- baseUrl: "claude-bridge",
2326
- apiKey: "not-used",
2327
- api: "claude-bridge",
2328
- models: MODELS,
2329
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
2330
- streamSimple: streamClaudeAgentSdk as any,
2331
- });
2332
- } else {
2333
- // Subsequent instance (subagent session): skip registration entirely.
2334
- // The subagent already has access to claude-bridge models via the shared
2335
- // ModelRegistry from the parent's registration. Calls to those models
2336
- // will route through the parent's streamSimple via the reentrant
2337
- // QueryContext stack mechanism.
2338
- debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
2339
- }
2340
-
1242
+ // Register the provider ONLY when real Claude credentials are present, so
1243
+ // claude-bridge models are never advertised as available/selectable when a
1244
+ // request would fail at spawn time (pi's ModelRegistry.hasConfiguredAuth()
1245
+ // treats the dummy apiKey as "configured", so the gate must live here).
1246
+ //
1247
+ // applyProviderRegistration also claims the primary-instance token (first
1248
+ // load wins) and enforces the multi-instance guard: a non-primary subagent
1249
+ // reload always no-ops, so it never overwrites the parent's streamSimple nor
1250
+ // steals ownership. See PRIMARY_INSTANCE_KEY / ACTIVE_STREAM_SIMPLE_KEY.
1251
+ applyProviderRegistration("load");
2341
1252
  }