@vanillagreen/pi-claude-bridge 1.4.0 → 1.5.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 CHANGED
@@ -9,7 +9,7 @@ Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi
9
9
 
10
10
  ## Highlights
11
11
 
12
- - `claude-bridge/claude-opus-4-8`, Opus 4-7, Sonnet, and Haiku in `/model`.
12
+ - `claude-bridge/claude-fable-5`, Opus 4.8, Opus 4.7, Sonnet, and Haiku in `/model`.
13
13
  - Pi tool calls run on Pi; Claude Code handles reasoning.
14
14
  - Tool-use turns block until Pi-delivered tool results reach Claude Code, including persistent subagent panes.
15
15
  - Session continuity across normal turns, `/compact`, tree navigation, and abort recovery.
@@ -45,6 +45,8 @@ Extra Pi context is off by default. Enable per item in the extension manager whe
45
45
 
46
46
  Open `/extensions:settings`; settings appear under the **Claude Bridge** tab.
47
47
 
48
+ Project settings in `.pi/settings.json` apply only after Pi marks the workspace trusted; before trust, vstack Pi extensions read user/global settings only.
49
+
48
50
  ### General
49
51
 
50
52
  | Setting | What it does |
@@ -90,6 +92,10 @@ Pi does not have a native `max` thinking level; it exposes up to `xhigh`, and ea
90
92
 
91
93
  Keys may be bare model IDs (`claude-opus-4-8`), `claude-bridge/<id>`, or `*` for all bridge models. Values are `low`, `medium`, `high`, `xhigh`, or `max`.
92
94
 
95
+ ### Fable 5 caveat
96
+
97
+ The bridge registers `claude-bridge/claude-fable-5` and `claude-bridge/claude-opus-4-8` even when Pi's Anthropic model registry has not shipped those entries yet. For Fable 5, the bridge asks Claude Code to use Opus 4.8 as the availability fallback and preserves Claude Code's content-safety fallback events so Pi labels rerouted turns as Opus 4.8. Content-safety fallback still depends on Claude Code's own Fable 5 support; use Claude Code 2.1.170 or newer, and set `ANTHROPIC_DEFAULT_FABLE_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` yourself when routing provider-specific model IDs through Bedrock, Vertex, or Foundry.
98
+
93
99
  ## Extra usage and rate limits
94
100
 
95
101
  Claude Code's `/extra-usage` local command works through the Claude Agent SDK. In Pi, use `/claude-bridge:extra` to run that flow from claude-bridge. Persist automatic launch on extra-usage errors with **Allow extra usage helper** in `/extensions:settings`.
@@ -98,7 +104,7 @@ When Claude Code emits rate-limit reset metadata, the bridge shows one red ASCII
98
104
 
99
105
  Allowed-warning rate-limit events are filtered before user notification. The bridge normalizes unambiguous numeric utilization (`0 < value < 1` as fractional, `1 < value <= 100` as percent), suppresses low or unit-ambiguous values such as exact `1`, and only shows a neutral warning at 80%+ instead of claiming an unverified `% used` value. Check Claude Code `/usage` for exact allowed-warning utilization.
100
106
 
101
- If Claude Code accepts a turn but produces no assistant/tool output, the bridge treats that stream-idle stall as a retryable overload/rate-limit failure: it closes the stalled Claude Code subprocess, emits a normal assistant error with a backoff hint, and lets Flightdeck or `pi-agents-tmux` reuse their existing rate-limit retry ladder. Tune the first-output timeout with `CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT` (bare numbers are seconds; suffixes `ms`, `s`, and `m` are accepted). Default: `90s`; set `0` to disable.
107
+ If Claude Code accepts a turn but produces no assistant/tool output, the bridge treats that stream-idle stall as a retryable overload/rate-limit failure: it closes the stalled Claude Code subprocess, emits a normal assistant error with a backoff hint, and lets `pi-agents-tmux` reuse its existing rate-limit retry ladder. Tune the first-output timeout with `CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT` (bare numbers are seconds; suffixes `ms`, `s`, and `m` are accepted). Default: `90s`; set `0` to disable.
102
108
 
103
109
  ## Debugging
104
110
 
