@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.
- package/dist/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- 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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
450
|
-
//
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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 {};
|