@vanillagreen/pi-claude-bridge 3.2.2 → 4.0.1

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.
@@ -1,13 +1,12 @@
1
- // Cross-PROCESS cache of the connector inventory (vstack#870).
1
+ // Cross-PROCESS cache of the connector inventory.
2
2
  //
3
- // #868 primes the inventory at provider registration, but the fetch takes ~1.5s
3
+ // primes the inventory at provider registration, but the fetch takes ~1.5s
4
4
  // while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
5
- // out with no declarations and gets exactly the #832 bug it was meant to fix.
5
+ // out with no declarations and preserves the failure this cache prevents.
6
6
  //
7
7
  // An in-process cache cannot help the consumer that needs it most. drovr builds
8
8
  // a sidecar lazily on the first bridge round and, since their sidecars are
9
- // per-SESSION, that is a fresh process for every new chat — so their exposure is
10
- // once per chat, indefinitely, and every one of those is a cold process. The
9
+ // per-SESSION, each chat starts a separate cold process. The
11
10
  // cache therefore has to survive process boundaries.
12
11
  //
13
12
  // Keyed by credential scope, because that is what selects the account: the org
@@ -18,7 +17,7 @@
18
17
  // wrong-version cache returns undefined and the caller falls back to today's
19
18
  // behaviour — the same fail-open contract as the inventory call itself.
20
19
  //
21
- // The ON-DISK FORMAT HAS AN EXTERNAL READER (vstack#892). drovr quarantines this
20
+ // The ON-DISK FORMAT HAS AN EXTERNAL READER. drovr quarantines this
22
21
  // bundle to its sidecar process, so rather than calling `listAccountConnectors`
23
22
  // in-process it re-implements the reader half — path
24
23
  // `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
@@ -43,7 +42,7 @@ import type { ConnectorEntry } from "./connector-inventory.js";
43
42
  const CACHE_VERSION = 2;
44
43
  /** Long enough to be useful across a machine's lifetime, short enough that a
45
44
  * removed connector stops being declared without needing a manual purge. A
46
- * stale entry is not dangerous — a connector that no longer resolves simply
45
+ * stale entry is not dangerous — a connector that does not resolve simply
47
46
  * fails to connect, which is the fail-open path — so this is hygiene, not a
48
47
  * correctness boundary. */
49
48
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -5,7 +5,7 @@
5
5
  // nothing in the result distinguishes "these are the connectors" from "these are
6
6
  // the connectors the search happened to return this time". Downstream then stored
7
7
  // that lower bound as authoritative, so an account with Slack attached could
8
- // report an inventory without Slack and no failure signal (vstack#838).
8
+ // report an inventory without Slack and no failure signal.
9
9
  //
10
10
  // This module asks the account instead of the model. Verified live against a
11
11
  // personal claude_max org: the endpoint is POST (a GET returns 405) and each
@@ -18,12 +18,11 @@
18
18
  // installed. It says nothing about whether a given connector's MCP server has
19
19
  // finished attaching inside the `claude` child that is about to run a turn —
20
20
  // this is a plain HTTPS call and does not consult that process at all. The two
21
- // were previously conflated by accident: the ToolSearch probe could only report
22
- // what was already attached, so an inventory implied availability (wrongly, but
23
- // conservatively — it under-reported, which fails safe). They are now separately
21
+ // are separate: the ToolSearch probe can report only what is attached, so an
22
+ // inventory must not imply availability. They are
24
23
  // observable and can legitimately disagree: a correct `complete: true` inventory
25
24
  // can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
26
- // process (vstack#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
25
+ // process. Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
27
26
  // availability and keep an attach-time check on the call path; do not derive
28
27
  // "can I call this tool right now" from this result.
29
28
  //
