@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.
@@ -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 } 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,8 +323,23 @@ 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 for debug logging; importMessages also repairs internally (idempotent).
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).
171
341
  const missingToolResults = findUnpairedToolUses(anthropicMessages);
342
+ if (missingToolResults.length > 0) insertLostToolResultPlaceholders(anthropicMessages, missingToolResults);
172
343
  const repaired = repairToolPairing(anthropicMessages);
173
344
  if (missingToolResults.length > 0) {
174
345
  reportSyntheticToolResultRepair(missingToolResults, {
@@ -187,12 +358,63 @@ function convertAndImportMessages(
187
358
 
188
359
  interface SyncResult {
189
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;
190
377
  }
191
378
 
192
379
  /**
193
- * Ensure the shared session has all messages up to (but not including) the last user message.
194
- * 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.
195
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
+
196
418
  // Read the session file we just wrote and sanity-check it. Warns instead of
197
419
  // throwing — CC may be more tolerant than our checks, so a false positive
198
420
  // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
@@ -202,18 +424,25 @@ function verifyWrittenSession(
202
424
  expectedSessionId: string,
203
425
  expectedRecordCount: number,
204
426
  cwd: string,
427
+ claudeDir: string | undefined,
205
428
  ): void {
206
429
  const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
207
430
  for (const msg of warnings) {
208
431
  debug(`WARNING session verify: ${msg}`);
209
- 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(
210
439
  `Session file issue: ${msg}\n` +
211
- `cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
212
- `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` +
213
442
  (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
214
443
  "warning",
215
444
  );
216
- 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 });
217
446
  }
218
447
  }
219
448
 
@@ -224,7 +453,7 @@ function safeRealpath(p: string): string {
224
453
  // Diagnostic snapshot of where a session file was just written. Catches the
225
454
  // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
226
455
  // from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
227
- function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
456
+ function debugSessionPaths(label: string, cwd: string, jsonlPath: string, claudeDir: string | undefined): void {
228
457
  const realCwd = safeRealpath(cwd);
229
458
  let fileSize: number | null = null;
230
459
  let fileExists = false;
@@ -237,14 +466,21 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void
237
466
  if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
238
467
  debug(`${label}: jsonlPath=${jsonlPath}`);
239
468
  debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
240
- 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)"}`);
241
470
  }
242
471
 
243
472
  // Two semantic paths:
244
- // REUSE — pi's history is in sync with the existing sharedSession (or drifted
245
- // only by the trailing final-assistant message that pi appends after
246
- // streamSimple returns, which CC's own persisted session already has).
247
- // 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.
248
484
  // REBUILD — no session yet, or pi's history has diverged (non-trailing
249
485
  // missed messages, e.g. another provider took a turn). Wipes the existing
250
486
  // session file (if any) and writes a fresh one containing all prior
@@ -270,32 +506,105 @@ export function syncSharedSession(
270
506
  cwd: string,
271
507
  customToolNameToSdk?: Map<string, string>,
272
508
  modelId?: string,
509
+ account?: AccountSessionScope,
273
510
  ): SyncResult {
511
+ const sharedSession = getSharedSession();
274
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
+ }
275
562
 
276
- // REUSE path
277
- if (sharedSession && !sharedSession.needsRebuild) {
278
- const missed = priorMessages.slice(sharedSession.cursor);
279
- const trailingAssistantOnly =
280
- missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
281
- if (missed.length === 0 || trailingAssistantOnly) {
282
- if (trailingAssistantOnly) {
283
- setSharedSession({ ...sharedSession, cursor: priorMessages.length, cwd });
284
- }
285
- debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
286
- debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
287
- 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
+ };
288
593
  }
289
594
  }
290
595
 
291
596
  // REBUILD path
292
597
  if (priorMessages.length === 0) {
293
- debug(`Case 1: clean start, ${messages.length} total messages`);
598
+ debug(`Case 1: clean start, ${messages.length} total messages, account=${accountProfileId ?? "default"}`);
294
599
  debug(`syncResult: path=clean-start`);
295
- return { sessionId: null };
600
+ return { sessionId: null, promptStart: messages.length - 1 };
296
601
  }
297
- const previousSessionId = sharedSession?.sessionId;
298
- 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;
299
608
  // preserveId: rebuild in place (deleteSession + createSession with the
300
609
  // existing UUID), so prompt-cache UUIDs stay stable for log correlation
301
610
  // and for any tools that key off them. Skipped only when there's a
@@ -303,27 +612,38 @@ export function syncSharedSession(
303
612
  const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
304
613
  if (preserveId) {
305
614
  // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
306
- deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
615
+ deleteSession(previousSessionId!, cwd, claudeDir);
307
616
  }
308
617
  const session = createSession({
309
618
  projectPath: cwd,
310
- claudeDir: process.env.CLAUDE_CONFIG_DIR,
619
+ claudeDir,
311
620
  ...(preserveId ? { sessionId: previousSessionId } : {}),
312
621
  ...(modelId ? { model: modelId } : {}),
313
622
  });
314
623
  convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
315
624
  session.save();
316
- verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
317
- setSharedSession({ sessionId: session.sessionId, cursor: priorMessages.length, cwd });
318
- 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) {
319
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)})`);
320
640
  } else if (preserveId) {
321
641
  const missedCount = priorMessages.length - previousCursor;
322
642
  debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
323
643
  } else {
324
- 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`);
325
645
  }
326
- debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
327
- debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
328
- 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 };
329
649
  }