@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
|
@@ -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,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
|
-
//
|
|
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
|
-
*
|
|
194
|
-
*
|
|
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
|
-
|
|
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)
|
|
212
|
-
`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` +
|
|
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:
|
|
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}:
|
|
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
|
|
245
|
-
// only by
|
|
246
|
-
// streamSimple returns, which CC's own persisted session
|
|
247
|
-
//
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
298
|
-
|
|
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,
|
|
615
|
+
deleteSession(previousSessionId!, cwd, claudeDir);
|
|
307
616
|
}
|
|
308
617
|
const session = createSession({
|
|
309
618
|
projectPath: cwd,
|
|
310
|
-
claudeDir
|
|
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({
|
|
318
|
-
|
|
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
|
|
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} ${
|
|
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
|
}
|