@cjhyy/code-shell-core 0.7.0-beta.1 → 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/cc-orchestrator/agent-adapter.d.ts +2 -0
- package/dist/cc-orchestrator/agent-adapter.js +4 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
- package/dist/cc-orchestrator/codex-session-history.js +64 -4
- package/dist/cc-orchestrator/external-agent-changes.js +22 -5
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
- package/dist/cc-orchestrator/external-agent-driver.js +202 -38
- package/dist/cc-orchestrator/session-history.d.ts +35 -0
- package/dist/cc-orchestrator/session-history.js +96 -13
- 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 +11 -1
- package/dist/credentials/access.js +77 -1
- package/dist/credentials/index.d.ts +3 -1
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/oauth.d.ts +25 -0
- package/dist/credentials/oauth.js +179 -0
- package/dist/credentials/store.d.ts +2 -1
- package/dist/credentials/store.js +19 -8
- package/dist/credentials/types.d.ts +84 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +67 -34
- package/dist/engine/engine.js +448 -247
- package/dist/engine/goal.d.ts +19 -0
- package/dist/engine/goal.js +16 -6
- package/dist/engine/input-attachments.js +156 -13
- package/dist/engine/run-image-input.d.ts +22 -0
- package/dist/engine/run-image-input.js +195 -0
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/steer-queue.d.ts +3 -1
- package/dist/engine/steer-queue.js +10 -2
- package/dist/engine/turn-loop.d.ts +48 -1
- package/dist/engine/turn-loop.js +307 -37
- 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 +44 -2
- package/dist/hooks/goal-stop-hook.js +775 -52
- 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/preset/index.js +14 -4
- 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 +291 -73
- package/dist/protocol/types.d.ts +36 -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 +396 -27
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
- package/dist/tool-system/builtin/agent-notifications.js +19 -7
- 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 +28 -5
- package/dist/tool-system/builtin/background-jobs.js +109 -7
- 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/cron-list.definition.d.ts +3 -0
- package/dist/tool-system/builtin/cron-list.definition.js +6 -0
- package/dist/tool-system/builtin/cron.d.ts +1 -2
- package/dist/tool-system/builtin/cron.js +9 -7
- package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
- package/dist/tool-system/builtin/drive-claude-code.js +307 -20
- 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 +60 -5
- package/dist/tool-system/builtin/sleep.d.ts +1 -2
- package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
- package/dist/tool-system/builtin/sleep.definition.js +28 -0
- package/dist/tool-system/builtin/sleep.js +1 -22
- 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 +35 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +20 -2
- package/dist/tool-system/mcp-manager.js +111 -12
- 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 +38 -7
- 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,14 +1,40 @@
|
|
|
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";
|
|
8
8
|
import { Transcript } from "./transcript.js";
|
|
9
9
|
import { SessionError } from "../exceptions.js";
|
|
10
10
|
import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
|
|
11
|
+
import { isSameGoalInstance } from "../engine/goal.js";
|
|
11
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;
|
|
12
38
|
/**
|
|
13
39
|
* Validate a session ID before it is joined into a filesystem path.
|
|
14
40
|
*
|
|
@@ -96,9 +122,54 @@ async function validateResumeWorktreeRoot(root) {
|
|
|
96
122
|
}
|
|
97
123
|
export class SessionManager {
|
|
98
124
|
sessionsDir;
|
|
125
|
+
registeredCloseEpochs = new Map();
|
|
99
126
|
constructor(storageDir) {
|
|
100
127
|
this.sessionsDir = storageDir ?? join(codeShellHome(), "sessions");
|
|
101
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;
|
|
102
173
|
}
|
|
103
174
|
/**
|
|
104
175
|
* Create a new on-disk session. If `explicitSessionId` is passed, use
|
|
@@ -366,7 +437,9 @@ export class SessionManager {
|
|
|
366
437
|
return undefined;
|
|
367
438
|
try {
|
|
368
439
|
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
369
|
-
return state.activeGoal
|
|
440
|
+
return isSameGoalInstance(state.activeGoal, state.goalTerminal)
|
|
441
|
+
? undefined
|
|
442
|
+
: state.activeGoal;
|
|
370
443
|
}
|
|
371
444
|
catch {
|
|
372
445
|
return undefined;
|
|
@@ -437,48 +510,188 @@ export class SessionManager {
|
|
|
437
510
|
Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
|
|
438
511
|
return { state, transcript };
|
|
439
512
|
}
|
|
440
|
-
|
|
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) {
|
|
441
542
|
// state.sessionId could come from a deserialized state.json that was
|
|
442
543
|
// tampered with on disk. Validate before joining.
|
|
443
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
|
+
}
|
|
444
553
|
const sessionDir = join(this.sessionsDir, state.sessionId);
|
|
445
554
|
mkdirSync(sessionDir, { recursive: true });
|
|
446
|
-
// Atomic
|
|
447
|
-
//
|
|
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.
|
|
448
559
|
const target = join(sessionDir, "state.json");
|
|
560
|
+
// Preserve the newest goal tombstone across whole-state writers. If an old
|
|
561
|
+
// detached bundle still carries the tombstoned goal, drop it before write;
|
|
562
|
+
// when disk already contains a newer replacement goal, retain that goal as
|
|
563
|
+
// well instead of letting the stale bundle erase it.
|
|
564
|
+
let persisted;
|
|
565
|
+
if (existsSync(target)) {
|
|
566
|
+
try {
|
|
567
|
+
persisted = JSON.parse(readFileSync(target, "utf-8"));
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// The atomic writer should make this rare. Preserve the existing
|
|
571
|
+
// behavior and overwrite malformed state with the caller's snapshot.
|
|
572
|
+
}
|
|
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
|
+
}
|
|
581
|
+
const terminal = newestGoalTerminal(state.goalTerminal, persisted?.goalTerminal);
|
|
582
|
+
if (terminal)
|
|
583
|
+
state.goalTerminal = terminal;
|
|
584
|
+
if (terminal && isSameGoalInstance(state.activeGoal, terminal)) {
|
|
585
|
+
const diskGoal = persisted?.activeGoal;
|
|
586
|
+
state.activeGoal = diskGoal && !isSameGoalInstance(diskGoal, terminal) ? diskGoal : undefined;
|
|
587
|
+
}
|
|
449
588
|
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
450
589
|
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
451
590
|
renameSync(tmp, target);
|
|
591
|
+
return true;
|
|
452
592
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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)}`);
|
|
471
655
|
}
|
|
472
|
-
|
|
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}`);
|
|
680
|
+
}
|
|
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 });
|
|
473
688
|
}
|
|
474
|
-
this.saveState(newBundle.state);
|
|
475
|
-
return newBundle;
|
|
476
689
|
}
|
|
477
690
|
list(limit = 20) {
|
|
478
691
|
if (!existsSync(this.sessionsDir))
|
|
479
692
|
return [];
|
|
480
693
|
const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
|
|
481
|
-
.filter((d) => d.isDirectory())
|
|
694
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".pending-"))
|
|
482
695
|
.map((d) => d.name);
|
|
483
696
|
const candidates = [];
|
|
484
697
|
for (const dir of dirs) {
|
|
@@ -520,6 +733,162 @@ export class SessionManager {
|
|
|
520
733
|
return sessions;
|
|
521
734
|
}
|
|
522
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
|
+
}
|
|
883
|
+
function newestGoalTerminal(incoming, persisted) {
|
|
884
|
+
if (!incoming)
|
|
885
|
+
return persisted;
|
|
886
|
+
if (!persisted)
|
|
887
|
+
return incoming;
|
|
888
|
+
const incomingAt = incoming.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
|
|
889
|
+
const persistedAt = persisted.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
|
|
890
|
+
return incomingAt > persistedAt ? incoming : persisted;
|
|
891
|
+
}
|
|
523
892
|
/**
|
|
524
893
|
* Scan a transcript.jsonl for the LAST user message and return a short
|
|
525
894
|
* preview, reading the file from the END in 64 KiB chunks.
|
|
@@ -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 {};
|