@vanillagreen/pi-claude-bridge 1.1.2 → 1.1.4

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 CHANGED
@@ -37,11 +37,11 @@ Restart Pi after installation.
37
37
 
38
38
  Default behavior matches upstream: append `AGENTS.md` plus Pi's skills block to Claude Code's `claude_code` preset prompt.
39
39
 
40
- Extra Pi context is off by default. Enable per item in the extension manager when you want Claude Code to see prompt blocks that other Pi extensions add to your session.
40
+ Extra Pi context is off by default. Enable per item in the extension manager when you want Claude Code to see prompt blocks that other Pi extensions add to your session. Forwarded blocks are wrapped in explicit XML tags so Pi 0.75+ project-context boundaries do not bleed into adjacent sections.
41
41
 
42
42
  ## Settings
43
43
 
44
- All settings live in the extension manager under **Claude Bridge**.
44
+ Open `/extensions:settings`; settings appear under the **Claude Bridge** tab.
45
45
 
46
46
  ### General
47
47
 
@@ -79,3 +79,5 @@ All settings live in the extension manager under **Claude Bridge**.
79
79
  ## Debugging
80
80
 
81
81
  Set `CLAUDE_BRIDGE_DEBUG=1` to write bridge logs to `~/.pi/agent/claude-bridge.log` and per-query Claude Code CLI logs under `~/.pi/agent/cc-cli-logs/`.
82
+
83
+ Before starting Claude Code, the bridge preflights the resolved executable and working directory. Failures include the underlying `code`, `errno`, `syscall`, `path`, `cwd`, and detected executable file type so spawn issues point at the real failing path instead of the Claude Agent SDK's generic native-binary message. If Node still emits a spawn error after preflight, the bridge wraps that error with the same context before handing it back to the SDK.
package/bundle/index.js CHANGED
@@ -19184,8 +19184,9 @@ function readSession(jsonlPath, projectPath) {
19184
19184
  }
19185
19185
 
19186
19186
  // src/index.ts
19187
+ import { spawn as spawnProcess } from "child_process";
19187
19188
  import { createHash } from "crypto";
