@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2

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.
@@ -6,10 +6,12 @@
6
6
  // "not-used"` as "configured" and the provider would look connected while every
7
7
  // request fails at spawn time.
8
8
  //
9
- // This module answers two pure questions used to gate registration:
10
- // 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
11
- // 2. decideRegistration() given credential presence + the primary-instance
12
- // / stream-guard tokens, should we register / unregister / do nothing?
9
+ // This module answers one pure question: hasClaudeCredentials() are real
10
+ // credentials present RIGHT NOW? Since 2.0 it feeds the native provider's
11
+ // auth check/resolve (native-provider.ts) and the pre-spawn fail-fast, rather
12
+ // than gating register/unregister transitions (the 1.x decideRegistration
13
+ // state machine is gone — registration is unconditional and pi hides
14
+ // unconfigured providers' models itself).
13
15
  //
14
16
  // SECURITY: this module only ever checks for the EXISTENCE of credentials — a
15
17
  // file's presence, an env var being non-empty, a settings key being a non-empty
@@ -110,49 +112,3 @@ export function hasClaudeCredentials(
110
112
  return false;
111
113
  }
112
114
 
113
- /**
114
- * Snapshot of the inputs to a registration decision.
115
- *
116
- * The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
117
- * token, claimed unconditionally by the first-loaded module instance, and the
118
- * stream-guard token holding the registered instance's streamSimple. ONLY the
119
- * primary instance may ever register/unregister or claim the stream guard — this
120
- * prevents a subagent module reload (a fresh, non-primary instance) from
121
- * stealing ownership and registering ITS streamSimple, which would split-brain
122
- * the shared session/ctx and break tool-result delivery.
123
- */
124
- export interface RegistrationState {
125
- /** Does the machine have Claude credentials right now? */
126
- credentialed: boolean;
127
- /** Is THIS module instance the primary (first-loaded) instance? */
128
- isPrimary: boolean;
129
- /** Has this instance already registered (owns the stream guard)? */
130
- registered: boolean;
131
- }
132
-
133
- export type RegistrationDecision = "register" | "unregister" | "noop";
134
-
135
- /**
136
- * Pure decision for extension load, every session_start re-check, and the
137
- * pre-spawn fail-fast path.
138
- *
139
- * Rules:
140
- * - Not the primary instance → NOOP (never touch registration).
141
- * - Primary + credentialed + not registered → REGISTER (claim guard + register).
142
- * - Primary + credentialed + already registered → NOOP.
143
- * - Primary + uncredentialed → UNREGISTER (defensive).
144
- *
145
- * The uncredentialed primary always returns UNREGISTER rather than NOOP:
146
- * pi.unregisterProvider is idempotent ("Has no effect if the provider was never
147
- * registered"), and a defensive call is the ONLY way to retract a registration
148
- * that survived a /reload — the ModelRegistry's registeredProviders is a
149
- * process-lifetime Map and module reload does NOT clear it. (At extension-load
150
- * time this defensive unregister only filters the pending-registration queue and
151
- * cannot mutate the persistent registry; the authoritative retraction happens on
152
- * the post-load session_start re-check — see applyProviderRegistration.)
153
- */
154
- export function decideRegistration(state: RegistrationState): RegistrationDecision {
155
- if (!state.isPrimary) return "noop";
156
- if (state.credentialed) return state.registered ? "noop" : "register";
157
- return "unregister";
158
- }
@@ -0,0 +1,86 @@
1
+ // The /pi-claude command surface: settings/status UI and the deterministic
2
+ // connector-inventory report. Extracted from index.ts (pure move).
3
+
4
+ import { type Model } from "@earendil-works/pi-ai";
5
+ import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
+ import { accountSessionScope, resolveClaudeAccountRouter } from "./account-router.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
9
+ import { connectorCredentialEnv, readCredentialFile } from "./connector-runtime.js";
10
+
11
+ const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
12
+
13
+ function commandCwd(ctx: unknown): string {
14
+ const value = (ctx as { cwd?: unknown })?.cwd;
15
+ return typeof value === "string" && value.length > 0 ? value : process.cwd();
16
+ }
17
+
18
+ async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
19
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
20
+ const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
21
+ if (typeof openQuickSettings !== "function") return false;
22
+ try {
23
+ await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
31
+ const config = loadConfig(commandCwd(ctx));
32
+ ctx.ui.notify([
33
+ `Pi Claude: ${config.enabled === false ? "disabled" : "enabled"}`,
34
+ "Claude account billing settings (including Extra Usage) are managed in Claude.",
35
+ ].join("\n"), "info");
36
+ }
37
+
38
+ // Deterministic connector enumeration for the host app (vstack#838). Reports the
39
+ // failure reason rather than an empty list, so "no connectors" and "could not
40
+ // check" stay distinguishable.
41
+ async function reportConnectorInventory(ctx: {
42
+ ui: ExtensionUIContext;
43
+ model?: Model<any>;
44
+ sessionManager?: { getSessionId?: () => string };
45
+ }): Promise<void> {
46
+ // With a router active, enumerate the CURRENT route's account rather than
47
+ // whatever the process env points at.
48
+ const account = ctx.model
49
+ ? resolveClaudeAccountRouter()?.current(ctx.model.id, ctx.sessionManager?.getSessionId?.())
50
+ : undefined;
51
+ const credentials = resolveClaudeOAuth(readCredentialFile, connectorCredentialEnv(account ? accountSessionScope(account).claudeConfigDir : undefined));
52
+ if (!credentials) {
53
+ ctx.ui.notify("Pi Claude: no Claude OAuth credentials found — cannot enumerate connectors.", "error");
54
+ return;
55
+ }
56
+ const inventory = await listAccountConnectors({ credentials });
57
+ if (!inventory.ok) {
58
+ ctx.ui.notify(`Pi Claude: connector enumeration failed — ${inventory.reason}`, "error");
59
+ return;
60
+ }
61
+ if (inventory.connectors.length === 0) {
62
+ ctx.ui.notify("Pi Claude: this account has no connectors installed.", "info");
63
+ return;
64
+ }
65
+ const names = inventory.connectors.map((c) => c.name).join(", ");
66
+ ctx.ui.notify(`Pi Claude: ${inventory.connectors.length} connector(s) installed — ${names}`, "info");
67
+ }
68
+
69
+ export function registerBridgeCommands(pi: ExtensionAPI): void {
70
+ const guard = pi as unknown as Record<PropertyKey, unknown>;
71
+ if (guard[COMMANDS_REGISTERED_KEY]) return;
72
+ guard[COMMANDS_REGISTERED_KEY] = true;
73
+
74
+ pi.registerCommand("pi-claude", {
75
+ description: "Open Pi Claude settings/status",
76
+ handler: async (args: string, ctx) => {
77
+ if (args.trim()) ctx.ui.notify("Unknown /pi-claude argument.", "warning");
78
+ if (await tryOpenExtensionManagerSettings(ctx)) return;
79
+ showBridgeStatus(ctx);
80
+ },
81
+ });
82
+ pi.registerCommand("pi-claude:connectors", {
83
+ description: "List the Claude account's installed claude.ai connectors",
84
+ handler: async (_args: string, ctx) => reportConnectorInventory(ctx),
85
+ });
86
+ }
@@ -1,12 +1,35 @@
1
1
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { debug, diagDump, diagLogPath } from "./debug.js";
2
+ import { debug, diagDump, diagGuidance } from "./debug.js";
3
3
  import { type QueryContext } from "./query-state.js";