package/bundle/index.js CHANGED
@@ -21976,7 +21976,7 @@ function readSession(jsonlPath, projectPath) {
21976
21976
  // src/index.ts
21977
21977
  import { spawn as spawnProcess } from "child_process";
21978
21978
  import { createHash } from "crypto";
21979
- import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
21979
+ import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
21980
21980
  import { resolve as pathResolve } from "path";
21981
21981
  import { homedir as homedir5 } from "os";
21982
21982
  import { delimiter, dirname as dirname5, join as join5 } from "path";
@@ -22237,9 +22237,41 @@ function convertPiMessages(messages, customToolNameToSdk) {
22237
22237
  }
22238
22238
 
22239
22239
  // src/models.ts
22240
- var MODEL_IDS_IN_ORDER = ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
22240
+ var FABLE_MODEL_ID = "claude-fable-5";
22241
+ var FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
22242
+ function fallbackModelForPrimaryModel(modelId) {
22243
+ return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : void 0;
22244
+ }
22245
+ var MODEL_IDS_IN_ORDER = [
22246
+ FABLE_MODEL_ID,
22247
+ FABLE_FALLBACK_MODEL_ID,
22248
+ "claude-opus-4-7",
22249
+ "claude-opus-4-6",
22250
+ "claude-sonnet-4-6",
22251
+ "claude-haiku-4-5"
22252
+ ];
22253
+ var FALLBACK_MODELS = {
22254
+ [FABLE_MODEL_ID]: {
22255
+ id: FABLE_MODEL_ID,
22256
+ name: "Claude Fable 5",
22257
+ reasoning: true,
22258
+ thinkingLevelMap: { xhigh: "xhigh" },
22259
+ input: ["text", "image"],
22260
+ contextWindow: 1e6,
22261
+ maxTokens: 128e3
22262
+ },
22263
+ [FABLE_FALLBACK_MODEL_ID]: {
22264
+ id: FABLE_FALLBACK_MODEL_ID,
22265
+ name: "Claude Opus 4.8",
22266
+ reasoning: true,
22267
+ thinkingLevelMap: { xhigh: "xhigh" },
22268
+ input: ["text", "image"],
22269
+ contextWindow: 1e6,
22270
+ maxTokens: 128e3
22271
+ }
22272
+ };
22241
22273
  function buildModels(piAiModels) {
22242
- return MODEL_IDS_IN_ORDER.map((id) => piAiModels.find((m4) => m4.id === id)).filter((m4) => m4 != null).map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
22274
+ return MODEL_IDS_IN_ORDER.map((id) => piAiModels.find((m4) => m4.id === id) ?? FALLBACK_MODELS[id]).filter((m4) => m4 != null).map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
22243
22275
  id,
22244
22276
  name,
22245
22277
  reasoning,
@@ -22272,7 +22304,46 @@ function rewriteSkillsBlock(skillsBlock) {
22272
22304
  }
22273
22305
 
22274
22306
  // src/session-verify.ts
22275
- import { statSync as statSync2, readFileSync as readFileSync3 } from "fs";
22307
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
22308
+ import { StringDecoder } from "node:string_decoder";
22309
+ function forEachJsonlLine(path, onLine) {
22310
+ const fd = openSync2(path, "r");
22311
+ const buffer = Buffer.allocUnsafe(64 * 1024);
22312
+ const decoder = new StringDecoder("utf8");
22313
+ let pending = "";
22314
+ try {
22315
+ for (; ; ) {
22316
+ const bytesRead = readSync2(fd, buffer, 0, buffer.length, null);
22317
+ if (bytesRead === 0) break;
22318
+ pending += decoder.write(buffer.subarray(0, bytesRead));
22319
+ let start = 0;
22320
+ for (; ; ) {
22321
+ const newline = pending.indexOf("\n", start);
22322
+ if (newline < 0) {
22323
+ pending = pending.slice(start);
22324
+ break;
22325
+ }
22326
+ const line = pending.slice(start, newline);
22327
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
22328
+ start = newline + 1;
22329
+ }
22330
+ }
22331
+ pending += decoder.end();
22332
+ if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
22333
+ } finally {
22334
+ closeSync2(fd);
22335
+ }
22336
+ }
22337
+ function summarizeJsonl(path) {
22338
+ const summary = { count: 0 };
22339
+ forEachJsonlLine(path, (line) => {
22340
+ if (!line.trim()) return;
22341
+ summary.count += 1;
22342
+ if (summary.firstLine === void 0) summary.firstLine = line;
22343
+ summary.lastLine = line;
22344
+ });
22345
+ return summary;
22346
+ }
22276
22347
  function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount) {
22277
22348
  const warnings = [];
22278
22349
  let st;
@@ -22282,21 +22353,20 @@ function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount)
22282
22353
  warnings.push(`file missing after save \u2014 path=${jsonlPath} err=${e2.message}`);
22283
22354
  return warnings;
22284
22355
  }