19188
- import { accessSync, appendFileSync as appendFileSync3, constants as fsConstants, mkdirSync as mkdirSync3, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
19189
+ import { accessSync, appendFileSync as appendFileSync3, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
19189
19190
  import { resolve as pathResolve } from "path";
19190
19191
  import { homedir as homedir5 } from "os";
19191
19192
  import { delimiter, dirname as dirname5, join as join5 } from "path";
@@ -19758,8 +19759,13 @@ function extractHeadingSection(systemPrompt, headings) {
19758
19759
  }
19759
19760
  if (start < 0) return void 0;
19760
19761
  const rest = systemPrompt.slice(start).trim();
19761
- const nextHeading = rest.slice(1).search(/\n##\s+/);
19762
- return (nextHeading >= 0 ? rest.slice(0, nextHeading + 1) : rest).trim();
19762
+ const endCandidates = [
19763
+ rest.slice(1).search(/\n##\s+/),
19764
+ rest.search(/\n<\/project_instructions>/),
19765
+ rest.search(/\n<\/project_context>/)
19766
+ ].map((index, offset) => index >= 0 && offset === 0 ? index + 1 : index).filter((index) => index >= 0);
19767
+ const end = endCandidates.length > 0 ? Math.min(...endCandidates) : -1;
19768
+ return (end >= 0 ? rest.slice(0, end) : rest).trim();
19763
19769
  }
19764
19770
  function extractBlockByMarkers(systemPrompt, markers) {
19765
19771
  for (const block of splitPromptBlocks(systemPrompt)) {
@@ -19772,48 +19778,57 @@ function buildPromptContextAppend(systemPrompt, cwd, settings) {
19772
19778
  const labels = [];
19773
19779
  if (settings.includeAppendSystemPromptMd) {
19774
19780
  for (const file2 of readAppendSystemPromptFiles(cwd)) {
19775
- parts.push(`### ${file2.label}
19776
-
19777
- ${file2.content}`);
19781
+ parts.push(xmlBlock("append_system_prompt", { label: file2.label }, file2.content));
19778
19782
  labels.push(file2.label);
19779
19783
  }
19780
19784
  }
19781
19785
  if (settings.includeProjectAgentsHook) {
19782
19786
  const projectAgents = extractHeadingSection(systemPrompt, ["## Project Agents", "## Project Subagents"]);
19783
19787
  if (projectAgents) {
19784
- parts.push(`### before_agent_start: project agents
19785
-
19786
- ${projectAgents}`);
19788
+ parts.push(xmlBlock("before_agent_start", { source: "project-agents" }, projectAgents));
19787
19789
  labels.push("project agents hook");
19788
19790
  }
19789
19791
  }
19790
19792
  if (settings.includeTaskPanelHook) {
19791
19793
  const taskReminder = extractBlockByMarkers(systemPrompt, [/^Task workflow reminder:/]);
19792
19794
  if (taskReminder) {
19793
- parts.push(`### before_agent_start: task panel
19794
-
19795
- ${taskReminder}`);
19795
+ parts.push(xmlBlock("before_agent_start", { source: "task-panel" }, taskReminder));
19796
19796
  labels.push("task panel hook");
19797
19797
  }
19798
19798
  }
19799
19799
  if (settings.includeCavemanHook) {
19800
19800
  const caveman = extractBlockByMarkers(systemPrompt, [/^You MUST respond in caveman /m]);
19801
19801
  if (caveman) {
19802
- parts.push(`### before_agent_start: caveman
19803
-
19804
- ${caveman}`);
19802
+ parts.push(xmlBlock("before_agent_start", { source: "caveman" }, caveman));
19805
19803
  labels.push("caveman hook");
19806
19804
  }
19807
19805
  }
19808
19806
  if (parts.length === 0) return { labels };
19809
19807
  return {
19810
19808
  labels,
19811
- text: [
19812
- "## Forwarded Pi Context",
19813
- "The following content was explicitly enabled in pi-claude-bridge settings and comes from Pi prompt files or before_agent_start prompt hooks.",
19814
- ...parts
19815
- ].join("\n\n")
19816
- };
19809
+ text: xmlBlock(
19810
+ "forwarded_pi_context",
19811
+ {},
19812
+ [
19813
+ "The following content was explicitly enabled in pi-claude-bridge settings and comes from Pi prompt files or before_agent_start prompt hooks.",
19814
+ ...parts
19815
+ ].join("\n\n"),
19816
+ false
19817
+ )
19818
+ };
19819
+ }
19820
+ function escapeXmlAttr(value) {
19821
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
19822
+ }
19823
+ function escapeXmlText(value) {
19824
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
19825
+ }
19826
+ function xmlBlock(tag, attrs, content, escapeContent = true) {
19827
+ const attrText = Object.entries(attrs).map(([key, value]) => ` ${key}="${escapeXmlAttr(value)}"`).join("");
19828
+ const body = escapeContent ? escapeXmlText(content.trim()) : content.trim();
19829
+ return `<${tag}${attrText}>
19830
+ ${body}
19831
+ </${tag}>`;
19817
19832
  }
19818
19833
 
19819
19834
  // node_modules/zod/v4/classic/external.js
@@ -34423,6 +34438,199 @@ function resolveClaudeExecutable(configured) {
34423
34438
  if (trimmed) return trimmed;
34424
34439
  return executableFromPath("claude") ?? executableFromPath("claude-code");
34425
34440
  }
34441
+ function errnoValue(err) {
34442
+ return typeof err?.errno === "number" ? err.errno : void 0;
34443
+ }
34444
+ function syscallValue(err) {
34445
+ return typeof err?.syscall === "string" ? err.syscall : void 0;
34446
+ }
34447
+ function pathValue(err) {
34448
+ const value = err?.path;
34449
+ return typeof value === "string" ? value : void 0;
34450
+ }
34451
+ function codeValue(err, fallback) {
34452
+ const value = err?.code;
34453
+ return typeof value === "string" ? value : fallback;
34454
+ }
34455
+ function displayValue(value) {
34456
+ return value === void 0 || value === null || value === "" ? "<none>" : String(value);
34457
+ }
34458
+ function makeClaudePreflightError(summary, details) {
34459
+ const detail = [
34460
+ `code=${details.code}`,
34461
+ `errno=${displayValue(details.errno)}`,
34462
+ `syscall=${displayValue(details.syscall)}`,
34463
+ `path=${details.path}`,
34464
+ `cwd=${details.cwd}`,
34465
+ ...details.fileType ? [`fileType=${details.fileType}`] : [],
34466
+ ...details.realPath ? [`realPath=${details.realPath}`] : []
34467
+ ].join(" ");
34468
+ const error51 = new Error(`${summary} (${detail})`);
34469
+ error51.name = "ClaudeExecutablePreflightError";
34470
+ error51.code = details.code;
34471
+ if (details.errno !== void 0) error51.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
34472
+ if (details.syscall) error51.syscall = details.syscall;
34473
+ error51.path = details.path;
34474
+ error51.cwd = details.cwd;
34475
+ if (details.fileType) error51.fileType = details.fileType;
34476
+ if (details.realPath) error51.realPath = details.realPath;
34477
+ if (details.cause !== void 0) error51.cause = details.cause;
34478
+ return error51;
34479
+ }
34480
+ function classifyClaudeExecutableBytes(bytes) {
34481
+ if (bytes.length === 0) return "empty";
34482
+ if (bytes.length >= 2 && bytes[0] === 35 && bytes[1] === 33) return "shebang-script";
34483
+ if (bytes.length >= 4 && bytes[0] === 127 && bytes[1] === 69 && bytes[2] === 76 && bytes[3] === 70) return "elf";
34484
+ if (bytes.length >= 2 && bytes[0] === 77 && bytes[1] === 90) return "pe";
34485
+ if (bytes.length >= 4) {
34486
+ const magic = bytes[0] * 16777216 + bytes[1] * 65536 + bytes[2] * 256 + bytes[3];
34487
+ if (magic === 4277009102 || magic === 4277009103 || magic === 3472551422 || magic === 3489328638 || magic === 3405691582 || magic === 3199925962) return "mach-o";
34488
+ }
34489
+ return "unknown";
34490
+ }
34491
+ function preflightClaudeExecutable(path, cwd) {
34492
+ let realCwd = cwd;
34493
+ try {
34494
+ const cwdStat = statSync3(cwd);
34495
+ if (!cwdStat.isDirectory()) {
34496
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
34497
+ code: "ENOTDIR",
34498
+ syscall: "chdir",
34499
+ path: cwd,
34500
+ cwd
34501
+ });
34502
+ }
34503
+ accessSync(cwd, fsConstants.X_OK);
34504
+ realCwd = realpathSync3(cwd);
34505
+ } catch (err) {
34506
+ if (err.name === "ClaudeExecutablePreflightError") throw err;
34507
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
34508
+ code: codeValue(err, "EACCES"),
34509
+ errno: errnoValue(err),
34510
+ syscall: syscallValue(err),
34511
+ path: pathValue(err) ?? cwd,
34512
+ cwd,
34513
+ cause: err
34514
+ });
34515
+ }
34516
+ let realPath = path;
34517
+ try {
34518
+ const stat = statSync3(path);
34519
+ if (!stat.isFile()) {
34520
+ throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
34521
+ code: "EACCES",
34522
+ syscall: "exec",
34523
+ path,
34524
+ cwd
34525
+ });
34526
+ }
34527
+ accessSync(path, fsConstants.X_OK);
34528
+ realPath = realpathSync3(path);
34529
+ } catch (err) {
34530
+ if (err.name === "ClaudeExecutablePreflightError") throw err;
34531
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
34532
+ code: codeValue(err, "ENOENT"),
34533
+ errno: errnoValue(err),
34534
+ syscall: syscallValue(err),
34535
+ path: pathValue(err) ?? path,
34536
+ cwd,
34537
+ cause: err
34538
+ });
34539
+ }
34540
+ let fileType;
34541
+ try {
34542
+ fileType = classifyClaudeExecutableBytes(readFileSync7(realPath).subarray(0, 16));
34543
+ } catch (err) {
34544
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
34545
+ code: codeValue(err, "EACCES"),
34546
+ errno: errnoValue(err),
34547
+ syscall: syscallValue(err),
34548
+ path: pathValue(err) ?? realPath,
34549
+ cwd,
34550
+ realPath,
34551
+ cause: err
34552
+ });
34553
+ }
34554
+ if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
34555
+ throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
34556
+ code: "ENOEXEC",
34557
+ syscall: "exec",
34558
+ path,
34559
+ cwd,
34560
+ fileType,
34561
+ realPath
34562
+ });
34563
+ }
34564
+ return { path, realPath, cwd, realCwd, fileType };
34565
+ }
34566
+ function envFlagEnabled(value) {
34567
+ return value === "1" || value?.toLowerCase() === "true";
34568
+ }
34569
+ function wrapClaudeSpawnErrorForSdk(err, options) {
34570
+ const originalCode = codeValue(err, "SPAWN_ERROR");
34571
+ const originalMessage = err.message;
34572
+ const spawnPath = pathValue(err) ?? options.command;
34573
+ const cwd = options.cwd ?? process.cwd();
34574
+ const detail = [
34575
+ `code=${originalCode}`,
34576
+ `errno=${displayValue(errnoValue(err))}`,
34577
+ `syscall=${displayValue(syscallValue(err))}`,
34578
+ `path=${spawnPath}`,
34579
+ `cwd=${cwd}`,
34580
+ `command=${options.command}`
34581
+ ].join(" ");
34582
+ const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`);
34583
+ wrapped.name = "ClaudeSpawnDiagnosticError";
34584
+ wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
34585
+ wrapped.originalCode = originalCode;
34586
+ wrapped.originalMessage = originalMessage;
34587
+ const errno = errnoValue(err);
34588
+ if (errno !== void 0) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
34589
+ const syscall = syscallValue(err);
34590
+ if (syscall) wrapped.syscall = syscall;
34591
+ wrapped.path = spawnPath;
34592
+ wrapped.cwd = cwd;
34593
+ return wrapped;
34594
+ }
34595
+ function spawnClaudeCodeWithDiagnostics(options) {
34596
+ const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
34597
+ const child = spawnProcess(options.command, options.args, {
34598
+ cwd: options.cwd,
34599
+ env: options.env,
34600
+ signal: options.signal,
34601
+ stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
34602
+ windowsHide: true
34603
+ });
34604
+ if (pipeStderr) {
34605
+ child.stderr?.on("data", (data) => {
34606
+ for (const line of data.toString().split(/\r?\n/)) {
34607
+ if (line) debug(`[cli-stderr spawn] ${line}`);
34608
+ }
34609
+ });
34610
+ }
34611
+ child.prependListener("error", (err) => {
34612
+ const originalStack = err.stack;
34613
+ const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
34614
+ Object.assign(err, wrapped);
34615
+ err.name = wrapped.name;
34616
+ err.message = wrapped.message;
34617
+ if (originalStack) err.stack = originalStack;
34618
+ });
34619
+ return {
34620
+ stdin: child.stdin,
34621
+ stdout: child.stdout,
34622
+ get killed() {
34623
+ return child.killed;
34624
+ },
34625
+ get exitCode() {
34626
+ return child.exitCode;
34627
+ },
34628
+ kill: child.kill.bind(child),
34629
+ on: child.on.bind(child),
34630
+ once: child.once.bind(child),
34631
+ off: child.off.bind(child)
34632
+ };
34633
+ }
34426
34634
  var nextCliDebugSeq = 1;
34427
34635
  function makeCliDebugOptions(tag) {
34428
34636
  if (!DEBUG) return {};
@@ -35150,7 +35358,6 @@ function streamClaudeAgentSdk(model, context, options) {
35150
35358
  ctx().latestCursor = 0;
35151
35359
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
35152
35360
  const cwd = options?.cwd ?? process.cwd();
35153
- const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
35154
35361
  const promptBlocks = extractUserPromptBlocks(context.messages);
35155
35362
  let promptText = extractUserPrompt(context.messages) ?? "";
35156
35363
  if (!promptText && !promptBlocks) {
@@ -35178,6 +35385,8 @@ function streamClaudeAgentSdk(model, context, options) {
35178
35385
  const settingSources = appendSystemPrompt ? void 0 : providerSettings.settingSources ?? ["user", "project"];
35179
35386
  const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
35180
35387
  const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
35388
+ const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : void 0;
35389
+ const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
35181
35390
  const effort = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] ?? REASONING_TO_EFFORT[options.reasoning] : void 0;
35182
35391
  const extraArgs = { model: model.id };
35183
35392
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
@@ -35200,6 +35409,7 @@ function streamClaudeAgentSdk(model, context, options) {
35200
35409
  ...mcpServers ? { mcpServers } : {},
35201
35410
  ...resumeSessionId ? { resume: resumeSessionId } : {},
35202
35411
  ...claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {},
35412
+ spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
35203
35413
  ...makeCliDebugOptions("provider")
35204
35414
  };
35205
35415
  debug(
@@ -35207,6 +35417,7 @@ function streamClaudeAgentSdk(model, context, options) {
35207
35417
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
35208
35418
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
35209
35419
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
35420
+ `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
35210
35421
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
35211
35422
  );
