@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.
@@ -6,10 +6,12 @@
6
6
  // "not-used"` as "configured" and the provider would look connected while every
7
7
  // request fails at spawn time.
8
8
  //
9
- // This module answers two pure questions used to gate registration:
10
- // 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
11
- // 2. decideRegistration() given credential presence + the primary-instance
12
- // / stream-guard tokens, should we register / unregister / do nothing?
9
+ // This module answers one pure question: hasClaudeCredentials() are real
10
+ // credentials present RIGHT NOW? Since 2.0 it feeds the native provider's
11
+ // auth check/resolve (native-provider.ts) and the pre-spawn fail-fast, rather
12
+ // than gating register/unregister transitions (the 1.x decideRegistration
13
+ // state machine is gone — registration is unconditional and pi hides
14
+ // unconfigured providers' models itself).
13
15
  //
14
16
  // SECURITY: this module only ever checks for the EXISTENCE of credentials — a
15
17
  // file's presence, an env var being non-empty, a settings key being a non-empty
@@ -110,49 +112,3 @@ export function hasClaudeCredentials(
110
112
  return false;
111
113
  }
112
114
 
113
- /**
114
- * Snapshot of the inputs to a registration decision.
115
- *
116
- * The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
117
- * token, claimed unconditionally by the first-loaded module instance, and the
118
- * stream-guard token holding the registered instance's streamSimple. ONLY the
119
- * primary instance may ever register/unregister or claim the stream guard — this
120
- * prevents a subagent module reload (a fresh, non-primary instance) from
121
- * stealing ownership and registering ITS streamSimple, which would split-brain
122
- * the shared session/ctx and break tool-result delivery.
123
- */
124
- export interface RegistrationState {
125
- /** Does the machine have Claude credentials right now? */
126
- credentialed: boolean;
127
- /** Is THIS module instance the primary (first-loaded) instance? */
128
- isPrimary: boolean;
129
- /** Has this instance already registered (owns the stream guard)? */
130
- registered: boolean;
131
- }
132
-
133
- export type RegistrationDecision = "register" | "unregister" | "noop";
134
-
135
- /**
136
- * Pure decision for extension load, every session_start re-check, and the
137
- * pre-spawn fail-fast path.
138
- *
139
- * Rules:
140
- * - Not the primary instance → NOOP (never touch registration).
141
- * - Primary + credentialed + not registered → REGISTER (claim guard + register).
142
- * - Primary + credentialed + already registered → NOOP.
143
- * - Primary + uncredentialed → UNREGISTER (defensive).
144
- *
145
- * The uncredentialed primary always returns UNREGISTER rather than NOOP:
146
- * pi.unregisterProvider is idempotent ("Has no effect if the provider was never
147
- * registered"), and a defensive call is the ONLY way to retract a registration
148
- * that survived a /reload — the ModelRegistry's registeredProviders is a
149
- * process-lifetime Map and module reload does NOT clear it. (At extension-load
150
- * time this defensive unregister only filters the pending-registration queue and
151
- * cannot mutate the persistent registry; the authoritative retraction happens on
152
- * the post-load session_start re-check — see applyProviderRegistration.)
153
- */
154
- export function decideRegistration(state: RegistrationState): RegistrationDecision {
155
- if (!state.isPrimary) return "noop";
156
- if (state.credentialed) return state.registered ? "noop" : "register";
157
- return "unregister";
158
- }
@@ -0,0 +1,178 @@
1
+ import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { debug, diagDump, diagLogPath } from "./debug.js";
3
+ import { type QueryContext } from "./query-state.js";
4
+ import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
5
+
6
+ export interface SessionState {
7
+ sessionId: string;
8
+ cursor: number;
9
+ cwd: string;
10
+ // Force the next syncSharedSession call down the REBUILD path. Set when
11
+ // pi has mutated its messages array out from under us (compact, tree
12
+ // navigation) or after an abort left the JSONL in an indeterminate state.
13
+ // REBUILD wipes and rewrites the file to match pi's current history.
14
+ needsRebuild?: boolean;
15
+ // Set ONLY after an abort. The killed CC subprocess may still be flushing
16
+ // a late "[Request interrupted by user]" record to the session JSONL.
17
+ // Reusing the same sessionId/path would race that orphan write into our
18
+ // fresh file and break CC's parent-uuid chain on the next resume. When
19
+ // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
20
+ // so the orphan writes land on a dead inode. Compact/tree do NOT set
21
+ // this — there's no concurrent CC writer during those events, so
22
+ // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
23
+ forceRotate?: boolean;
24
+ }
25
+
26
+ // Shared mutable bridge state. Lives in its own module so the extracted
27
+ // modules and index.ts observe the SAME state: ESM live bindings let every
28
+ // importer READ these `let` bindings live, but only this module may assign
29
+ // them — cross-module writes must go through the setters below.
30
+ export let sharedSession: SessionState | null = null;
31
+ export let extensionApi: ExtensionAPI | undefined;
32
+ export let piUI: ExtensionUIContext | undefined;
33
+
34
+ export function setSharedSession(next: SessionState | null): void {
35
+ sharedSession = next;
36
+ }
37
+
38
+ export function setExtensionApi(next: ExtensionAPI | undefined): void {
39
+ extensionApi = next;
40
+ }
41
+
42
+ export function setPiUI(next: ExtensionUIContext | undefined): void {
43
+ piUI = next;
44
+ }
45
+
46
+ export function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
47
+ try { piUI?.notify(message, level); }
48
+ catch (error) { debug("notify failed:", error); }
49
+ }
50
+
51
+ export function argKeys(args: Record<string, unknown> | undefined): string[] {
52
+ return Object.keys(args ?? {}).sort();
53
+ }
54
+
55
+ export function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
56
+ return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
57
+ }
58
+
59
+ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
60
+
61
+ /**
62
+ * Persist a bridge integrity event into the pi session transcript.
63
+ *
64
+ * The diag log and a piUI toast both die with the machine or the render cycle:
65
+ * the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
66
+ * SHOWN but existed nowhere in the pi session file, making analysis from the
67
+ * session alone impossible. A `CustomEntry` closes that gap the same way the
68
+ * connector-call audit does — persisted, never part of built context, never
69
+ * dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
70
+ * counts, tool names), never tool output.
71
+ *
72
+ * Never throws; returns whether the entry was appended (false outside a pi
73
+ * session — tests, embedded hosts without extensionApi).
74
+ */
75
+ export function appendIntegrityEntry(label: string, data: Record<string, unknown>): boolean {
76
+ try {
77
+ if (!extensionApi) return false;
78
+ extensionApi.appendEntry(INTEGRITY_CUSTOM_TYPE, { label, at: new Date().toISOString(), ...data });
79
+ return true;
80
+ } catch (error) {
81
+ debug("appendIntegrityEntry failed:", error);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
87
+ const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
88
+ if (names.length > limit) shown.push(`+${names.length - limit} more`);
89
+ return shown;
90
+ }
91
+
92
+ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
93
+ try {
94
+ if (missing.length === 0) return;
95
+ const toolNames = summarizeMissingToolNames(missing);
96
+ const toolNameSummary = compactToolNameSummary(toolNames);
97
+ const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
98
+ diagDump("repair_tool_pairing_synthetic_results", {
99
+ count: missing.length,
100
+ toolNames,
101
+ sampledToolCallIds,
102
+ missing: missing.slice(0, 50),
103
+ ...context,
104
+ });
105
+ appendIntegrityEntry("repair_tool_pairing_synthetic_results", {
106
+ count: missing.length,
107
+ toolNames,
108
+ sampledToolCallIds: sampledToolCallIds.slice(0, 12),
109
+ });
110
+ safeNotify(
111
+ `Claude bridge: ${missing.length} missing tool result(s) repaired with an explicit error placeholder` +
112
+ `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
113
+ `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
114
+ "error",
115
+ );
116
+ } catch (error) {
117
+ debug("reportSyntheticToolResultRepair failed:", error);
118
+ }
119
+ }
120
+
121
+ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
122
+ try {
123
+ if (queryCtx.reportedToolResultMismatch) return false;
124
+ const progress = queryCtx.toolResultProgress();
125
+ const hasMismatch = progress.expectedCount > 0
126
+ ? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
127
+ : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
128
+ if (!hasMismatch) return false;
129
+ queryCtx.reportedToolResultMismatch = true;
130
+ if (sharedSession) {
131
+ sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
132
+ }
133
+ const toolNameSummary = compactToolNameSummary(progress.toolNames);
134
+ diagDump("tool_result_delivery_mismatch", {
135
+ reason,
136
+ cwd,
137
+ progress,
138
+ activeQueryExists: queryCtx.activeQuery !== null,
139
+ sharedSession: sharedSession ? {
140
+ sessionId: sharedSession.sessionId.slice(0, 8),
141
+ cursor: sharedSession.cursor,
142
+ needsRebuild: sharedSession.needsRebuild === true,
143
+ forceRotate: sharedSession.forceRotate === true,
144
+ } : null,
145
+ });
146
+ appendIntegrityEntry("tool_result_delivery_mismatch", {
147
+ reason,
148
+ toolNames: progress.toolNames,
149
+ expectedCount: progress.expectedCount,
150
+ deliveredCount: progress.deliveredCount,
151
+ resolvedCount: progress.resolvedCount,
152
+ waitingIds: progress.waitingIds,
153
+ queuedIds: progress.queuedIds,
154
+ unmatchedResultIds: progress.unmatchedResultIds,
155
+ });
156
+ safeNotify(
157
+ `Claude bridge: tool result delivery interrupted during ${reason}; ` +
158
+ `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
159
+ `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
160
+ `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
161
+ `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
162
+ "error",
163
+ );
164
+ return true;
165
+ } catch (error) {
166
+ debug("reportToolResultMismatch failed:", error);
167
+ return false;
168
+ }
169
+ }
170
+
171
+ export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
172
+ if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
173
+ if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
174
+ }
175
+
176
+ export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
177
+ return { sharedSession };
178
+ }
@@ -0,0 +1,264 @@
1
+ import { type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
2
+ import { spawn as spawnProcess } from "child_process";
3
+ import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "fs";
4
+ import { delimiter, join } from "path";
5
+ import { isolatedFromEnv } from "./config.js";
6
+ import { DEBUG, debug } from "./debug.js";
7
+
8
+ function executableFromPath(name: string): string | undefined {
9
+ const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
10
+ for (const dir of paths) {
11
+ const candidate = join(dir, name);
12
+ try {
13
+ accessSync(candidate, fsConstants.X_OK);
14
+ return candidate;
15
+ } catch {
16
+ // keep searching
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ export function resolveClaudeExecutable(configured?: string): string | undefined {
23
+ const trimmed = configured?.trim();
24
+ if (trimmed) return trimmed;
25
+ // Isolated mode: never run whatever `claude` happens to be on $PATH — the
26
+ // host app either pins an executable in config or gets the SDK's bundled
27
+ // default, which ships inside the host bundle.
28
+ if (isolatedFromEnv()) return undefined;
29
+ return executableFromPath("claude") ?? executableFromPath("claude-code");
30
+ }
31
+
32
+ export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
33
+
34
+ export interface ClaudeExecutablePreflightResult {
35
+ path: string;
36
+ realPath: string;
37
+ cwd: string;
38
+ realCwd: string;
39
+ fileType: ClaudeExecutableFileType;
40
+ }
41
+
42
+ function errnoValue(err: unknown): string | number | undefined {
43
+ return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
44
+ }
45
+
46
+ function syscallValue(err: unknown): string | undefined {
47
+ return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
48
+ }
49
+
50
+ function pathValue(err: unknown): string | undefined {
51
+ const value = (err as NodeJS.ErrnoException)?.path;
52
+ return typeof value === "string" ? value : undefined;
53
+ }
54
+
55
+ function codeValue(err: unknown, fallback: string): string {
56
+ const value = (err as NodeJS.ErrnoException)?.code;
57
+ return typeof value === "string" ? value : fallback;
58
+ }
59
+
60
+ function displayValue(value: unknown): string {
61
+ return value === undefined || value === null || value === "" ? "<none>" : String(value);
62
+ }
63
+
64
+ function makeClaudePreflightError(
65
+ summary: string,
66
+ details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
67
+ ): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
68
+ const detail = [
69
+ `code=${details.code}`,
70
+ `errno=${displayValue(details.errno)}`,
71
+ `syscall=${displayValue(details.syscall)}`,
72
+ `path=${details.path}`,
73
+ `cwd=${details.cwd}`,
74
+ ...(details.fileType ? [`fileType=${details.fileType}`] : []),
75
+ ...(details.realPath ? [`realPath=${details.realPath}`] : []),
76
+ ].join(" ");
77
+ const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
78
+ error.name = "ClaudeExecutablePreflightError";
79
+ error.code = details.code;
80
+ if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
81
+ if (details.syscall) error.syscall = details.syscall;
82
+ error.path = details.path;
83
+ error.cwd = details.cwd;
84
+ if (details.fileType) error.fileType = details.fileType;
85
+ if (details.realPath) error.realPath = details.realPath;
86
+ if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
87
+ return error;
88
+ }
89
+
90
+ export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
91
+ if (bytes.length === 0) return "empty";
92
+ if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
93
+ if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
94
+ if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
95
+ if (bytes.length >= 4) {
96
+ const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
97
+ if (
98
+ magic === 0xfeedface ||
99
+ magic === 0xfeedfacf ||
100
+ magic === 0xcefaedfe ||
101
+ magic === 0xcffaedfe ||
102
+ magic === 0xcafebabe ||
103
+ magic === 0xbebafeca
104
+ ) return "mach-o";
105
+ }
106
+ return "unknown";
107
+ }
108
+
109
+ export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
110
+ let realCwd: string;
111
+ try {
112
+ const cwdStat = statSync(cwd);
113
+ if (!cwdStat.isDirectory()) {
114
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
115
+ code: "ENOTDIR",
116
+ syscall: "chdir",
117
+ path: cwd,
118
+ cwd,
119
+ });
120
+ }
121
+ accessSync(cwd, fsConstants.X_OK);
122
+ realCwd = realpathSync(cwd);
123
+ } catch (err) {
124
+ if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
125
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
126
+ code: codeValue(err, "EACCES"),
127
+ errno: errnoValue(err),
128
+ syscall: syscallValue(err),
129
+ path: pathValue(err) ?? cwd,
130
+ cwd,
131
+ cause: err,
132
+ });
133
+ }
134
+
135
+ let realPath: string;
136
+ try {
137
+ const stat = statSync(path);
138
+ if (!stat.isFile()) {
139
+ throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
140
+ code: "EACCES",
141
+ syscall: "exec",
142
+ path,
143
+ cwd,
144
+ });
145
+ }
146
+ accessSync(path, fsConstants.X_OK);
147
+ realPath = realpathSync(path);
148
+ } catch (err) {
149
+ if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
150
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
151
+ code: codeValue(err, "ENOENT"),
152
+ errno: errnoValue(err),
153
+ syscall: syscallValue(err),
154
+ path: pathValue(err) ?? path,
155
+ cwd,
156
+ cause: err,
157
+ });
158
+ }
159
+
160
+ let fileType: ClaudeExecutableFileType;
161
+ try {
162
+ fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
163
+ } catch (err) {
164
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
165
+ code: codeValue(err, "EACCES"),
166
+ errno: errnoValue(err),
167
+ syscall: syscallValue(err),
168
+ path: pathValue(err) ?? realPath,
169
+ cwd,
170
+ realPath,
171
+ cause: err,
172
+ });
173
+ }
174
+
175
+ if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
176
+ throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
177
+ code: "ENOEXEC",
178
+ syscall: "exec",
179
+ path,
180
+ cwd,
181
+ fileType,
182
+ realPath,
183
+ });
184
+ }
185
+
186
+ return { path, realPath, cwd, realCwd, fileType };
187
+ }
188
+
189
+ function envFlagEnabled(value: string | undefined): boolean {
190
+ return value === "1" || value?.toLowerCase() === "true";
191
+ }
192
+
193
+ export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
194
+ const originalCode = codeValue(err, "SPAWN_ERROR");
195
+ const originalMessage = err.message;
196
+ const spawnPath = pathValue(err) ?? options.command;
197
+ const cwd = options.cwd ?? process.cwd();
198
+ const detail = [
199
+ `code=${originalCode}`,
200
+ `errno=${displayValue(errnoValue(err))}`,
201
+ `syscall=${displayValue(syscallValue(err))}`,
202
+ `path=${spawnPath}`,
203
+ `cwd=${cwd}`,
204
+ `command=${options.command}`,
205
+ ].join(" ");
206
+ const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
207
+ wrapped.name = "ClaudeSpawnDiagnosticError";
208
+ // The SDK special-cases code === ENOENT and replaces the message with its
209
+ // generic "native binary not found" text. Preserve the original code in the
210
+ // message/originalCode while using a bridge code so the SDK surfaces context.
211
+ wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
212
+ wrapped.originalCode = originalCode;
213
+ wrapped.originalMessage = originalMessage;
214
+ const errno = errnoValue(err);
215
+ if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
216
+ const syscall = syscallValue(err);
217
+ if (syscall) wrapped.syscall = syscall;
218
+ wrapped.path = spawnPath;
219
+ wrapped.cwd = cwd;
220
+ // Do not set `cause` here: the listener copies these structured fields back
221
+ // onto the original Error. A cause reference to that same object would become
222
+ // `err.cause === err`, making JSON.stringify throw on a circular structure.
223
+ // originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
224
+ return wrapped;
225
+ }
226
+
227
+ export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
228
+ const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
229
+ const child = spawnProcess(options.command, options.args, {
230
+ cwd: options.cwd,
231
+ env: options.env,
232
+ signal: options.signal,
233
+ stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
234
+ windowsHide: true,
235
+ });
236
+ if (pipeStderr) {
237
+ child.stderr?.on("data", (data) => {
238
+ for (const line of data.toString().split(/\r?\n/)) {
239
+ if (line) debug(`[cli-stderr spawn] ${line}`);
240
+ }
241
+ });
242
+ }
243
+ child.prependListener("error", (err) => {
244
+ const originalStack = err.stack;
245
+ const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
246
+ Object.assign(err, wrapped);
247
+ err.name = wrapped.name;
248
+ err.message = wrapped.message;
249
+ // Keep V8's stack from the actual Node spawn failure, not the wrapper
250
+ // construction site. Diagnostic fields above remain enumerable and
251
+ // JSON-serializable; stack stays the spawn-time breadcrumb for operators.
252
+ if (originalStack) err.stack = originalStack;
253
+ });
254
+ return {
255
+ stdin: child.stdin,
256
+ stdout: child.stdout,
257
+ get killed() { return child.killed; },
258
+ get exitCode() { return child.exitCode; },
259
+ kill: child.kill.bind(child),
260
+ on: child.on.bind(child),
261
+ once: child.once.bind(child),
262
+ off: child.off.bind(child),
263
+ };
264
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // User-facing extension config. Legacy config is loaded from
2
2
  // ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. vstack extension
