pikiloom 0.4.68 → 0.4.70
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/package.json +1 -1
- package/packages/kernel/dist/contracts/driver.d.ts +9 -0
- package/packages/kernel/dist/contracts/ports.d.ts +16 -0
- package/packages/kernel/dist/contracts/surface.d.ts +9 -0
- package/packages/kernel/dist/drivers/claude.d.ts +9 -0
- package/packages/kernel/dist/drivers/claude.js +45 -6
- package/packages/kernel/dist/drivers/codex.d.ts +5 -0
- package/packages/kernel/dist/drivers/codex.js +34 -6
- package/packages/kernel/dist/drivers/native.d.ts +4 -0
- package/packages/kernel/dist/drivers/native.js +95 -0
- package/packages/kernel/dist/drivers/rpc.d.ts +7 -0
- package/packages/kernel/dist/drivers/rpc.js +8 -0
- package/packages/kernel/dist/ports/defaults.d.ts +12 -0
- package/packages/kernel/dist/ports/defaults.js +55 -0
- package/packages/kernel/dist/protocol/index.d.ts +1 -0
- package/packages/kernel/dist/protocol/index.js +1 -1
- package/packages/kernel/dist/runtime/fork.d.ts +2 -0
- package/packages/kernel/dist/runtime/fork.js +35 -0
- package/packages/kernel/dist/runtime/hub.d.ts +4 -1
- package/packages/kernel/dist/runtime/hub.js +112 -3
- package/packages/kernel/dist/runtime/loom.js +12 -1
- package/packages/kernel/dist/runtime/session-runner.js +2 -0
- package/packages/kernel/dist/workspace/sessions.d.ts +5 -0
- package/packages/kernel/dist/workspace/sessions.js +1 -0
package/package.json
CHANGED
|
@@ -3,6 +3,9 @@ export interface AgentTurnInput {
|
|
|
3
3
|
prompt: string;
|
|
4
4
|
attachments?: string[];
|
|
5
5
|
sessionId?: string | null;
|
|
6
|
+
fork?: {
|
|
7
|
+
anchor?: string | null;
|
|
8
|
+
} | null;
|
|
6
9
|
workdir: string;
|
|
7
10
|
model?: string | null;
|
|
8
11
|
effort?: string | null;
|
|
@@ -68,6 +71,7 @@ export interface DriverResult {
|
|
|
68
71
|
stopReason?: string | null;
|
|
69
72
|
sessionId?: string | null;
|
|
70
73
|
usage?: UniversalUsage | null;
|
|
74
|
+
anchor?: string | null;
|
|
71
75
|
}
|
|
72
76
|
export interface TuiInput {
|
|
73
77
|
workdir: string;
|
|
@@ -101,6 +105,7 @@ export interface AgentDriver {
|
|
|
101
105
|
interact?: boolean;
|
|
102
106
|
resume?: boolean;
|
|
103
107
|
tui?: boolean;
|
|
108
|
+
fork?: boolean;
|
|
104
109
|
};
|
|
105
110
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
106
111
|
tui?(input: TuiInput): TuiSpec;
|
|
@@ -108,4 +113,8 @@ export interface AgentDriver {
|
|
|
108
113
|
workdir: string;
|
|
109
114
|
limit?: number;
|
|
110
115
|
}): NativeSessionInfo[] | Promise<NativeSessionInfo[]>;
|
|
116
|
+
resolveNativeAnchor?(opts: {
|
|
117
|
+
sessionId: string;
|
|
118
|
+
workdir: string;
|
|
119
|
+
}): string | null | Promise<string | null>;
|
|
111
120
|
}
|
|
@@ -14,6 +14,17 @@ export interface CoreSessionRecord {
|
|
|
14
14
|
effort?: string | null;
|
|
15
15
|
runState?: 'running' | 'completed' | 'incomplete';
|
|
16
16
|
runDetail?: string | null;
|
|
17
|
+
runPid?: number | null;
|
|
18
|
+
runStartedAt?: number | null;
|
|
19
|
+
forkedFrom?: {
|
|
20
|
+
sessionKey: string;
|
|
21
|
+
taskId?: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
pendingFork?: {
|
|
24
|
+
parentNativeSessionId: string | null;
|
|
25
|
+
anchor: string | null;
|
|
26
|
+
mode: 'native' | 'seed';
|
|
27
|
+
} | null;
|
|
17
28
|
}
|
|
18
29
|
export interface SessionStore {
|
|
19
30
|
ensure(agent: string, opts: {
|
|
@@ -30,6 +41,11 @@ export interface SessionStore {
|
|
|
30
41
|
limit?: number;
|
|
31
42
|
}): Promise<CoreSessionRecord[]>;
|
|
32
43
|
recordResult(agent: string, sessionId: string, result: DriverResult): Promise<void>;
|
|
44
|
+
markRunning?(agent: string, sessionId: string, owner: {
|
|
45
|
+
pid: number;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
|
|
33
49
|
appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
34
50
|
history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
35
51
|
}
|
|
@@ -9,11 +9,20 @@ export interface PromptInput {
|
|
|
9
9
|
effort?: string | null;
|
|
10
10
|
attachments?: string[];
|
|
11
11
|
}
|
|
12
|
+
export interface ForkSessionInput {
|
|
13
|
+
fromSessionKey: string;
|
|
14
|
+
atTaskId?: string | null;
|
|
15
|
+
anchor?: string | null;
|
|
16
|
+
title?: string | null;
|
|
17
|
+
}
|
|
12
18
|
export interface LoomIO {
|
|
13
19
|
prompt(input: PromptInput): Promise<{
|
|
14
20
|
sessionKey: string;
|
|
15
21
|
taskId: string;
|
|
16
22
|
}>;
|
|
23
|
+
forkSession(input: ForkSessionInput): Promise<{
|
|
24
|
+
sessionKey: string;
|
|
25
|
+
}>;
|
|
17
26
|
stop(sessionKey: string): boolean;
|
|
18
27
|
steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
|
|
19
28
|
interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
|
|
@@ -8,6 +8,7 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
8
8
|
interact: boolean;
|
|
9
9
|
resume: boolean;
|
|
10
10
|
tui: boolean;
|
|
11
|
+
fork: boolean;
|
|
11
12
|
};
|
|
12
13
|
constructor(bin?: string);
|
|
13
14
|
tui(input: TuiInput): TuiSpec;
|
|
@@ -17,7 +18,14 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
17
18
|
}): NativeSessionInfo[];
|
|
18
19
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
19
20
|
private usage;
|
|
21
|
+
resolveNativeAnchor(opts: {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
workdir: string;
|
|
24
|
+
}): string | null;
|
|
20
25
|
}
|
|
26
|
+
export declare function claudeResumeArgs(sessionId: string, fork?: {
|
|
27
|
+
anchor?: string | null;
|
|
28
|
+
} | null): string[];
|
|
21
29
|
export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
|
|
22
30
|
export declare function claudeBgHoldCapMs(): number;
|
|
23
31
|
export declare function claudeBgAgentHoldCapMs(): number;
|
|
@@ -34,6 +42,7 @@ export declare function claudeResumeNoopRetryLimit(): number;
|
|
|
34
42
|
export declare function claudeUserEventHasToolResult(ev: any): boolean;
|
|
35
43
|
export declare function isTerminalTaskStatus(status: unknown): boolean;
|
|
36
44
|
export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
|
|
45
|
+
export declare function sameClaudeText(a: string, b: string): boolean;
|
|
37
46
|
export declare function markClaudeTaskNotificationTerminal(content: any, s: any): void;
|
|
38
47
|
export declare function pendingClaudeBackgroundTasks(s: any): number;
|
|
39
48
|
export declare function claudeTurnEndedDangling(s: any): boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { discoverClaudeNativeSessions } from './native.js';
|
|
3
|
+
import { claudeTranscriptTailAnchor, discoverClaudeNativeSessions } from './native.js';
|
|
4
4
|
import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, parseJsonLine, sigterm, wireAbort } from './shared.js';
|
|
5
5
|
// Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
|
|
6
6
|
// events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
|
|
@@ -9,7 +9,7 @@ import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, p
|
|
|
9
9
|
export class ClaudeDriver {
|
|
10
10
|
bin;
|
|
11
11
|
id = 'claude';
|
|
12
|
-
capabilities = { steer: true, interact: false, resume: true, tui: true };
|
|
12
|
+
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
|
|
13
13
|
constructor(bin = 'claude') {
|
|
14
14
|
this.bin = bin;
|
|
15
15
|
}
|
|
@@ -43,7 +43,7 @@ export class ClaudeDriver {
|
|
|
43
43
|
if (input.effort)
|
|
44
44
|
args.push('--effort', input.effort === 'ultra' ? 'max' : input.effort); // request extended thinking (ultra is a display-only alias for max)
|
|
45
45
|
if (input.sessionId)
|
|
46
|
-
args.push(
|
|
46
|
+
args.push(...claudeResumeArgs(input.sessionId, input.fork));
|
|
47
47
|
if (input.systemPrompt)
|
|
48
48
|
args.push('--append-system-prompt', input.systemPrompt);
|
|
49
49
|
if (input.mcpConfigPath)
|
|
@@ -62,6 +62,9 @@ export class ClaudeDriver {
|
|
|
62
62
|
// Wall-clock of the last parsed stream event — the hold cap defers while this is fresh.
|
|
63
63
|
lastEventAt: Date.now(),
|
|
64
64
|
sessionId: null, model: null,
|
|
65
|
+
// Fork anchor: uuid of the latest REAL assistant transcript record seen this turn —
|
|
66
|
+
// the inclusive keep-boundary a later fork of this session passes to --resume-session-at.
|
|
67
|
+
anchor: null,
|
|
65
68
|
stopReason: null, error: null,
|
|
66
69
|
input: null, output: null, cached: null,
|
|
67
70
|
cacheCreation: null,
|
|
@@ -138,7 +141,7 @@ export class ClaudeDriver {
|
|
|
138
141
|
};
|
|
139
142
|
const settleResult = (opts = {}) => finish({
|
|
140
143
|
ok: opts.ok ?? !state.error, text: state.text, reasoning: state.reasoning || undefined,
|
|
141
|
-
error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, usage: usageOf(),
|
|
144
|
+
error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf(),
|
|
142
145
|
}, opts.kill ?? true);
|
|
143
146
|
// Cap while holding for a still-running background task (stopReason marks it as
|
|
144
147
|
// "still running in the background" so the terminal presentation reads right). Idempotent —
|
|
@@ -336,7 +339,7 @@ export class ClaudeDriver {
|
|
|
336
339
|
if (settled)
|
|
337
340
|
return;
|
|
338
341
|
if (ctx.signal.aborted) {
|
|
339
|
-
finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, usage: usageOf() }, false);
|
|
342
|
+
finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, anchor: state.anchor, usage: usageOf() }, false);
|
|
340
343
|
return;
|
|
341
344
|
}
|
|
342
345
|
const ok = !state.error && code === 0;
|
|
@@ -345,7 +348,7 @@ export class ClaudeDriver {
|
|
|
345
348
|
// (state.stopReason is typically the tool round's leftover 'tool_use' here, so the
|
|
346
349
|
// dangling check must win over it.)
|
|
347
350
|
const stopReason = (ok && claudeTurnEndedDangling(state)) ? 'truncated' : state.stopReason;
|
|
348
|
-
finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason, sessionId: state.sessionId, usage: usageOf() }, false);
|
|
351
|
+
finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf() }, false);
|
|
349
352
|
});
|
|
350
353
|
try {
|
|
351
354
|
// Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
|
|
@@ -358,6 +361,23 @@ export class ClaudeDriver {
|
|
|
358
361
|
usage(s) {
|
|
359
362
|
return claudeUsageOf(s);
|
|
360
363
|
}
|
|
364
|
+
// Current tail keep-boundary of a native session: the uuid of the last user/assistant
|
|
365
|
+
// record in its transcript. Pins a tail fork at fork time (see AgentDriver contract).
|
|
366
|
+
resolveNativeAnchor(opts) {
|
|
367
|
+
return claudeTranscriptTailAnchor(opts.workdir, opts.sessionId);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// The --resume flag family for one turn: plain resume appends to the session; a fork branches
|
|
371
|
+
// a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
|
|
372
|
+
// uuid (--resume-session-at). Pure so tests can pin the arg contract without spawning.
|
|
373
|
+
export function claudeResumeArgs(sessionId, fork) {
|
|
374
|
+
const args = ['--resume', sessionId];
|
|
375
|
+
if (fork) {
|
|
376
|
+
args.push('--fork-session');
|
|
377
|
+
if (fork.anchor)
|
|
378
|
+
args.push('--resume-session-at', fork.anchor);
|
|
379
|
+
}
|
|
380
|
+
return args;
|
|
361
381
|
}
|
|
362
382
|
function claudeUsageOf(s) {
|
|
363
383
|
// While a message is still streaming, the CLI's live thinking estimate (system/thinking_tokens)
|
|
@@ -557,6 +577,10 @@ export function handleClaudeEvent(ev, s, emit) {
|
|
|
557
577
|
}
|
|
558
578
|
return;
|
|
559
579
|
}
|
|
580
|
+
// A real assistant event's uuid IS its persisted transcript record uuid — track the
|
|
581
|
+
// latest as this turn's fork anchor (inclusive keep-boundary for --resume-session-at).
|
|
582
|
+
if (typeof ev.uuid === 'string' && ev.uuid)
|
|
583
|
+
s.anchor = ev.uuid;
|
|
560
584
|
const contents = ev.message?.content || [];
|
|
561
585
|
for (const b of contents) {
|
|
562
586
|
if (b?.type !== 'tool_use')
|
|
@@ -728,6 +752,15 @@ export function handleClaudeEvent(ev, s, emit) {
|
|
|
728
752
|
: (typeof ev.result === 'string' && ev.result.trim() ? ev.result.trim()
|
|
729
753
|
: `claude ended the turn with an error result${subtype ? ` (${subtype})` : ''}`);
|
|
730
754
|
}
|
|
755
|
+
// When the WHOLE assistant body is that same error notice — a spend/usage-limit hit or a
|
|
756
|
+
// permission refusal the CLI narrates as a message AND flags the `result` an error, so the
|
|
757
|
+
// identical text lands in BOTH s.text and s.error — the turn is a single notice, not an
|
|
758
|
+
// answer-plus-red-echo. Drop the duplicate body so it renders once (as the error notice).
|
|
759
|
+
// Only exact-equal collapses: a real reply that merely ENDS with an error keeps both.
|
|
760
|
+
if (s.error && s.text.trim() && sameClaudeText(s.text, s.error)) {
|
|
761
|
+
s.text = '';
|
|
762
|
+
s.streamedText = false;
|
|
763
|
+
}
|
|
731
764
|
if (!isErrorResult && typeof ev.result === 'string' && ev.result.trim()) {
|
|
732
765
|
if (!s.text.trim())
|
|
733
766
|
s.text = ev.result;
|
|
@@ -909,6 +942,12 @@ function claudeContentText(content) {
|
|
|
909
942
|
return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
910
943
|
return '';
|
|
911
944
|
}
|
|
945
|
+
// Whitespace-insensitive text equality — the error notice (a trimmed `result` string) and the
|
|
946
|
+
// streamed body (may carry trailing newlines / soft breaks) can differ only in whitespace yet be
|
|
947
|
+
// the same message. Used to collapse an error that IS the whole body (see the result handler).
|
|
948
|
+
export function sameClaudeText(a, b) {
|
|
949
|
+
return a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim();
|
|
950
|
+
}
|
|
912
951
|
// Extra completion signal (mirrors the legacy driver): Claude delivers a background wake-up as a
|
|
913
952
|
// `type:'user'` message carrying a `<task-notification>` tag (<task-id>/<tool-use-id>/<status>).
|
|
914
953
|
// Mark that task terminal too, so a missed/absent system task_notification still lets pending
|
|
@@ -27,6 +27,7 @@ export declare class CodexDriver implements AgentDriver {
|
|
|
27
27
|
interact: boolean;
|
|
28
28
|
resume: boolean;
|
|
29
29
|
tui: boolean;
|
|
30
|
+
fork: boolean;
|
|
30
31
|
};
|
|
31
32
|
constructor(bin?: string);
|
|
32
33
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
@@ -35,4 +36,8 @@ export declare class CodexDriver implements AgentDriver {
|
|
|
35
36
|
workdir: string;
|
|
36
37
|
limit?: number;
|
|
37
38
|
}): NativeSessionInfo[];
|
|
39
|
+
resolveNativeAnchor(opts: {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
workdir: string;
|
|
42
|
+
}): string | null;
|
|
38
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { discoverCodexNativeSessions } from './native.js';
|
|
1
|
+
import { codexRolloutTailAnchor, discoverCodexNativeSessions } from './native.js';
|
|
2
2
|
import { StdioRpcClient } from './rpc.js';
|
|
3
3
|
import { attachedFileNote, contextPercent, imageMimeForFile, wireAbort } from './shared.js';
|
|
4
4
|
function planFromUpdate(params) {
|
|
@@ -237,7 +237,7 @@ export function codexFinalReasoning(s) {
|
|
|
237
237
|
export class CodexDriver {
|
|
238
238
|
bin;
|
|
239
239
|
id = 'codex';
|
|
240
|
-
capabilities = { steer: true, interact: false, resume: true, tui: true };
|
|
240
|
+
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
|
|
241
241
|
constructor(bin = 'codex') {
|
|
242
242
|
this.bin = bin;
|
|
243
243
|
}
|
|
@@ -263,8 +263,25 @@ export class CodexDriver {
|
|
|
263
263
|
let steerRegistered = false;
|
|
264
264
|
if (!srv.start())
|
|
265
265
|
return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
|
|
266
|
-
let
|
|
267
|
-
|
|
266
|
+
let settled = false;
|
|
267
|
+
let resolveTurn = () => { };
|
|
268
|
+
const turnDone = new Promise((res) => { resolveTurn = res; });
|
|
269
|
+
// Idempotent: turnDone can be settled from three racing sources (turn/completed, abort,
|
|
270
|
+
// process death); the first wins and the rest are no-ops.
|
|
271
|
+
const settle = () => { if (settled)
|
|
272
|
+
return; settled = true; resolveTurn(); };
|
|
273
|
+
// If the app-server process dies WITHOUT a turn/completed (crash, kill, disconnect), the
|
|
274
|
+
// `await turnDone` below would otherwise hang forever — run() never resolves, recordResult
|
|
275
|
+
// never fires, and the session is stranded runState:"running" in the orchestrator even though
|
|
276
|
+
// the codex process is already dead. Settle it as a failed turn so the orchestrator can
|
|
277
|
+
// finalize it (mirrors the claude driver's child.on('close') settle).
|
|
278
|
+
srv.onClose(() => {
|
|
279
|
+
if (settled)
|
|
280
|
+
return;
|
|
281
|
+
state.error = state.error || srv.stderrText().trim().split('\n').pop() || 'codex app-server exited before the turn completed';
|
|
282
|
+
state.status = 'error';
|
|
283
|
+
settle();
|
|
284
|
+
});
|
|
268
285
|
// On abort: gracefully interrupt the running turn, then settle turnDone OURSELVES. A bare
|
|
269
286
|
// srv.kill() (SIGTERM) never produces a turn/completed notification, so without this explicit
|
|
270
287
|
// settle() the `await turnDone` below hangs forever — run() never resolves and the task stays
|
|
@@ -289,11 +306,15 @@ export class CodexDriver {
|
|
|
289
306
|
threadParams.approvalPolicy = 'never';
|
|
290
307
|
threadParams.sandbox = 'danger-full-access';
|
|
291
308
|
}
|
|
309
|
+
// Fork-on-dispatch: thread/fork copies the parent's stored history into a NEW thread
|
|
310
|
+
// (inclusive cut at lastTurnId when an anchor is pinned) and never mutates the parent.
|
|
292
311
|
const threadResp = input.sessionId
|
|
293
|
-
?
|
|
312
|
+
? (input.fork
|
|
313
|
+
? await srv.request('thread/fork', { threadId: input.sessionId, ...threadParams, ...(input.fork.anchor ? { lastTurnId: input.fork.anchor } : {}) })
|
|
314
|
+
: await srv.request('thread/resume', { threadId: input.sessionId, ...threadParams }))
|
|
294
315
|
: await srv.request('thread/start', threadParams);
|
|
295
316
|
if (threadResp.error)
|
|
296
|
-
return { ok: false, text: '', error: threadResp.error.message || 'thread/start failed', stopReason: 'error' };
|
|
317
|
+
return { ok: false, text: '', error: threadResp.error.message || (input.fork ? 'thread/fork failed' : 'thread/start failed'), stopReason: 'error' };
|
|
297
318
|
const threadId = threadResp.result?.thread?.id ?? input.sessionId ?? null;
|
|
298
319
|
if (threadId && threadId !== state.sessionId) {
|
|
299
320
|
state.sessionId = threadId;
|
|
@@ -478,6 +499,8 @@ export class CodexDriver {
|
|
|
478
499
|
error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
|
|
479
500
|
stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
|
|
480
501
|
sessionId: state.sessionId,
|
|
502
|
+
// Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
|
|
503
|
+
anchor: state.turnId,
|
|
481
504
|
usage,
|
|
482
505
|
};
|
|
483
506
|
}
|
|
@@ -496,6 +519,11 @@ export class CodexDriver {
|
|
|
496
519
|
listNativeSessions(opts) {
|
|
497
520
|
return discoverCodexNativeSessions(opts.workdir, { limit: opts.limit });
|
|
498
521
|
}
|
|
522
|
+
// Current tail keep-boundary of a native thread: the last turn id in its rollout.
|
|
523
|
+
// Pins a tail fork at fork time (see AgentDriver contract).
|
|
524
|
+
resolveNativeAnchor(opts) {
|
|
525
|
+
return codexRolloutTailAnchor(opts.sessionId);
|
|
526
|
+
}
|
|
499
527
|
}
|
|
500
528
|
function buildTurnInput(prompt, attachments) {
|
|
501
529
|
const input = [];
|
|
@@ -11,3 +11,7 @@ export declare function encodeClaudeProjectDir(workdir: string): string;
|
|
|
11
11
|
export declare function discoverClaudeNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
12
12
|
export declare function discoverCodexNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
13
13
|
export declare function discoverGeminiNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
14
|
+
/** Claude: uuid of the last user/assistant record in the session's transcript. */
|
|
15
|
+
export declare function claudeTranscriptTailAnchor(workdir: string, sessionId: string, opts?: DiscoverOptions): string | null;
|
|
16
|
+
/** Codex: the last turn id recorded in the session's rollout. */
|
|
17
|
+
export declare function codexRolloutTailAnchor(sessionId: string, opts?: DiscoverOptions): string | null;
|
|
@@ -505,3 +505,98 @@ export function discoverGeminiNativeSessions(workdir, opts = {}) {
|
|
|
505
505
|
const out = [...byId.values()].sort((a, b) => Date.parse(b.updatedAt || '') - Date.parse(a.updatedAt || ''));
|
|
506
506
|
return typeof opts.limit === 'number' ? out.slice(0, Math.max(0, opts.limit)) : out;
|
|
507
507
|
}
|
|
508
|
+
// ---- Fork anchors: the CURRENT tail keep-boundary of a native transcript ----
|
|
509
|
+
// Same terms as AgentTurnInput.fork.anchor: claude = transcript record uuid, codex = turn id.
|
|
510
|
+
// Used by fork-capable drivers' resolveNativeAnchor so Hub.forkSession can pin a tail cut at
|
|
511
|
+
// fork time (the branch must not absorb turns the parent runs afterwards). Bounded tail reads.
|
|
512
|
+
/** Claude: uuid of the last user/assistant record in the session's transcript. */
|
|
513
|
+
export function claudeTranscriptTailAnchor(workdir, sessionId, opts = {}) {
|
|
514
|
+
const home = homeOf(opts);
|
|
515
|
+
const filePath = path.join(home, '.claude', 'projects', encodeClaudeProjectDir(workdir), `${sessionId}.jsonl`);
|
|
516
|
+
const stat = statSafe(filePath);
|
|
517
|
+
if (!stat)
|
|
518
|
+
return null;
|
|
519
|
+
let anchor = null;
|
|
520
|
+
try {
|
|
521
|
+
const start = Math.max(0, stat.size - TAIL_BYTES);
|
|
522
|
+
for (const line of readRegion(filePath, start, Math.min(TAIL_BYTES, stat.size)).split('\n')) {
|
|
523
|
+
const t = line.trim();
|
|
524
|
+
if (!t || t[0] !== '{')
|
|
525
|
+
continue;
|
|
526
|
+
let ev;
|
|
527
|
+
try {
|
|
528
|
+
ev = JSON.parse(t);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if ((ev.type === 'assistant' || ev.type === 'user') && typeof ev.uuid === 'string' && ev.uuid)
|
|
534
|
+
anchor = ev.uuid;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
return anchor;
|
|
541
|
+
}
|
|
542
|
+
/** Codex: the last turn id recorded in the session's rollout. */
|
|
543
|
+
export function codexRolloutTailAnchor(sessionId, opts = {}) {
|
|
544
|
+
const filePath = findCodexRolloutPath(homeOf(opts), sessionId);
|
|
545
|
+
const stat = filePath ? statSafe(filePath) : null;
|
|
546
|
+
if (!filePath || !stat)
|
|
547
|
+
return null;
|
|
548
|
+
let anchor = null;
|
|
549
|
+
try {
|
|
550
|
+
const start = Math.max(0, stat.size - TAIL_BYTES);
|
|
551
|
+
for (const line of readRegion(filePath, start, Math.min(TAIL_BYTES, stat.size)).split('\n')) {
|
|
552
|
+
const t = line.trim();
|
|
553
|
+
if (!t || t[0] !== '{' || !t.includes('turn_id'))
|
|
554
|
+
continue;
|
|
555
|
+
let ev;
|
|
556
|
+
try {
|
|
557
|
+
ev = JSON.parse(t);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const tid = ev.payload?.turn_id;
|
|
563
|
+
if (typeof tid === 'string' && tid)
|
|
564
|
+
anchor = tid;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
return anchor;
|
|
571
|
+
}
|
|
572
|
+
/** Locate a codex rollout by session id (filenames end in `-<sessionId>.jsonl`). */
|
|
573
|
+
function findCodexRolloutPath(home, sessionId) {
|
|
574
|
+
const suffix = `${sessionId}.jsonl`;
|
|
575
|
+
let found = null;
|
|
576
|
+
const walk = (dir) => {
|
|
577
|
+
if (found)
|
|
578
|
+
return;
|
|
579
|
+
let entries;
|
|
580
|
+
try {
|
|
581
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
if (found)
|
|
588
|
+
return;
|
|
589
|
+
const full = path.join(dir, entry.name);
|
|
590
|
+
if (entry.isDirectory()) {
|
|
591
|
+
walk(full);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (entry.name.startsWith('rollout-') && entry.name.endsWith(suffix)) {
|
|
595
|
+
found = full;
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
walk(path.join(home, '.codex', 'sessions'));
|
|
601
|
+
return found;
|
|
602
|
+
}
|
|
@@ -25,12 +25,19 @@ export declare class StdioRpcClient {
|
|
|
25
25
|
private readonly pending;
|
|
26
26
|
private notifyCb?;
|
|
27
27
|
private requestCb?;
|
|
28
|
+
private closeCb?;
|
|
28
29
|
private readonly stderrTail;
|
|
29
30
|
private readonly label;
|
|
30
31
|
constructor(opts: StdioRpcOptions);
|
|
31
32
|
onNotification(cb: (method: string, params: any) => void): void;
|
|
32
33
|
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
33
34
|
onRequest(cb: (method: string, params: any, id: number | string) => any | Promise<any>): void;
|
|
35
|
+
/**
|
|
36
|
+
* Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
|
|
37
|
+
* (not a pending request) MUST use this to settle its turn — otherwise a process that dies
|
|
38
|
+
* without emitting its completion notification leaves the turn hanging forever.
|
|
39
|
+
*/
|
|
40
|
+
onClose(cb: () => void): void;
|
|
34
41
|
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
35
42
|
stderrText(): string;
|
|
36
43
|
start(): boolean;
|
|
@@ -16,6 +16,7 @@ export class StdioRpcClient {
|
|
|
16
16
|
pending = new Map();
|
|
17
17
|
notifyCb;
|
|
18
18
|
requestCb;
|
|
19
|
+
closeCb;
|
|
19
20
|
stderrTail = [];
|
|
20
21
|
label;
|
|
21
22
|
constructor(opts) {
|
|
@@ -25,6 +26,12 @@ export class StdioRpcClient {
|
|
|
25
26
|
onNotification(cb) { this.notifyCb = cb; }
|
|
26
27
|
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
27
28
|
onRequest(cb) { this.requestCb = cb; }
|
|
29
|
+
/**
|
|
30
|
+
* Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
|
|
31
|
+
* (not a pending request) MUST use this to settle its turn — otherwise a process that dies
|
|
32
|
+
* without emitting its completion notification leaves the turn hanging forever.
|
|
33
|
+
*/
|
|
34
|
+
onClose(cb) { this.closeCb = cb; }
|
|
28
35
|
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
29
36
|
stderrText() { return this.stderrTail.join('\n'); }
|
|
30
37
|
start() {
|
|
@@ -55,6 +62,7 @@ export class StdioRpcClient {
|
|
|
55
62
|
for (const cb of this.pending.values())
|
|
56
63
|
cb({ error: { message: `${this.label} exited` } });
|
|
57
64
|
this.pending.clear();
|
|
65
|
+
this.closeCb?.();
|
|
58
66
|
});
|
|
59
67
|
this.proc.on('error', () => { });
|
|
60
68
|
return true;
|
|
@@ -25,9 +25,21 @@ export declare class FsSessionStore implements SessionStore {
|
|
|
25
25
|
text?: string;
|
|
26
26
|
sessionId?: string | null;
|
|
27
27
|
}): Promise<void>;
|
|
28
|
+
markRunning(agent: string, sessionId: string, owner: {
|
|
29
|
+
pid: number;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
|
|
28
33
|
appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
29
34
|
history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
30
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
38
|
+
* no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
|
|
39
|
+
* The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
|
|
40
|
+
* that might still be running.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
31
43
|
export declare function defaultBaseDir(appNamespace: string): string;
|
|
32
44
|
export declare class NullModelResolver implements ModelResolver {
|
|
33
45
|
resolve(): Promise<null>;
|
|
@@ -74,6 +74,7 @@ export class FsSessionStore {
|
|
|
74
74
|
return;
|
|
75
75
|
rec.runState = result.ok ? 'completed' : 'incomplete';
|
|
76
76
|
rec.runDetail = result.error ?? null;
|
|
77
|
+
rec.runPid = null; // turn is over — drop the owner so reconciliation ignores it
|
|
77
78
|
if (result.sessionId)
|
|
78
79
|
rec.nativeSessionId = result.sessionId;
|
|
79
80
|
if (result.text && !rec.title)
|
|
@@ -82,6 +83,43 @@ export class FsSessionStore {
|
|
|
82
83
|
rec.preview = result.text.replace(/\s+/g, ' ').trim().slice(0, 200) || rec.preview || null;
|
|
83
84
|
await this.save(rec);
|
|
84
85
|
}
|
|
86
|
+
async markRunning(agent, sessionId, owner) {
|
|
87
|
+
const rec = await this.get(agent, sessionId);
|
|
88
|
+
if (!rec)
|
|
89
|
+
return;
|
|
90
|
+
rec.runState = 'running';
|
|
91
|
+
rec.runDetail = null;
|
|
92
|
+
rec.runPid = owner.pid;
|
|
93
|
+
rec.runStartedAt = owner.startedAt;
|
|
94
|
+
await this.save(rec);
|
|
95
|
+
}
|
|
96
|
+
async reconcileRunning(isAlive) {
|
|
97
|
+
let agents = [];
|
|
98
|
+
try {
|
|
99
|
+
agents = fs.readdirSync(this.baseDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
let repaired = 0;
|
|
105
|
+
for (const agent of agents) {
|
|
106
|
+
for (const rec of await this.list(agent)) {
|
|
107
|
+
if (rec.runState !== 'running')
|
|
108
|
+
continue;
|
|
109
|
+
// Only reap a KNOWN-dead owner. No pid (legacy record) or a live pid (possibly a turn
|
|
110
|
+
// running in another process against this shared store) is left alone — never clobber a
|
|
111
|
+
// turn that might still be live elsewhere.
|
|
112
|
+
if (typeof rec.runPid !== 'number' || isAlive(rec.runPid))
|
|
113
|
+
continue;
|
|
114
|
+
rec.runState = 'incomplete';
|
|
115
|
+
rec.runDetail = rec.runDetail || 'interrupted: owner process exited';
|
|
116
|
+
rec.runPid = null;
|
|
117
|
+
await this.save(rec);
|
|
118
|
+
repaired++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return repaired;
|
|
122
|
+
}
|
|
85
123
|
async appendTurn(agent, sessionId, turn) {
|
|
86
124
|
const p = this.turnsPath(agent, sessionId);
|
|
87
125
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -108,6 +146,23 @@ export class FsSessionStore {
|
|
|
108
146
|
return out;
|
|
109
147
|
}
|
|
110
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
151
|
+
* no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
|
|
152
|
+
* The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
|
|
153
|
+
* that might still be running.
|
|
154
|
+
*/
|
|
155
|
+
export function isProcessAlive(pid) {
|
|
156
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return e?.code === 'EPERM';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
111
166
|
export function defaultBaseDir(appNamespace) {
|
|
112
167
|
return path.join(os.homedir(), `.${appNamespace}`, 'sessions');
|
|
113
168
|
}
|
|
@@ -17,7 +17,7 @@ export function emptySnapshot() {
|
|
|
17
17
|
}
|
|
18
18
|
const APPEND_FIELDS = ['text', 'reasoning'];
|
|
19
19
|
const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
|
|
20
|
-
const SCALAR_FIELDS = ['phase', 'taskId', 'sessionId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt'];
|
|
20
|
+
const SCALAR_FIELDS = ['phase', 'taskId', 'sessionId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt', 'anchor'];
|
|
21
21
|
export function diffSnapshot(prev, next) {
|
|
22
22
|
const patch = {};
|
|
23
23
|
let set;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Context replay for a SEED-mode fork — the fallback for drivers without native fork
|
|
2
|
+
// (capabilities.fork unset) or forks whose cut point could not be pinned to a native
|
|
3
|
+
// anchor. The branch starts a FRESH native session whose first prompt is prefixed with
|
|
4
|
+
// the copied transcript, role-tagged and tail-truncated. Native-mode forks never see
|
|
5
|
+
// this: their context comes from the agent's own store via resume+fork.
|
|
6
|
+
const FORK_SEED_BUDGET_CHARS = 60_000;
|
|
7
|
+
export function buildForkSeed(turns, budget = FORK_SEED_BUDGET_CHARS) {
|
|
8
|
+
const messages = [];
|
|
9
|
+
for (const t of turns) {
|
|
10
|
+
const prompt = (t.prompt || '').trim();
|
|
11
|
+
const text = (t.text || '').trim();
|
|
12
|
+
if (prompt)
|
|
13
|
+
messages.push(`User: ${prompt}`);
|
|
14
|
+
if (text)
|
|
15
|
+
messages.push(`Assistant: ${text}`);
|
|
16
|
+
}
|
|
17
|
+
if (!messages.length)
|
|
18
|
+
return null;
|
|
19
|
+
// Tail-truncate whole messages into the budget — the most recent context matters most.
|
|
20
|
+
const kept = [];
|
|
21
|
+
let used = 0;
|
|
22
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
23
|
+
const cost = messages[i].length + 1;
|
|
24
|
+
if (used + cost > budget && kept.length)
|
|
25
|
+
break;
|
|
26
|
+
kept.unshift(messages[i]);
|
|
27
|
+
used += cost;
|
|
28
|
+
}
|
|
29
|
+
return [
|
|
30
|
+
`<fork-context turns=${turns.length}>`,
|
|
31
|
+
'[This conversation was forked from an earlier session; the transcript so far follows. Continue from this context — do not re-answer it. The next user message follows the closing tag.]',
|
|
32
|
+
...kept,
|
|
33
|
+
'</fork-context>',
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type UniversalSnapshot, type SnapshotPatch, type SessionMeta, type AgentInfo, type ModelDescriptor, type EffortOption, type ToolDescriptor, type SkillDescriptor } from '../protocol/index.js';
|
|
2
2
|
import type { AgentDriver, TuiSpec } from '../contracts/driver.js';
|
|
3
3
|
import type { SessionStore, ModelResolver, ToolProvider, SystemPromptBuilder, InteractionHandler, Catalog } from '../contracts/ports.js';
|
|
4
|
-
import type { LoomIO, PromptInput, Plugin } from '../contracts/surface.js';
|
|
4
|
+
import type { LoomIO, PromptInput, ForkSessionInput, Plugin } from '../contracts/surface.js';
|
|
5
5
|
export interface HubDeps {
|
|
6
6
|
drivers: Map<string, AgentDriver>;
|
|
7
7
|
defaultAgent: string;
|
|
@@ -43,6 +43,9 @@ export declare class Hub implements LoomIO {
|
|
|
43
43
|
sessionKey: string;
|
|
44
44
|
taskId: string;
|
|
45
45
|
}>;
|
|
46
|
+
forkSession(input: ForkSessionInput): Promise<{
|
|
47
|
+
sessionKey: string;
|
|
48
|
+
}>;
|
|
46
49
|
private enqueue;
|
|
47
50
|
private runNow;
|
|
48
51
|
private promoteNext;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { diffSnapshot, emptySnapshot, makeSessionKey, splitSessionKey, } from '../protocol/index.js';
|
|
3
3
|
import { SessionRunner } from './session-runner.js';
|
|
4
|
+
import { buildForkSeed } from './fork.js';
|
|
4
5
|
export class Hub {
|
|
5
6
|
deps;
|
|
6
7
|
sessions = new Map();
|
|
@@ -74,6 +75,74 @@ export class Hub {
|
|
|
74
75
|
}
|
|
75
76
|
return { sessionKey, taskId };
|
|
76
77
|
}
|
|
78
|
+
// Branch `fromSessionKey` at a turn boundary into a NEW managed session. Copies the kept
|
|
79
|
+
// transcript prefix, pins the native keep-boundary NOW (so the branch is immune to the
|
|
80
|
+
// parent continuing afterwards), and stamps a pendingFork the first prompt() dispatch
|
|
81
|
+
// consumes (fork-on-dispatch). The parent — record, transcript, native store — is never
|
|
82
|
+
// mutated. Works for sessions the kernel never ran: a native-only session's id IS its
|
|
83
|
+
// native id (the same assumption prompt() makes when resuming one).
|
|
84
|
+
async forkSession(input) {
|
|
85
|
+
const { agent, sessionId: fromId } = splitSessionKey(input.fromSessionKey);
|
|
86
|
+
const driver = this.deps.drivers.get(agent);
|
|
87
|
+
if (!driver)
|
|
88
|
+
throw new Error(`No driver registered for agent "${agent}"`);
|
|
89
|
+
const parent = await this.deps.sessionStore.get(agent, fromId);
|
|
90
|
+
const parentNativeId = parent?.nativeSessionId || fromId;
|
|
91
|
+
const workdir = parent?.workdir || this.deps.workdir;
|
|
92
|
+
// Kept transcript prefix: through atTaskId inclusive (default: the whole transcript).
|
|
93
|
+
const turns = this.deps.sessionStore.history
|
|
94
|
+
? await this.deps.sessionStore.history(agent, fromId).catch(() => [])
|
|
95
|
+
: [];
|
|
96
|
+
let kept = turns;
|
|
97
|
+
if (input.atTaskId) {
|
|
98
|
+
const cut = turns.findIndex(t => t.taskId === input.atTaskId);
|
|
99
|
+
if (cut < 0)
|
|
100
|
+
throw new Error(`fork point ${input.atTaskId} not found in ${input.fromSessionKey}`);
|
|
101
|
+
kept = turns.slice(0, cut + 1);
|
|
102
|
+
}
|
|
103
|
+
const cutIsTail = kept.length === turns.length;
|
|
104
|
+
// Pin the native keep-boundary at fork time: explicit override → the kept turn's recorded
|
|
105
|
+
// anchor → the parent's CURRENT tail (tail cuts only). A mid cut that can't be anchored
|
|
106
|
+
// falls back to a seed fork — a tail-anchored native fork there would silently absorb the
|
|
107
|
+
// dropped turns, which is worse than the seed's lower fidelity.
|
|
108
|
+
let anchor = input.anchor ?? kept[kept.length - 1]?.anchor ?? null;
|
|
109
|
+
if (!anchor && cutIsTail && driver.resolveNativeAnchor) {
|
|
110
|
+
try {
|
|
111
|
+
anchor = (await driver.resolveNativeAnchor({ sessionId: parentNativeId, workdir })) ?? null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
anchor = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const mode = driver.capabilities?.fork && parentNativeId && (anchor || cutIsTail) ? 'native' : 'seed';
|
|
118
|
+
const { sessionId: newId } = await this.deps.sessionStore.ensure(agent, {
|
|
119
|
+
workdir, title: input.title ?? parent?.title ?? null,
|
|
120
|
+
});
|
|
121
|
+
for (const turn of kept) {
|
|
122
|
+
try {
|
|
123
|
+
await this.deps.sessionStore.appendTurn?.(agent, newId, turn);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
this.deps.log?.(`[hub] fork transcript copy failed ${newId}: ${e?.message || e}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const rec = await this.deps.sessionStore.get(agent, newId);
|
|
130
|
+
if (rec) {
|
|
131
|
+
const last = kept[kept.length - 1];
|
|
132
|
+
rec.model = parent?.model ?? last?.model ?? null;
|
|
133
|
+
rec.effort = parent?.effort ?? last?.effort ?? null;
|
|
134
|
+
rec.preview = parent?.preview ?? null;
|
|
135
|
+
// ensure() stamps fresh records 'running' (prompt() marks them properly right after);
|
|
136
|
+
// a fork has not dispatched anything yet — settle it so it can't read as a live turn.
|
|
137
|
+
rec.runState = 'completed';
|
|
138
|
+
rec.runDetail = null;
|
|
139
|
+
rec.forkedFrom = { sessionKey: input.fromSessionKey, taskId: input.atTaskId ?? last?.taskId ?? null };
|
|
140
|
+
rec.pendingFork = { parentNativeSessionId: parentNativeId, anchor, mode };
|
|
141
|
+
await this.deps.sessionStore.save(rec);
|
|
142
|
+
}
|
|
143
|
+
this.deps.log?.(`[hub] forked ${input.fromSessionKey} -> ${agent}:${newId} (${mode}${anchor ? `, anchor ${anchor}` : ''}, ${kept.length}/${turns.length} turns)`);
|
|
144
|
+
return { sessionKey: makeSessionKey(agent, newId) };
|
|
145
|
+
}
|
|
77
146
|
enqueue(item) {
|
|
78
147
|
const q = this.waiting.get(item.sessionKey) ?? [];
|
|
79
148
|
q.push(item);
|
|
@@ -94,9 +163,17 @@ export class Hub {
|
|
|
94
163
|
};
|
|
95
164
|
this.sessions.set(sessionKey, entry);
|
|
96
165
|
this.emitSessionsChanged();
|
|
166
|
+
// Stamp the persisted record as running under THIS process, so runState is authoritative for
|
|
167
|
+
// the whole turn (new AND resumed) and a crash strands an owner pid that boot reconciliation
|
|
168
|
+
// can reap. Best-effort: a store without markRunning just won't be reconcilable.
|
|
169
|
+
await this.deps.sessionStore.markRunning?.(agent, sessionId, { pid: process.pid, startedAt: Date.now() }).catch(() => { });
|
|
97
170
|
// Resolve injection / tools / resume-target at run time so a promoted turn sees the
|
|
98
171
|
// prior turn's native session id and any refreshed credentials/tools.
|
|
99
172
|
const rec = await this.deps.sessionStore.get(agent, sessionId);
|
|
173
|
+
// Fork-on-dispatch: a pendingFork rides every dispatch until a native id lands (a failed
|
|
174
|
+
// first turn keeps the intent, so the retry forks again). Native mode resumes the PARENT's
|
|
175
|
+
// native id with the fork flag; seed mode starts fresh and replays the copied transcript.
|
|
176
|
+
const pendingFork = rec && !rec.nativeSessionId ? rec.pendingFork ?? null : null;
|
|
100
177
|
const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
|
|
101
178
|
const model = injection?.model ?? input.model ?? null;
|
|
102
179
|
const effort = input.effort ?? null;
|
|
@@ -109,11 +186,29 @@ export class Hub {
|
|
|
109
186
|
{ env: tools.env },
|
|
110
187
|
...pluginParts,
|
|
111
188
|
]);
|
|
112
|
-
|
|
189
|
+
// A seed fork opens a brand-new native session, so it takes the first-turn system prompt;
|
|
190
|
+
// a native fork is resume-shaped (the parent's opening turn already carried it).
|
|
191
|
+
const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted || pendingFork?.mode === 'seed');
|
|
192
|
+
let driverSessionId = rec?.nativeSessionId || (input.sessionKey ? sessionId : null);
|
|
193
|
+
let driverFork = null;
|
|
194
|
+
let driverPrompt = input.prompt;
|
|
195
|
+
if (pendingFork) {
|
|
196
|
+
if (pendingFork.mode === 'native' && pendingFork.parentNativeSessionId) {
|
|
197
|
+
driverSessionId = pendingFork.parentNativeSessionId;
|
|
198
|
+
driverFork = { anchor: pendingFork.anchor };
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
driverSessionId = null;
|
|
202
|
+
const seed = buildForkSeed(await this.getHistory(sessionKey));
|
|
203
|
+
if (seed)
|
|
204
|
+
driverPrompt = `${seed}\n\n${input.prompt}`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
113
207
|
const turnInput = {
|
|
114
|
-
prompt:
|
|
208
|
+
prompt: driverPrompt,
|
|
115
209
|
attachments: input.attachments,
|
|
116
|
-
sessionId:
|
|
210
|
+
sessionId: driverSessionId,
|
|
211
|
+
fork: driverFork,
|
|
117
212
|
workdir,
|
|
118
213
|
model, effort, systemPrompt,
|
|
119
214
|
env: spawn.env,
|
|
@@ -130,6 +225,20 @@ export class Hub {
|
|
|
130
225
|
runner.run(driver, turnInput, input.prompt, model, effort)
|
|
131
226
|
.then(async (result) => {
|
|
132
227
|
await this.deps.sessionStore.recordResult(agent, sessionId, result);
|
|
228
|
+
// The branch materialized natively (recordResult stored its id) — consume the fork
|
|
229
|
+
// intent. A turn that died before a native id landed keeps it, so the retry re-forks.
|
|
230
|
+
if (pendingFork && result.sessionId) {
|
|
231
|
+
try {
|
|
232
|
+
const settled = await this.deps.sessionStore.get(agent, sessionId);
|
|
233
|
+
if (settled?.pendingFork) {
|
|
234
|
+
settled.pendingFork = null;
|
|
235
|
+
await this.deps.sessionStore.save(settled);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.deps.log?.(`[hub] pendingFork clear failed ${sessionKey}: ${e?.message || e}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
133
242
|
// Persist the completed turn's final snapshot as a transcript entry. The runner's
|
|
134
243
|
// snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
|
|
135
244
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, } from '../ports/defaults.js';
|
|
1
|
+
import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, isProcessAlive, } from '../ports/defaults.js';
|
|
2
2
|
import { Hub } from './hub.js';
|
|
3
3
|
import { PtyBridge } from './pty.js';
|
|
4
4
|
import { attachTui } from './tui.js';
|
|
@@ -59,6 +59,17 @@ export function createLoom(config = {}) {
|
|
|
59
59
|
if (started)
|
|
60
60
|
return;
|
|
61
61
|
started = true;
|
|
62
|
+
// Repair sessions stranded at runState:'running' by a previous process that died mid-turn
|
|
63
|
+
// (crash / kill / power loss) before its driver could settle. Safe under a shared store:
|
|
64
|
+
// only records whose owner pid is dead are reaped (see reconcileRunning). Best-effort.
|
|
65
|
+
try {
|
|
66
|
+
const repaired = await sessionStore.reconcileRunning?.(isProcessAlive);
|
|
67
|
+
if (repaired)
|
|
68
|
+
log(`[loom] reconciled ${repaired} orphaned running session(s) at startup`);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
log(`[loom] startup reconcile failed: ${e?.message || e}`);
|
|
72
|
+
}
|
|
62
73
|
for (const t of surfaces) {
|
|
63
74
|
await t.start(hub, { openTui }); // hub = Lane S (LoomIO); openTui = Lane R
|
|
64
75
|
log(`[loom] terminal started: ${t.id}`);
|
|
@@ -60,6 +60,8 @@ export class SessionRunner {
|
|
|
60
60
|
this.snapshot.reasoning = result.reasoning;
|
|
61
61
|
if (result.sessionId)
|
|
62
62
|
this.snapshot.sessionId = result.sessionId;
|
|
63
|
+
if (result.anchor)
|
|
64
|
+
this.snapshot.anchor = result.anchor;
|
|
63
65
|
if (result.usage)
|
|
64
66
|
this.snapshot.usage = result.usage;
|
|
65
67
|
this.snapshot.error = result.error ?? null;
|
|
@@ -17,6 +17,11 @@ export interface ManagedSessionInfo {
|
|
|
17
17
|
createdAt: string | null;
|
|
18
18
|
updatedAt: string | null;
|
|
19
19
|
messageCount?: number | null;
|
|
20
|
+
/** Fork lineage when this session was branched off another (see CoreSessionRecord.forkedFrom). */
|
|
21
|
+
forkedFrom?: {
|
|
22
|
+
sessionKey: string;
|
|
23
|
+
taskId?: string | null;
|
|
24
|
+
} | null;
|
|
20
25
|
}
|
|
21
26
|
export interface ListSessionsOptions {
|
|
22
27
|
/**
|
|
@@ -25,6 +25,7 @@ function managedToInfo(agent, rec) {
|
|
|
25
25
|
createdAt: rec.createdAt ?? null,
|
|
26
26
|
updatedAt: rec.updatedAt ?? null,
|
|
27
27
|
messageCount: null, // managed records don't track a turn count; native rows carry the head-approx one
|
|
28
|
+
forkedFrom: rec.forkedFrom ?? null,
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
function nativeToInfo(agent, n) {
|