@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.
- package/README.md +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/auth-presence.ts
CHANGED
|
@@ -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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
+
}
|
package/src/bridge-state.ts
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { debug, diagDump,
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
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(
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
}
|