@cjhyy/code-shell-core 0.8.1 → 0.8.3

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.
@@ -142,6 +142,12 @@ function adoptCompatibilityGoalMutation(state) {
142
142
  state.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
143
143
  }
144
144
  }
145
+ // Quick Chat sessions are intentionally process-local. SessionManager
146
+ // instances are created per Engine, so this registry lives at module scope to
147
+ // let a fork created by one Engine be resumed by another without touching
148
+ // disk. The storage root remains part of the key to preserve identity/data-root
149
+ // isolation.
150
+ const processLocalSessionBundles = new Map();
145
151
  const FORK_COPY_EVENT_TYPES = new Set([
146
152
  "message",
147
153
  "tool_use",
@@ -292,6 +298,26 @@ export class SessionManager {
292
298
  mkdirSync(this.sessionsDir, { recursive: true });
293
299
  this.cleanupStaleForkStaging();
294
300
  }
301
+ processLocalKey(sessionId) {
302
+ return `${this.sessionsDir}\0${sessionId}`;
303
+ }
304
+ processLocalBundle(sessionId) {
305
+ return processLocalSessionBundles.get(this.processLocalKey(sessionId));
306
+ }
307
+ storeProcessLocalBundle(bundle) {
308
+ processLocalSessionBundles.set(this.processLocalKey(bundle.state.sessionId), {
309
+ state: structuredClone(bundle.state),
310
+ transcript: bundle.transcript,
311
+ });
312
+ }
313
+ /** Forget a Quick Chat/side-chat bundle immediately; nothing remains on disk. */
314
+ forgetEphemeralSession(sessionId) {
315
+ assertSafeSessionId(sessionId);
316
+ const bundle = this.processLocalBundle(sessionId);
317
+ if (!bundle || !isEphemeralSessionState(bundle.state))
318
+ return false;
319
+ return processLocalSessionBundles.delete(this.processLocalKey(sessionId));
320
+ }
295
321
  cleanupStaleForkStaging() {
296
322
  let removed = 0;
297
323
  let entries;
@@ -336,11 +362,8 @@ export class SessionManager {
336
362
  return next;
337
363
  }
338
364
  /**
339
- * Create a new on-disk session. If `explicitSessionId` is passed, use
340
- * it verbatim (ChatSessionManager-driven hosts choose a logical sid like
341
- * "tui-main" and expect us to honor it). Otherwise generate one with
342
- * nanoid. Either way the on-disk directory is materialized and the
343
- * state.json + transcript.jsonl files are written before return.
365
+ * Create a session. `qchat-` sessions stay process-local; ordinary sessions
366
+ * materialize state.json + transcript.jsonl before return.
344
367
  */
345
368
  create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
346
369
  // External callers may pass any string; nanoid output is trusted. Either
@@ -349,16 +372,6 @@ export class SessionManager {
349
372
  if (explicitSessionId !== undefined)
350
373
  assertSafeSessionId(explicitSessionId);
351
374
  const sessionId = explicitSessionId ?? nanoid(16);
352
- const sessionDir = join(this.sessionsDir, sessionId);
353
- try {
354
- mkdirSync(sessionDir);
355
- }
356
- catch (err) {
357
- if (err.code === "EEXIST") {
358
- throw new SessionError(`Session already exists: ${sessionId}`);
359
- }
360
- throw err;
361
- }
362
375
  const state = {
363
376
  sessionId,
364
377
  kind,
@@ -383,6 +396,33 @@ export class SessionManager {
383
396
  ...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
384
397
  ...(origin ? { origin } : {}),
385
398
  };
399
+ if (isEphemeralSessionState(state)) {
400
+ if (this.processLocalBundle(sessionId)) {
401
+ throw new SessionError(`Session already exists: ${sessionId}`);
402
+ }
403
+ const transcript = Transcript.inMemory(sessionId);
404
+ transcript.append("session_meta", {
405
+ sessionId,
406
+ cwd,
407
+ model,
408
+ provider,
409
+ startedAt: state.startedAt,
410
+ kind,
411
+ });
412
+ const bundle = { state, transcript };
413
+ this.storeProcessLocalBundle(bundle);
414
+ return bundle;
415
+ }
416
+ const sessionDir = join(this.sessionsDir, sessionId);
417
+ try {
418
+ mkdirSync(sessionDir);
419
+ }
420
+ catch (err) {
421
+ if (err.code === "EEXIST") {
422
+ throw new SessionError(`Session already exists: ${sessionId}`);
423
+ }
424
+ throw err;
425
+ }
386
426
  // Atomic write (tmp+rename) like saveState, so a crash during this one-time
387
427
  // create can't leave a torn state.json that resume() then fails to parse.
388
428
  const stateTarget = join(sessionDir, "state.json");
@@ -400,11 +440,7 @@ export class SessionManager {
400
440
  });
401
441
  return { state, transcript };
402
442
  }
403
- /**
404
- * Whether a session directory exists on disk. Used by ChatSession-driven
405
- * cold starts to decide between resume vs create-with-explicit-sid
406
- * without catching SessionError.
407
- */
443
+ /** Whether a persisted or process-local session exists. */
408
444
  exists(sessionId) {
409
445
  // exists() is a probe — callers use it to decide between resume and
410
446
  // create-with-explicit-sid. Treat an invalid id as "not present"
@@ -415,6 +451,12 @@ export class SessionManager {
415
451
  catch {
416
452
  return false;
417
453
  }
454
+ if (this.processLocalBundle(sessionId))
455
+ return true;
456
+ // Never resurrect a legacy Quick Chat directory after the process-local
457
+ // record has expired.
458
+ if (sessionId.startsWith("qchat-"))
459
+ return false;
418
460
  return existsSync(join(this.sessionsDir, sessionId));
419
461
  }
420
462
  /**
@@ -430,6 +472,11 @@ export class SessionManager {
430
472
  catch {
431
473
  return undefined;
432
474
  }
475
+ const processLocal = this.processLocalBundle(sessionId);
476
+ if (processLocal)
477
+ return sessionMainRoot(processLocal.state);
478
+ if (sessionId.startsWith("qchat-"))
479
+ return undefined;
433
480
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
434
481
  if (!existsSync(stateFile))
435
482
  return undefined;
@@ -449,6 +496,11 @@ export class SessionManager {
449
496
  catch {
450
497
  return undefined;
451
498
  }
499
+ const processLocal = this.processLocalBundle(sessionId);
500
+ if (processLocal)
501
+ return normalizedSessionKind(processLocal.state.kind);
502
+ if (sessionId.startsWith("qchat-"))
503
+ return undefined;
452
504
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
453
505
  if (!existsSync(stateFile))
454
506
  return undefined;
@@ -464,6 +516,13 @@ export class SessionManager {
464
516
  readSessionWorkspaceProfile(sessionId) {
465
517
  try {
466
518
  assertSafeSessionId(sessionId);
519
+ const processLocal = this.processLocalBundle(sessionId);
520
+ if (processLocal) {
521
+ const profile = processLocal.state.workspaceProfile;
522
+ return typeof profile === "string" && profile ? profile : undefined;
523
+ }
524
+ if (sessionId.startsWith("qchat-"))
525
+ return undefined;
467
526
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
468
527
  if (!existsSync(stateFile))
469
528
  return undefined;
@@ -534,7 +593,7 @@ export class SessionManager {
534
593
  readCwd(sessionId) {
535
594
  return this.readSessionMainRoot(sessionId);
536
595
  }
537
- /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
596
+ /** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
538
597
  readParentSessionId(sessionId) {
539
598
  try {
540
599
  assertSafeSessionId(sessionId);
@@ -542,6 +601,13 @@ export class SessionManager {
542
601
  catch {
543
602
  return undefined;
544
603
  }
604
+ const processLocal = this.processLocalBundle(sessionId);
605
+ if (processLocal) {
606
+ const parent = processLocal.state.parentSessionId;
607
+ return parent === null || typeof parent === "string" ? parent : undefined;
608
+ }
609
+ if (sessionId.startsWith("qchat-"))
610
+ return undefined;
545
611
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
546
612
  if (!existsSync(stateFile))
547
613
  return undefined;
@@ -556,7 +622,7 @@ export class SessionManager {
556
622
  }
557
623
  }
558
624
  /**
559
- * Disk-only workspace pointer reader. Legacy sessions written before
625
+ * Workspace pointer reader. Legacy sessions written before
560
626
  * `workspace` existed are treated as main-workspace sessions rooted at
561
627
  * `state.cwd`; the read is intentionally non-mutating.
562
628
  */
@@ -567,6 +633,16 @@ export class SessionManager {
567
633
  catch {
568
634
  return undefined;
569
635
  }
636
+ const processLocal = this.processLocalBundle(sessionId);
637
+ if (processLocal) {
638
+ const state = processLocal.state;
639
+ if (isSessionWorkspace(state.workspace))
640
+ return structuredClone(state.workspace);
641
+ const mainRoot = sessionMainRoot(state);
642
+ return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
643
+ }
644
+ if (sessionId.startsWith("qchat-"))
645
+ return undefined;
570
646
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
571
647
  if (!existsSync(stateFile))
572
648
  return undefined;
@@ -601,6 +677,11 @@ export class SessionManager {
601
677
  catch {
602
678
  return undefined;
603
679
  }
680
+ const processLocal = this.processLocalBundle(sessionId);
681
+ if (processLocal)
682
+ return processLocal.state.archivedAt;
683
+ if (sessionId.startsWith("qchat-"))
684
+ return undefined;
604
685
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
605
686
  if (!existsSync(stateFile))
606
687
  return undefined;
@@ -618,6 +699,19 @@ export class SessionManager {
618
699
  }
619
700
  recordWorkspaceHandoff(sessionId, from, to) {
620
701
  assertSafeSessionId(sessionId);
702
+ const processLocal = this.processLocalBundle(sessionId);
703
+ if (processLocal) {
704
+ processLocal.transcript.append("session_meta", {
705
+ sessionId,
706
+ cwd: to.root,
707
+ workspace: to,
708
+ handoffFrom: from?.root,
709
+ handoffAt: Date.now(),
710
+ });
711
+ return;
712
+ }
713
+ if (sessionId.startsWith("qchat-"))
714
+ return;
621
715
  const transcriptFile = join(this.sessionsDir, sessionId, "transcript.jsonl");
622
716
  if (!existsSync(transcriptFile))
623
717
  return;
@@ -645,15 +739,13 @@ export class SessionManager {
645
739
  */
646
740
  async resolveSessionWorkspaceForResume(sessionId) {
647
741
  assertSafeSessionId(sessionId);
648
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
649
- if (!existsSync(stateFile)) {
650
- throw new SessionError(`Session state file not found: ${sessionId}`);
651
- }
652
742
  let state;
653
743
  try {
654
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
744
+ state = this.readPersistedState(sessionId);
655
745
  }
656
746
  catch (err) {
747
+ if (err instanceof SessionError)
748
+ throw err;
657
749
  throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
658
750
  }
659
751
  const mainRoot = sessionMainRoot(state);
@@ -731,6 +823,26 @@ export class SessionManager {
731
823
  catch {
732
824
  return undefined;
733
825
  }
826
+ const processLocal = this.processLocalBundle(sessionId);
827
+ if (processLocal) {
828
+ try {
829
+ const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
830
+ const lifecycle = state.goalLifecycle;
831
+ if (!lifecycle || lifecycle.phase === "terminal")
832
+ return undefined;
833
+ const goal = goalConfigFromLifecycle(lifecycle);
834
+ return {
835
+ ...goal,
836
+ goalId: goal.goalId ?? deriveLegacyGoalId(sessionId, goal),
837
+ revision: goal.revision ?? 1,
838
+ };
839
+ }
840
+ catch {
841
+ return undefined;
842
+ }
843
+ }
844
+ if (sessionId.startsWith("qchat-"))
845
+ return undefined;
734
846
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
735
847
  if (!existsSync(stateFile))
736
848
  return undefined;
@@ -873,6 +985,18 @@ export class SessionManager {
873
985
  }
874
986
  resume(sessionId) {
875
987
  assertSafeSessionId(sessionId);
988
+ const processLocal = this.processLocalBundle(sessionId);
989
+ if (processLocal) {
990
+ const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
991
+ state.kind = normalizedSessionKind(state.kind);
992
+ state.status = "active";
993
+ delete state.lastCompletionKind;
994
+ Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
995
+ return { state, transcript: processLocal.transcript };
996
+ }
997
+ if (sessionId.startsWith("qchat-")) {
998
+ throw new SessionError(`Session not found: ${sessionId}`);
999
+ }
876
1000
  const sessionDir = join(this.sessionsDir, sessionId);
877
1001
  if (!existsSync(sessionDir)) {
878
1002
  throw new SessionError(`Session not found: ${sessionId}`);
@@ -1166,6 +1290,34 @@ export class SessionManager {
1166
1290
  (currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
1167
1291
  return { ok: false, reason: "generation_conflict" };
1168
1292
  }
1293
+ const processLocal = this.processLocalBundle(state.sessionId);
1294
+ if (processLocal) {
1295
+ const persisted = structuredClone(processLocal.state);
1296
+ const incomingKind = normalizedSessionKind(state.kind);
1297
+ const persistedKind = normalizedSessionKind(persisted.kind);
1298
+ if (incomingKind !== persistedKind)
1299
+ return { ok: false, reason: "kind_conflict" };
1300
+ state.kind = persistedKind;
1301
+ const persistedRevision = persisted.stateRevision;
1302
+ const incomingRevision = state.stateRevision;
1303
+ const revisionsMatch = (persistedRevision === undefined && incomingRevision === undefined) ||
1304
+ (typeof persistedRevision === "number" && incomingRevision === persistedRevision);
1305
+ if (!revisionsMatch)
1306
+ return { ok: false, reason: "revision_conflict" };
1307
+ if (persisted.title !== undefined && !("title" in state))
1308
+ state.title = persisted.title;
1309
+ state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
1310
+ const next = hydrateGoalLifecycle(structuredClone(stateForPersistence(state)));
1311
+ this.rebaseLiveState(processLocal.state, next);
1312
+ if (state !== processLocal.state)
1313
+ this.rebaseLiveState(state, next);
1314
+ return { ok: true };
1315
+ }
1316
+ // A closed process-local session must stay gone even if a late writer
1317
+ // races the close fence.
1318
+ if (isEphemeralSessionState(state)) {
1319
+ return { ok: false, reason: "revision_conflict" };
1320
+ }
1169
1321
  const sessionDir = join(this.sessionsDir, state.sessionId);
1170
1322
  mkdirSync(sessionDir, { recursive: true });
1171
1323
  const target = join(sessionDir, "state.json");
@@ -1272,6 +1424,12 @@ export class SessionManager {
1272
1424
  }
1273
1425
  }
1274
1426
  readPersistedState(sessionId) {
1427
+ const processLocal = this.processLocalBundle(sessionId);
1428
+ if (processLocal)
1429
+ return hydrateGoalLifecycle(structuredClone(processLocal.state));
1430
+ if (sessionId.startsWith("qchat-")) {
1431
+ throw new SessionError(`Session state file not found: ${sessionId}`);
1432
+ }
1275
1433
  const stateFile = join(this.sessionsDir, sessionId, "state.json");
1276
1434
  if (!existsSync(stateFile)) {
1277
1435
  throw new SessionError(`Session state file not found: ${sessionId}`);
@@ -1322,6 +1480,13 @@ export class SessionManager {
1322
1480
  /** Freeze and validate an inclusive source range before any model call. */
1323
1481
  selectContextPackage(sourceSessionId, range) {
1324
1482
  assertSafeSessionId(sourceSessionId);
1483
+ const processLocal = this.processLocalBundle(sourceSessionId);
1484
+ if (processLocal) {
1485
+ return Transcript.selectContextRange(processLocal.transcript.getEvents(), range);
1486
+ }
1487
+ if (sourceSessionId.startsWith("qchat-")) {
1488
+ throw new SessionError(`Session not found: ${sourceSessionId}`);
1489
+ }
1325
1490
  const transcriptFile = join(this.sessionsDir, sourceSessionId, "transcript.jsonl");
1326
1491
  const stateFile = join(this.sessionsDir, sourceSessionId, "state.json");
1327
1492
  if (!existsSync(stateFile))
@@ -1377,6 +1542,13 @@ export class SessionManager {
1377
1542
  return { bundle, lineage, copiedEventCount: 0 };
1378
1543
  }
1379
1544
  readForkSnapshot(sourceSessionId, throughEventId, snapshotMode) {
1545
+ const processLocal = this.processLocalBundle(sourceSessionId);
1546
+ if (processLocal) {
1547
+ return this.freezeForkSnapshot(sourceSessionId, structuredClone(processLocal.state), processLocal.transcript.getEvents(), throughEventId, snapshotMode);
1548
+ }
1549
+ if (sourceSessionId.startsWith("qchat-")) {
1550
+ throw new SessionError(`Session not found: ${sourceSessionId}`);
1551
+ }
1380
1552
  const sessionDir = join(this.sessionsDir, sourceSessionId);
1381
1553
  const stateFile = join(sessionDir, "state.json");
1382
1554
  const transcriptFile = join(sessionDir, "transcript.jsonl");
@@ -1393,7 +1565,10 @@ export class SessionManager {
1393
1565
  if (parsed.malformedLineCount > 0) {
1394
1566
  throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
1395
1567
  }
1396
- const sourceEvents = structuredClone(parsed.events);
1568
+ return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
1569
+ }
1570
+ freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
1571
+ const sourceEvents = structuredClone([...events]);
1397
1572
  let frozen = sourceEvents;
1398
1573
  const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
1399
1574
  if (snapshotMode === "completed" && effectiveCursor === undefined) {
@@ -1436,6 +1611,17 @@ export class SessionManager {
1436
1611
  }
1437
1612
  publishSessionAtomically(targetSessionId, state, events) {
1438
1613
  const targetDir = join(this.sessionsDir, targetSessionId);
1614
+ if (isEphemeralSessionState(state)) {
1615
+ if (this.processLocalBundle(targetSessionId) || existsSync(targetDir)) {
1616
+ throw new SessionError(`Session already exists: ${targetSessionId}`);
1617
+ }
1618
+ const bundle = {
1619
+ state: hydrateGoalLifecycle(structuredClone(state)),
1620
+ transcript: Transcript.fromMemoryEvents(targetSessionId, events),
1621
+ };
1622
+ this.storeProcessLocalBundle(bundle);
1623
+ return bundle;
1624
+ }
1439
1625
  if (existsSync(targetDir))
1440
1626
  throw new SessionError(`Session already exists: ${targetSessionId}`);
1441
1627
  const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
@@ -48,10 +48,18 @@ export declare class Transcript {
48
48
  private filePath;
49
49
  private currentTurn;
50
50
  private readonly writer;
51
+ private readonly persistent;
51
52
  private dirty;
52
53
  private lastFlushFailure;
53
54
  getFilePath(): string;
54
- constructor(filePath: string, writer?: TranscriptWriter);
55
+ constructor(filePath: string, writer?: TranscriptWriter, options?: {
56
+ persistent?: boolean;
57
+ });
58
+ /** A process-local transcript that never creates or appends a file. */
59
+ static inMemory(label: string): Transcript;
60
+ /** Rehydrate a process-local fork without serializing its copied history. */
61
+ static fromMemoryEvents(label: string, events: readonly TranscriptEvent[]): Transcript;
62
+ isPersistent(): boolean;
55
63
  append(type: TranscriptEventType, data: Record<string, unknown>): TranscriptEvent;
56
64
  /**
57
65
  * Append a chat message to the transcript.
@@ -116,9 +124,16 @@ export declare class Transcript {
116
124
  private errorErrno;
117
125
  private errorMessage;
118
126
  /**
119
- * Repair tool_result pairing issues:
120
- * - Orphaned tool_results (no matching tool_use) get removed
121
- * - Missing tool_results (tool_use with no result) get synthetic error results
127
+ * Normalize tool_result pairing in this detached in-memory snapshot:
128
+ * - orphaned results are removed;
129
+ * - duplicate results collapse to one, preferring a real result over the
130
+ * legacy synthetic interrupted placeholder;
131
+ * - missing results remain missing. The run-resume boundary patches those
132
+ * in its request-local Message[] after it has established ownership.
133
+ *
134
+ * This method must never append to the JSONL file. loadFromFile is used by
135
+ * read-only/background consumers while another run may be waiting for tool
136
+ * approval; persisting a synthetic result there races the real executor.
122
137
  */
123
138
  repairToolResultPairs(): void;
124
139
  static readEvents(filePath: string): ParsedEvents;
@@ -133,5 +148,6 @@ export declare class Transcript {
133
148
  */
134
149
  static selectContextRange(events: readonly TranscriptEvent[], range: ContextEventRange): SelectedContextRange;
135
150
  static loadFromFile(filePath: string): Transcript;
151
+ private loadEvents;
136
152
  }
137
153
  export {};
@@ -13,24 +13,84 @@ const CONTEXT_EVENT_TYPES = new Set([
13
13
  "summary",
14
14
  "context_transfer",
15
15
  ]);
16
+ const INTERRUPTED_TOOL_RESULT_ERROR = "[Tool result missing due to interrupted session]";
17
+ function isSyntheticInterruptedToolResult(event) {
18
+ return (event.type === "tool_result" &&
19
+ event.data.toolName === "unknown" &&
20
+ event.data.error === INTERRUPTED_TOOL_RESULT_ERROR);
21
+ }
22
+ /**
23
+ * Choose at most one result for every declared tool call. A real late result
24
+ * wins over the legacy synthetic "interrupted" placeholder that an older
25
+ * reader could persist while the tool was merely waiting for approval.
26
+ */
27
+ function preferredToolResults(events) {
28
+ const toolUseIds = new Set();
29
+ for (const event of events) {
30
+ if (event.type === "tool_use" && typeof event.data.toolCallId === "string") {
31
+ toolUseIds.add(event.data.toolCallId);
32
+ }
33
+ if (event.type === "message" &&
34
+ event.data.role === "assistant" &&
35
+ Array.isArray(event.data.content)) {
36
+ for (const block of event.data.content) {
37
+ if (block.type === "tool_use" && typeof block.id === "string") {
38
+ toolUseIds.add(block.id);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ const preferred = new Map();
44
+ for (const event of events) {
45
+ if (event.type !== "tool_result")
46
+ continue;
47
+ const toolCallId = event.data.toolCallId;
48
+ if (typeof toolCallId !== "string" || !toolUseIds.has(toolCallId))
49
+ continue;
50
+ const current = preferred.get(toolCallId);
51
+ if (!current ||
52
+ isSyntheticInterruptedToolResult(current) ||
53
+ !isSyntheticInterruptedToolResult(event)) {
54
+ preferred.set(toolCallId, event);
55
+ }
56
+ }
57
+ return preferred;
58
+ }
16
59
  export class Transcript {
17
60
  events = [];
18
61
  filePath;
19
62
  currentTurn = 0;
20
63
  writer;
64
+ persistent;
21
65
  dirty = false;
22
66
  lastFlushFailure;
23
67
  getFilePath() {
24
68
  return this.filePath;
25
69
  }
26
- constructor(filePath, writer = appendFileSync) {
70
+ constructor(filePath, writer = appendFileSync, options = {}) {
27
71
  this.filePath = filePath;
28
72
  this.writer = writer;
73
+ this.persistent = options.persistent !== false;
74
+ if (!this.persistent)
75
+ return;
29
76
  mkdirSync(dirname(filePath), { recursive: true });
30
77
  if (!existsSync(filePath)) {
31
78
  writeFileSync(filePath, "", "utf-8");
32
79
  }
33
80
  }
81
+ /** A process-local transcript that never creates or appends a file. */
82
+ static inMemory(label) {
83
+ return new Transcript(`<memory:${label}>`, () => undefined, { persistent: false });
84
+ }
85
+ /** Rehydrate a process-local fork without serializing its copied history. */
86
+ static fromMemoryEvents(label, events) {
87
+ const transcript = Transcript.inMemory(label);
88
+ transcript.loadEvents(events);
89
+ return transcript;
90
+ }
91
+ isPersistent() {
92
+ return this.persistent;
93
+ }
34
94
  append(type, data) {
35
95
  const event = {
36
96
  id: nanoid(12),
@@ -161,6 +221,7 @@ export class Transcript {
161
221
  */
162
222
  toMessages() {
163
223
  const messages = [];
224
+ const selectedToolResults = preferredToolResults(this.events);
164
225
  for (const event of this.events) {
165
226
  switch (event.type) {
166
227
  case "message": {
@@ -174,6 +235,11 @@ export class Transcript {
174
235
  break;
175
236
  }
176
237
  case "tool_result": {
238
+ const eventToolCallId = event.data.toolCallId;
239
+ if (typeof eventToolCallId !== "string" ||
240
+ selectedToolResults.get(eventToolCallId) !== event) {
241
+ break;
242
+ }
177
243
  const { toolCallId, result, error, contentBlocks } = event.data;
178
244
  // Find if there's already a user message with tool_results to append to
179
245
  const lastMsg = messages[messages.length - 1];
@@ -243,6 +309,8 @@ export class Transcript {
243
309
  event.data.clientMessageId === clientMessageId);
244
310
  }
245
311
  flush(event) {
312
+ if (!this.persistent)
313
+ return true;
246
314
  const line = JSON.stringify(event) + "\n";
247
315
  try {
248
316
  this.writer(this.filePath, line, "utf-8");
@@ -290,39 +358,24 @@ export class Transcript {
290
358
  return error instanceof Error ? error.message : String(error);
291
359
  }
292
360
  /**
293
- * Repair tool_result pairing issues:
294
- * - Orphaned tool_results (no matching tool_use) get removed
295
- * - Missing tool_results (tool_use with no result) get synthetic error results
361
+ * Normalize tool_result pairing in this detached in-memory snapshot:
362
+ * - orphaned results are removed;
363
+ * - duplicate results collapse to one, preferring a real result over the
364
+ * legacy synthetic interrupted placeholder;
365
+ * - missing results remain missing. The run-resume boundary patches those
366
+ * in its request-local Message[] after it has established ownership.
367
+ *
368
+ * This method must never append to the JSONL file. loadFromFile is used by
369
+ * read-only/background consumers while another run may be waiting for tool
370
+ * approval; persisting a synthetic result there races the real executor.
296
371
  */
297
372
  repairToolResultPairs() {
298
- const toolUseIds = new Set();
299
- const toolResultIds = new Set();
300
- for (const event of this.events) {
301
- if (event.type === "tool_use") {
302
- toolUseIds.add(event.data.toolCallId);
303
- }
304
- else if (event.type === "tool_result") {
305
- toolResultIds.add(event.data.toolCallId);
306
- }
307
- }
308
- // Find tool_use events without matching tool_result
309
- for (const id of toolUseIds) {
310
- if (!toolResultIds.has(id)) {
311
- // Synthesize an error result
312
- this.append("tool_result", {
313
- toolCallId: id,
314
- toolName: "unknown",
315
- error: "[Tool result missing due to interrupted session]",
316
- });
317
- }
318
- }
319
- // Remove orphaned tool_results (result without matching use)
373
+ const selectedToolResults = preferredToolResults(this.events);
320
374
  this.events = this.events.filter((event) => {
321
- if (event.type === "tool_result") {
322
- const id = event.data.toolCallId;
323
- return toolUseIds.has(id);
324
- }
325
- return true;
375
+ if (event.type !== "tool_result")
376
+ return true;
377
+ const toolCallId = event.data.toolCallId;
378
+ return typeof toolCallId === "string" && selectedToolResults.get(toolCallId) === event;
326
379
  });
327
380
  }
328
381
  static readEvents(filePath) {
@@ -436,22 +489,30 @@ export class Transcript {
436
489
  return transcript;
437
490
  const content = readFileSync(filePath, "utf-8");
438
491
  const lines = content.split("\n").filter((l) => l.trim());
492
+ const events = [];
439
493
  for (const line of lines) {
440
494
  try {
441
- const event = JSON.parse(line);
442
- transcript.events.push(event);
443
- if (event.type === "turn_boundary") {
444
- transcript.currentTurn = event.data.turnNumber ?? transcript.currentTurn + 1;
445
- }
495
+ events.push(JSON.parse(line));
446
496
  }
447
497
  catch {
448
498
  // Skip malformed lines
449
499
  }
450
500
  }
501
+ transcript.loadEvents(events);
451
502
  // Repair pairing on load
452
503
  transcript.repairToolResultPairs();
453
504
  return transcript;
454
505
  }
506
+ loadEvents(events) {
507
+ this.events = structuredClone([...events]);
508
+ this.currentTurn = 0;
509
+ for (const event of this.events) {
510
+ if (event.type === "turn_boundary") {
511
+ this.currentTurn =
512
+ typeof event.data.turnNumber === "number" ? event.data.turnNumber : this.currentTurn + 1;
513
+ }
514
+ }
515
+ }
455
516
  }
456
517
  function isEngineResultReceipt(value) {
457
518
  if (!value || typeof value !== "object" || Array.isArray(value))