@vanillagreen/pi-claude-bridge 2.0.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 +40 -135
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2432 -1123
- package/package.json +11 -20
- 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 +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +170 -20
- package/src/connector-cache.ts +43 -13
- package/src/connector-inventory.ts +14 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +286 -40
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +769 -702
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +17 -14
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
|
@@ -3,22 +3,107 @@ import { createSession, deleteSession, openSession, repairToolPairing } from "cc
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { realpathSync, statSync } from "fs";
|
|
5
5
|
import { resolve as pathResolve } from "path";
|
|
6
|
-
import { extensionApi,
|
|
6
|
+
import { extensionApi, getSharedSession, reportSyntheticToolResultRepair, safeNotify, setSharedSession, type SessionState } from "./bridge-state.js";
|
|
7
|
+
import { displayPath } from "./config.js";
|
|
7
8
|
import { convertPiMessages } from "./convert.js";
|
|
8
9
|
import { DEBUG, DEBUG_LOG_PATH, debug, diagDump } from "./debug.js";
|
|
9
10
|
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
findUnpairedToolUses,
|
|
13
|
+
insertLostToolResultPlaceholders,
|
|
14
|
+
recoverLaterToolResults,
|
|
15
|
+
} from "./tool-pairing-audit.js";
|
|
16
|
+
import { claudeDirForProfile, resolveClaudeAccountRouter, type AccountSessionScope } from "./account-router.js";
|
|
11
17
|
|
|
12
18
|
// --- Session persistence ---
|
|
13
19
|
|
|
14
20
|
const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
// Persisted shape: SessionState MINUS claudeConfigDir. Config-dir paths are
|
|
23
|
+
// account-identifying and travel with shared session archives, so only the
|
|
24
|
+
// opaque accountProfileId is written; the dir is re-derived via the router on
|
|
25
|
+
// restore (no back-compat reader for older shapes — see CHANGELOG 3.0.0).
|
|
26
|
+
interface PersistedBridgeSessionState extends Omit<SessionState, "claudeConfigDir"> {
|
|
17
27
|
fingerprint: string;
|
|
18
28
|
piSessionId?: string;
|
|
19
29
|
updatedAt: string;
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
function normalizedMessageText(message: unknown): string {
|
|
33
|
+
const content = (message as { content?: unknown }).content;
|
|
34
|
+
const text = typeof content === "string"
|
|
35
|
+
? content
|
|
36
|
+
: Array.isArray(content)
|
|
37
|
+
? content
|
|
38
|
+
.map((block) => (block as { type?: string; text?: string }).type === "text" ? (block as { text?: string }).text ?? "" : "")
|
|
39
|
+
.join("\n")
|
|
40
|
+
: "";
|
|
41
|
+
return text.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shortHash(text: string): string {
|
|
45
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Identity anchor for a pi conversation, encoded component-wise as
|
|
49
|
+
* `u:<12hex>` (short sha256 of the FIRST user message's normalized text) or
|
|
50
|
+
* `u:<12hex>|a:<12hex>` (plus the FIRST assistant message's normalized text
|
|
51
|
+
* once the conversation has one with any text). The opening messages are the
|
|
52
|
+
* stable elements of a pi history — later messages get appended, compacted,
|
|
53
|
+
* or tree-navigated (all of which set needsRebuild), but the first user/
|
|
54
|
+
* assistant pair survives for the session's lifetime. The two-component form
|
|
55
|
+
* exists because a first USER message alone is a weak anchor: unrelated
|
|
56
|
+
* conversations routinely open with identical text ("continue"), and a
|
|
57
|
+
* same-opener foreign context shaped to align with the cursor would REUSE
|
|
58
|
+
* across genuinely different histories. Returns undefined when the context
|
|
59
|
+
* has no user message or its text normalizes to empty (image-only) —
|
|
60
|
+
* identity unknown, callers must fail open to the pre-fingerprint behavior,
|
|
61
|
+
* never treat it as a mismatch. Distinct from fingerprintMessages below,
|
|
62
|
+
* which hashes a cursor slice for restore integrity. */
|
|
63
|
+
export function conversationFingerprint(messages: Context["messages"]): string | undefined {
|
|
64
|
+
const firstUser = messages.find((message) => (message as { role?: string }).role === "user");
|
|
65
|
+
if (!firstUser) return undefined;
|
|
66
|
+
const userText = normalizedMessageText(firstUser);
|
|
67
|
+
if (!userText) return undefined;
|
|
68
|
+
const firstAssistant = messages.find((message) => (message as { role?: string }).role === "assistant");
|
|
69
|
+
const assistantText = firstAssistant ? normalizedMessageText(firstAssistant) : "";
|
|
70
|
+
return assistantText ? `u:${shortHash(userText)}|a:${shortHash(assistantText)}` : `u:${shortHash(userText)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseConversationFingerprint(fp: string): { user: string; assistant?: string } | undefined {
|
|
74
|
+
const match = /^u:([0-9a-f]+)(?:\|a:([0-9a-f]+))?$/.exec(fp);
|
|
75
|
+
if (!match) return undefined;
|
|
76
|
+
return { user: match[1], ...(match[2] ? { assistant: match[2] } : {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Component-wise anchor comparison. The user component must always match; the
|
|
80
|
+
* assistant component is compared only when BOTH sides carry one — a record
|
|
81
|
+
* stamped on turn 1 has no assistant yet, and its own conversation grown past
|
|
82
|
+
* turn 1 is an upgrade, not a mismatch (see conversationFingerprintUpgrade).
|
|
83
|
+
* An unparseable side means identity unknown: fail open (match), consistent
|
|
84
|
+
* with the guard's treatment of absent fingerprints. */
|
|
85
|
+
export function conversationFingerprintsMatch(recorded: string, incoming: string): boolean {
|
|
86
|
+
const rec = parseConversationFingerprint(recorded);
|
|
87
|
+
const inc = parseConversationFingerprint(incoming);
|
|
88
|
+
if (!rec || !inc) return true;
|
|
89
|
+
if (rec.user !== inc.user) return false;
|
|
90
|
+
return !(rec.assistant && inc.assistant && rec.assistant !== inc.assistant);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether a REUSE-matched context's anchor should replace the recorded one:
|
|
94
|
+
* a legacy record with none adopts it outright (the planner accepting this
|
|
95
|
+
* context as the recorded conversation's continuation is the identity proof),
|
|
96
|
+
* and a turn-1 user-only record upgrades to the two-component form the
|
|
97
|
+
* moment its own conversation carries a first assistant message. A recorded
|
|
98
|
+
* two-component anchor is never rewritten. */
|
|
99
|
+
function conversationFingerprintUpgrade(recorded: string | undefined, incoming: string | undefined): string | undefined {
|
|
100
|
+
if (!incoming) return undefined;
|
|
101
|
+
if (!recorded) return incoming;
|
|
102
|
+
const rec = parseConversationFingerprint(recorded);
|
|
103
|
+
const inc = parseConversationFingerprint(incoming);
|
|
104
|
+
return rec && inc && !rec.assistant && inc.assistant && rec.user === inc.user ? incoming : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
22
107
|
function fingerprintMessages(messages: Context["messages"]): string {
|
|
23
108
|
const normalized = messages.map((message) => {
|
|
24
109
|
if (message.role === "assistant") {
|
|
@@ -52,9 +137,9 @@ function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeS
|
|
|
52
137
|
return undefined;
|
|
53
138
|
}
|
|
54
139
|
|
|
55
|
-
function claudeSessionExists(sessionId: string, cwd: string): boolean {
|
|
140
|
+
function claudeSessionExists(sessionId: string, cwd: string, claudeDir: string | undefined): boolean {
|
|
56
141
|
try {
|
|
57
|
-
const session = openSession({ sessionId, projectPath: cwd, claudeDir
|
|
142
|
+
const session = openSession({ sessionId, projectPath: cwd, claudeDir });
|
|
58
143
|
statSync(session.jsonlPath);
|
|
59
144
|
return true;
|
|
60
145
|
} catch {
|
|
@@ -111,35 +196,106 @@ export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?
|
|
|
111
196
|
debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
|
|
112
197
|
return;
|
|
113
198
|
}
|
|
114
|
-
|
|
199
|
+
// Only the opaque profile id is persisted; a managed session re-derives its
|
|
200
|
+
// claude dir through the live router (router absent or id unknown → the
|
|
201
|
+
// default-profile rule, which may fail the existence check and rebuild).
|
|
202
|
+
const accountProfileId = typeof persisted.accountProfileId === "string" ? persisted.accountProfileId : undefined;
|
|
203
|
+
const claudeConfigDir = accountProfileId
|
|
204
|
+
? claudeDirForProfile(resolveClaudeAccountRouter()?.resolveProfile?.(accountProfileId) ?? {})
|
|
205
|
+
: undefined;
|
|
206
|
+
if (!claudeSessionExists(persisted.sessionId, persisted.cwd, claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR)) {
|
|
115
207
|
debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
|
|
116
208
|
return;
|
|
117
209
|
}
|
|
118
|
-
setSharedSession({
|
|
119
|
-
|
|
210
|
+
setSharedSession({
|
|
211
|
+
sessionId: persisted.sessionId,
|
|
212
|
+
cursor,
|
|
213
|
+
cwd: persisted.cwd,
|
|
214
|
+
// Absent on pre-3.1.1 markers: restore as identity-unknown (the foreign
|
|
215
|
+
// guard fails open) rather than rejecting the entry.
|
|
216
|
+
...(typeof persisted.conversationFingerprint === "string" ? { conversationFingerprint: persisted.conversationFingerprint } : {}),
|
|
217
|
+
...(accountProfileId ? { accountProfileId, claudeConfigDir } : {}),
|
|
218
|
+
});
|
|
219
|
+
debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}, account=${accountProfileId ?? "default"}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One pending persist per SessionManager: cancelling a shutting-down session's
|
|
223
|
+
// persist must not drop a concurrent session's unwritten marker. On globalThis
|
|
224
|
+
// under a versioned symbol for the same reason as the lane registries in
|
|
225
|
+
// bridge-state.ts and query-state.ts — the scheduling and the cancelling module
|
|
226
|
+
// instance can differ. Scheduling again for the same manager REPLACES the
|
|
227
|
+
// pending timer: each fire appends the record's state as of its schedule and
|
|
228
|
+
// restore reads the last marker, so the superseded entry is a stale duplicate.
|
|
229
|
+
const SCHEDULED_PERSISTENCE_SYMBOL = Symbol.for("vstack.pi.claude-bridge.scheduled-persistence.v1");
|
|
230
|
+
|
|
231
|
+
type PersistenceTimer = ReturnType<typeof setTimeout>;
|
|
232
|
+
|
|
233
|
+
function scheduledPersistenceTimers(): Map<object, PersistenceTimer> {
|
|
234
|
+
const host = globalThis as Record<symbol, unknown>;
|
|
235
|
+
let store = host[SCHEDULED_PERSISTENCE_SYMBOL] as Map<object, PersistenceTimer> | undefined;
|
|
236
|
+
if (!store) {
|
|
237
|
+
store = new Map<object, PersistenceTimer>();
|
|
238
|
+
host[SCHEDULED_PERSISTENCE_SYMBOL] = store;
|
|
239
|
+
}
|
|
240
|
+
return store;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function cancelScheduledSessionPersistence(sessionManager: object): void {
|
|
244
|
+
const timers = scheduledPersistenceTimers();
|
|
245
|
+
const timer = timers.get(sessionManager);
|
|
246
|
+
if (timer === undefined) return;
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
timers.delete(sessionManager);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Test-only: drop every pending persist so a test file starts clean. */
|
|
252
|
+
export function __testCancelAllScheduledSessionPersistence(): void {
|
|
253
|
+
const timers = scheduledPersistenceTimers();
|
|
254
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
255
|
+
timers.clear();
|
|
120
256
|
}
|
|
121
257
|
|
|
122
258
|
export function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
|
|
259
|
+
const sharedSession = getSharedSession();
|
|
123
260
|
if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
|
|
124
|
-
|
|
261
|
+
// Extension contexts become guarded/stale as soon as shutdown or replacement
|
|
262
|
+
// starts. Capture the plain SessionManager reference now and cancel the timer
|
|
263
|
+
// on shutdown rather than dereferencing the ctx proxy from the next tick.
|
|
264
|
+
const sessionManager = ctxLike.sessionManager as object;
|
|
265
|
+
// Persist only the opaque profile id — the resolved config dir is an
|
|
266
|
+
// account-identifying path and stays in memory (see PersistedBridgeSessionState).
|
|
267
|
+
const { claudeConfigDir: _omitted, ...snapshot } = sharedSession;
|
|
268
|
+
const timers = scheduledPersistenceTimers();
|
|
269
|
+
const superseded = timers.get(sessionManager);
|
|
270
|
+
if (superseded !== undefined) clearTimeout(superseded);
|
|
125
271
|
const timer = setTimeout(() => {
|
|
272
|
+
if (timers.get(sessionManager) === timer) timers.delete(sessionManager);
|
|
126
273
|
try {
|
|
127
|
-
const built = readBuiltSessionContext(
|
|
274
|
+
const built = readBuiltSessionContext(sessionManager);
|
|
128
275
|
if (!built) return;
|
|
129
276
|
const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
|
|
130
277
|
const data: PersistedBridgeSessionState = {
|
|
131
278
|
...snapshot,
|
|
132
279
|
cursor,
|
|
133
280
|
fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
|
|
134
|
-
piSessionId: typeof (
|
|
281
|
+
piSessionId: typeof (sessionManager as any)?.getSessionId === "function" ? (sessionManager as any).getSessionId() : undefined,
|
|
135
282
|
updatedAt: new Date().toISOString(),
|
|
136
283
|
};
|
|
137
284
|
extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
|
|
138
285
|
debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
|
|
139
286
|
} catch (error) {
|
|
140
|
-
|
|
287
|
+
// A failed persist means the next startup restores a stale (or no)
|
|
288
|
+
// bridge marker and silently rebuilds — worth a diagnostic entry.
|
|
289
|
+
// Like all diagDump output this lands only under CLAUDE_BRIDGE_DEBUG=1
|
|
290
|
+
// (VST-15); the failure itself stays non-fatal either way.
|
|
291
|
+
diagDump("persist_shared_session_failed", {
|
|
292
|
+
sessionId: snapshot.sessionId.slice(0, 8),
|
|
293
|
+
cursor: snapshot.cursor,
|
|
294
|
+
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
|
295
|
+
});
|
|
141
296
|
}
|
|
142
297
|
}, 0);
|
|
298
|
+
timers.set(sessionManager, timer);
|
|
143
299
|
timer.unref?.();
|
|
144
300
|
}
|
|
145
301
|
|
|
@@ -167,11 +323,21 @@ function convertAndImportMessages(
|
|
|
167
323
|
debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
|
|
168
324
|
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
169
325
|
}
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
326
|
+
// A steer can make Pi split one parallel Claude batch across several visible
|
|
327
|
+
// assistant/tool-result pairs. Recover those real later results before the
|
|
328
|
+
// generic repair layer mistakes them for lost output.
|
|
329
|
+
const recoveredToolResults = recoverLaterToolResults(anthropicMessages);
|
|
330
|
+
if (recoveredToolResults.length > 0) {
|
|
331
|
+
debug(
|
|
332
|
+
`convertAndImportMessages: recovered ${recoveredToolResults.length} later tool result(s) for original parallel batch`,
|
|
333
|
+
recoveredToolResults.map((item) => item.id).join(", "),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
// Pre-repair: pair every REMAINING orphaned tool_use with an EXPLICIT
|
|
337
|
+
// bridge-authored error result before cc-session-io's repairToolPairing can
|
|
338
|
+
// backfill its bare "[no tool result recorded]" placeholder — which the model
|
|
339
|
+
// reads as tool output and silently reasons on. Ours is is_error and says
|
|
340
|
+
// what to do. repairToolPairing still runs after (idempotent; finds nothing left).
|
|
175
341
|
const missingToolResults = findUnpairedToolUses(anthropicMessages);
|
|
176
342
|
if (missingToolResults.length > 0) insertLostToolResultPlaceholders(anthropicMessages, missingToolResults);
|
|
177
343
|
const repaired = repairToolPairing(anthropicMessages);
|
|
@@ -192,12 +358,63 @@ function convertAndImportMessages(
|
|
|
192
358
|
|
|
193
359
|
interface SyncResult {
|
|
194
360
|
sessionId: string | null;
|
|
361
|
+
// Index into the caller's messages array where this query's prompt begins.
|
|
362
|
+
// Everything from promptStart to the end is user input Claude has not seen;
|
|
363
|
+
// the caller slices it out itself (single owner of the messages array).
|
|
364
|
+
promptStart: number;
|
|
365
|
+
// True when the incoming context's conversation fingerprint contradicts the
|
|
366
|
+
// shared record's (Case 6): the query runs as a clean one-shot and its
|
|
367
|
+
// completion must NOT persist over the module-level record — the caller
|
|
368
|
+
// gates its persistSession/markRebuild exactly like the reentrant path.
|
|
369
|
+
foreignContext?: boolean;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface IncrementalPromptBatchPlan {
|
|
373
|
+
// Doubles as the cursor to store before the query runs: Claude owns
|
|
374
|
+
// [0, promptStart) and the prompt delivers [promptStart, end).
|
|
375
|
+
promptStart: number;
|
|
376
|
+
userMessageCount: number;
|
|
195
377
|
}
|
|
196
378
|
|
|
197
379
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
380
|
+
* Recognize history that Claude already owns followed only by user messages
|
|
381
|
+
* delivered together by Pi (for example, followUpMode="all"). Claude Code has
|
|
382
|
+
* already persisted the optional leading assistant message; every user message
|
|
383
|
+
* after it must be sent as this query's prompt rather than imported via rebuild.
|
|
200
384
|
*/
|
|
385
|
+
export function planIncrementalPromptBatch(
|
|
386
|
+
messages: Context["messages"],
|
|
387
|
+
cursor: number,
|
|
388
|
+
): IncrementalPromptBatchPlan | undefined {
|
|
389
|
+
const lastIndex = messages.length - 1;
|
|
390
|
+
if (lastIndex < 0 || (messages[lastIndex] as { role?: string }).role !== "user") return undefined;
|
|
391
|
+
|
|
392
|
+
// A cursor past the end is PROOF this messages array is not the conversation
|
|
393
|
+
// the cursor describes (e.g. a reentrant subagent's short context arriving
|
|
394
|
+
// while the parent's cursor is large). Clamping it used to fabricate a REUSE
|
|
395
|
+
// plan against foreign history — reject so the caller takes the rebuild path.
|
|
396
|
+
if (cursor > lastIndex) {
|
|
397
|
+
debug(`planIncrementalPromptBatch: rejected — cursor=${cursor} beyond last index ${lastIndex}; messages are not the conversation this cursor describes`);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
const boundedCursor = Math.max(0, cursor);
|
|
401
|
+
let promptStart = boundedCursor;
|
|
402
|
+
if ((messages[promptStart] as { role?: string } | undefined)?.role === "assistant") promptStart++;
|
|
403
|
+
|
|
404
|
+
const pendingPrompts = messages.slice(promptStart);
|
|
405
|
+
if (pendingPrompts.length === 0 || pendingPrompts.some((message) => (message as { role?: string }).role !== "user")) {
|
|
406
|
+
// Log the rejected tail so a diag log can tell apart "two assistants in
|
|
407
|
+
// tail" vs "toolResult in tail" vs "stale cursor" without a repro.
|
|
408
|
+
debug(`planIncrementalPromptBatch: rejected — cursor=${cursor} promptStart=${promptStart} tail roles=[${messages.slice(boundedCursor).map((m) => (m as { role?: string }).role).join(", ")}]`);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
promptStart,
|
|
414
|
+
userMessageCount: pendingPrompts.length,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
201
418
|
// Read the session file we just wrote and sanity-check it. Warns instead of
|
|
202
419
|
// throwing — CC may be more tolerant than our checks, so a false positive
|
|
203
420
|
// shouldn't block the user. Pure logic is in session-verify.js; this wrapper
|
|
@@ -207,18 +424,25 @@ function verifyWrittenSession(
|
|
|
207
424
|
expectedSessionId: string,
|
|
208
425
|
expectedRecordCount: number,
|
|
209
426
|
cwd: string,
|
|
427
|
+
claudeDir: string | undefined,
|
|
210
428
|
): void {
|
|
211
429
|
const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
|
|
212
430
|
for (const msg of warnings) {
|
|
213
431
|
debug(`WARNING session verify: ${msg}`);
|
|
214
|
-
|
|
432
|
+
// No CLAUDE_CONFIG_DIR value here: this text asks to be pasted into a
|
|
433
|
+
// public issue and config-dir paths are account-identifying (see the
|
|
434
|
+
// persisted-shape note at the top of this file). The diagDump below
|
|
435
|
+
// records it locally instead. Paths are home-relativized for the same
|
|
436
|
+
// reason — an absolute cwd carries the username; the diagDump keeps the
|
|
437
|
+
// absolute forms.
|
|
438
|
+
safeNotify(
|
|
215
439
|
`Session file issue: ${msg}\n` +
|
|
216
|
-
`cwd=${cwd} realpath=${safeRealpath(cwd)
|
|
217
|
-
`Please copy and paste this message into a new issue at https://github.com/
|
|
440
|
+
`cwd=${displayPath(cwd)} realpath=${displayPath(safeRealpath(cwd))}\n` +
|
|
441
|
+
`Please copy and paste this message into a new issue at https://github.com/vanillagreencom/vstack/issues/new` +
|
|
218
442
|
(DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
219
443
|
"warning",
|
|
220
444
|
);
|
|
221
|
-
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir:
|
|
445
|
+
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: claudeDir ?? null });
|
|
222
446
|
}
|
|
223
447
|
}
|
|
224
448
|
|
|
@@ -229,7 +453,7 @@ function safeRealpath(p: string): string {
|
|
|
229
453
|
// Diagnostic snapshot of where a session file was just written. Catches the
|
|
230
454
|
// class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
|
|
231
455
|
// from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
|
|
232
|
-
function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
|
|
456
|
+
function debugSessionPaths(label: string, cwd: string, jsonlPath: string, claudeDir: string | undefined): void {
|
|
233
457
|
const realCwd = safeRealpath(cwd);
|
|
234
458
|
let fileSize: number | null = null;
|
|
235
459
|
let fileExists = false;
|
|
@@ -242,14 +466,21 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void
|
|
|
242
466
|
if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
|
|
243
467
|
debug(`${label}: jsonlPath=${jsonlPath}`);
|
|
244
468
|
debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
|
|
245
|
-
debug(`${label}:
|
|
469
|
+
debug(`${label}: selected.CLAUDE_CONFIG_DIR=${claudeDir ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
|
|
246
470
|
}
|
|
247
471
|
|
|
248
472
|
// Two semantic paths:
|
|
249
|
-
// REUSE — pi's history is in sync with the existing sharedSession
|
|
250
|
-
// only by
|
|
251
|
-
// streamSimple returns, which CC's own persisted session
|
|
252
|
-
//
|
|
473
|
+
// REUSE — pi's history is in sync with the existing sharedSession, drifted
|
|
474
|
+
// only by an optional trailing assistant message (the final-assistant pi
|
|
475
|
+
// appends after streamSimple returns, which CC's own persisted session
|
|
476
|
+
// already has) plus an unbounded trailing run of user messages delivered
|
|
477
|
+
// together by pi (steer-queue drain, followUpMode="all"). The whole user
|
|
478
|
+
// run becomes this query's prompt. The unbounded run is safe because
|
|
479
|
+
// promptStart can never land on a user message Claude already persisted:
|
|
480
|
+
// Claude owns [0, cursor), promptStart starts at the cursor and only ever
|
|
481
|
+
// advances (past the one optional assistant), so everything from
|
|
482
|
+
// promptStart on is new input. Returns the existing sessionId. Keeps CC's
|
|
483
|
+
// prompt cache warm.
|
|
253
484
|
// REBUILD — no session yet, or pi's history has diverged (non-trailing
|
|
254
485
|
// missed messages, e.g. another provider took a turn). Wipes the existing
|
|
255
486
|
// session file (if any) and writes a fresh one containing all prior
|
|
@@ -275,32 +506,105 @@ export function syncSharedSession(
|
|
|
275
506
|
cwd: string,
|
|
276
507
|
customToolNameToSdk?: Map<string, string>,
|
|
277
508
|
modelId?: string,
|
|
509
|
+
account?: AccountSessionScope,
|
|
278
510
|
): SyncResult {
|
|
511
|
+
const sharedSession = getSharedSession();
|
|
279
512
|
const priorMessages = messages.slice(0, -1); // everything before the new user prompt
|
|
513
|
+
const accountProfileId = account?.accountProfileId;
|
|
514
|
+
const scopeConfigDir = account?.claudeConfigDir; // resolved dir for managed, undefined for legacy
|
|
515
|
+
// What cc-session-io reads/writes. Managed requests always carry a resolved
|
|
516
|
+
// dir (accountSessionScope) so this never falls back to the process env the
|
|
517
|
+
// child no longer sees; legacy keeps the env rule unchanged.
|
|
518
|
+
const claudeDir = scopeConfigDir ?? process.env.CLAUDE_CONFIG_DIR;
|
|
519
|
+
const sameAccount = Boolean(
|
|
520
|
+
sharedSession &&
|
|
521
|
+
sharedSession.accountProfileId === accountProfileId &&
|
|
522
|
+
sharedSession.claudeConfigDir === scopeConfigDir,
|
|
523
|
+
);
|
|
524
|
+
const incomingFingerprint = conversationFingerprint(messages);
|
|
525
|
+
|
|
526
|
+
// FOREIGN-CONVERSATION guard (Case 6, vstack#1001). A subagent-shaped query
|
|
527
|
+
// arriving while the parent is IDLE is not reentrant, so it lands here as an
|
|
528
|
+
// outermost query. Without an identity check its short foreign context takes
|
|
529
|
+
// the REBUILD path — rewriting the PARENT's session file from foreign
|
|
530
|
+
// history — and its completion swaps the parent's record for the child's.
|
|
531
|
+
// A conversation-fingerprint mismatch is that identity signal: run the query
|
|
532
|
+
// as a clean one-shot (same semantics as the reentrant path) and leave the
|
|
533
|
+
// record completely alone. Two deliberate limits keep misclassification
|
|
534
|
+
// self-healing instead of sticky:
|
|
535
|
+
// - needsRebuild is a carve-out: pi just mutated its history out from
|
|
536
|
+
// under us (compact, tree-nav, abort recovery), so the next outermost
|
|
537
|
+
// context is authoritative for THIS conversation even if its anchor
|
|
538
|
+
// moved — it must reach REBUILD, not be shunted into a one-shot.
|
|
539
|
+
// - Length monotonicity (the issue's second signal): a real conversation
|
|
540
|
+
// only grows, so a context LONGER than what the record's cursor covers
|
|
541
|
+
// can be the recorded conversation while a mismatching shorter one
|
|
542
|
+
// cannot. Should a foreign fingerprint ever capture the record (legacy
|
|
543
|
+
// no-fingerprint records still rebuild, below), the parent's longer
|
|
544
|
+
// context falls through to REBUILD and reclaims it in one turn — a
|
|
545
|
+
// mismatch-always-one-shot rule would instead degrade every subsequent
|
|
546
|
+
// parent turn to a historyless one-shot with no recovery.
|
|
547
|
+
// Either fingerprint being unknown (no user message, image-only opener,
|
|
548
|
+
// pre-3.1.1 record) fails open to the pre-fingerprint behavior.
|
|
549
|
+
if (
|
|
550
|
+
sharedSession && !sharedSession.needsRebuild &&
|
|
551
|
+
sharedSession.conversationFingerprint && incomingFingerprint &&
|
|
552
|
+
!conversationFingerprintsMatch(sharedSession.conversationFingerprint, incomingFingerprint) &&
|
|
553
|
+
priorMessages.length <= sharedSession.cursor
|
|
554
|
+
) {
|
|
555
|
+
debug(
|
|
556
|
+
`Case 6 foreign-conversation: fingerprint ${incomingFingerprint.slice(0, 8)} != record ${sharedSession.conversationFingerprint.slice(0, 8)} ` +
|
|
557
|
+
`(cursor=${sharedSession.cursor}, priors=${priorMessages.length}) — clean one-shot, record untouched`,
|
|
558
|
+
);
|
|
559
|
+
debug(`syncResult: path=foreign-one-shot`);
|
|
560
|
+
return { sessionId: null, promptStart: messages.length - 1, foreignContext: true };
|
|
561
|
+
}
|
|
280
562
|
|
|
281
|
-
// REUSE path
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
563
|
+
// REUSE path. A Claude session can only be resumed under the credential
|
|
564
|
+
// profile that created its JSONL and prompt cache.
|
|
565
|
+
if (sharedSession && sameAccount && !sharedSession.needsRebuild) {
|
|
566
|
+
const batch = planIncrementalPromptBatch(messages, sharedSession.cursor);
|
|
567
|
+
if (batch) {
|
|
568
|
+
// Read the pre-update cursor first: setSharedSession reassigns the live
|
|
569
|
+
// binding, so comparing against sharedSession.cursor afterwards would
|
|
570
|
+
// always be equal and the "advanced past trailing assistant" debug
|
|
571
|
+
// branch could never print (vstack#993).
|
|
572
|
+
const cursorBeforeUpdate = sharedSession.cursor;
|
|
573
|
+
// A REUSE match proves identity, so the anchor may only strengthen here:
|
|
574
|
+
// a pre-3.1.1 record adopts it outright, and a turn-1 user-only anchor
|
|
575
|
+
// upgrades to the two-component form once the conversation has its
|
|
576
|
+
// first assistant message (see conversationFingerprintUpgrade).
|
|
577
|
+
const upgradedFingerprint = conversationFingerprintUpgrade(sharedSession.conversationFingerprint, incomingFingerprint);
|
|
578
|
+
setSharedSession({
|
|
579
|
+
...sharedSession,
|
|
580
|
+
cursor: batch.promptStart,
|
|
581
|
+
cwd,
|
|
582
|
+
...(upgradedFingerprint ? { conversationFingerprint: upgradedFingerprint } : {}),
|
|
583
|
+
});
|
|
584
|
+
const batching = batch.userMessageCount > 1
|
|
585
|
+
? `batched ${batch.userMessageCount} consecutive user messages, `
|
|
586
|
+
: batch.promptStart > cursorBeforeUpdate ? "advanced cursor past trailing assistant, " : "";
|
|
587
|
+
debug(`Case 3: ${batching}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${batch.promptStart}, account=${accountProfileId ?? "default"}`);
|
|
588
|
+
debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${batch.promptStart} promptUsers=${batch.userMessageCount}`);
|
|
589
|
+
return {
|
|
590
|
+
sessionId: sharedSession.sessionId,
|
|
591
|
+
promptStart: batch.promptStart,
|
|
592
|
+
};
|
|
293
593
|
}
|
|
294
594
|
}
|
|
295
595
|
|
|
296
596
|
// REBUILD path
|
|
297
597
|
if (priorMessages.length === 0) {
|
|
298
|
-
debug(`Case 1: clean start, ${messages.length} total messages`);
|
|
598
|
+
debug(`Case 1: clean start, ${messages.length} total messages, account=${accountProfileId ?? "default"}`);
|
|
299
599
|
debug(`syncResult: path=clean-start`);
|
|
300
|
-
return { sessionId: null };
|
|
600
|
+
return { sessionId: null, promptStart: messages.length - 1 };
|
|
301
601
|
}
|
|
302
|
-
const
|
|
303
|
-
|
|
602
|
+
const replacedSessionId = sharedSession?.sessionId;
|
|
603
|
+
// Preserve a UUID only within the same credential profile: reusing account
|
|
604
|
+
// A's session id under B could resume the wrong transcript, and deleting A's
|
|
605
|
+
// JSONL from B's rebuild would destroy A's still-valid history.
|
|
606
|
+
const previousSessionId = sameAccount ? sharedSession?.sessionId : undefined;
|
|
607
|
+
const previousCursor = sameAccount ? sharedSession?.cursor ?? 0 : 0;
|
|
304
608
|
// preserveId: rebuild in place (deleteSession + createSession with the
|
|
305
609
|
// existing UUID), so prompt-cache UUIDs stay stable for log correlation
|
|
306
610
|
// and for any tools that key off them. Skipped only when there's a
|
|
@@ -308,27 +612,38 @@ export function syncSharedSession(
|
|
|
308
612
|
const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
|
|
309
613
|
if (preserveId) {
|
|
310
614
|
// Wipe prior jsonl + companion dir (no-op if nothing to wipe).
|
|
311
|
-
deleteSession(previousSessionId!, cwd,
|
|
615
|
+
deleteSession(previousSessionId!, cwd, claudeDir);
|
|
312
616
|
}
|
|
313
617
|
const session = createSession({
|
|
314
618
|
projectPath: cwd,
|
|
315
|
-
claudeDir
|
|
619
|
+
claudeDir,
|
|
316
620
|
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
317
621
|
...(modelId ? { model: modelId } : {}),
|
|
318
622
|
});
|
|
319
623
|
convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
|
|
320
624
|
session.save();
|
|
321
|
-
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
322
|
-
setSharedSession({
|
|
323
|
-
|
|
625
|
+
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd, claudeDir);
|
|
626
|
+
setSharedSession({
|
|
627
|
+
sessionId: session.sessionId,
|
|
628
|
+
cursor: priorMessages.length,
|
|
629
|
+
cwd,
|
|
630
|
+
// The rebuilt file's content IS this context, so its anchor is the
|
|
631
|
+
// record's identity — including after a compact/tree-nav that moved it.
|
|
632
|
+
...(incomingFingerprint ? { conversationFingerprint: incomingFingerprint } : {}),
|
|
633
|
+
...(accountProfileId ? { accountProfileId } : {}),
|
|
634
|
+
...(scopeConfigDir ? { claudeConfigDir: scopeConfigDir } : {}),
|
|
635
|
+
});
|
|
636
|
+
if (replacedSessionId === undefined) {
|
|
324
637
|
debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
|
|
638
|
+
} else if (!sameAccount) {
|
|
639
|
+
debug(`Case 5 account-rotation: ${priorMessages.length} prior messages → new session ${session.sessionId.slice(0, 8)} for account ${accountProfileId ?? "default"} (replaced ${replacedSessionId.slice(0, 8)})`);
|
|
325
640
|
} else if (preserveId) {
|
|
326
641
|
const missedCount = priorMessages.length - previousCursor;
|
|
327
642
|
debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
|
|
328
643
|
} else {
|
|
329
|
-
debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId
|
|
644
|
+
debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId!.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
|
|
330
645
|
}
|
|
331
|
-
debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
|
|
332
|
-
debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${
|
|
333
|
-
return { sessionId: session.sessionId };
|
|
646
|
+
debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath, claudeDir);
|
|
647
|
+
debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${replacedSessionId === undefined ? "first" : !sameAccount ? "account-rotated" : preserveId ? "preserved" : "rotated-post-abort"}`);
|
|
648
|
+
return { sessionId: session.sessionId, promptStart: messages.length - 1 };
|
|
334
649
|
}
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
// repairs them with synthetic "[no tool result recorded]" blocks.
|
|
3
3
|
// Kept pure so tests can exercise the exact audit without activating Pi.
|
|
4
4
|
|
|
5
|
+
export interface RecoveredToolResult {
|
|
6
|
+
id: string;
|
|
7
|
+
assistantIndex: number;
|
|
8
|
+
sourceUserIndex: number;
|
|
9
|
+
targetUserIndex: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export interface MissingToolResult {
|
|
6
13
|
id: string;
|
|
7
14
|
toolName: string;
|
|
@@ -27,6 +34,68 @@ function toolResultIds(content: unknown): Set<string> {
|
|
|
27
34
|
return ids;
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Pi can split a parallel Claude tool batch into several visible turns when a
|
|
39
|
+
* steer is drained after the first tool result. The first assistant message
|
|
40
|
+
* still contains every tool_use, while later sibling results sit behind small
|
|
41
|
+
* duplicate assistant/tool-result pairs. Anthropic history requires every
|
|
42
|
+
* result immediately after the original batch, so copy those already-recorded
|
|
43
|
+
* later results into that first user result message before generic repair adds
|
|
44
|
+
* a false "[no tool result recorded]" placeholder.
|
|
45
|
+
*
|
|
46
|
+
* The later pair stays intact because Pi also recorded its duplicate tool_use;
|
|
47
|
+
* copying is therefore required to keep both assistant messages valid.
|
|
48
|
+
*/
|
|
49
|
+
export function recoverLaterToolResults(
|
|
50
|
+
messages: Array<{ role?: string; content?: unknown }>,
|
|
51
|
+
): RecoveredToolResult[] {
|
|
52
|
+
const recovered: RecoveredToolResult[] = [];
|
|
53
|
+
for (let i = 0; i < messages.length; i++) {
|
|
54
|
+
const assistant = messages[i];
|
|
55
|
+
if (assistant?.role !== "assistant") continue;
|
|
56
|
+
const uses = toolUses(assistant.content);
|
|
57
|
+
if (uses.length === 0) continue;
|
|
58
|
+
|
|
59
|
+
const target = messages[i + 1];
|
|
60
|
+
if (target?.role !== "user") continue;
|
|
61
|
+
const present = toolResultIds(target.content);
|
|
62
|
+
const missing = uses.filter((use) => !present.has(use.id));
|
|
63
|
+
if (missing.length === 0) continue;
|
|
64
|
+
|
|
65
|
+
for (const use of missing) {
|
|
66
|
+
let sourceBlock: Record<string, any> | undefined;
|
|
67
|
+
let sourceUserIndex = -1;
|
|
68
|
+
for (let j = i + 2; j < messages.length; j++) {
|
|
69
|
+
const candidate = messages[j];
|
|
70
|
+
if (candidate?.role !== "user") continue;
|
|
71
|
+
sourceBlock = contentBlocks(candidate.content).find(
|
|
72
|
+
(block) => block.type === "tool_result" && block.tool_use_id === use.id,
|
|
73
|
+
);
|
|
74
|
+
if (sourceBlock) {
|
|
75
|
+
sourceUserIndex = j;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!sourceBlock) continue;
|
|
80
|
+
|
|
81
|
+
const targetBlocks = Array.isArray(target.content)
|
|
82
|
+
? target.content as Array<Record<string, any>>
|
|
83
|
+
: typeof target.content === "string" && target.content
|
|
84
|
+
? [{ type: "text", text: target.content }]
|
|
85
|
+
: [];
|
|
86
|
+
// tool_result blocks must lead the user message; insert the recovered
|
|
87
|
+
// result after the existing tool_results, never after trailing text.
|
|
88
|
+
let insertAt = 0;
|
|
89
|
+
while (insertAt < targetBlocks.length && targetBlocks[insertAt]?.type === "tool_result") insertAt++;
|
|
90
|
+
targetBlocks.splice(insertAt, 0, { ...sourceBlock });
|
|
91
|
+
target.content = targetBlocks;
|
|
92
|
+
present.add(use.id);
|
|
93
|
+
recovered.push({ id: use.id, assistantIndex: i, sourceUserIndex, targetUserIndex: i + 1 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return recovered;
|
|
97
|
+
}
|
|
98
|
+
|
|
30
99
|
/**
|
|
31
100
|
* Anthropic history requires an assistant message containing tool_use blocks to
|
|
32
101
|
* be followed by a user message containing matching tool_result blocks. Return
|