@pikiloom/kernel 0.3.11 → 0.3.12
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/contracts/driver.d.ts +9 -0
- package/dist/contracts/ports.d.ts +9 -0
- package/dist/contracts/surface.d.ts +9 -0
- package/dist/drivers/claude.d.ts +9 -0
- package/dist/drivers/claude.js +45 -6
- package/dist/drivers/codex.d.ts +5 -0
- package/dist/drivers/codex.js +15 -4
- package/dist/drivers/native.d.ts +4 -0
- package/dist/drivers/native.js +95 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +1 -1
- package/dist/runtime/fork.d.ts +2 -0
- package/dist/runtime/fork.js +35 -0
- package/dist/runtime/hub.d.ts +4 -1
- package/dist/runtime/hub.js +108 -3
- package/dist/runtime/session-runner.js +2 -0
- package/dist/workspace/sessions.d.ts +5 -0
- package/dist/workspace/sessions.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -16,6 +16,15 @@ export interface CoreSessionRecord {
|
|
|
16
16
|
runDetail?: string | null;
|
|
17
17
|
runPid?: number | null;
|
|
18
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;
|
|
19
28
|
}
|
|
20
29
|
export interface SessionStore {
|
|
21
30
|
ensure(agent: string, opts: {
|
|
@@ -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;
|
package/dist/drivers/claude.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/claude.js
CHANGED
|
@@ -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
|
package/dist/drivers/codex.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/drivers/codex.js
CHANGED
|
@@ -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
|
}
|
|
@@ -306,11 +306,15 @@ export class CodexDriver {
|
|
|
306
306
|
threadParams.approvalPolicy = 'never';
|
|
307
307
|
threadParams.sandbox = 'danger-full-access';
|
|
308
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.
|
|
309
311
|
const threadResp = input.sessionId
|
|
310
|
-
?
|
|
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 }))
|
|
311
315
|
: await srv.request('thread/start', threadParams);
|
|
312
316
|
if (threadResp.error)
|
|
313
|
-
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' };
|
|
314
318
|
const threadId = threadResp.result?.thread?.id ?? input.sessionId ?? null;
|
|
315
319
|
if (threadId && threadId !== state.sessionId) {
|
|
316
320
|
state.sessionId = threadId;
|
|
@@ -495,6 +499,8 @@ export class CodexDriver {
|
|
|
495
499
|
error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
|
|
496
500
|
stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
|
|
497
501
|
sessionId: state.sessionId,
|
|
502
|
+
// Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
|
|
503
|
+
anchor: state.turnId,
|
|
498
504
|
usage,
|
|
499
505
|
};
|
|
500
506
|
}
|
|
@@ -513,6 +519,11 @@ export class CodexDriver {
|
|
|
513
519
|
listNativeSessions(opts) {
|
|
514
520
|
return discoverCodexNativeSessions(opts.workdir, { limit: opts.limit });
|
|
515
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
|
+
}
|
|
516
527
|
}
|
|
517
528
|
function buildTurnInput(prompt, attachments) {
|
|
518
529
|
const input = [];
|
package/dist/drivers/native.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/native.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/protocol/index.d.ts
CHANGED
package/dist/protocol/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/hub.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/hub.js
CHANGED
|
@@ -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);
|
|
@@ -101,6 +170,10 @@ export class Hub {
|
|
|
101
170
|
// Resolve injection / tools / resume-target at run time so a promoted turn sees the
|
|
102
171
|
// prior turn's native session id and any refreshed credentials/tools.
|
|
103
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;
|
|
104
177
|
const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
|
|
105
178
|
const model = injection?.model ?? input.model ?? null;
|
|
106
179
|
const effort = input.effort ?? null;
|
|
@@ -113,11 +186,29 @@ export class Hub {
|
|
|
113
186
|
{ env: tools.env },
|
|
114
187
|
...pluginParts,
|
|
115
188
|
]);
|
|
116
|
-
|
|
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
|
+
}
|
|
117
207
|
const turnInput = {
|
|
118
|
-
prompt:
|
|
208
|
+
prompt: driverPrompt,
|
|
119
209
|
attachments: input.attachments,
|
|
120
|
-
sessionId:
|
|
210
|
+
sessionId: driverSessionId,
|
|
211
|
+
fork: driverFork,
|
|
121
212
|
workdir,
|
|
122
213
|
model, effort, systemPrompt,
|
|
123
214
|
env: spawn.env,
|
|
@@ -134,6 +225,20 @@ export class Hub {
|
|
|
134
225
|
runner.run(driver, turnInput, input.prompt, model, effort)
|
|
135
226
|
.then(async (result) => {
|
|
136
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
|
+
}
|
|
137
242
|
// Persist the completed turn's final snapshot as a transcript entry. The runner's
|
|
138
243
|
// snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
|
|
139
244
|
try {
|
|
@@ -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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikiloom/kernel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|