@vanillagreen/pi-claude-bridge 1.4.1 → 1.6.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
@@ -3,13 +3,13 @@
3
3
  ![Claude bridge demo response](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-claude-bridge/assets/bridge-demo.png)
4
4
  ![Claude Bridge settings panel](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-claude-bridge/assets/settings-panel.png)
5
5
 
6
- Run Claude Code as a Pi provider. Adds `claude-bridge/*` models to `/model` and routes Pi turns through the Claude Agent SDK while keeping Pi's tools and TUI.
6
+ Run Claude Code as a Pi provider. Adds `claude-bridge/*` models to `/model` while keeping Pi's tools and TUI.
7
7
 
8
- Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi-claude-bridge). The provider, MCP bridge, session sync, and SDK plumbing come from upstream; this fork removes the AskClaude tool and adds opt-in forwarding for Pi prompt context.
8
+ Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi-claude-bridge). This fork removes the AskClaude tool and adds opt-in forwarding for Pi prompt context.
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`, Sonnet 5, Opus 4.8, Opus 4.7, Sonnet 4.6, 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,22 +92,26 @@ 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`, `claude-bridge/claude-sonnet-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`.
96
102
 
97
- When Claude Code emits rate-limit reset metadata, the bridge shows one red ASCII `[rate-limit]` Pi warning with the reset timestamp including timezone context, deduplicates repeated Claude Code error lines, and suppresses the SDK's follow-up `Claude Code returned an error result: ...` wrapper when the bridge already emitted the terminal error. The bridge also emits `vstack:rate-limit` on Pi's extension event bus so `pi-qol` can opt into reset-time auto-resume.
103
+ When Claude Code reports a rate-limit reset time, the bridge shows one clear `[rate-limit]` warning with timezone context and avoids repeating the same error line. If `pi-qol` is installed, it can use the reset time to resume later.
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 visible output, the bridge returns a retryable assistant error with a backoff hint instead of leaving Pi stuck waiting. 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
 
