@pikiloom/kernel 0.3.11 → 0.3.13

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.
@@ -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;
@@ -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('--resume', input.sessionId);
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
  }
@@ -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
- ? await srv.request('thread/resume', { threadId: input.sessionId, ...threadParams })
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 = [];
@@ -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
+ }
@@ -93,6 +93,7 @@ export interface UniversalSnapshot {
93
93
  incomplete?: boolean;
94
94
  startedAt?: number;
95
95
  updatedAt: number;
96
+ anchor?: string | null;
96
97
  }
97
98
  export interface SessionMeta {
98
99
  sessionKey: string;
@@ -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,2 @@
1
+ import type { UniversalSnapshot } from '../protocol/index.js';
2
+ export declare function buildForkSeed(turns: UniversalSnapshot[], budget?: number): string | null;
@@ -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);
@@ -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
- const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted);
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: input.prompt,
208
+ prompt: driverPrompt,
119
209
  attachments: input.attachments,
120
- sessionId: rec?.nativeSessionId || (input.sessionKey ? sessionId : null),
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.11",
3
+ "version": "0.3.13",
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",