22285
- let content;
22356
+ let summary;
22286
22357
  try {
22287
- content = readFileSync3(jsonlPath, "utf8");
22358
+ summary = summarizeJsonl(jsonlPath);
22288
22359
  } catch (e2) {
22289
22360
  warnings.push(`file unreadable \u2014 path=${jsonlPath} size=${st.size} err=${e2.message}`);
22290
22361
  return warnings;
22291
22362
  }
22292
- const lines = content.split("\n").filter((l2) => l2.trim().length > 0);
22293
- if (lines.length !== expectedRecordCount) {
22294
- warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
22363
+ if (summary.count !== expectedRecordCount) {
22364
+ warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
22295
22365
  return warnings;
22296
22366
  }
22297
22367
  try {
22298
- const firstRec = JSON.parse(lines[0]);
22299
- const lastRec = JSON.parse(lines[lines.length - 1]);
22368
+ const firstRec = JSON.parse(summary.firstLine ?? "");
22369
+ const lastRec = JSON.parse(summary.lastLine ?? "");
22300
22370
  if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
22301
22371
  warnings.push(`sessionId drift \u2014 expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
22302
22372
  }
@@ -22564,7 +22634,7 @@ function summarizeMissingToolNames(missing) {
22564
22634
  }
22565
22635
 
22566
22636
  // src/config.ts
22567
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
22637
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
22568
22638
  import { homedir as homedir2 } from "os";
22569
22639
  import { dirname as dirname2, join as join2, resolve } from "path";
22570
22640
  var PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
@@ -22600,13 +22670,39 @@ function projectSettingsPath(cwd) {
22600
22670
  current = parent;
22601
22671
  }
22602
22672
  }
22673
+ var PROJECT_TRUST_SYMBOL = /* @__PURE__ */ Symbol.for("vstack.pi.project-trust");
22674
+ function projectTrustRegistry() {
22675
+ const host = globalThis;
22676
+ const existing = host[PROJECT_TRUST_SYMBOL];
22677
+ if (existing) return existing;
22678
+ const created = {};
22679
+ host[PROJECT_TRUST_SYMBOL] = created;
22680
+ return created;
22681
+ }
22682
+ function recordProjectTrust(ctx2) {
22683
+ if (!ctx2.cwd) return;
22684
+ let trusted = true;
22685
+ try {
22686
+ trusted = ctx2.isProjectTrusted?.() === true;
22687
+ } catch {
22688
+ trusted = false;
22689
+ }
22690
+ const registry2 = projectTrustRegistry();
22691
+ if (!registry2.projectSettings) registry2.projectSettings = /* @__PURE__ */ new Map();
22692
+ registry2.projectSettings.set(projectSettingsPath(ctx2.cwd), trusted);
22693
+ }
22694
+ function projectSettingsTrusted(settingsPath) {
22695
+ return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
22696
+ }
22603
22697
  function settingsPaths(cwd) {
22604
- return [join2(piUserDir(), "settings.json"), projectSettingsPath(cwd)];
22698
+ const user = join2(piUserDir(), "settings.json");
22699
+ const project = projectSettingsPath(cwd);
22700
+ return projectSettingsTrusted(project) ? [user, project] : [user];
22605
22701
  }
22606
22702
  function tryParseJson(path) {
22607
22703
  if (!existsSync3(path)) return {};
22608
22704
  try {
22609
- return JSON.parse(readFileSync4(path, "utf-8"));
22705
+ return JSON.parse(readFileSync3(path, "utf-8"));
22610
22706
  } catch {
22611
22707
  return {};
22612
22708
  }
@@ -22616,7 +22712,7 @@ function readManagerConfig(cwd) {
22616
22712
  for (const path of settingsPaths(cwd)) {
22617
22713
  if (!existsSync3(path)) continue;
22618
22714
  try {
22619
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
22715
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
22620
22716
  const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
22621
22717
  const config2 = asRecord(configRoot?.[PACKAGE_ID]);
22622
22718
  if (config2) mergeDeep(merged, config2);
@@ -22709,7 +22805,9 @@ function managerToConfig(raw) {
22709
22805
  }
22710
22806
  function loadConfig(cwd) {
22711
22807
  const global2 = tryParseJson(join2(piUserDir(), "claude-bridge.json"));
22712
- const project = tryParseJson(join2(cwd, ".pi", "claude-bridge.json"));
22808
+ const projectSettings = projectSettingsPath(cwd);
22809
+ const trustedProject = projectSettingsTrusted(projectSettings);
22810
+ const project = trustedProject ? tryParseJson(join2(dirname2(projectSettings), "claude-bridge.json")) : {};
22713
22811
  const manager = managerToConfig(readManagerConfig(cwd));
22714
22812
  const provider = normalizeProviderConfig({ ...global2.provider, ...project.provider, ...manager.provider });
22715
22813
  return {
@@ -22720,7 +22818,7 @@ function loadConfig(cwd) {
22720
22818
  }
22721
22819
 
22722
22820
  // src/agents-md.ts
22723
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
22821
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
22724
22822
  import { homedir as homedir3 } from "os";
22725
22823
  import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
22726
22824
  var GLOBAL_AGENTS_PATH = join3(homedir3(), ".pi", "agent", "AGENTS.md");
@@ -22745,7 +22843,7 @@ function extractAgentsAppend() {
22745
22843
  const agentsPath = resolveAgentsMdPath();
22746
22844
  if (!agentsPath) return void 0;
22747
22845
  try {
22748
- const content = readFileSync5(agentsPath, "utf-8").trim();
22846
+ const content = readFileSync4(agentsPath, "utf-8").trim();
22749
22847
  if (!content) return void 0;
22750
22848
  const sanitized = sanitizeAgentsContent(content);
22751
22849
  return sanitized.length > 0 ? `# CLAUDE.md
@@ -22765,7 +22863,7 @@ function sanitizeAgentsContent(content) {
22765
22863
  }
22766
22864
 
22767
22865
  // src/prompt-context.ts
22768
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
22866
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
22769
22867
  import { homedir as homedir4 } from "os";
22770
22868
  import { dirname as dirname4, join as join4, resolve as resolve3 } from "path";
22771
22869
  function piUserDir2() {
@@ -22776,7 +22874,7 @@ function piUserDir2() {
22776
22874
  function readTrimmed(path) {
22777
22875
  try {
22778
22876
  if (!existsSync5(path)) return void 0;
22779
- const content = readFileSync6(path, "utf8").trim();
22877
+ const content = readFileSync5(path, "utf8").trim();
22780
22878
  return content.length > 0 ? content : void 0;
22781
22879
  } catch {
22782
22880
  return void 0;
@@ -37607,7 +37705,7 @@ function preflightClaudeExecutable(path, cwd) {
37607
37705
  }
37608
37706
  let fileType;
37609
37707
  try {
37610
- fileType = classifyClaudeExecutableBytes(readFileSync7(realPath).subarray(0, 16));
37708
+ fileType = classifyClaudeExecutableBytes(readFileSync6(realPath).subarray(0, 16));
37611
37709
  } catch (err) {
37612
37710
  throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
37613
37711
  code: codeValue(err, "EACCES"),
@@ -38511,6 +38609,13 @@ function finalizeCurrentStream(stopReason) {
38511
38609
  ctx().currentPiStream.end();
38512
38610
  ctx().currentPiStream = null;
38513
38611
  }
38612
+ function updateTurnOutputModel(modelId) {
38613
+ const c2 = ctx();
38614
+ if (typeof modelId !== "string" || !modelId || !c2.turnOutput) return;
38615
+ if (c2.turnOutput.model === modelId) return;
38616
+ debug(`provider: active Claude model changed ${c2.turnOutput.model} -> ${modelId}`);
38617
+ c2.turnOutput.model = modelId;
38618
+ }
38514
38619
  function processStreamEvent(message, customToolNameToPi, model) {
38515
38620
  const c2 = ctx();
38516
38621
  if (!c2.currentPiStream || !c2.turnOutput) return;
@@ -38522,6 +38627,7 @@ function processStreamEvent(message, customToolNameToPi, model) {
38522
38627
  }
38523
38628
  if (event?.type === "message_start") {
38524
38629
  c2.resetToolTracking();
38630
+ updateTurnOutputModel(event.message?.model);
38525
38631
  if (event.message?.usage) updateUsage(c2.turnOutput, event.message.usage, model);
38526
38632
  return;
38527
38633
  }
@@ -38660,6 +38766,7 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38660
38766
  const c2 = ctx();
38661
38767
  const assistantMsg = message.message;
38662
38768
  if (!assistantMsg?.content) return;
38769
+ updateTurnOutputModel(assistantMsg.model);
38663
38770
  if (c2.turnSawStreamEvent) {
38664
38771
  if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
38665
38772
  c2.turnSawToolCall = true;
@@ -38706,6 +38813,8 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38706
38813
  const toolBlock = c2.turnBlocks[idx];
38707
38814
  c2.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c2.turnOutput });
38708
38815
  c2.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock, partial: c2.turnOutput });
38816
+ } else if (block.type === "fallback") {
38817
+ updateTurnOutputModel(block.to?.model);
38709
38818
  } else {
38710
38819
  debug("processAssistantMessage: unhandled block type", block.type);
38711
38820
  }
@@ -38757,6 +38866,14 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
38757
38866
  case "system":
38758
38867
  if (message.subtype === "init" && message.session_id) {
38759
38868
  capturedSessionId = message.session_id;
38869
+ } else if (message.subtype === "model_refusal_fallback") {
38870
+ const originalModel = message.original_model;
38871
+ const fallbackModel = message.fallback_model;
38872
+ updateTurnOutputModel(fallbackModel);
38873
+ debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
38874
+ if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
38875
+ safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
38876
+ }
38760
38877
  }
38761
38878
  break;
38762
38879
  case "user":
@@ -38911,16 +39028,19 @@ function streamClaudeAgentSdk(model, context, options) {
38911
39028
  const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
38912
39029
  const requestedEffort = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] ?? REASONING_TO_EFFORT[options.reasoning] : void 0;
38913
39030
  const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
38914
- const extraArgs = { model: model.id };
39031
+ const extraArgs = {};
38915
39032
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
38916
39033
  if (effort) extraArgs["thinking-display"] = "summarized";
39034
+ const fallbackModel = fallbackModelForPrimaryModel(model.id);
38917
39035
  const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
38918
39036
  const queryOptions = {
38919
39037
  cwd,
39038
+ model: model.id,
38920
39039
  env: childEnv,
38921
39040
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
38922
39041
  permissionMode: "bypassPermissions",
38923
39042
  includePartialMessages: true,
39043
+ ...fallbackModel ? { fallbackModel } : {},
38924
39044
  ...providerSettings.fastMode ? { settings: { fastMode: true } } : {},
38925
39045
  systemPrompt: {
38926
39046
  type: "preset",
@@ -38940,6 +39060,7 @@ function streamClaudeAgentSdk(model, context, options) {
38940
39060
  "provider: fresh query",
38941
39061
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
38942
39062
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
39063
+ `fallback=${fallbackModel ?? "none"}`,
38943
39064
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
38944
39065
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
38945
39066
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
@@ -39200,6 +39321,7 @@ function index_default(pi) {
39200
39321
  }
39201
39322
  };
39202
39323
  pi.on("session_start", (event, ctx2) => {
39324
+ recordProjectTrust(ctx2);
39203
39325
  piUI = ctx2.ui;
39204
39326
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
39205
39327
  clearSession(`session_start:${event.reason}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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": [
package/src/config.ts CHANGED
@@ -76,8 +76,43 @@ function projectSettingsPath(cwd: string): string {
76
76
  }
77
77
  }
78
78
 
79
+ const PROJECT_TRUST_SYMBOL = Symbol.for("vstack.pi.project-trust");
80
+
81
+ interface ProjectTrustRegistry {
82
+ projectSettings?: Map<string, boolean>;
83
+ }
84
+
85
+ function projectTrustRegistry(): ProjectTrustRegistry {
86
+ const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
87
+ const existing = host[PROJECT_TRUST_SYMBOL];
88
+ if (existing) return existing;
89
+ const created: ProjectTrustRegistry = {};
90
+ host[PROJECT_TRUST_SYMBOL] = created;
91
+ return created;
92
+ }
93
+
94
+ export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
95
+ if (!ctx.cwd) return;
96
+ let trusted = true;
97
+ try {
98
+ trusted = ctx.isProjectTrusted?.() === true;
99
+ } catch {
100
+ trusted = false;
101
+ }
102
+ const registry = projectTrustRegistry();
103
+ if (!registry.projectSettings) registry.projectSettings = new Map();
104
+ registry.projectSettings.set(projectSettingsPath(ctx.cwd), trusted);
105
+ }
106
+
107
+ function projectSettingsTrusted(settingsPath: string): boolean {
108
+ return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
109
+ }
110
+
111
+
79
112
  function settingsPaths(cwd: string): string[] {
80
- return [join(piUserDir(), "settings.json"), projectSettingsPath(cwd)];
113
+ const user = join(piUserDir(), "settings.json");
114
+ const project = projectSettingsPath(cwd);
115
+ return projectSettingsTrusted(project) ? [user, project] : [user];
81
116
  }
82
117
 
83
118
  export function tryParseJson(path: string): Partial<Config> {
@@ -202,7 +237,9 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
202
237
 
203
238
  export function loadConfig(cwd: string): Config {
204
239
  const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
205
- const project = tryParseJson(join(cwd, ".pi", "claude-bridge.json"));
240
+ const projectSettings = projectSettingsPath(cwd);
241
+ const trustedProject = projectSettingsTrusted(projectSettings);
242
+ const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
206
243
  const manager = managerToConfig(readManagerConfig(cwd));
207
244
  const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
208
245
  return {
package/src/index.ts CHANGED
@@ -11,13 +11,13 @@ import { resolve as pathResolve } from "path";
11
11
  import { homedir } from "os";
12
12
  import { delimiter, dirname, join } from "path";
13
13
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
14
- import { buildModels } from "./models.js";
14
+ import { FABLE_FALLBACK_MODEL_ID, FABLE_MODEL_ID, buildModels, fallbackModelForPrimaryModel } from "./models.js";
15
15
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
16
16
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
17
17
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
18
18
  import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
19
19
  import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
20
- import { loadConfig, normalizeEffortLevel, type Config } from "./config.js";
20
+ import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
21
21
  import { extractAgentsAppend } from "./agents-md.js";
22
22
  import { buildPromptContextAppend } from "./prompt-context.js";
23
23
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
@@ -1401,6 +1401,14 @@ function finalizeCurrentStream(stopReason?: string): void {
1401
1401
  ctx().currentPiStream = null;
1402
1402
  }
1403
1403
 
1404
+ function updateTurnOutputModel(modelId: unknown): void {
1405
+ const c = ctx();
1406
+ if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
1407
+ if (c.turnOutput.model === modelId) return;
1408
+ debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
1409
+ c.turnOutput.model = modelId;
1410
+ }
1411
+
1404
1412
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1405
1413
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1406
1414
  export function processStreamEvent(
@@ -1419,6 +1427,7 @@ export function processStreamEvent(
1419
1427
 
1420
1428
  if (event?.type === "message_start") {
1421
1429
  c.resetToolTracking();
1430
+ updateTurnOutputModel(event.message?.model);
1422
1431
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1423
1432
  return;
1424
1433
  }
@@ -1579,6 +1588,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
1579
1588
  const c = ctx();
1580
1589
  const assistantMsg = (message as any).message;
1581
1590
  if (!assistantMsg?.content) return;
1591
+ updateTurnOutputModel(assistantMsg.model);
1582
1592
  if (c.turnSawStreamEvent) {
1583
1593
  // Claude Agent SDK can yield the completed assistant message before (or
1584
1594
  // instead of) a stream_event message_stop for a tool-use turn. Treat that
@@ -1630,6 +1640,8 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
1630
1640
  const toolBlock = c.turnBlocks[idx];
1631
1641
  c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1632
1642
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1643
+ } else if (block.type === "fallback") {
1644
+ updateTurnOutputModel(block.to?.model);
1633
1645
  } else {
1634
1646
  debug("processAssistantMessage: unhandled block type", block.type);
1635
1647
  }
@@ -1698,6 +1710,14 @@ async function consumeQuery(
1698
1710
  case "system":
1699
1711
  if ((message as any).subtype === "init" && (message as any).session_id) {
1700
1712
  capturedSessionId = (message as any).session_id;
1713
+ } else if ((message as any).subtype === "model_refusal_fallback") {
1714
+ const originalModel = (message as any).original_model;
1715
+ const fallbackModel = (message as any).fallback_model;
1716
+ updateTurnOutputModel(fallbackModel);
1717
+ debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
1718
+ if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
1719
+ safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
1720
+ }
1701
1721
  }
1702
1722
  break;
1703
1723
  case "user":
@@ -1908,11 +1928,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1908
1928
  : undefined;
1909
1929
  const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
1910
1930
 
1911
- const extraArgs: Record<string, string | null> = { model: model.id };
1931
+ const extraArgs: Record<string, string | null> = {};
1912
1932
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
1913
1933
  // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
1914
1934
  // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
1915
1935
  if (effort) extraArgs["thinking-display"] = "summarized";
1936
+ const fallbackModel = fallbackModelForPrimaryModel(model.id);
1916
1937
 
1917
1938
  // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
1918
1939
  // when the user is logged into Anthropic). These are a separate code path from
@@ -1927,10 +1948,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1927
1948
  const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
1928
1949
  const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
1929
1950
  cwd,
1951
+ model: model.id,
1930
1952
  env: childEnv,
1931
1953
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
1932
1954
  permissionMode: "bypassPermissions",
1933
1955
  includePartialMessages: true,
1956
+ ...(fallbackModel ? { fallbackModel } : {}),
1934
1957
  ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
1935
1958
  systemPrompt: {
1936
1959
  type: "preset", preset: "claude_code",
@@ -1949,6 +1972,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1949
1972
  debug("provider: fresh query",
1950
1973
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1951
1974
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1975
+ `fallback=${fallbackModel ?? "none"}`,
1952
1976
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
1953
1977
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1954
1978
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
@@ -2248,6 +2272,7 @@ export default function (pi: ExtensionAPI) {
2248
2272
  }
2249
2273
  };
2250
2274
  pi.on("session_start", (event, ctx) => {
2275
+ recordProjectTrust(ctx);
2251
2276
  piUI = ctx.ui;
2252
2277
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
2253
2278
  clearSession(`session_start:${event.reason}`);
package/src/models.ts CHANGED
@@ -2,13 +2,59 @@
2
2
  // `resolveModelId` returns the first partial match, so `opus` resolves to the first-listed opus entry.
3
3
  // Extracted from index.ts so tests can import without activating the extension.
4
4
 
5
- export const MODEL_IDS_IN_ORDER = ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
5
+ export const FABLE_MODEL_ID = "claude-fable-5";
6
+ export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
7
+
8
+ export function fallbackModelForPrimaryModel(modelId: string): string | undefined {
9
+ return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
10
+ }
11
+
12
+ export const MODEL_IDS_IN_ORDER = [
13
+ FABLE_MODEL_ID,
14
+ FABLE_FALLBACK_MODEL_ID,
15
+ "claude-opus-4-7",
16
+ "claude-opus-4-6",
17
+ "claude-sonnet-4-6",
18
+ "claude-haiku-4-5",
19
+ ];
20
+
21
+ type BridgeModelMetadata = {
22
+ id: string;
23
+ name: string;
24
+ reasoning: boolean;
25
+ thinkingLevelMap?: Record<string, string | null>;
26
+ input: ("text" | "image")[];
27
+ contextWindow: number;
28
+ maxTokens: number;
29
+ };
30
+
31
+ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
32
+ [FABLE_MODEL_ID]: {
33
+ id: FABLE_MODEL_ID,
34
+ name: "Claude Fable 5",
35
+ reasoning: true,
36
+ thinkingLevelMap: { xhigh: "xhigh" },
37
+ input: ["text", "image"],
38
+ contextWindow: 1000000,
39
+ maxTokens: 128000,
40
+ },
41
+ [FABLE_FALLBACK_MODEL_ID]: {
42
+ id: FABLE_FALLBACK_MODEL_ID,
43
+ name: "Claude Opus 4.8",
44
+ reasoning: true,
45
+ thinkingLevelMap: { xhigh: "xhigh" },
46
+ input: ["text", "image"],
47
+ contextWindow: 1000000,
48
+ maxTokens: 128000,
49
+ },
50
+ };
6
51
 
7
52
  // Project pi-ai's model entries down to the fields pi's registerProvider expects,
8
- // and keep MODEL_IDS_IN_ORDER ordering. IDs missing from pi-ai are silently dropped.
53
+ // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
54
+ // has not shipped metadata for them yet. Unknown missing IDs are still dropped.
9
55
  export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
10
56
  return MODEL_IDS_IN_ORDER
11
- .map((id) => piAiModels.find((m) => m.id === id))
57
+ .map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])
12
58
  .filter((m) => m != null)
13
59
  // Forward thinkingLevelMap so per-model overrides (e.g. opus-4-7 mapping
14
60
  // xhigh→xhigh instead of xhigh→max) are visible to the effort lookup.
@@ -2,7 +2,54 @@
2
2
  // callers decide how to surface them (debug log, piUI, diagDump, etc.).
3
3
  // Extracted from index.ts so tests can import without activating the extension.
4
4
 
5
- import { statSync, readFileSync } from "fs";
5
+ import { closeSync, openSync, readSync, statSync } from "fs";
6
+ import { StringDecoder } from "node:string_decoder";
7
+
8
+ interface JsonlSummary {
9
+ count: number;
10
+ firstLine?: string;
11
+ lastLine?: string;
12
+ }
13
+
14
+ function forEachJsonlLine(path: string, onLine: (line: string) => void): void {
15
+ const fd = openSync(path, "r");
16
+ const buffer = Buffer.allocUnsafe(64 * 1024);
17
+ const decoder = new StringDecoder("utf8");
18
+ let pending = "";
19
+ try {
20
+ for (;;) {
21
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
22
+ if (bytesRead === 0) break;
23
+ pending += decoder.write(buffer.subarray(0, bytesRead));
24
+ let start = 0;
25
+ for (;;) {
26
+ const newline = pending.indexOf("\n", start);
27
+ if (newline < 0) {
28
+ pending = pending.slice(start);
29
+ break;
30
+ }
31
+ const line = pending.slice(start, newline);
32
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
33
+ start = newline + 1;
34
+ }
35
+ }
36
+ pending += decoder.end();
37
+ if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
38
+ } finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+
43
+ function summarizeJsonl(path: string): JsonlSummary {
44
+ const summary: JsonlSummary = { count: 0 };
45
+ forEachJsonlLine(path, (line) => {
46
+ if (!line.trim()) return;
47
+ summary.count += 1;
48
+ if (summary.firstLine === undefined) summary.firstLine = line;
49
+ summary.lastLine = line;
50
+ });
51
+ return summary;
52
+ }
6
53
 
7
54
  export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
8
55
  const warnings = [];
@@ -13,21 +60,20 @@ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: strin
13
60
  warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
14
61
  return warnings;
15
62
  }
16
- let content;
63
+ let summary;
17
64
  try {
18
- content = readFileSync(jsonlPath, "utf8");
65
+ summary = summarizeJsonl(jsonlPath);
19
66
  } catch (e) {
20
67
  warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
21
68
  return warnings;
22
69
  }
23
- const lines = content.split("\n").filter((l) => l.trim().length > 0);
24
- if (lines.length !== expectedRecordCount) {
25
- warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
70
+ if (summary.count !== expectedRecordCount) {
71
+ warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
26
72
  return warnings;
27
73
  }
28
74
  try {
29
- const firstRec = JSON.parse(lines[0]);
30
- const lastRec = JSON.parse(lines[lines.length - 1]);
75
+ const firstRec = JSON.parse(summary.firstLine ?? "");
76
+ const lastRec = JSON.parse(summary.lastLine ?? "");
31
77
  if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
32
78
  warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
33
79
  }