@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.
@@ -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, piUI, reportSyntheticToolResultRepair, setSharedSession, sharedSession, type SessionState } from "./bridge-state.js";
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 { findUnpairedToolUses, insertLostToolResultPlaceholders } from "./tool-pairing-audit.js";
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
- interface PersistedBridgeSessionState extends SessionState {
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: process.env.CLAUDE_CONFIG_DIR });
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
- if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
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({ sessionId: persisted.sessionId, cursor, cwd: persisted.cwd });
119
- debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
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
- const snapshot = { ...sharedSession };
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(ctxLike.sessionManager);
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 (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
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
- debug("persistSharedSession failed:", error);
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
- // Pre-repair: pair every orphaned tool_use with an EXPLICIT bridge-authored
171
- // error result before cc-session-io's repairToolPairing can backfill its bare
172
- // "[no tool result recorded]" placeholder which the model reads as tool
173
- // output and silently reasons on. Ours is is_error and says what to do.
174
- // repairToolPairing still runs after (idempotent; finds nothing left).
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
- * Ensure the shared session has all messages up to (but not including) the last user message.
199
- * Returns session ID to resume from, or null if no resume needed.
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
- piUI?.notify(
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)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
217
- `Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
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: process.env.CLAUDE_CONFIG_DIR ?? null });
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}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
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 (or drifted
250
- // only by the trailing final-assistant message that pi appends after
251
- // streamSimple returns, which CC's own persisted session already has).
252
- // Returns the existing sessionId. Keeps CC's prompt cache warm.
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
- if (sharedSession && !sharedSession.needsRebuild) {
283
- const missed = priorMessages.slice(sharedSession.cursor);
284
- const trailingAssistantOnly =
285
- missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
286
- if (missed.length === 0 || trailingAssistantOnly) {
287
- if (trailingAssistantOnly) {
288
- setSharedSession({ ...sharedSession, cursor: priorMessages.length, cwd });
289
- }
290
- debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
291
- debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
292
- return { sessionId: sharedSession.sessionId };
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 previousSessionId = sharedSession?.sessionId;
303
- const previousCursor = sharedSession?.cursor ?? 0;
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, process.env.CLAUDE_CONFIG_DIR);
615
+ deleteSession(previousSessionId!, cwd, claudeDir);
312
616
  }
313
617
  const session = createSession({
314
618
  projectPath: cwd,
315
- claudeDir: process.env.CLAUDE_CONFIG_DIR,
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({ sessionId: session.sessionId, cursor: priorMessages.length, cwd });
323
- if (previousSessionId === undefined) {
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.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
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} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
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