35212
35423
  let wasAborted = false;
@@ -35376,8 +35587,12 @@ function index_default(pi) {
35376
35587
  export {
35377
35588
  CLAUDE_BRIDGE_TOOL_ISOLATION,
35378
35589
  DISALLOWED_BUILTIN_TOOLS,
35590
+ classifyClaudeExecutableBytes,
35379
35591
  index_default as default,
35380
35592
  mapToolName,
35593
+ preflightClaudeExecutable,
35381
35594
  restoreSharedSessionFromPi,
35382
- shouldRestorePersistedBridgeEntry
35595
+ shouldRestorePersistedBridgeEntry,
35596
+ spawnClaudeCodeWithDiagnostics,
35597
+ wrapClaudeSpawnErrorForSdk
35383
35598
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -108,15 +108,15 @@
108
108
  "@earendil-works/pi-coding-agent": "*"
109
109
  },
110
110
  "devDependencies": {
111
- "@earendil-works/pi-ai": "^0.74.0",
112
- "@earendil-works/pi-coding-agent": "^0.74.0",
111
+ "@earendil-works/pi-ai": "^0.75.0",
112
+ "@earendil-works/pi-coding-agent": "^0.75.0",
113
113
  "@types/node": "^24.3.0",
114
114
  "esbuild": "^0.28.0",
115
115
  "tsx": "^4.21.0",
116
116
  "typescript": "^6.0.3"
117
117
  },
118
118
  "scripts": {
119
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
119
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
120
120
  "prepack": "npm run build",
121
121
  "test:unit": "node --import tsx --test tests/unit-*.mjs",
122
122
  "test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
@@ -145,6 +145,14 @@
145
145
  "access": "public"
146
146
  },
147
147
  "engines": {
148
- "node": ">=20"
148
+ "node": ">=22.19.0"
149
+ },
150
+ "peerDependenciesMeta": {
151
+ "@earendil-works/pi-ai": {
152
+ "optional": true
153
+ },
154
+ "@earendil-works/pi-coding-agent": {
155
+ "optional": true
156
+ }
149
157
  }
150
158
  }