3
- // manager config is loaded from settings.json and overrides legacy files.
3
+ // manager config is loaded from settings.json and overrides legacy files in
4
+ // normal Pi sessions. Isolated embedding hosts consume only the authoritative
5
+ // user claude-bridge.json and never read extension-manager settings.
4
6
 
5
7
  import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
6
8
  import { existsSync, readFileSync } from "fs";
@@ -87,10 +89,11 @@ export function piUserDir(): string {
87
89
  /**
88
90
  * Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
89
91
  * declares that nothing outside its explicitly configured dirs may be read.
90
- * Disables every cwd/home discovery fallback — the cwd AGENTS.md walk, project
91
- * `.pi/` settings + claude-bridge.json, project APPEND_SYSTEM.md, and the
92
- * `$PATH` claude executable search. Reads stay confined to `piUserDir()` (i.e.
93
- * `PI_CODING_AGENT_DIR`) and the explicitly configured executable path.
92
+ * Disables every cwd/home discovery fallback — all AGENTS.md discovery,
93
+ * extension-manager settings, project `.pi/` settings + claude-bridge.json,
94
+ * project APPEND_SYSTEM.md, and the `$PATH` claude executable search. Bridge
95
+ * configuration comes only from `piUserDir()/claude-bridge.json` and any
96
+ * explicitly configured executable path.
94
97
  * Default (unset) behavior for normal pi CLI users is unchanged.
95
98
  */
96
99
  export function isolatedFromEnv(): boolean {
@@ -163,7 +166,10 @@ function projectSettingsTrusted(settingsPath: string): boolean {
163
166
 
164
167
  function settingsPaths(cwd: string): string[] {
165
168
  const user = join(piUserDir(), "settings.json");
166
- if (isolatedFromEnv()) return [user];
169
+ // An embedding host may have to share PI_CODING_AGENT_DIR with an in-process
170
+ // Pi SDK. In isolated mode, settings.json is therefore not authoritative and
171
+ // must not be consulted even at user scope.
172
+ if (isolatedFromEnv()) return [];
167
173
  const project = projectSettingsPath(cwd);
168
174
  return projectSettingsTrusted(project) ? [user, project] : [user];
169
175
  }
@@ -312,7 +318,7 @@ export function loadConfig(cwd: string): Config {
312
318
  const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
313
319
  const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
314
320
  const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
315
- const manager = managerToConfig(readManagerConfig(cwd));
321
+ const manager: Partial<Config> = isolated ? {} : managerToConfig(readManagerConfig(cwd));
316
322
  const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
317
323
  return {
318
324
  enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,