105
111
  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/`.
106
112
 
107
- If a Claude Code SDK stream yields a completed assistant tool-use message before a `message_stop` stream event, the bridge treats that assistant message as the tool-turn boundary. Pi executes the tool calls immediately and Claude Code's MCP handlers stay blocked until the matching Pi tool results are delivered, preventing empty inline tool results or one-render-cycle-late result batches in subagent panes.
113
+ Tool-result integrity problems are surfaced even when debug logging is off. Pi shows an error notification and writes a diagnostic file to `~/.pi/agent/claude-bridge-diag.log` so lost or mismatched tool output is visible.
108
114
 
109
- Tool-result integrity failures are surfaced even when debug logging is off. If the bridge has to repair missing Claude Code `tool_use` / Pi `toolResult` pairs with `[no tool result recorded]`, Pi shows an error notification and writes a JSON diagnostic to `~/.pi/agent/claude-bridge-diag.log` with counts, affected tool names, and sampled tool-call IDs so the lost output is visible. Tool results whose IDs were never registered in the active assistant tool-use turn are refused instead of being queued against another pending call, and any remaining MCP handlers receive an internal-error result so the turn cannot report false success. If a query tears down while parallel tool results are still queued or unresolved, the bridge writes the same kind of diagnostic, marks the Claude session for rebuild, and re-imports delivered results from Pi history on the next turn instead of silently resuming a corrupted session.
115
+ Startup failures include the resolved Claude executable and working directory, which makes missing binaries and wrong launch directories easier to fix.
110
116
 
111
- 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.
117
+ Contributor-facing stream, tool-result, and startup diagnostics are documented in [`DEVELOPMENT.md`](./DEVELOPMENT.md).
package/bundle/index.js CHANGED
@@ -22237,9 +22237,51 @@ 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
+ var SONNET_5_MODEL_ID = "claude-sonnet-5";
22243
+ function fallbackModelForPrimaryModel(modelId) {
22244
+ return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : void 0;
22245
+ }
22246
+ var MODEL_IDS_IN_ORDER = [
22247
+ FABLE_MODEL_ID,
22248
+ FABLE_FALLBACK_MODEL_ID,
22249
+ "claude-opus-4-7",
22250
+ "claude-opus-4-6",
22251
+ SONNET_5_MODEL_ID,
22252
+ "claude-sonnet-4-6",
22253
+ "claude-haiku-4-5"
22254
+ ];
22255
+ var FALLBACK_MODELS = {
22256
+ [FABLE_MODEL_ID]: {
22257
+ id: FABLE_MODEL_ID,
22258
+ name: "Claude Fable 5",
22259
+ reasoning: true,
22260
+ thinkingLevelMap: { xhigh: "xhigh" },
22261
+ input: ["text", "image"],
22262
+ contextWindow: 1e6,
22263
+ maxTokens: 128e3
22264
+ },
22265
+ [FABLE_FALLBACK_MODEL_ID]: {
22266
+ id: FABLE_FALLBACK_MODEL_ID,
22267
+ name: "Claude Opus 4.8",
22268
+ reasoning: true,
22269
+ thinkingLevelMap: { xhigh: "xhigh" },
22270
+ input: ["text", "image"],
22271
+ contextWindow: 1e6,
22272
+ maxTokens: 128e3
22273
+ },
22274
+ [SONNET_5_MODEL_ID]: {
22275
+ id: SONNET_5_MODEL_ID,
22276
+ name: "Claude Sonnet 5",
22277
+ reasoning: true,
22278
+ input: ["text", "image"],
22279
+ contextWindow: 1e6,
22280
+ maxTokens: 128e3
22281
+ }
22282
+ };
22241
22283
  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 }) => ({
22284
+ 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
22285
  id,
22244
22286
  name,
22245
22287
  reasoning,
@@ -22638,8 +22680,34 @@ function projectSettingsPath(cwd) {
22638
22680
  current = parent;
22639
22681
  }
22640
22682
  }
22683
+ var PROJECT_TRUST_SYMBOL = /* @__PURE__ */ Symbol.for("vstack.pi.project-trust");
22684
+ function projectTrustRegistry() {
22685
+ const host = globalThis;
22686
+ const existing = host[PROJECT_TRUST_SYMBOL];
22687
+ if (existing) return existing;
22688
+ const created = {};
22689
+ host[PROJECT_TRUST_SYMBOL] = created;
22690
+ return created;
22691
+ }
22692
+ function recordProjectTrust(ctx2) {
22693
+ if (!ctx2.cwd) return;
22694
+ let trusted = true;
22695
+ try {
22696
+ trusted = ctx2.isProjectTrusted?.() === true;
22697
+ } catch {
22698
+ trusted = false;
22699
+ }
22700
+ const registry2 = projectTrustRegistry();
22701
+ if (!registry2.projectSettings) registry2.projectSettings = /* @__PURE__ */ new Map();
22702
+ registry2.projectSettings.set(projectSettingsPath(ctx2.cwd), trusted);
22703
+ }
22704
+ function projectSettingsTrusted(settingsPath) {
22705
+ return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
22706
+ }
22641
22707
  function settingsPaths(cwd) {
22642
- return [join2(piUserDir(), "settings.json"), projectSettingsPath(cwd)];
22708
+ const user = join2(piUserDir(), "settings.json");
22709
+ const project = projectSettingsPath(cwd);
22710
+ return projectSettingsTrusted(project) ? [user, project] : [user];
22643
22711
  }
22644
22712
  function tryParseJson(path) {
22645
22713
  if (!existsSync3(path)) return {};
@@ -22747,7 +22815,9 @@ function managerToConfig(raw) {
22747
22815
  }
22748
22816
  function loadConfig(cwd) {
22749
22817
  const global2 = tryParseJson(join2(piUserDir(), "claude-bridge.json"));
22750
- const project = tryParseJson(join2(cwd, ".pi", "claude-bridge.json"));
22818
+ const projectSettings = projectSettingsPath(cwd);
22819
+ const trustedProject = projectSettingsTrusted(projectSettings);
22820
+ const project = trustedProject ? tryParseJson(join2(dirname2(projectSettings), "claude-bridge.json")) : {};
22751
22821
  const manager = managerToConfig(readManagerConfig(cwd));
22752
22822
  const provider = normalizeProviderConfig({ ...global2.provider, ...project.provider, ...manager.provider });
22753
22823
  return {
@@ -38549,6 +38619,13 @@ function finalizeCurrentStream(stopReason) {
38549
38619
  ctx().currentPiStream.end();
38550
38620
  ctx().currentPiStream = null;
38551
38621
  }
38622
+ function updateTurnOutputModel(modelId) {
38623
+ const c2 = ctx();
38624
+ if (typeof modelId !== "string" || !modelId || !c2.turnOutput) return;
38625
+ if (c2.turnOutput.model === modelId) return;
38626
+ debug(`provider: active Claude model changed ${c2.turnOutput.model} -> ${modelId}`);
38627
+ c2.turnOutput.model = modelId;
38628
+ }
38552
38629
  function processStreamEvent(message, customToolNameToPi, model) {
38553
38630
  const c2 = ctx();
38554
38631
  if (!c2.currentPiStream || !c2.turnOutput) return;
@@ -38560,6 +38637,7 @@ function processStreamEvent(message, customToolNameToPi, model) {
38560
38637
  }
38561
38638
  if (event?.type === "message_start") {
38562
38639
  c2.resetToolTracking();
38640
+ updateTurnOutputModel(event.message?.model);
38563
38641
  if (event.message?.usage) updateUsage(c2.turnOutput, event.message.usage, model);
38564
38642
  return;
38565
38643
  }
@@ -38698,6 +38776,7 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38698
38776
  const c2 = ctx();
38699
38777
  const assistantMsg = message.message;
38700
38778
  if (!assistantMsg?.content) return;
38779
+ updateTurnOutputModel(assistantMsg.model);
38701
38780
  if (c2.turnSawStreamEvent) {
38702
38781
  if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
38703
38782
  c2.turnSawToolCall = true;
@@ -38744,6 +38823,8 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38744
38823
  const toolBlock = c2.turnBlocks[idx];
38745
38824
  c2.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c2.turnOutput });
38746
38825
  c2.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock, partial: c2.turnOutput });
38826
+ } else if (block.type === "fallback") {
38827
+ updateTurnOutputModel(block.to?.model);
38747
38828
  } else {
38748
38829
  debug("processAssistantMessage: unhandled block type", block.type);
38749
38830
  }
@@ -38795,6 +38876,14 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
38795
38876
  case "system":
38796
38877
  if (message.subtype === "init" && message.session_id) {
38797
38878
  capturedSessionId = message.session_id;
38879
+ } else if (message.subtype === "model_refusal_fallback") {
38880
+ const originalModel = message.original_model;
38881
+ const fallbackModel = message.fallback_model;
38882
+ updateTurnOutputModel(fallbackModel);
38883
+ debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
38884
+ if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
38885
+ safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
38886
+ }
38798
38887
  }
38799
38888
  break;
38800
38889
  case "user":
@@ -38949,16 +39038,19 @@ function streamClaudeAgentSdk(model, context, options) {
38949
39038
  const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
38950
39039
  const requestedEffort = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] ?? REASONING_TO_EFFORT[options.reasoning] : void 0;
38951
39040
  const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
38952
- const extraArgs = { model: model.id };
39041
+ const extraArgs = {};
38953
39042
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
38954
39043
  if (effort) extraArgs["thinking-display"] = "summarized";
39044
+ const fallbackModel = fallbackModelForPrimaryModel(model.id);
38955
39045
  const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
38956
39046
  const queryOptions = {
38957
39047
  cwd,
39048
+ model: model.id,
38958
39049
  env: childEnv,
38959
39050
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
38960
39051
  permissionMode: "bypassPermissions",
38961
39052
  includePartialMessages: true,
39053
+ ...fallbackModel ? { fallbackModel } : {},
38962
39054
  ...providerSettings.fastMode ? { settings: { fastMode: true } } : {},
38963
39055
  systemPrompt: {
38964
39056
  type: "preset",
@@ -38978,6 +39070,7 @@ function streamClaudeAgentSdk(model, context, options) {
38978
39070
  "provider: fresh query",
38979
39071
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
38980
39072
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
39073
+ `fallback=${fallbackModel ?? "none"}`,
38981
39074
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
38982
39075
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
38983
39076
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
@@ -39238,6 +39331,7 @@ function index_default(pi) {
39238
39331
  }
39239
39332
  };
39240
39333
  pi.on("session_start", (event, ctx2) => {
39334
+ recordProjectTrust(ctx2);
39241
39335
  piUI = ctx2.ui;
39242
39336
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
39243
39337
  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.1",
3
+ "version": "1.6.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,69 @@
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
+ export const SONNET_5_MODEL_ID = "claude-sonnet-5";
8
+
9
+ export function fallbackModelForPrimaryModel(modelId: string): string | undefined {
10
+ return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
11
+ }
12
+
13
+ export const MODEL_IDS_IN_ORDER = [
14
+ FABLE_MODEL_ID,
15
+ FABLE_FALLBACK_MODEL_ID,
16
+ "claude-opus-4-7",
17
+ "claude-opus-4-6",
18
+ SONNET_5_MODEL_ID,
19
+ "claude-sonnet-4-6",
20
+ "claude-haiku-4-5",
21
+ ];
22
+
23
+ type BridgeModelMetadata = {
24
+ id: string;
25
+ name: string;
26
+ reasoning: boolean;
27
+ thinkingLevelMap?: Record<string, string | null>;
28
+ input: ("text" | "image")[];
29
+ contextWindow: number;
30
+ maxTokens: number;
31
+ };
32
+
33
+ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
34
+ [FABLE_MODEL_ID]: {
35
+ id: FABLE_MODEL_ID,
36
+ name: "Claude Fable 5",
37
+ reasoning: true,
38
+ thinkingLevelMap: { xhigh: "xhigh" },
39
+ input: ["text", "image"],
40
+ contextWindow: 1000000,
41
+ maxTokens: 128000,
42
+ },
43
+ [FABLE_FALLBACK_MODEL_ID]: {
44
+ id: FABLE_FALLBACK_MODEL_ID,
45
+ name: "Claude Opus 4.8",
46
+ reasoning: true,
47
+ thinkingLevelMap: { xhigh: "xhigh" },
48
+ input: ["text", "image"],
49
+ contextWindow: 1000000,
50
+ maxTokens: 128000,
51
+ },
52
+ [SONNET_5_MODEL_ID]: {
53
+ id: SONNET_5_MODEL_ID,
54
+ name: "Claude Sonnet 5",
55
+ reasoning: true,
56
+ input: ["text", "image"],
57
+ contextWindow: 1000000,
58
+ maxTokens: 128000,
59
+ },
60
+ };
6
61
 
7
62
  // 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.
63
+ // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
64
+ // has not shipped metadata for them yet. Unknown missing IDs are still dropped.
9
65
  export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
10
66
  return MODEL_IDS_IN_ORDER
11
- .map((id) => piAiModels.find((m) => m.id === id))
67
+ .map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])
12
68
  .filter((m) => m != null)
13
69
  // Forward thinkingLevelMap so per-model overrides (e.g. opus-4-7 mapping
14
70
  // xhigh→xhigh instead of xhigh→max) are visible to the effort lookup.