package/src/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { calculateCost, getModels, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
- import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
4
+ import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
6
6
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
7
+ import { spawn as spawnProcess } from "child_process";
7
8
  import { createHash } from "crypto";
8
- import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, realpathSync, statSync } from "fs";
9
+ import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
9
10
  import { resolve as pathResolve } from "path";
10
11
  import { homedir } from "os";
11
12
  import { delimiter, dirname, join } from "path";
@@ -79,6 +80,240 @@ function resolveClaudeExecutable(configured?: string): string | undefined {
79
80
  return executableFromPath("claude") ?? executableFromPath("claude-code");
80
81
  }
81
82
 
83
+ export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
84
+
85
+ export interface ClaudeExecutablePreflightResult {
86
+ path: string;
87
+ realPath: string;
88
+ cwd: string;
89
+ realCwd: string;
90
+ fileType: ClaudeExecutableFileType;
91
+ }
92
+
93
+ function errnoValue(err: unknown): string | number | undefined {
94
+ return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
95
+ }
96
+
97
+ function syscallValue(err: unknown): string | undefined {
98
+ return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
99
+ }
100
+
101
+ function pathValue(err: unknown): string | undefined {
102
+ const value = (err as NodeJS.ErrnoException)?.path;
103
+ return typeof value === "string" ? value : undefined;
104
+ }
105
+
106
+ function codeValue(err: unknown, fallback: string): string {
107
+ const value = (err as NodeJS.ErrnoException)?.code;
108
+ return typeof value === "string" ? value : fallback;
109
+ }
110
+
111
+ function displayValue(value: unknown): string {
112
+ return value === undefined || value === null || value === "" ? "<none>" : String(value);
113
+ }
114
+
115
+ function makeClaudePreflightError(
116
+ summary: string,
117
+ details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
118
+ ): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
119
+ const detail = [
120
+ `code=${details.code}`,
121
+ `errno=${displayValue(details.errno)}`,
122
+ `syscall=${displayValue(details.syscall)}`,
123
+ `path=${details.path}`,
124
+ `cwd=${details.cwd}`,
125
+ ...(details.fileType ? [`fileType=${details.fileType}`] : []),
126
+ ...(details.realPath ? [`realPath=${details.realPath}`] : []),
127
+ ].join(" ");
128
+ const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
129
+ error.name = "ClaudeExecutablePreflightError";
130
+ error.code = details.code;
131
+ if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
132
+ if (details.syscall) error.syscall = details.syscall;
133
+ error.path = details.path;
134
+ error.cwd = details.cwd;
135
+ if (details.fileType) error.fileType = details.fileType;
136
+ if (details.realPath) error.realPath = details.realPath;
137
+ if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
138
+ return error;
139
+ }
140
+
141
+ export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
142
+ if (bytes.length === 0) return "empty";
143
+ if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
144
+ if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
145
+ if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
146
+ if (bytes.length >= 4) {
147
+ const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
148
+ if (
149
+ magic === 0xfeedface ||
150
+ magic === 0xfeedfacf ||
151
+ magic === 0xcefaedfe ||
152
+ magic === 0xcffaedfe ||
153
+ magic === 0xcafebabe ||
154
+ magic === 0xbebafeca
155
+ ) return "mach-o";
156
+ }
157
+ return "unknown";
158
+ }
159
+
160
+ export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
161
+ let realCwd = cwd;
162
+ try {
163
+ const cwdStat = statSync(cwd);
164
+ if (!cwdStat.isDirectory()) {
165
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
166
+ code: "ENOTDIR",
167
+ syscall: "chdir",
168
+ path: cwd,
169
+ cwd,
170
+ });
171
+ }
172
+ accessSync(cwd, fsConstants.X_OK);
173
+ realCwd = realpathSync(cwd);
174
+ } catch (err) {
175
+ if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
176
+ throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
177
+ code: codeValue(err, "EACCES"),
178
+ errno: errnoValue(err),
179
+ syscall: syscallValue(err),
180
+ path: pathValue(err) ?? cwd,
181
+ cwd,
182
+ cause: err,
183
+ });
184
+ }
185
+
186
+ let realPath = path;
187
+ try {
188
+ const stat = statSync(path);
189
+ if (!stat.isFile()) {
190
+ throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
191
+ code: "EACCES",
192
+ syscall: "exec",
193
+ path,
194
+ cwd,
195
+ });
196
+ }
197
+ accessSync(path, fsConstants.X_OK);
198
+ realPath = realpathSync(path);
199
+ } catch (err) {
200
+ if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
201
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
202
+ code: codeValue(err, "ENOENT"),
203
+ errno: errnoValue(err),
204
+ syscall: syscallValue(err),
205
+ path: pathValue(err) ?? path,
206
+ cwd,
207
+ cause: err,
208
+ });
209
+ }
210
+
211
+ let fileType: ClaudeExecutableFileType;
212
+ try {
213
+ fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
214
+ } catch (err) {
215
+ throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
216
+ code: codeValue(err, "EACCES"),
217
+ errno: errnoValue(err),
218
+ syscall: syscallValue(err),
219
+ path: pathValue(err) ?? realPath,
220
+ cwd,
221
+ realPath,
222
+ cause: err,
223
+ });
224
+ }
225
+
226
+ if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
227
+ throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
228
+ code: "ENOEXEC",
229
+ syscall: "exec",
230
+ path,
231
+ cwd,
232
+ fileType,
233
+ realPath,
234
+ });
235
+ }
236
+
237
+ return { path, realPath, cwd, realCwd, fileType };
238
+ }
239
+
240
+ function envFlagEnabled(value: string | undefined): boolean {
241
+ return value === "1" || value?.toLowerCase() === "true";
242
+ }
243
+
244
+ export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
245
+ const originalCode = codeValue(err, "SPAWN_ERROR");
246
+ const originalMessage = err.message;
247
+ const spawnPath = pathValue(err) ?? options.command;
248
+ const cwd = options.cwd ?? process.cwd();
249
+ const detail = [
250
+ `code=${originalCode}`,
251
+ `errno=${displayValue(errnoValue(err))}`,
252
+ `syscall=${displayValue(syscallValue(err))}`,
253
+ `path=${spawnPath}`,
254
+ `cwd=${cwd}`,
255
+ `command=${options.command}`,
256
+ ].join(" ");
257
+ const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
258
+ wrapped.name = "ClaudeSpawnDiagnosticError";
259
+ // The SDK special-cases code === ENOENT and replaces the message with its
260
+ // generic "native binary not found" text. Preserve the original code in the
261
+ // message/originalCode while using a bridge code so the SDK surfaces context.
262
+ wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
263
+ wrapped.originalCode = originalCode;
264
+ wrapped.originalMessage = originalMessage;
265
+ const errno = errnoValue(err);
266
+ if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
267
+ const syscall = syscallValue(err);
268
+ if (syscall) wrapped.syscall = syscall;
269
+ wrapped.path = spawnPath;
270
+ wrapped.cwd = cwd;
271
+ // Do not set `cause` here: the listener copies these structured fields back
272
+ // onto the original Error. A cause reference to that same object would become
273
+ // `err.cause === err`, making JSON.stringify throw on a circular structure.
274
+ // originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
275
+ return wrapped;
276
+ }
277
+
278
+ export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
279
+ const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
280
+ const child = spawnProcess(options.command, options.args, {
281
+ cwd: options.cwd,
282
+ env: options.env,
283
+ signal: options.signal,
284
+ stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
285
+ windowsHide: true,
286
+ });
287
+ if (pipeStderr) {
288
+ child.stderr?.on("data", (data) => {
289
+ for (const line of data.toString().split(/\r?\n/)) {
290
+ if (line) debug(`[cli-stderr spawn] ${line}`);
291
+ }
292
+ });
293
+ }
294
+ child.prependListener("error", (err) => {
295
+ const originalStack = err.stack;
296
+ const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
297
+ Object.assign(err, wrapped);
298
+ err.name = wrapped.name;
299
+ err.message = wrapped.message;
300
+ // Keep V8's stack from the actual Node spawn failure, not the wrapper
301
+ // construction site. Diagnostic fields above remain enumerable and
302
+ // JSON-serializable; stack stays the spawn-time breadcrumb for operators.
303
+ if (originalStack) err.stack = originalStack;
304
+ });
305
+ return {
306
+ stdin: child.stdin,
307
+ stdout: child.stdout,
308
+ get killed() { return child.killed; },
309
+ get exitCode() { return child.exitCode; },
310
+ kill: child.kill.bind(child),
311
+ on: child.on.bind(child),
312
+ once: child.once.bind(child),
313
+ off: child.off.bind(child),
314
+ };
315
+ }
316
+
82
317
  // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