@@ -47,8 +46,7 @@ export type ConnectorEntry = {
47
46
  /**
48
47
  * Account-side install state. `"connected"` marks the connectors the CLI
49
48
  * actually attempts; everything else it never gives a `Starting connection`
50
- * line at all. Verified live 2026-07-26: 7 `connected` / 20 `unknown` on the
51
- * app account, and the CLI connected exactly those 7.
49
+ * line at all. The CLI attempts exactly the entries marked `connected`.
52
50
  */
53
51
  installState?: string;
54
52
  description?: string;
@@ -1,5 +1,5 @@
1
1
  // Connector prime/snapshot host: the per-credential-scope inventory cache the
2
- // query path reads synchronously (vstack#832/#870). Extracted from index.ts —
2
+ // query path reads synchronously. Extracted from index.ts —
3
3
  // this is process-lifetime runtime state, not provider streaming logic.
4
4
  //
5
5
  // Connector declarations for the query path, cached per credential scope. The
@@ -32,15 +32,15 @@ export function readCredentialFile(path: string): string | undefined {
32
32
  const connectorServerCache = new Map<string, Record<string, unknown>>();
33
33
  const connectorServerPending = new Set<string>();
34
34
  // Epoch ms of the last FAILED inventory attempt per scope, so a persistently
35
- // failing account cools down instead of issuing one new HTTPS request per turn
36
- // (VST-14). Missing credentials are exempt: that check is a local file read
35
+ // failing account cools down instead of issuing one HTTPS request per turn
36
+ // Missing credentials are exempt: that check is a local file read
37
37
  // with no request to bound, and a just-completed `claude login` must take
38
38
  // effect on the next turn.
39
39
  const connectorServerFailureAt = new Map<string, number>();
40
40
 
41
41
  // Deadline on the inventory round trip. Without one, a hung claude.ai request
42
42
  // held the pending flag forever — same budget as the account-host probe's
43
- // ACCOUNT_PROBE_DEADLINE_MS (VST-14).
43
+ // ACCOUNT_PROBE_DEADLINE_MS.
44
44
  const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
45
45
  const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
46
46
 
@@ -81,12 +81,12 @@ export function connectorCredentialEnv(claudeConfigDir: string | undefined = pro
81
81
  //
82
82
  // FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
83
83
  // all resolve to "declare nothing" rather than breaking the turn. Failures are
84
- // NOT cached a transient blip at registration used to pin `{}` for the
84
+ // NOT cached. Caching a transient registration failure would pin `{}` for the
85
85
  // process lifetime, keeping every later turn undeclared and the disk-cache
86
86
  // fallback unreachable. Leaving the key unset makes the next snapshot retry;
87
87
  // the pending set dedupes concurrent fetches, and a failed inventory attempt
88
88
  // stamps a per-scope cooldown so a persistently failing account backs off
89
- // instead of re-priming on every turn (VST-14).
89
+ // instead of re-priming on every turn.
90
90
  export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
91
91
  const key = connectorScopeKey(claudeConfigDir);
92
92
  if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
@@ -124,7 +124,7 @@ export function primeConnectorServers(claudeConfigDir?: string, overrides: Prime
124
124
  connectorServerFailureAt.delete(key);
125
125
  // Persist so the NEXT cold process has this synchronously. Priming always
126
126
  // loses the race against turn 1 in its own process; a cache written by an
127
- // earlier run is the only thing turn 1 can read in time (vstack#870).
127
+ // earlier run is the only thing turn 1 can read in time.
128
128
  if (writeCachedConnectors(inventory.connectors, key)) {
129
129
  debug(`connectors: cached ${inventory.connectors.length} entries`);
130
130
  }
@@ -148,7 +148,7 @@ export function connectorServersSnapshot(claudeConfigDir?: string): Record<strin
148
148
  primeConnectorServers(claudeConfigDir);
149
149
  // Fall back to the previous run's inventory, read synchronously. This is the
150
150
  // only thing that can populate turn 1 of a cold process, because priming
151
- // cannot finish before the first query is built (vstack#870).
151
+ // cannot finish before the first query is built.
152
152
  const cached = readCachedConnectors(key);
153
153
  if (!cached) return {};
154
154
  const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
package/src/connectors.ts CHANGED
@@ -57,7 +57,7 @@ export function connectorsEnabledFor(config?: Config): boolean {
57
57
  // isolation (no sources), which drops the connectors even with
58
58
  // ENABLE_CLAUDEAI_MCP_SERVERS=1. So connectors mode must pass SOME source list.
59
59
  //
60
- // It must be `["user"]` and nothing more (vstack#990). Connector state lives in
60
+ // It must be `["user"]` and nothing more. Connector state lives in
61
61
  // USER scope — the account's config dir (CLAUDE_CONFIG_DIR for managed router
62
62
  // profiles) — so user scope is sufficient for connectors to surface. Claude
63
63
  // Code settings files can also carry an `env` map and `apiKeyHelper`; including
@@ -98,7 +98,7 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
98
98
  "mcp__claude_ai_Atlassian__*",
99
99
  ];
100
100
 
101
- // --- The SDK's two-name trap for built-in tools (vstack#1007, vstack#1011) ---
101
+ // --- The SDK's two-name trap for built-in tools ---
102
102
  //
103
103
  // The CLI gives some built-ins TWO spellings, and which one you see depends on
104
104
  // which SURFACE the name crosses:
@@ -118,9 +118,9 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
118
118
  // This map declares each alias pair ONCE; every delivered-side membership set
119
119
  // derives its spellings from it instead of hand-copying names. Both prior
120
120
  // instances of the trap were exactly that hand-copy: CHILD_INTERNAL_TOOLS held
121
- // request-side spellings that no stream name ever matched (vstack#1007), and
121
+ // request-side spellings that no stream name ever matched, and
122
122
  // the connectors allowlist hook DENIED the two discovery tools it exists to
123
- // permit (vstack#1011). Delivered-side sets accept BOTH spellings, so a CLI
123
+ // permit. Delivered-side sets accept BOTH spellings, so a CLI
124
124
  // version that drops the aliasing cannot reintroduce the bug in either
125
125
  // direction.
126
126
  const SDK_TOOL_ALIASES: Record<string, string> = {
@@ -144,14 +144,18 @@ function deliveredSpellings(name: string): string[] {
144
144
  // REQUEST-side spellings: this list feeds the SDK option surface (the
145
145
  // disallowedTools filter in toolIsolationForQuery) and is the exported public
146
146
  // name. Delivered-side checks use CONNECTOR_DISCOVERY_TOOL_NAMES below.
147
- export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
147
+ const MCP_RESOURCE_TOOLS = ["ListMcpResources", "ReadMcpResource"];
148
+ export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", ...MCP_RESOURCE_TOOLS];
148
149
 
149
- // Delivered-side membership set for the discovery tools: both spellings of
150
- // each entry, derived from SDK_TOOL_ALIASES. A PreToolUse hook's `tool_name`
151
- // carries the canonical spelling (`ListMcpResourcesTool`), so testing the
152
- // request-side list directly made the fail-closed allowlist hook DENY the two
153
- // resource tools it exists to permit (vstack#1011).
150
+ // Delivered-side membership sets carry both spellings, derived from the one
151
+ // alias declaration above. The resource-only set keeps those audit calls
152
+ // dispatchable without making ToolSearch a Pi call.
154
153
  const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
154
+ const MCP_RESOURCE_TOOL_NAMES = new Set(MCP_RESOURCE_TOOLS.flatMap(deliveredSpellings));
155
+
156
+ export function isMcpResourceTool(name: string): boolean {
157
+ return MCP_RESOURCE_TOOL_NAMES.has(name);
158
+ }
155
159
 
156
160
  // --- Connector WRITE tool control (read-inline / write-by-approval) ---
157
161
  //
@@ -243,12 +247,12 @@ function connectorNameWords(segment: string): string[] {
243
247
  // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
244
248
  //
245
249
  // PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
246
- // dependents that gate real user-facing approvals on them (vstack#892):
250
+ // dependents that gate real user-facing approvals on them:
247
251
  //
248
252
  // memsira routes connector writes through its own gated approval flow
249
253
  // drovr keeps its chat sidecar permanently write-`deny` and runs an
250
254
  // approved write as a separate one-shot `claude -p` scoped by
251
- // `--allowedTools` to exactly one connector tool (drovr#288)
255
+ // `--allowedTools` to exactly one connector tool
252
256
  //
253
257
  // Both pin the actions they expose against this classification, because "the
254
258
  // sidecar structurally cannot do this itself" is THIS module's claim, not
@@ -329,23 +333,21 @@ export function isConnectorTool(name: string | undefined): boolean {
329
333
  // `content_block_start` / assistant-message blocks, the exact fields
330
334
  // processStreamEvent/processAssistantMessage read. Stream names are a
331
335
  // DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
332
- // request-side spellings used to sit in this set and never matched anything
333
- // (vstack#1007). If an aliased name is ever added here, derive its spellings
334
- // via deliveredSpellings rather than hand-copying them.
336
+ // request-side spellings do not belong in this set because they match nothing.
337
+ // Derive every aliased name via deliveredSpellings rather than hand-copying it.
335
338
  //
336
- // The MCP-resource tools are now EXCLUDED deliberately, under BOTH spellings:
339
+ // The MCP-resource tools are EXCLUDED under BOTH spellings:
337
340
  // a resource read is a real account-surface access, and both consumer hosts
338
341
  // audit it through the Pi mirror (an out-of-process sidecar has no view of the
339
342
  // bridge's own connector-call entries or the child transcript), so it stays
340
- // mirrored into Pi. Do not re-add either spelling without revisiting that
341
- // decision in vstack#1007. The allowlist hook is what lets those calls run at
342
- // all — see isAllowlistedConnectorSessionTool (vstack#1011).
343
+ // mirrored into Pi. Do not add either spelling to this set. The allowlist hook
344
+ // is what lets those calls run; see isAllowlistedConnectorSessionTool.
343
345
  const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
344
346
 
345
347
  /**
346
348
  * True for a Claude Code built-in meta-tool the child resolves in-process.
347
349
  *
348
- * Mirroring one into the Pi stream (vstack#980) made Pi's agent loop dispatch a
350
+ * Mirroring one into the Pi stream would make Pi's agent loop dispatch a
349
351
  * tool it does not have and deliver an error result for an id no MCP handler
350
352
  * ever claimed. The result queued in `pendingResults` until the reaper dropped
351
353
  * it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
@@ -375,13 +377,13 @@ export function isChildInternalTool(name: string | undefined): boolean {
375
377
  * miss, and write a synthetic `Tool <name> not found` error result into the
376
378
  * transcript — while the child went on and executed the real call. The Pi
377
379
  * transcript then RECORDED A FAILURE FOR A CALL THAT SUCCEEDED, next to an
378
- * answer built from the real payload, so the model's correct answer read as
379
- * a fabrication (drovr#311, memsira#320). The false result is also projected
380
+ * answer built from the real payload, so the model's correct answer appears
381
+ * fabricated. The false result is also projected
380
382
  * back into the child's session on a rebuild (`syncSharedSession`), which is
381
383
  * how a lie in a mirror becomes a lie in the conversation of record.
382
384
  *
383
385
  * 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
384
- * child resolves without any dispatcher at all (vstack#980).
386
+ * child resolves without any dispatcher at all.
385
387
  *
386
388
  * Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
387
389
  * no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
@@ -487,7 +489,7 @@ export function connectorWriteDenyHook(): HookCallback {
487
489
  // the tool call proceed (fail OPEN) — so any exception in this body
488
490
  // must convert to a deny, never an allow. Today's body is pure string
489
491
  // checks on schema-validated input; the catch pins that invariant for
490
- // whatever gets added here later.
492
+ // subsequent changes to this body.
491
493
  try {
492
494
  if (input.hook_event_name !== "PreToolUse") return { continue: true };
493
495
  // A non-string tool name cannot be classified, and this hook fails
@@ -507,7 +509,7 @@ export function connectorWriteDenyHook(): HookCallback {
507
509
  // stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
508
510
  // Naming one host told a different app's model to use a product it has never
509
511
  // heard of, which is confusing at exactly the moment someone is debugging a
510
- // refused write (vstack#892). Each host describes its own approval flow in its
512
+ // refused write. Each host describes its own approval flow in its
511
513
  // own prompt; this string only has to say that one exists.
512
514
  function connectorWriteDenyOutput(toolName: string) {
513
515
  return {
@@ -533,8 +535,8 @@ function connectorWriteDenyOutput(toolName: string) {
533
535
  //
534
536
  // This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
535
537
  // carries the canonical spelling — so membership is tested against
536
- // CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
537
- // (vstack#1011). That also carries vstack#1007's mirroring decision: the
538
+ // CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list.
539
+ // The allowlist hook also carries the mirroring rule: the
538
540
  // MCP-resource tools are deliberately NOT child-internal so every resource
539
541
  // read mirrors into Pi as the consumers' audit surface — a mirror that can
540
542
  // only exist if this allowlist lets the call execute. Denying the canonical
@@ -669,14 +671,12 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
669
671
  /**
670
672
  * Explicit `mcpServers` declarations for the account's CONNECTED connectors.
671
673
  *
672
- * Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
674
+ * Why this exists: claude.ai connectors load async and non-blocking,
673
675
  * and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
674
676
  * CLI has even fetched the connector list. The model therefore composes its first
675
677
  * answer against a manifest containing no connectors and says it has no access,
676
- * while the connector attaches ~1s later and is never asked. Measured end to end
677
- * on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
678
- * 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
679
- * went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
678
+ * while the connector attaches later and is never asked. The declaration must
679
+ * be available before the first query. Confirmed at seven
680
680
  * declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
681
681
  *
682
682
  * It is also FASTER, which is the opposite of what the startup barrier suggests.
@@ -736,9 +736,8 @@ export function connectorMcpServers(inventory: ConnectorInventory): Record<strin
736
736
 
737
737
  /**
738
738
  * `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
739
- * connector declarations while leaving connectors themselves enabled. Falls back
740
- * to the pre-#832 behaviour: connectors still load, they just race the turn-1
741
- * manifest again.
739
+ * connector declarations while leaving connectors themselves enabled. Without
740
+ * declarations, connector loading races the turn-1 manifest.
742
741
  */
743
742
  export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
744
743
  const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
@@ -111,7 +111,7 @@ export async function consumeQuery(
111
111
  if (account) {
112
112
  // Thunk, not a value: this runs once per SDK message — including one
113
113
  // stream_event per streamed token — and debug() only evaluates function
114
- // args after its DEBUG early return (VST-15).
114
+ // args after its DEBUG early return.
115
115
  debug("consumeQuery: managed message", () => JSON.stringify({
116
116
  type: message.type,
117
117
  subtype: (message as any).subtype,
@@ -203,8 +203,8 @@ export async function consumeQuery(
203
203
  }
204
204
  // Other non-success subtypes (error_max_turns,
205
205
  // error_during_execution) surface at completion via the held
206
- // failure an explicit error event where these turns previously
207
- // ended silently. Session persistence and deferred replay still run.
206
+ // failure. These turns require an explicit error event instead of
207
+ // silent completion. Session persistence and deferred replay still run.
208
208
  }
209
209
  break;
210
210
  case "system":
package/src/convert.ts CHANGED
@@ -28,7 +28,7 @@ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<strin
28
28
  // `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
29
29
  // projected history, so the model imitated it on the next turn and got a
30
30
  // real `Tool ... not found` from the MCP dispatcher before retrying the
31
- // canonical name — one wasted round-trip per affected call (memsira#320).
31
+ // canonical name — one wasted round-trip per affected call.
32
32
  //
33
33
  // Connector names stopped reaching this function at all once they stopped
34
34
  // being mirrored as Pi tool calls (isChildExecutedTool), so this is the
package/src/debug.ts CHANGED
@@ -50,7 +50,7 @@ export function debug(...args: unknown[]) {
50
50
  if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
51
51
  // A function argument is a lazy payload: hot-path call sites (per-token
52
52
  // stream events) pass a thunk so the expensive formatting only runs when
53
- // DEBUG is on — fmt is only reached past the early return (VST-15).
53
+ // DEBUG is on — fmt is only reached past the early return.
54
54
  if (typeof a === "function") return fmt((a as () => unknown)());
55
55
  return JSON.stringify(a);
56
56
  };
@@ -121,7 +121,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
121
121
  /** Diagnostic dump — for "should never happen" paths. Gated on the same
122
122
  * CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
123
123
  * and land in a log outside any host app's retention/cleanup boundary, so
124
- * a host that has not opted into debugging must get no disk write (VST-15). */
124
+ * a host that has not opted into debugging must get no disk write. */
125
125
  export function diagDump(label: string, data: Record<string, unknown>) {
126
126
  if (!DEBUG) return;
127
127
  try {
package/src/index.ts CHANGED
@@ -79,7 +79,7 @@ export { cancelScheduledSessionPersistence, conversationFingerprint, conversatio
79
79
  export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
80
80
  export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
81
81
  export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
82
- export { mapToolName } from "./tool-mapping.js";
82
+ export { isPiDispatchable, mapToolName } from "./tool-mapping.js";
83
83
  export { cancelScheduledToolUseEnd, endToolUseTurn, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, reapStaleQueuedResults, scheduleToolUseTurnEnd } from "./assistant-stream.js";
84
84
  export {
85
85
  accountSessionScope,
@@ -239,18 +239,17 @@ export interface DeferredUserReplayPlan {
239
239
  // with no image blocks).
240
240
  prompt: string | null;
241
241
  // Present when the run carries image blocks — the replay must send these
242
- // (via wrapPromptStream) or the images are silently lost (vstack#993).
242
+ // (via wrapPromptStream) or the images are silently lost.
243
243
  blocks: ContentBlockParam[] | null;
244
244
  }
245
245
 
246
246
  /** Plan replay of user messages pi injected mid-query (steer drain, followUp).
247
247
  * Captures the ENTIRE trailing consecutive user run, not just the last
248
- * message dropping the earlier ones was silent input loss (vstack#967) —
249
- * but never walks below `capturedThrough`, the position an earlier callback
248
+ * message, but never walks below `capturedThrough`, the position a prior callback
250
249
  * of the SAME query already captured (or deliberately held at, for an
251
250
  * all-empty run). Without that lower bound a second mid-query steer re-planned
252
251
  * the whole run from scratch and the first steer was queued — and delivered to
253
- * Claude — twice (vstack#1009). */
252
+ * Claude — twice. */
254
253
  export function planDeferredUserReplay(messages: Context["messages"], capturedThrough = 0): DeferredUserReplayPlan {
255
254
  let runStart = messages.length;
256
255
  while (runStart > capturedThrough && messages[runStart - 1]?.role === "user") runStart--;
@@ -299,7 +298,7 @@ export function resolveMcpTools(context: Context, excludeToolName?: string): {
299
298
  // namespace belongs to the child's own MCP servers, so a Pi tool sitting
300
299
  // on it would be advertised a SECOND time under our prefix — two names
301
300
  // for one capability, and the model picking the wrong one gets a real
302
- // `Tool ... not found` from the dispatcher (memsira#320). It would also
301
+ // `Tool... not found` from the dispatcher. It would also
303
302
  // be uncallable in any case: a `tool_use` under that namespace is treated
304
303
  // as child-executed and never handed to Pi (isChildExecutedTool), so
305
304
  // filtering here is what makes the two halves agree end to end.
@@ -418,8 +417,8 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
418
417
  // 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
419
418
  // 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
420
419
  // blocks the generator naturally — no events arrive until resolved.
421
- // 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
422
- // resolve the MCP handler, and the generator unblocks events flow to new stream.
420
+ // 4. Pi executes the tool and calls streamSimple again. We swap in the replacement
421
+ // stream, resolve the MCP handler, and the generator unblocks into that stream.
423
422
  //
424
423
  // Note: resetTurnState clears turnSawStreamEvent while the generator may still
425
424
  // have queued messages from the previous turn. This is safe because step 3 nulls
@@ -502,7 +501,7 @@ function applyProviderRegistration(trigger: string): void {
502
501
  debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
503
502
  // Start the connector inventory now, not on the first turn: the query path
504
503
  // can only read a synchronous snapshot, so priming here is what gets the
505
- // declarations in place before turn 1 (vstack#832). Fire and forget —
504
+ // declarations in place before turn 1. Fire and forget —
506
505
  // registration must not wait on the network. Primes the DEFAULT credential
507
506
  // scope only; managed profiles are primed per request in their own scope.
508
507
  if (hasClaudeCredentials() && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
@@ -529,7 +528,7 @@ function applyProviderRegistration(trigger: string): void {
529
528
  }
530
529
  }
531
530
 
532
- /** Provider entry point. Pi calls this for each new prompt and each tool result.
531
+ /** Provider entry point. Pi calls this for each prompt and each tool result.
533
532
  * Two cases: tool result delivery (active query) or fresh query. Exported for
534
533
  * the rotation-stream unit tests, which drive it with a fake SDK factory. */
535
534
  export function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
@@ -619,7 +618,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
619
618
  if (queryCtx.pendingToolCalls.size > 0) {
620
619
  // A waiting handler whose call never reached Pi can never be answered —
621
620
  // fail it now with a retryable error instead of letting the SDK await it
622
- // forever (the 2026-08-17 five-and-a-half-hour deadlock, vstack#1469).
621
+ // indefinitely.
623
622
  // Forwarded-but-unanswered handlers stay: steer-split batches legitimately
624
623
  // deliver their results in a later callback.
625
624
  const stranded = drainStrandedToolCalls(queryCtx);
@@ -646,7 +645,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
646
645
  // so we save them for replay as continuation queries after consumeQuery ends.
647
646
  // The cursor may only advance over messages actually captured for replay:
648
647
  // claiming Claude owns a user message that was never deferred is permanent
649
- // silent input loss (vstack#967 — only the LAST of several trailing user
648
+ // silent input loss ( — only the LAST of several trailing user
650
649
  // messages was captured while the cursor skipped them all).
651
650
  let capturedThrough = context.messages.length;
652
651
  if (lastMsgRole === "user") {
@@ -654,13 +653,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
654
653
  // Math.max-advances with every callback's capturedThrough below), so a
655
654
  // second steer callback only queues messages BEYOND what the first one
656
655
  // already owns — re-planning the whole trailing run queued the earlier
657
- // steer twice (vstack#1009). latestCursor, not the shared record's
656
+ // steer twice. latestCursor, not the shared record's
658
657
  // cursor, deliberately: it lives on this QueryContext, so it is correct
659
658
  // for reentrant and detached foreign queries too, whose contexts the
660
- // shared cursor does not index (vstack#1001).
659
+ // shared cursor does not index.
661
660
  const replay = planDeferredUserReplay(context.messages, queryCtx.latestCursor);
662
661
  // Image-only runs have no usable text but must still replay — capture
663
- // whenever EITHER form has content (vstack#993).
662
+ // whenever EITHER form has content.
664
663
  if (replay.prompt || replay.blocks) {
665
664
  ctx().deferredUserMessages.push({ text: replay.prompt ?? "", blocks: replay.blocks ?? undefined });
666
665
  debug(`provider: deferred ${replay.userMessageCount} user message(s) for replay after query${replay.blocks ? ` (${replay.blocks.length} blocks incl. images)` : ""}: ${(replay.prompt ?? "[image-only]").slice(0, 60)}`);
@@ -678,12 +677,12 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
678
677
  // Cursor may only ADVANCE, and only for a query that holds the record's
679
678
  // claim. A reentrant subagent call routed through this instance arrives
680
679
  // here with a SHORT foreign context (its own [user…] conversation, not
681
- // the one the cursor indexes) writing its length used to shrink the
680
+ // the one the cursor indexes). Writing its length would shrink the
682
681
  // parent cursor and make the next REUSE replay already-owned history.
683
682
  // The stackDepth guard covers a pushed subagent context; the detached
684
683
  // flag covers a foreign one-shot on the top-level ctx, whose GROWN
685
684
  // mid-query context could otherwise out-length the parent's cursor and
686
- // advance it past history Claude never saw (vstack#1001); Math.max
685
+ // advance it past history Claude never saw; Math.max
687
686
  // remains the backstop for a legacy-record foreign context the
688
687
  // fingerprint guard could not classify.
689
688
  const activeSession = getSharedSession();
@@ -702,7 +701,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
702
701
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
703
702
  // The detached flag deliberately survives query end: an orphaned result
704
703
  // from a foreign one-shot indexes ITS conversation, and writing that
705
- // length here would move (even shrink) the parent's cursor (vstack#1001).
704
+ // length here would move (even shrink) the parent's cursor.
706
705
  const activeSession = getSharedSession();
707
706
  if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
708
707
  const c = ctx(); // capture current context for the microtask
@@ -883,7 +882,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
883
882
  const { sessionId: resumeSessionId, promptStart } = syncResult;
884
883
  // A FOREIGN-conversation query (conversation-fingerprint mismatch against
885
884
  // the shared record — a subagent-shaped request arriving while the parent
886
- // is IDLE, vstack#1001) also runs as a clean one-shot and gets the same
885
+ // is IDLE,) also runs as a clean one-shot and gets the same
887
886
  // hands-off treatment as a reentrant one below: never persist over the
888
887
  // module-level record, never mark it for rebuild. The flag also rides the
889
888
  // QueryContext so the mismatch/abort/teardown paths that mutate the record
@@ -961,13 +960,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
961
960
 
962
961
  // 4. Capture context for abort handling (must be AFTER pushContext)
963
962
  const abortCtx = ctx();
964
- // Failure metadata consumeQuery observed, surviving an iterator THROW (the
965
- // .catch below reuses it instead of re-classifying see C5 note there).
963
+ // Failure metadata from consumeQuery survives an iterator throw. The catch
964
+ // below reuses it instead of re-classifying it; see the C5 note.
966
965
  const attemptFailure: { failure?: ClaudeAttemptFailure } = {};
967
966
  // A reentrant (subagent) query must never write the module-level shared
968
967
  // session: its completion/failure handlers would overwrite the PARENT's
969
968
  // record with the child's session id and cursor. A foreign-conversation
970
- // one-shot (vstack#1001) has exactly the same non-claim on the record.
969
+ // one-shot has exactly the same non-claim on the record.
971
970
  const persistSession = (next: SessionState | null): void => {
972
971
  if (isReentrant || foreignContext) return;
973
972
  setSharedSession(next && conversationFp ? { conversationFingerprint: conversationFp, ...next } : next);
@@ -976,7 +975,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
976
975
  if (isReentrant || foreignContext) return;
977
976
  markSessionForRebuild(opts);
978
977
  };
979
- // #967 invariant: a deferred (mid-query) user message may be dropped only
978
+ // invariant: a deferred (mid-query) user message may be dropped only
980
979
  // LOUDLY — the cursor already advanced over it on the promise of replay.
981
980
  // Callers that keep a session record after a non-empty drop must persist it
982
981
  // with needsRebuild so the next turn re-imports the steers from Pi history.
@@ -1185,7 +1184,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1185
1184
  // execute. Deferred user input is dropped LOUDLY, and when steers
1186
1185
  // were dropped the record is marked needsRebuild: the cursor already
1187
1186
  // advanced over them on the promise of replay, so a plain REUSE next
1188
- // turn would silently lose them forever (#967).
1187
+ // turn would silently lose them forever.
1189
1188
  if (!abortCtx.handledTerminalError) surfaceFailure(failure);
1190
1189
  const droppedSteers = dropDeferredUserMessages("terminal-failure");
1191
1190
  const activeSession = getSharedSession();
@@ -1235,7 +1234,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1235
1234
 
1236
1235
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1237
1236
  // Runs carrying image blocks replay as blocks (wrapPromptStream) so
1238
- // the images survive; text-only runs stay plain strings (vstack#993).
1237
+ // the images survive; text-only runs stay plain strings.
1239
1238
  const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
1240
1239
  abortCtx.activeQuery = contQuery;
1241
1240
 
@@ -1250,7 +1249,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1250
1249
  if (!abortCtx.handledTerminalError) surfaceFailure(continuation.failure);
1251
1250
  // The shifted steer may never have reached the child, and any
1252
1251
  // remaining ones certainly did not — the record must rebuild so
1253
- // they re-import from Pi history (#967).
1252
+ // they re-import from Pi history.
1254
1253
  if (dropDeferredUserMessages("continuation-failure", steer).length > 0) {
1255
1254
  markRebuildForThisQuery();
1256
1255
  }
@@ -1269,7 +1268,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1269
1268
  };
1270
1269
  recordAttemptFailure(continuationFailure);
1271
1270
  if (!abortCtx.handledTerminalError) surfaceFailure(continuationFailure);
1272
- // Same #967 posture as the failure branch above.
1271
+ // Apply the same rebuild rule as the failure branch above.
1273
1272
  if (dropDeferredUserMessages("continuation-error", steer).length > 0) {
1274
1273
  markRebuildForThisQuery();
1275
1274
  }
@@ -1291,7 +1290,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1291
1290
  if (wasAborted || options?.signal?.aborted) {
1292
1291
  markRebuildForThisQuery({ forceRotate: true });
1293
1292
  }
1294
- // #967: a record kept past this error with steers behind its cursor
1293
+ // a record kept past this error with steers behind its cursor
1295
1294
  // must rebuild so they re-import from Pi history. (The non-abort
1296
1295
  // surface path below replaces the record with null, which rebuilds too.)
1297
1296
  if (dropDeferredUserMessages("query-error").length > 0) {
@@ -1351,7 +1350,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1351
1350
  ...(options ?? {}),
1352
1351
  [ROTATION_STATE_KEY]: rotationState,
1353
1352
  } as BridgeStreamOptions);
1354
- // End exactly once per outcome (VST-53). Ending in a `finally` ran on
1353
+ // End exactly once per outcome. Ending in a `finally` ran on
1355
1354
  // the throw path too, BEFORE the .catch below could push its error
1356
1355
  // event — and EventStream.push is a silent no-op after end, so a failed
1357
1356
  // rotation ended the turn with no error event at all. Success ends
@@ -1445,8 +1444,8 @@ export default function (pi: ExtensionAPI) {
1445
1444
  // branch switch) both mutate pi's messages array out from under the
1446
1445
  // bridge. syncSharedSession's REUSE check would otherwise see
1447
1446
  // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
1448
- // session that no longer matches pi's history. /compact in particular
1449
- // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1447
+ // session that does not match pi's history. /compact in particular
1448
+ // triggers CC's autocompact-thrashing guard. Force the next
1450
1449
  // call down the REBUILD path so CC sees the current history.
1451
1450
  const markRebuild = (event: string) => {
1452
1451
  const activeSession = getSharedSession();
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // Canonical selection + display order for the model picker.
2
2
  // Extracted from index.ts so tests can import without activating the extension.
3
3
 
4
- export const FABLE_MODEL_ID = "claude-fable-5";
4
+ export const FABLE_MODEL_ID = "claude-fable-5-1";
5
5
  // Opus 4.8 is both a selectable model and the safety-fallback target for the two
6
- // primaries whose classifiers can decline a turn (Fable 5, Opus 5).
6
+ // primaries whose classifiers can decline a turn (Fable 5.1, Opus 5).
7
7
  export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
8
8
  export const OPUS_5_MODEL_ID = "claude-opus-5";
9
9
  export const SONNET_5_MODEL_ID = "claude-sonnet-5";
@@ -36,7 +36,7 @@ type BridgeModelMetadata = {
36
36
  const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
37
37
  [FABLE_MODEL_ID]: {
38
38
  id: FABLE_MODEL_ID,
39
- name: "Claude Fable 5",
39
+ name: "Claude Fable 5.1",
40
40
  reasoning: true,
41
41
  thinkingLevelMap: { xhigh: "xhigh", max: "max" },
42
42
  input: ["text", "image"],
@@ -80,8 +80,8 @@ export function modelDisplayName(modelId: string): string {
80
80
  }
81
81
 
82
82
  // Project pi-ai's model entries down to the fields pi's registerProvider expects,
83
- // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
84
- // has not shipped metadata for them yet. Unknown missing IDs are still dropped.
83
+ // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned metadata for supported
84
+ // IDs absent from pi-ai. Unknown missing IDs are still dropped.
85
85
  export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
86
86
  return MODEL_IDS_IN_ORDER
87
87
  .map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])