@vanillagreen/pi-claude-bridge 1.6.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -6
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +28385 -22407
- package/package.json +10 -6
- package/src/agents-md.ts +12 -4
- package/src/assistant-stream.ts +307 -0
- package/src/auth-presence.ts +158 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +83 -5
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +339 -1428
- package/src/models.ts +22 -1
- package/src/prompt-context.ts +3 -8
- package/src/query-state.ts +42 -0
- package/src/rate-limit.ts +63 -0
- package/src/session-persistence.ts +329 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
|
|
60
|
+
const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
|
|
61
|
+
if (names.length > limit) shown.push(`+${names.length - limit} more`);
|
|
62
|
+
return shown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
|
|
66
|
+
try {
|
|
67
|
+
if (missing.length === 0) return;
|
|
68
|
+
const toolNames = summarizeMissingToolNames(missing);
|
|
69
|
+
const toolNameSummary = compactToolNameSummary(toolNames);
|
|
70
|
+
const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
|
|
71
|
+
diagDump("repair_tool_pairing_synthetic_results", {
|
|
72
|
+
count: missing.length,
|
|
73
|
+
toolNames,
|
|
74
|
+
sampledToolCallIds,
|
|
75
|
+
missing: missing.slice(0, 50),
|
|
76
|
+
...context,
|
|
77
|
+
});
|
|
78
|
+
safeNotify(
|
|
79
|
+
`Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
|
|
80
|
+
`${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
|
|
81
|
+
`Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
|
|
82
|
+
"error",
|
|
83
|
+
);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
debug("reportSyntheticToolResultRepair failed:", error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
|
|
90
|
+
try {
|
|
91
|
+
if (queryCtx.reportedToolResultMismatch) return false;
|
|
92
|
+
const progress = queryCtx.toolResultProgress();
|
|
93
|
+
const hasMismatch = progress.expectedCount > 0
|
|
94
|
+
? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
|
|
95
|
+
: progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
|
|
96
|
+
if (!hasMismatch) return false;
|
|
97
|
+
queryCtx.reportedToolResultMismatch = true;
|
|
98
|
+
if (sharedSession) {
|
|
99
|
+
sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
|
|
100
|
+
}
|
|
101
|
+
const toolNameSummary = compactToolNameSummary(progress.toolNames);
|
|
102
|
+
diagDump("tool_result_delivery_mismatch", {
|
|
103
|
+
reason,
|
|
104
|
+
cwd,
|
|
105
|
+
progress,
|
|
106
|
+
activeQueryExists: queryCtx.activeQuery !== null,
|
|
107
|
+
sharedSession: sharedSession ? {
|
|
108
|
+
sessionId: sharedSession.sessionId.slice(0, 8),
|
|
109
|
+
cursor: sharedSession.cursor,
|
|
110
|
+
needsRebuild: sharedSession.needsRebuild === true,
|
|
111
|
+
forceRotate: sharedSession.forceRotate === true,
|
|
112
|
+
} : null,
|
|
113
|
+
});
|
|
114
|
+
safeNotify(
|
|
115
|
+
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
116
|
+
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|
|
117
|
+
`waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
|
|
118
|
+
`${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
|
|
119
|
+
`Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
|
|
120
|
+
"error",
|
|
121
|
+
);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
debug("reportToolResultMismatch failed:", error);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
|
|
130
|
+
if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
|
|
131
|
+
if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
|
|
135
|
+
return { sharedSession };
|
|
136
|
+
}
|
|
@@ -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";
|
|
@@ -13,6 +15,14 @@ export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|
|
13
15
|
|
|
14
16
|
const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Per-session control over claude.ai connector WRITE tools when connectors are
|
|
20
|
+
* enabled. `deny` (default) hides Gmail/Calendar/Drive mutating tools so
|
|
21
|
+
* connector chat sessions are read-only; `allow` exposes them (used only by the
|
|
22
|
+
* one-shot approved-write executor). Reads are always available.
|
|
23
|
+
*/
|
|
24
|
+
export type ConnectorWriteMode = "deny" | "allow";
|
|
25
|
+
|
|
16
26
|
export interface Config {
|
|
17
27
|
enabled?: boolean;
|
|
18
28
|
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
@@ -28,6 +38,27 @@ export interface Config {
|
|
|
28
38
|
settingSources?: SettingSource[];
|
|
29
39
|
strictMcpConfig?: boolean;
|
|
30
40
|
pathToClaudeCodeExecutable?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Expose the authenticated Claude account's claude.ai cloud MCP
|
|
43
|
+
* connectors (Gmail / Google Calendar / Google Drive, etc.) to the model.
|
|
44
|
+
* Off by default so Pi owns tool execution and tokens stay lean. Also
|
|
45
|
+
* settable via the CLAUDE_BRIDGE_ENABLE_CONNECTORS env var (env OR config
|
|
46
|
+
* enables it). See docs/plans/claude-bridge-google-connectors.md.
|
|
47
|
+
*/
|
|
48
|
+
enableConnectors?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* When connectors are enabled, whether their WRITE tools
|
|
51
|
+
* (create/update/delete/label/etc.) are exposed. Defaults to `deny`
|
|
52
|
+
* (read-only), enforced two ways: known write tools are removed from the
|
|
53
|
+
* model's context (disallowedTools by exact id), and a PreToolUse hook
|
|
54
|
+
* blocks any connector write tool by name prefix at call time (covers
|
|
55
|
+
* future write tools). `allow` disables both — intended ONLY for a
|
|
56
|
+
* one-shot approved-write executor process. Also settable via
|
|
57
|
+
* CLAUDE_BRIDGE_CONNECTOR_WRITE=deny|allow (env wins over config). Any
|
|
58
|
+
* value but exact `allow` is treated as `deny`. Ignored when connectors
|
|
59
|
+
* are disabled.
|
|
60
|
+
*/
|
|
61
|
+
connectorWriteMode?: ConnectorWriteMode;
|
|
31
62
|
};
|
|
32
63
|
/** Extra Pi context forwarded to Claude Code on top of AGENTS.md + skills. */
|
|
33
64
|
promptContext?: {
|
|
@@ -46,10 +77,30 @@ function expandHome(input: string): string {
|
|
|
46
77
|
return input;
|
|
47
78
|
}
|
|
48
79
|
|
|
49
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The Pi agent config dir: `PI_CODING_AGENT_DIR` when set, else `~/.pi/agent`.
|
|
82
|
+
* Every bridge default that used to hardcode `~/.pi/agent` routes through this
|
|
83
|
+
* so a host app that owns the agent dir owns those paths too.
|
|
84
|
+
*/
|
|
85
|
+
export function piUserDir(): string {
|
|
50
86
|
return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
|
|
51
87
|
}
|
|
52
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
|
|
91
|
+
* declares that nothing outside its explicitly configured dirs may be read.
|
|
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.
|
|
97
|
+
* Default (unset) behavior for normal pi CLI users is unchanged.
|
|
98
|
+
*/
|
|
99
|
+
export function isolatedFromEnv(): boolean {
|
|
100
|
+
const v = (process.env.CLAUDE_BRIDGE_ISOLATED ?? "").trim().toLowerCase();
|
|
101
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
102
|
+
}
|
|
103
|
+
|
|
53
104
|
function asRecord(value: unknown): SettingsRecord | undefined {
|
|
54
105
|
return value && typeof value === "object" && !Array.isArray(value) ? value as SettingsRecord : undefined;
|
|
55
106
|
}
|
|
@@ -93,6 +144,10 @@ function projectTrustRegistry(): ProjectTrustRegistry {
|
|
|
93
144
|
|
|
94
145
|
export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
|
|
95
146
|
if (!ctx.cwd) return;
|
|
147
|
+
// Isolated mode never reads project config, so recording trust would only
|
|
148
|
+
// run the cwd-ancestor `.pi/settings.json` walk (a filesystem probe outside
|
|
149
|
+
// the host-owned dirs) for a result nothing consumes. Skip it entirely.
|
|
150
|
+
if (isolatedFromEnv()) return;
|
|
96
151
|
let trusted = true;
|
|
97
152
|
try {
|
|
98
153
|
trusted = ctx.isProjectTrusted?.() === true;
|
|
@@ -111,6 +166,10 @@ function projectSettingsTrusted(settingsPath: string): boolean {
|
|
|
111
166
|
|
|
112
167
|
function settingsPaths(cwd: string): string[] {
|
|
113
168
|
const user = join(piUserDir(), "settings.json");
|
|
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 [];
|
|
114
173
|
const project = projectSettingsPath(cwd);
|
|
115
174
|
return projectSettingsTrusted(project) ? [user, project] : [user];
|
|
116
175
|
}
|
|
@@ -155,6 +214,13 @@ function hasOwn(raw: SettingsRecord, key: string): boolean {
|
|
|
155
214
|
return Object.prototype.hasOwnProperty.call(raw, key);
|
|
156
215
|
}
|
|
157
216
|
|
|
217
|
+
export function normalizeConnectorWriteMode(value: unknown): ConnectorWriteMode | undefined {
|
|
218
|
+
if (typeof value !== "string") return undefined;
|
|
219
|
+
const normalized = value.trim().toLowerCase();
|
|
220
|
+
if (normalized === "deny" || normalized === "allow") return normalized;
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
158
224
|
export function normalizeEffortLevel(value: unknown): BridgeEffortLevel | undefined {
|
|
159
225
|
if (typeof value !== "string") return undefined;
|
|
160
226
|
const normalized = value.trim().toLowerCase();
|
|
@@ -195,6 +261,13 @@ function normalizeProviderConfig(provider: Config["provider"] | undefined): Conf
|
|
|
195
261
|
const modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
|
|
196
262
|
if (modelEffortOverrides) out.modelEffortOverrides = modelEffortOverrides;
|
|
197
263
|
else delete out.modelEffortOverrides;
|
|
264
|
+
// Fail closed: legacy config files are merged raw, so an unvalidated
|
|
265
|
+
// connectorWriteMode (e.g. "Deny", "read-only", true) must not slip through as
|
|
266
|
+
// a truthy non-"allow" value. Drop anything that isn't exactly deny/allow so
|
|
267
|
+
// the resolver falls back to the default deny.
|
|
268
|
+
const connectorWriteMode = normalizeConnectorWriteMode(raw.connectorWriteMode);
|
|
269
|
+
if (connectorWriteMode) out.connectorWriteMode = connectorWriteMode;
|
|
270
|
+
else delete out.connectorWriteMode;
|
|
198
271
|
return out;
|
|
199
272
|
}
|
|
200
273
|
|
|
@@ -216,6 +289,10 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
216
289
|
}
|
|
217
290
|
const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
|
|
218
291
|
if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
|
|
292
|
+
const enableConnectors = boolFrom(raw, "enableConnectors");
|
|
293
|
+
if (enableConnectors !== undefined) provider.enableConnectors = enableConnectors;
|
|
294
|
+
const connectorWriteMode = normalizeConnectorWriteMode(raw.connectorWriteMode);
|
|
295
|
+
if (connectorWriteMode) provider.connectorWriteMode = connectorWriteMode;
|
|
219
296
|
const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
|
|
220
297
|
if (claudePath) provider.pathToClaudeCodeExecutable = claudePath;
|
|
221
298
|
|
|
@@ -237,10 +314,11 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
237
314
|
|
|
238
315
|
export function loadConfig(cwd: string): Config {
|
|
239
316
|
const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
|
|
240
|
-
const
|
|
241
|
-
const
|
|
317
|
+
const isolated = isolatedFromEnv();
|
|
318
|
+
const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
|
|
319
|
+
const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
|
|
242
320
|
const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
|
|
243
|
-
const manager = managerToConfig(readManagerConfig(cwd));
|
|
321
|
+
const manager: Partial<Config> = isolated ? {} : managerToConfig(readManagerConfig(cwd));
|
|
244
322
|
const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
|
|
245
323
|
return {
|
|
246
324
|
enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
|