83
318
  // CLI subprocess to write its own debug log to a file we choose, and also
84
319
  // forward its stderr into our debug stream. Drops straight into the real SDK's
@@ -1072,7 +1307,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1072
1307
 
1073
1308
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1074
1309
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1075
- const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1076
1310
  const promptBlocks = extractUserPromptBlocks(context.messages);
1077
1311
  let promptText = extractUserPrompt(context.messages) ?? "";
1078
1312
 
@@ -1114,6 +1348,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1114
1348
  : providerSettings.settingSources ?? ["user", "project"];
1115
1349
  const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
1116
1350
  const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
1351
+ const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
1352
+ const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1117
1353
 
1118
1354
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1119
1355
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
@@ -1156,6 +1392,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1156
1392
  ...(mcpServers ? { mcpServers } : {}),
1157
1393
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1158
1394
  ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
1395
+ spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
1159
1396
  ...makeCliDebugOptions("provider"),
1160
1397
  };
1161
1398
 
@@ -1163,6 +1400,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1163
1400
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1164
1401
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1165
1402
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
1403
+ `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1166
1404
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1167
1405
 
1168
1406
  // 3. Start SDK query and claim it for this context
@@ -76,8 +76,15 @@ function extractHeadingSection(systemPrompt: string | undefined, headings: strin
76
76
  }
77
77
  if (start < 0) return undefined;
78
78
  const rest = systemPrompt.slice(start).trim();
79
- const nextHeading = rest.slice(1).search(/\n##\s+/);
80
- return (nextHeading >= 0 ? rest.slice(0, nextHeading + 1) : rest).trim();
79
+ const endCandidates = [
80
+ rest.slice(1).search(/\n##\s+/),
81
+ rest.search(/\n<\/project_instructions>/),
82
+ rest.search(/\n<\/project_context>/),
83
+ ]
84
+ .map((index, offset) => (index >= 0 && offset === 0 ? index + 1 : index))
85
+ .filter((index) => index >= 0);
86
+ const end = endCandidates.length > 0 ? Math.min(...endCandidates) : -1;
87
+ return (end >= 0 ? rest.slice(0, end) : rest).trim();
81
88
  }
82
89
 
83
90
  function extractBlockByMarkers(systemPrompt: string | undefined, markers: RegExp[]): string | undefined {
@@ -93,7 +100,7 @@ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd:
93
100
 
94
101
  if (settings.includeAppendSystemPromptMd) {
95
102
  for (const file of readAppendSystemPromptFiles(cwd)) {
96
- parts.push(`### ${file.label}\n\n${file.content}`);
103
+ parts.push(xmlBlock("append_system_prompt", { label: file.label }, file.content));
97
104
  labels.push(file.label);
