@vanillagreen/pi-claude-bridge 1.4.1 → 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
@@ -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,
@@ -22638,8 +22670,34 @@ function projectSettingsPath(cwd) {
22638
22670
  current = parent;
22639
22671
  }
22640
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
+ }
22641
22697
  function settingsPaths(cwd) {
22642
- 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];
22643
22701
  }
22644
22702
  function tryParseJson(path) {
22645
22703
  if (!existsSync3(path)) return {};
@@ -22747,7 +22805,9 @@ function managerToConfig(raw) {
22747
22805
  }
22748
22806
  function loadConfig(cwd) {
22749
22807
  const global2 = tryParseJson(join2(piUserDir(), "claude-bridge.json"));
22750
- 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")) : {};
22751
22811
  const manager = managerToConfig(readManagerConfig(cwd));
22752
22812
  const provider = normalizeProviderConfig({ ...global2.provider, ...project.provider, ...manager.provider });
22753
22813
  return {
@@ -38549,6 +38609,13 @@ function finalizeCurrentStream(stopReason) {
38549
38609
  ctx().currentPiStream.end();
38550
38610
  ctx().currentPiStream = null;
38551
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
+ }
38552
38619
  function processStreamEvent(message, customToolNameToPi, model) {
38553
38620
  const c2 = ctx();
38554
38621
  if (!c2.currentPiStream || !c2.turnOutput) return;
@@ -38560,6 +38627,7 @@ function processStreamEvent(message, customToolNameToPi, model) {
38560
38627
  }
38561
38628
  if (event?.type === "message_start") {
38562
38629
  c2.resetToolTracking();
38630
+ updateTurnOutputModel(event.message?.model);
38563
38631
  if (event.message?.usage) updateUsage(c2.turnOutput, event.message.usage, model);
38564
38632
  return;
38565
38633
  }
@@ -38698,6 +38766,7 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38698
38766
  const c2 = ctx();
38699
38767
  const assistantMsg = message.message;
38700
38768
  if (!assistantMsg?.content) return;
38769
+ updateTurnOutputModel(assistantMsg.model);
38701
38770
  if (c2.turnSawStreamEvent) {
38702
38771
  if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
38703
38772
  c2.turnSawToolCall = true;
@@ -38744,6 +38813,8 @@ function processAssistantMessage(message, model, customToolNameToPi) {
38744
38813
  const toolBlock = c2.turnBlocks[idx];
38745
38814
  c2.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c2.turnOutput });
38746
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);
38747
38818
  } else {
38748
38819
  debug("processAssistantMessage: unhandled block type", block.type);
38749
38820
  }
@@ -38795,6 +38866,14 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
38795
38866
  case "system":
38796
38867
  if (message.subtype === "init" && message.session_id) {
38797
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
+ }
38798
38877
  }
38799
38878
  break;
38800
38879
  case "user":
@@ -38949,16 +39028,19 @@ function streamClaudeAgentSdk(model, context, options) {
38949
39028
  const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
38950
39029
  const requestedEffort = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] ?? REASONING_TO_EFFORT[options.reasoning] : void 0;
38951
39030
  const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
38952
- const extraArgs = { model: model.id };
39031
+ const extraArgs = {};
38953
39032
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
38954
39033
  if (effort) extraArgs["thinking-display"] = "summarized";
39034
+ const fallbackModel = fallbackModelForPrimaryModel(model.id);
38955
39035
  const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
38956
39036
  const queryOptions = {
38957
39037
  cwd,
39038
+ model: model.id,
38958
39039
  env: childEnv,
38959
39040
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
38960
39041
  permissionMode: "bypassPermissions",
38961
39042
  includePartialMessages: true,
39043
+ ...fallbackModel ? { fallbackModel } : {},
38962
39044
  ...providerSettings.fastMode ? { settings: { fastMode: true } } : {},
38963
39045
  systemPrompt: {
38964
39046
  type: "preset",
@@ -38978,6 +39060,7 @@ function streamClaudeAgentSdk(model, context, options) {
38978
39060
  "provider: fresh query",
38979
39061
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
38980
39062
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
39063
+ `fallback=${fallbackModel ?? "none"}`,
38981
39064
  `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
38982
39065
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
38983
39066
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
@@ -39238,6 +39321,7 @@ function index_default(pi) {
39238
39321
  }
39239
39322
  };
39240
39323
  pi.on("session_start", (event, ctx2) => {
39324
+ recordProjectTrust(ctx2);
39241
39325
  piUI = ctx2.ui;
39242
39326
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
39243
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.1",
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.