4
+ import { currentRequestLaneId } from "./request-lane.js";
4
5
  import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
5
6
 
6
7
  export interface SessionState {
7
8
  sessionId: string;
8
9
  cursor: number;
9
10
  cwd: string;
11
+ // Claude Code session files and resume IDs are credential-profile scoped.
12
+ // Missing values mean the legacy/default Claude profile (process env rules).
13
+ // `claudeConfigDir` is the RESOLVED dir (see claudeDirForProfile) and is
14
+ // in-memory only — persistence strips it and keeps just the opaque profile
15
+ // id, re-deriving the dir through the router on restore.
16
+ accountProfileId?: string;
17
+ claudeConfigDir?: string;
18
+ // Identity anchor of the pi conversation this record belongs to, encoded
19
+ // component-wise as `u:<12hex>` or `u:<12hex>|a:<12hex>` (see
20
+ // conversationFingerprint in session-persistence.ts): a short sha256 of the
21
+ // FIRST user message's normalized text, plus — once the conversation has
22
+ // one — of the FIRST assistant message's normalized text. Pi histories
23
+ // never rewrite those opening messages — compact/tree-nav mutations set
24
+ // needsRebuild instead — so a component mismatch marks a FOREIGN
25
+ // conversation (a subagent-shaped query arriving while the parent is idle,
26
+ // vstack#1001) that must run as a clean one-shot without touching this
27
+ // record. The user component must always match; the assistant component is
28
+ // compared only when BOTH sides carry one, so a record stamped on turn 1
29
+ // (no assistant yet) still matches its own grown conversation and upgrades
30
+ // to the two-component form on the next REUSE. Absent on records restored
31
+ // from pre-3.1.1 markers → identity unknown, pre-fingerprint behavior.
32
+ conversationFingerprint?: string;
10
33
  // Force the next syncSharedSession call down the REBUILD path. Set when
11
34
  // pi has mutated its messages array out from under us (compact, tree
12
35
  // navigation) or after an abort left the JSONL in an indeterminate state.
@@ -23,16 +46,101 @@ export interface SessionState {
23
46
  forceRotate?: boolean;
24
47
  }
25
48
 
26
- // Shared mutable bridge state. Lives in its own module so the extracted
27
- // modules and index.ts observe the SAME state: ESM live bindings let every
28
- // importer READ these `let` bindings live, but only this module may assign
29
- // them cross-module writes must go through the setters below.
30
- export let sharedSession: SessionState | null = null;
49
+ // Claude session state is scoped to Pi's provider `sessionId`. Parent and child
50
+ // agents can load separate copies of the extension module while sharing the
51
+ // primary provider closure, so the lane registry must live on globalThis rather
52
+ // than in one module instance. The versioned symbol prevents an incompatible
53
+ // future store shape from being mistaken for this one.
54
+ interface SharedSessionLaneStoreV1 {
55
+ defaultSession: SessionState | null;
56
+ sessions: Map<string, SessionState | null>;
57
+ }
58
+
59
+ const SHARED_SESSION_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.shared-session-lanes.v1");
60
+
61
+ function sharedSessionLaneStore(): SharedSessionLaneStoreV1 {
62
+ const host = globalThis as Record<symbol, unknown>;
63
+ let store = host[SHARED_SESSION_LANES_SYMBOL] as SharedSessionLaneStoreV1 | undefined;
64
+ if (!store) {
65
+ store = { defaultSession: null, sessions: new Map() };
66
+ host[SHARED_SESSION_LANES_SYMBOL] = store;
67
+ }
68
+ return store;
69
+ }
70
+
31
71
  export let extensionApi: ExtensionAPI | undefined;
32
72
  export let piUI: ExtensionUIContext | undefined;
33
73
 
74
+ export function getSharedSession(): SessionState | null {
75
+ const store = sharedSessionLaneStore();
76
+ const sessionId = currentRequestLaneId();
77
+ return sessionId === undefined
78
+ ? store.defaultSession
79
+ : (store.sessions.get(sessionId) ?? null);
80
+ }
81
+
34
82
  export function setSharedSession(next: SessionState | null): void {
35
- sharedSession = next;
83
+ const store = sharedSessionLaneStore();
84
+ const sessionId = currentRequestLaneId();
85
+ if (sessionId === undefined) store.defaultSession = next;
86
+ else store.sessions.set(sessionId, next);
87
+ }
88
+
89
+ export function deleteSharedSessionLane(sessionId: string | undefined): void {
90
+ const store = sharedSessionLaneStore();
91
+ if (sessionId === undefined) store.defaultSession = null;
92
+ else store.sessions.delete(sessionId);
93
+ }
94
+
95
+ export function clearSharedSessionLanes(): void {
96
+ const store = sharedSessionLaneStore();
97
+ store.sessions.clear();
98
+ store.defaultSession = null;
99
+ }
100
+
101
+ // The lane each pi session started in, keyed by its SessionManager. An in-memory
102
+ // session (`pi --no-session`) forks by mutating the SAME SessionManager's id
103
+ // before session_shutdown fires, so the live id there names the fork rather than
104
+ // the session being torn down, and the fallback to it would prune a live
105
+ // sibling. Keyed per manager (not one slot) so overlapping parent/child
106
+ // session_start events keep their own entries, and on globalThis like the
107
+ // registry above because session_start and session_shutdown can reach different
108
+ // module instances (`/reload` mid-session, a child agent's own copy) and both
109
+ // must see the same entry.
110
+ const STARTED_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.started-lanes.v1");
111
+
112
+ function startedLaneStore(): WeakMap<object, string> {
113
+ const host = globalThis as Record<symbol, unknown>;
114
+ let store = host[STARTED_LANES_SYMBOL] as WeakMap<object, string> | undefined;
115
+ if (!store) {
116
+ store = new WeakMap<object, string>();
117
+ host[STARTED_LANES_SYMBOL] = store;
118
+ }
119
+ return store;
120
+ }
121
+
122
+ export function recordStartedLane(sessionManager: object, sessionId: string): void {
123
+ startedLaneStore().set(sessionManager, sessionId);
124
+ }
125
+
126
+ /** The lane recorded at this manager's session_start, removed as it is read —
127
+ * one shutdown per start. Undefined when no start was recorded (the caller
128
+ * falls back to the manager's live id). */
129
+ export function takeStartedLane(sessionManager: object): string | undefined {
130
+ const store = startedLaneStore();
131
+ const sessionId = store.get(sessionManager);
132
+ store.delete(sessionManager);
133
+ return sessionId;
134
+ }
135
+
136
+ /** Force the next syncSharedSession down the REBUILD path (no-op without a
137
+ * session). `forceRotate` additionally rotates the session UUID — set it when
138
+ * a concurrent CC writer may still be flushing (abort, idle kill); see the
139
+ * field docs on SessionState. */
140
+ export function markSessionForRebuild(opts: { forceRotate?: boolean } = {}): void {
141
+ const sharedSession = getSharedSession();
142
+ if (!sharedSession) return;
143
+ setSharedSession({ ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) });
36
144
  }