98
105
  }
99
106
  }
@@ -101,7 +108,7 @@ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd:
101
108
  if (settings.includeProjectAgentsHook) {
102
109
  const projectAgents = extractHeadingSection(systemPrompt, ["## Project Agents", "## Project Subagents"]);
103
110
  if (projectAgents) {
104
- parts.push(`### before_agent_start: project agents\n\n${projectAgents}`);
111
+ parts.push(xmlBlock("before_agent_start", { source: "project-agents" }, projectAgents));
105
112
  labels.push("project agents hook");
106
113
  }
107
114
  }
@@ -109,7 +116,7 @@ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd:
109
116
  if (settings.includeTaskPanelHook) {
110
117
  const taskReminder = extractBlockByMarkers(systemPrompt, [/^Task workflow reminder:/]);
111
118
  if (taskReminder) {
112
- parts.push(`### before_agent_start: task panel\n\n${taskReminder}`);
119
+ parts.push(xmlBlock("before_agent_start", { source: "task-panel" }, taskReminder));
113
120
  labels.push("task panel hook");
114
121
  }
115
122
  }
@@ -117,7 +124,7 @@ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd:
117
124
  if (settings.includeCavemanHook) {
118
125
  const caveman = extractBlockByMarkers(systemPrompt, [/^You MUST respond in caveman /m]);
119
126
  if (caveman) {
120
- parts.push(`### before_agent_start: caveman\n\n${caveman}`);
127
+ parts.push(xmlBlock("before_agent_start", { source: "caveman" }, caveman));
121
128
  labels.push("caveman hook");
122
129
  }
123
130
  }
