@cjhyy/code-shell-core 0.7.0 → 0.7.1

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.
Files changed (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * Session lifecycle manager.
3
3
  */
4
- import type { SessionState, SessionWorkspace } from "../types.js";
4
+ import type { SessionForkLineage, SessionState, SessionWorkspace, TranscriptEvent } from "../types.js";
5
5
  import { Transcript } from "./transcript.js";
6
6
  export interface SessionBundle {
7
7
  state: SessionState;
8
8
  transcript: Transcript;
9
9
  }
10
+ export interface ForkSessionOptions {
11
+ targetSessionId?: string;
12
+ /** Inclusive source event cursor; omitted means the frozen transcript tail. */
13
+ throughEventId?: string;
14
+ }
15
+ export interface ForkSessionResult {
16
+ bundle: SessionBundle;
17
+ lineage: SessionForkLineage;
18
+ copiedEventCount: number;
19
+ }
10
20
  export type SessionWorkspaceResumeResolution = {
11
21
  ok: true;
12
22
  cwd: string;
@@ -44,7 +54,13 @@ export declare function assertSafeSessionId(sessionId: unknown): asserts session
44
54
  export declare function codeShellHome(): string;
45
55
  export declare class SessionManager {
46
56
  private readonly sessionsDir;
57
+ private readonly registeredCloseEpochs;
47
58
  constructor(storageDir?: string);
59
+ private cleanupStaleForkStaging;
60
+ /** Bind one Engine/session pair to the current close epoch without advancing it. */
61
+ registerSessionGeneration(sessionId: string): number;
62
+ /** Advance the close epoch once before close waits for the old run to settle. */
63
+ incrementSessionGeneration(sessionId: string): number;
48
64
  /**
49
65
  * Create a new on-disk session. If `explicitSessionId` is passed, use
50
66
  * it verbatim (ChatSessionManager-driven hosts choose a logical sid like
@@ -121,15 +137,28 @@ export declare class SessionManager {
121
137
  */
122
138
  clearActiveGoal(sessionId: string): boolean;
123
139
  resume(sessionId: string): SessionBundle;
124
- saveState(state: SessionState): void;
125
140
  /**
126
- * Fork a session from a specific point.
127
- * Creates a new session that shares events up to the given turn,
128
- * then diverges independently.
141
+ * Merge a field-level state update into the latest persisted snapshot.
142
+ *
143
+ * Unlike saveState(), callers do not supply a potentially stale whole-state
144
+ * object. The read, shallow merge, and atomic write are synchronous, so no
145
+ * other callback on this process's event loop can interleave between them.
146
+ * This is deliberately not a session-level lock. Engine.run rejects re-entry
147
+ * on one Engine (G6), and the generation check in saveState rejects an older
148
+ * registered Engine in this process. Workers and separate processes remain
149
+ * an unresolved finding requiring locking/CAS.
129
150
  */
130
- fork(sourceSessionId: string, forkAtTurn?: number): SessionBundle;
151
+ updateSessionState(sessionId: string, partial: Readonly<Partial<Omit<SessionState, "sessionId">>>): void;
152
+ saveState(state: SessionState, generation?: number): boolean;
153
+ private generationKey;
154
+ /** Create an independent, top-level session from a frozen event cursor. */
155
+ fork(sourceSessionId: string, options?: ForkSessionOptions): ForkSessionResult;
156
+ private readForkSnapshot;
157
+ private publishSessionAtomically;
131
158
  list(limit?: number): SessionListEntry[];
132
159
  }
160
+ export declare function buildForkState(source: SessionState, targetSessionId: string, lineage: SessionForkLineage, startedAt?: number): SessionState;
161
+ export declare function buildForkTranscript(sourceEvents: readonly TranscriptEvent[], state: SessionState): TranscriptEvent[];
133
162
  export type SessionListEntry = SessionState & {
134
163
  preview?: string;
135
164
  /** Last activity time — transcript mtime, falling back to startedAt. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Session lifecycle manager.
3
3
  */
4
- import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { nanoid } from "nanoid";
@@ -10,6 +10,31 @@ import { SessionError } from "../exceptions.js";
10
10
  import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
11
11
  import { isSameGoalInstance } from "../engine/goal.js";
12
12
  import { branchExists, isGitWorktreeRoot } from "../git/worktree.js";
13
+ // Shared close epochs for SessionManager instances in this process. Concurrent
14
+ // Engines bind the same epoch; only close advances it. This intentionally does
15
+ // not claim cross-process/Worker protection.
16
+ const currentSessionCloseEpochs = new Map();
17
+ const FORK_COPY_EVENT_TYPES = new Set([
18
+ "message",
19
+ "tool_use",
20
+ "tool_result",
21
+ "summary",
22
+ "content_replace",
23
+ "subagent",
24
+ "external_file_changes",
25
+ "goal_progress",
26
+ "turn_boundary",
27
+ "turn_stopped",
28
+ "error",
29
+ ]);
30
+ const FORK_SKIP_EVENT_TYPES = new Set([
31
+ "session_meta",
32
+ "file_history",
33
+ "plan_operation",
34
+ ]);
35
+ const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
36
+ const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
37
+ const FORK_STAGING_CLEANUP_LIMIT = 32;
13
38
  /**
14
39
  * Validate a session ID before it is joined into a filesystem path.
15
40
  *
@@ -97,9 +122,54 @@ async function validateResumeWorktreeRoot(root) {
97
122
  }
98
123
  export class SessionManager {
99
124
  sessionsDir;
125
+ registeredCloseEpochs = new Map();
100
126
  constructor(storageDir) {
101
127
  this.sessionsDir = storageDir ?? join(codeShellHome(), "sessions");
102
128
  mkdirSync(this.sessionsDir, { recursive: true });
129
+ this.cleanupStaleForkStaging();
130
+ }
131
+ cleanupStaleForkStaging() {
132
+ let removed = 0;
133
+ let entries;
134
+ try {
135
+ entries = readdirSync(this.sessionsDir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ for (const entry of entries) {
141
+ if (removed >= FORK_STAGING_CLEANUP_LIMIT)
142
+ return;
143
+ if (!entry.isDirectory() || !FORK_STAGING_NAME.test(entry.name))
144
+ continue;
145
+ const path = join(this.sessionsDir, entry.name);
146
+ try {
147
+ if (Date.now() - statSync(path).mtimeMs <= FORK_STAGING_MAX_AGE_MS)
148
+ continue;
149
+ rmSync(path, { recursive: true, force: true });
150
+ removed++;
151
+ }
152
+ catch {
153
+ // Startup cleanup is best effort; a live/unreadable staging directory
154
+ // must never prevent the SessionManager from serving normal sessions.
155
+ }
156
+ }
157
+ }
158
+ /** Bind one Engine/session pair to the current close epoch without advancing it. */
159
+ registerSessionGeneration(sessionId) {
160
+ assertSafeSessionId(sessionId);
161
+ const key = this.generationKey(sessionId);
162
+ const generation = currentSessionCloseEpochs.get(key) ?? 0;
163
+ this.registeredCloseEpochs.set(sessionId, generation);
164
+ return generation;
165
+ }
166
+ /** Advance the close epoch once before close waits for the old run to settle. */
167
+ incrementSessionGeneration(sessionId) {
168
+ assertSafeSessionId(sessionId);
169
+ const key = this.generationKey(sessionId);
170
+ const next = (currentSessionCloseEpochs.get(key) ?? 0) + 1;
171
+ currentSessionCloseEpochs.set(key, next);
172
+ return next;
103
173
  }
104
174
  /**
105
175
  * Create a new on-disk session. If `explicitSessionId` is passed, use
@@ -440,14 +510,52 @@ export class SessionManager {
440
510
  Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
441
511
  return { state, transcript };
442
512
  }
443
- saveState(state) {
513
+ /**
514
+ * Merge a field-level state update into the latest persisted snapshot.
515
+ *
516
+ * Unlike saveState(), callers do not supply a potentially stale whole-state
517
+ * object. The read, shallow merge, and atomic write are synchronous, so no
518
+ * other callback on this process's event loop can interleave between them.
519
+ * This is deliberately not a session-level lock. Engine.run rejects re-entry
520
+ * on one Engine (G6), and the generation check in saveState rejects an older
521
+ * registered Engine in this process. Workers and separate processes remain
522
+ * an unresolved finding requiring locking/CAS.
523
+ */
524
+ updateSessionState(sessionId, partial) {
525
+ assertSafeSessionId(sessionId);
526
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
527
+ if (!existsSync(stateFile)) {
528
+ throw new SessionError(`Session state file not found: ${sessionId}`);
529
+ }
530
+ let state;
531
+ try {
532
+ state = JSON.parse(readFileSync(stateFile, "utf-8"));
533
+ }
534
+ catch (err) {
535
+ throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
536
+ }
537
+ Object.assign(state, partial);
538
+ state.sessionId = sessionId;
539
+ this.saveState(state);
540
+ }
541
+ saveState(state, generation) {
444
542
  // state.sessionId could come from a deserialized state.json that was
445
543
  // tampered with on disk. Validate before joining.
446
544
  assertSafeSessionId(state.sessionId);
545
+ const writerGeneration = generation ?? this.registeredCloseEpochs.get(state.sessionId);
546
+ if (writerGeneration !== undefined &&
547
+ (currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
548
+ // A closing/old Engine may finish asynchronously after its generation was
549
+ // invalidated. Reject explicitly without throwing into an unobserved
550
+ // engine finally path, and most importantly without touching the disk.
551
+ return false;
552
+ }
447
553
  const sessionDir = join(this.sessionsDir, state.sessionId);
448
554
  mkdirSync(sessionDir, { recursive: true });
449
- // Atomic write: stage to .tmp, then rename. Protects against two processes
450
- // clobbering each other's state.json mid-write.
555
+ // Atomic file replacement: stage to .tmp, then rename, preventing a torn
556
+ // state.json. Registered Engines share one epoch until close advances it,
557
+ // so concurrent opens are not fenced from each other. Workers, separate
558
+ // processes, and their stale-writer ordering still require locking/CAS.
451
559
  const target = join(sessionDir, "state.json");
452
560
  // Preserve the newest goal tombstone across whole-state writers. If an old
453
561
  // detached bundle still carries the tombstoned goal, drop it before write;
@@ -463,6 +571,13 @@ export class SessionManager {
463
571
  // behavior and overwrite malformed state with the caller's snapshot.
464
572
  }
465
573
  }
574
+ // A title is generated after the first run and may be merged while the
575
+ // next run still owns a state object loaded before that title existed.
576
+ // Preserve the newly persisted title when such a stale whole-state writer
577
+ // saves later; assigning title = undefined explicitly still clears it.
578
+ if (persisted?.title !== undefined && !("title" in state)) {
579
+ state.title = persisted.title;
580
+ }
466
581
  const terminal = newestGoalTerminal(state.goalTerminal, persisted?.goalTerminal);
467
582
  if (terminal)
468
583
  state.goalTerminal = terminal;
@@ -473,36 +588,110 @@ export class SessionManager {
473
588
  const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
474
589
  writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
475
590
  renameSync(tmp, target);
591
+ return true;
476
592
  }
477
- /**
478
- * Fork a session from a specific point.
479
- * Creates a new session that shares events up to the given turn,
480
- * then diverges independently.
481
- */
482
- fork(sourceSessionId, forkAtTurn) {
483
- const source = this.resume(sourceSessionId);
484
- const events = source.transcript.getEvents();
485
- // Determine fork point
486
- const forkTurn = forkAtTurn ?? source.state.turnCount;
487
- // Create new session
488
- const newBundle = this.create(source.state.cwd, source.state.model, source.state.provider);
489
- newBundle.state.parentSessionId = sourceSessionId;
490
- // Copy events up to the fork point
491
- for (const event of events) {
492
- if (event.type === "turn_boundary" &&
493
- (event.data.turnNumber ?? -1) > forkTurn) {
494
- break;
593
+ generationKey(sessionId) {
594
+ return `${this.sessionsDir}\0${sessionId}`;
595
+ }
596
+ /** Create an independent, top-level session from a frozen event cursor. */
597
+ fork(sourceSessionId, options = {}) {
598
+ assertSafeSessionId(sourceSessionId);
599
+ if (options.targetSessionId !== undefined)
600
+ assertSafeSessionId(options.targetSessionId);
601
+ const snapshot = this.readForkSnapshot(sourceSessionId, options.throughEventId);
602
+ const targetSessionId = options.targetSessionId ?? nanoid(16);
603
+ const createdAt = Date.now();
604
+ const lineage = {
605
+ sessionId: sourceSessionId,
606
+ mode: "full",
607
+ fromEventId: snapshot.copiedEvents[0]?.id,
608
+ throughEventId: snapshot.copiedEvents[snapshot.copiedEvents.length - 1]?.id,
609
+ sourceEventCount: snapshot.copiedEvents.length,
610
+ createdAt,
611
+ };
612
+ const state = buildForkState(snapshot.sourceState, targetSessionId, lineage, createdAt);
613
+ const events = buildForkTranscript(snapshot.copiedEvents, state);
614
+ const bundle = this.publishSessionAtomically(targetSessionId, state, events);
615
+ return { bundle, lineage, copiedEventCount: snapshot.copiedEvents.length };
616
+ }
617
+ readForkSnapshot(sourceSessionId, throughEventId) {
618
+ const sessionDir = join(this.sessionsDir, sourceSessionId);
619
+ const stateFile = join(sessionDir, "state.json");
620
+ const transcriptFile = join(sessionDir, "transcript.jsonl");
621
+ if (!existsSync(stateFile))
622
+ throw new SessionError(`Session not found: ${sourceSessionId}`);
623
+ let sourceState;
624
+ try {
625
+ sourceState = JSON.parse(readFileSync(stateFile, "utf-8"));
626
+ }
627
+ catch (err) {
628
+ throw new SessionError(`Session state is corrupt for ${sourceSessionId}: ${err instanceof Error ? err.message : String(err)}`);
629
+ }
630
+ const parsed = Transcript.readEvents(transcriptFile);
631
+ if (parsed.malformedLineCount > 0) {
632
+ throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
633
+ }
634
+ const sourceEvents = structuredClone(parsed.events);
635
+ let frozen = sourceEvents;
636
+ if (throughEventId !== undefined) {
637
+ const matches = sourceEvents
638
+ .map((event, index) => (event.id === throughEventId ? index : -1))
639
+ .filter((index) => index >= 0);
640
+ if (matches.length !== 1) {
641
+ throw new SessionError(`Fork cursor must identify exactly one source event`);
642
+ }
643
+ const cursor = sourceEvents[matches[0]];
644
+ if (cursor.type === "session_meta") {
645
+ throw new SessionError(`Fork cursor cannot point at session metadata`);
646
+ }
647
+ frozen = sourceEvents.slice(0, matches[0] + 1);
648
+ }
649
+ const copiedEvents = [];
650
+ for (const event of frozen) {
651
+ if (FORK_SKIP_EVENT_TYPES.has(event.type))
652
+ continue;
653
+ if (!FORK_COPY_EVENT_TYPES.has(event.type)) {
654
+ throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
655
+ }
656
+ copiedEvents.push(structuredClone(event));
657
+ }
658
+ validateForkToolPairs(copiedEvents);
659
+ return { sourceState: structuredClone(sourceState), copiedEvents };
660
+ }
661
+ publishSessionAtomically(targetSessionId, state, events) {
662
+ const targetDir = join(this.sessionsDir, targetSessionId);
663
+ if (existsSync(targetDir))
664
+ throw new SessionError(`Session already exists: ${targetSessionId}`);
665
+ const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
666
+ let published = false;
667
+ try {
668
+ mkdirSync(stagingDir);
669
+ writeFileSync(join(stagingDir, "state.json"), JSON.stringify(state, null, 2), {
670
+ encoding: "utf-8",
671
+ mode: 0o600,
672
+ });
673
+ const jsonl = events.map((event) => JSON.stringify(event)).join("\n") + "\n";
674
+ writeFileSync(join(stagingDir, "transcript.jsonl"), jsonl, {
675
+ encoding: "utf-8",
676
+ mode: 0o600,
677
+ });
678
+ if (existsSync(targetDir)) {
679
+ throw new SessionError(`Session already exists: ${targetSessionId}`);
495
680
  }
496
- newBundle.transcript.append(event.type, event.data);
681
+ renameSync(stagingDir, targetDir);
682
+ published = true;
683
+ return this.resume(targetSessionId);
684
+ }
685
+ finally {
686
+ if (!published)
687
+ rmSync(stagingDir, { recursive: true, force: true });
497
688
  }
498
- this.saveState(newBundle.state);
499
- return newBundle;
500
689
  }
501
690
  list(limit = 20) {
502
691
  if (!existsSync(this.sessionsDir))
503
692
  return [];
504
693
  const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
505
- .filter((d) => d.isDirectory())
694
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".pending-"))
506
695
  .map((d) => d.name);
507
696
  const candidates = [];
508
697
  for (const dir of dirs) {
@@ -544,6 +733,153 @@ export class SessionManager {
544
733
  return sessions;
545
734
  }
546
735
  }
736
+ export function buildForkState(source, targetSessionId, lineage, startedAt = Date.now()) {
737
+ const workspace = isSessionWorkspace(source.workspace)
738
+ ? structuredClone(source.workspace)
739
+ : { root: source.cwd, kind: "main" };
740
+ return {
741
+ sessionId: targetSessionId,
742
+ cwd: source.cwd,
743
+ workspace,
744
+ startedAt,
745
+ model: source.model,
746
+ provider: source.provider,
747
+ tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
748
+ cumulativePromptTokens: 0,
749
+ cumulativeCacheReadTokens: 0,
750
+ cumulativeCacheCreationTokens: 0,
751
+ turnCount: 0,
752
+ turnSeq: 0,
753
+ invokedSkills: [],
754
+ parentSessionId: null,
755
+ forkedFrom: structuredClone(lineage),
756
+ ...(source.origin ? { origin: source.origin } : {}),
757
+ status: "active",
758
+ };
759
+ }
760
+ export function buildForkTranscript(sourceEvents, state) {
761
+ const meta = {
762
+ id: nanoid(12),
763
+ type: "session_meta",
764
+ timestamp: state.startedAt,
765
+ turnNumber: 0,
766
+ data: {
767
+ sessionId: state.sessionId,
768
+ cwd: state.cwd,
769
+ workspace: structuredClone(state.workspace),
770
+ model: state.model,
771
+ provider: state.provider,
772
+ startedAt: state.startedAt,
773
+ forkedFrom: structuredClone(state.forkedFrom),
774
+ },
775
+ };
776
+ return [
777
+ meta,
778
+ ...sourceEvents.map((source) => ({
779
+ ...structuredClone(source),
780
+ id: nanoid(12),
781
+ })),
782
+ ];
783
+ }
784
+ function validateForkToolPairs(events) {
785
+ const projectedUses = [];
786
+ const projectedResults = [];
787
+ const seenProjectedUses = new Set();
788
+ const seenProjectedResults = new Set();
789
+ const projectedPending = [];
790
+ for (const message of Transcript.eventsToMessages(events)) {
791
+ const blocks = Array.isArray(message.content) ? message.content : [];
792
+ const uses = blocks.filter((block) => block.type === "tool_use");
793
+ const results = blocks.filter((block) => block.type === "tool_result");
794
+ if (projectedPending.length > 0) {
795
+ if (message.role !== "user" || results.length !== blocks.length || results.length === 0) {
796
+ throw new SessionError(`Fork provider history inserts a message before pending tool results`);
797
+ }
798
+ }
799
+ else if (results.length > 0) {
800
+ throw new SessionError(`Fork provider history contains an orphaned tool result`);
801
+ }
802
+ if (uses.length > 0) {
803
+ if (message.role !== "assistant" || results.length > 0) {
804
+ throw new SessionError(`Fork provider history contains tool use blocks in an invalid role`);
805
+ }
806
+ for (const block of uses) {
807
+ const id = block.id;
808
+ if (typeof id !== "string" || !id) {
809
+ throw new SessionError(`Fork provider history contains a tool use without id`);
810
+ }
811
+ if (seenProjectedUses.has(id)) {
812
+ throw new SessionError(`Fork provider history contains duplicate tool use ${id}`);
813
+ }
814
+ seenProjectedUses.add(id);
815
+ projectedUses.push(id);
816
+ projectedPending.push(id);
817
+ }
818
+ }
819
+ for (const block of results) {
820
+ const id = block.tool_use_id;
821
+ if (typeof id !== "string" || !id) {
822
+ throw new SessionError(`Fork provider history contains a tool result without id`);
823
+ }
824
+ if (seenProjectedResults.has(id)) {
825
+ throw new SessionError(`Fork provider history contains duplicate tool result ${id}`);
826
+ }
827
+ const expected = projectedPending.shift();
828
+ if (!expected) {
829
+ throw new SessionError(`Fork provider history contains an orphaned tool result (${id})`);
830
+ }
831
+ if (expected !== id) {
832
+ throw new SessionError(`Fork provider history tool result order mismatch: expected ${expected}, received ${id}`);
833
+ }
834
+ seenProjectedResults.add(id);
835
+ projectedResults.push(id);
836
+ }
837
+ }
838
+ if (projectedPending.length > 0) {
839
+ throw new SessionError(`Fork cursor splits an unfinished tool round in provider history (${projectedPending[0]})`);
840
+ }
841
+ const metadataUses = [];
842
+ const metadataResults = [];
843
+ const seenMetadataUses = new Set();
844
+ const seenMetadataResults = new Set();
845
+ const metadataPending = [];
846
+ for (const event of events) {
847
+ if (event.type !== "tool_use" && event.type !== "tool_result")
848
+ continue;
849
+ const id = event.data.toolCallId;
850
+ if (typeof id !== "string" || !id) {
851
+ throw new SessionError(`Fork snapshot contains a tool event without toolCallId`);
852
+ }
853
+ if (event.type === "tool_use") {
854
+ if (seenMetadataUses.has(id)) {
855
+ throw new SessionError(`Fork snapshot contains duplicate tool use metadata ${id}`);
856
+ }
857
+ seenMetadataUses.add(id);
858
+ metadataUses.push(id);
859
+ metadataPending.push(id);
860
+ continue;
861
+ }
862
+ if (seenMetadataResults.has(id)) {
863
+ throw new SessionError(`Fork snapshot contains duplicate tool result metadata ${id}`);
864
+ }
865
+ const expected = metadataPending.shift();
866
+ if (!expected) {
867
+ throw new SessionError(`Fork snapshot contains tool result metadata before use (${id})`);
868
+ }
869
+ if (expected !== id) {
870
+ throw new SessionError(`Fork snapshot tool metadata order mismatch: expected ${expected}, received ${id}`);
871
+ }
872
+ seenMetadataResults.add(id);
873
+ metadataResults.push(id);
874
+ }
875
+ if (metadataPending.length > 0) {
876
+ throw new SessionError(`Fork cursor splits unfinished tool metadata (${metadataPending[0]})`);
877
+ }
878
+ const sameIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
879
+ if (!sameIds(projectedUses, metadataUses) || !sameIds(projectedResults, metadataResults)) {
880
+ throw new SessionError(`Fork tool metadata does not match provider history`);
881
+ }
882
+ }
547
883
  function newestGoalTerminal(incoming, persisted) {
548
884
  if (!incoming)
549
885
  return persisted;
@@ -3,12 +3,29 @@
3
3
  * toMessages() derives Message[] from events for sending to LLM.
4
4
  */
5
5
  import type { TranscriptEvent, TranscriptEventType, Message, ContentBlock } from "../types.js";
6
+ type TranscriptWriter = (filePath: string, data: string, encoding: "utf-8") => void;
7
+ type ParsedEvents = {
8
+ events: TranscriptEvent[];
9
+ malformedLineCount: number;
10
+ };
11
+ export interface TranscriptFlushFailure {
12
+ errno: string | number;
13
+ code?: string;
14
+ message: string;
15
+ timestamp: number;
16
+ attempts: 2;
17
+ recoverable: false;
18
+ filePath: string;
19
+ }
6
20
  export declare class Transcript {
7
21
  private events;
8
22
  private filePath;
9
23
  private currentTurn;
24
+ private readonly writer;
25
+ private dirty;
26
+ private lastFlushFailure;
10
27
  getFilePath(): string;
11
- constructor(filePath: string);
28
+ constructor(filePath: string, writer?: TranscriptWriter);
12
29
  append(type: TranscriptEventType, data: Record<string, unknown>): TranscriptEvent;
13
30
  /**
14
31
  * Append a chat message to the transcript.
@@ -55,13 +72,25 @@ export declare class Transcript {
55
72
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
56
73
  get turnNumber(): number;
57
74
  get eventCount(): number;
75
+ /** True once any event failed both persistence attempts. Sticky by design. */
76
+ flushFailed(): boolean;
77
+ /** Structured details for the most recent unrecoverable flush failure. */
78
+ getFlushFailure(): TranscriptFlushFailure | undefined;
58
79
  private findMessageByClientId;
59
80
  private flush;
81
+ private errorErrno;
82
+ private errorMessage;
60
83
  /**
61
84
  * Repair tool_result pairing issues:
62
85
  * - Orphaned tool_results (no matching tool_use) get removed
63
86
  * - Missing tool_results (tool_use with no result) get synthetic error results
64
87
  */
65
88
  repairToolResultPairs(): void;
89
+ static readEvents(filePath: string): ParsedEvents;
90
+ static eventsToMessages(events: readonly TranscriptEvent[]): Array<{
91
+ role: string;
92
+ content: string | ContentBlock[];
93
+ }>;
66
94
  static loadFromFile(filePath: string): Transcript;
67
95
  }
96
+ export {};