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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.68",
3
+ "version": "0.4.70",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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('--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
  }
@@ -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 settle = () => { };
267
- const turnDone = new Promise((res) => { settle = res; });
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
- ? 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 }))
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
  }
@@ -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);
@@ -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
- 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
+ }
113
207
  const turnInput = {
114
- prompt: input.prompt,
208
+ prompt: driverPrompt,
115
209
  attachments: input.attachments,
116
- sessionId: rec?.nativeSessionId || (input.sessionKey ? sessionId : null),
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) {