@@ -125,10 +132,37 @@ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd:
125
132
  if (parts.length === 0) return { labels };
126
133
  return {
127
134
  labels,
128
- text: [
129
- "## Forwarded Pi Context",
130
- "The following content was explicitly enabled in pi-claude-bridge settings and comes from Pi prompt files or before_agent_start prompt hooks.",
131
- ...parts,
132
- ].join("\n\n"),
135
+ text: xmlBlock(
136
+ "forwarded_pi_context",
137
+ {},
138
+ [
139
+ "The following content was explicitly enabled in pi-claude-bridge settings and comes from Pi prompt files or before_agent_start prompt hooks.",
140
+ ...parts,
141
+ ].join("\n\n"),
142
+ false,
143
+ ),
133
144
  };
134
145
  }
146
+
147
+ function escapeXmlAttr(value: string): string {
148
+ return value
149
+ .replace(/&/g, "&amp;")
150
+ .replace(/"/g, "&quot;")
151
+ .replace(/</g, "&lt;")
152
+ .replace(/>/g, "&gt;");
153
+ }
154
+
155
+ function escapeXmlText(value: string): string {
156
+ return value
157
+ .replace(/&/g, "&amp;")
158
+ .replace(/</g, "&lt;")
159
+ .replace(/>/g, "&gt;");
160
+ }
161
+
162
+ function xmlBlock(tag: string, attrs: Record<string, string>, content: string, escapeContent = true): string {
163
+ const attrText = Object.entries(attrs)
164
+ .map(([key, value]) => ` ${key}="${escapeXmlAttr(value)}"`)
165
+ .join("");
166
+ const body = escapeContent ? escapeXmlText(content.trim()) : content.trim();
167
+ return `<${tag}${attrText}>\n${body}\n</${tag}>`;
168
+ }