37
145
 
38
146
  export function setExtensionApi(next: ExtensionAPI | undefined): void {
@@ -56,6 +164,33 @@ export function safeToolCallSummary(calls: Array<{ id: string; toolName: string;
56
164
  return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
57
165
  }
58
166
 
167
+ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
168
+
169
+ /**
170
+ * Persist a bridge integrity event into the pi session transcript.
171
+ *
172
+ * The diag log and a piUI toast both die with the machine or the render cycle:
173
+ * the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
174
+ * SHOWN but existed nowhere in the pi session file, making analysis from the
175
+ * session alone impossible. A `CustomEntry` closes that gap the same way the
176
+ * connector-call audit does — persisted, never part of built context, never
177
+ * dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
178
+ * counts, tool names), never tool output.
179
+ *
180
+ * Never throws; returns whether the entry was appended (false outside a pi
181
+ * session — tests, embedded hosts without extensionApi).
182
+ */
183
+ export function appendIntegrityEntry(label: string, data: Record<string, unknown>): boolean {
184
+ try {
185
+ if (!extensionApi) return false;
186
+ extensionApi.appendEntry(INTEGRITY_CUSTOM_TYPE, { label, at: new Date().toISOString(), ...data });
187
+ return true;
188
+ } catch (error) {
189
+ debug("appendIntegrityEntry failed:", error);
190
+ return false;
191
+ }
192
+ }
193
+
59
194
  function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
60
195
  const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
61
196
  if (names.length > limit) shown.push(`+${names.length - limit} more`);
@@ -75,10 +210,15 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
75
210
  missing: missing.slice(0, 50),
76
211
  ...context,
77
212
  });
