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