213
+ appendIntegrityEntry("repair_tool_pairing_synthetic_results", {
214
+ count: missing.length,
215
+ toolNames,
216
+ sampledToolCallIds: sampledToolCallIds.slice(0, 12),
217
+ });
78
218
  safeNotify(
79
- `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
219
+ `Claude bridge: ${missing.length} missing tool result(s) repaired with an explicit error placeholder` +
80
220
  `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
81
- `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
221
+ `Real tool output was lost before Claude session import; ${diagGuidance()}.`,
82
222
  "error",
83
223
  );
84
224
  } catch (error) {
@@ -86,7 +226,12 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
86
226
  }
87
227
  }
88
228
 
89
- export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
229
+ export function reportToolResultMismatch(
230
+ queryCtx: QueryContext,
231
+ reason: string,
232
+ cwd: string | undefined,
233
+ opts: { expectedInterruption?: boolean; forceRotate?: boolean } = {},
234
+ ): boolean {
90
235
  try {
91
236
  if (queryCtx.reportedToolResultMismatch) return false;
92
237
  const progress = queryCtx.toolResultProgress();
@@ -95,15 +240,33 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
95
240
  : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
96
241
  if (!hasMismatch) return false;
97
242
  queryCtx.reportedToolResultMismatch = true;
98
- if (sharedSession) {
99
- sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
243
+ // The single choke point every mismatch path funnels through (abort,
244
+ // unmatched result, stream-idle, teardown). A context with no claim on
245
+ // the shared record (reentrant subagent or foreign one-shot, vstack#1001)
246
+ // still gets the full diagnostics below, but its unresolved tool state is
247
+ // its own — marking the PARENT's record needsRebuild/forceRotate here
248
+ // would flush the parent's prompt cache for a query that never touched
249
+ // its session.
250
+ if (!queryCtx.detachedFromSharedSession) markSessionForRebuild(opts);
251
+ // A user abort interrupting in-flight tool calls is expected teardown, not
252
+ // an integrity fault: mark the rebuild but skip the diag dump and toast.
253
+ if (opts.expectedInterruption) {
254
+ debug(
255
+ `tool result delivery interrupted as expected during ${reason}; ` +
256
+ `delivered=${progress.deliveredCount}/${progress.expectedCount} ` +
257
+ `resolved=${progress.resolvedCount}/${progress.expectedCount} ` +
258
+ `waiting=${progress.waitingCount} queued=${progress.queuedCount}`,
259
+ );
260
+ return true;
100
261
  }
101
262
  const toolNameSummary = compactToolNameSummary(progress.toolNames);
263
+ const sharedSession = getSharedSession();
102
264
  diagDump("tool_result_delivery_mismatch", {
103
265
  reason,
104
266
  cwd,
105
267
  progress,
106
268
  activeQueryExists: queryCtx.activeQuery !== null,
269
+ detachedFromSharedSession: queryCtx.detachedFromSharedSession,
107
270
  sharedSession: sharedSession ? {
108
271
  sessionId: sharedSession.sessionId.slice(0, 8),
109
272
  cursor: sharedSession.cursor,
@@ -111,12 +274,24 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
111
274
  forceRotate: sharedSession.forceRotate === true,
112
275
  } : null,
113
276
  });
277
+ appendIntegrityEntry("tool_result_delivery_mismatch", {
278
+ reason,
279
+ toolNames: progress.toolNames,
280
+ expectedCount: progress.expectedCount,
281
+ deliveredCount: progress.deliveredCount,
282
+ resolvedCount: progress.resolvedCount,
283
+ waitingIds: progress.waitingIds,
284
+ queuedIds: progress.queuedIds,
285
+ unmatchedResultIds: progress.unmatchedResultIds,
286
+ });
114
287
  safeNotify(
115
288
  `Claude bridge: tool result delivery interrupted during ${reason}; ` +
116
289
  `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
117
290
  `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
118
291
  `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
119
- `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
292
+ (queryCtx.detachedFromSharedSession
293
+ ? `Detached one-shot query — shared Claude session record left untouched; ${diagGuidance()}.`
294
+ : `Claude session will rebuild before the next turn; ${diagGuidance()}.`),
120
295
  "error",
121
296
  );
122
297
  return true;
@@ -128,9 +303,19 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
128
303
 
129
304
  export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
130
305
  if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
131
- if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
306
+ if ("sharedSession" in state) {
307
+ if (currentRequestLaneId() !== undefined) setSharedSession(state.sharedSession ?? null);
308
+ else {
309
+ clearSharedSessionLanes();
310
+ sharedSessionLaneStore().defaultSession = state.sharedSession ?? null;
311
+ }
312
+ }
132
313
  }
133
314
 
134
315
  export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
135
- return { sharedSession };
316
+ return { sharedSession: getSharedSession() };
317
+ }
318
+
319
+ export function __testSharedSessionLaneCount(): number {
320
+ return sharedSessionLaneStore().sessions.size;
136
321
  }