@pikiloom/kernel 0.3.14 → 0.3.16
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 +4 -0
- package/dist/contracts/ports.d.ts +4 -0
- package/dist/contracts/surface.d.ts +7 -0
- package/dist/drivers/claude.d.ts +3 -0
- package/dist/drivers/claude.js +9 -4
- package/dist/drivers/codex.d.ts +8 -1
- package/dist/drivers/codex.js +151 -7
- package/dist/ports/defaults.d.ts +1 -0
- package/dist/ports/defaults.js +20 -0
- package/dist/runtime/hub.d.ts +7 -0
- package/dist/runtime/hub.js +71 -0
- package/package.json +1 -1
|
@@ -6,6 +6,9 @@ export interface AgentTurnInput {
|
|
|
6
6
|
fork?: {
|
|
7
7
|
anchor?: string | null;
|
|
8
8
|
} | null;
|
|
9
|
+
rewind?: {
|
|
10
|
+
anchor?: string | null;
|
|
11
|
+
} | null;
|
|
9
12
|
workdir: string;
|
|
10
13
|
model?: string | null;
|
|
11
14
|
effort?: string | null;
|
|
@@ -110,6 +113,7 @@ export interface AgentDriver {
|
|
|
110
113
|
resume?: boolean;
|
|
111
114
|
tui?: boolean;
|
|
112
115
|
fork?: boolean;
|
|
116
|
+
rewind?: boolean;
|
|
113
117
|
};
|
|
114
118
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
115
119
|
tui?(input: TuiInput): TuiSpec;
|
|
@@ -25,6 +25,9 @@ export interface CoreSessionRecord {
|
|
|
25
25
|
anchor: string | null;
|
|
26
26
|
mode: 'native' | 'seed';
|
|
27
27
|
} | null;
|
|
28
|
+
pendingRewind?: {
|
|
29
|
+
anchor: string | null;
|
|
30
|
+
} | null;
|
|
28
31
|
}
|
|
29
32
|
export interface SessionStore {
|
|
30
33
|
ensure(agent: string, opts: {
|
|
@@ -48,6 +51,7 @@ export interface SessionStore {
|
|
|
48
51
|
reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
|
|
49
52
|
appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
50
53
|
history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
54
|
+
truncateTurns?(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
|
|
51
55
|
}
|
|
52
56
|
export interface ModelInjection {
|
|
53
57
|
model?: string | null;
|
|
@@ -23,6 +23,13 @@ export interface LoomIO {
|
|
|
23
23
|
forkSession(input: ForkSessionInput): Promise<{
|
|
24
24
|
sessionKey: string;
|
|
25
25
|
}>;
|
|
26
|
+
rewindSession(input: {
|
|
27
|
+
sessionKey: string;
|
|
28
|
+
atTaskId: string;
|
|
29
|
+
anchor?: string | null;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
sessionKey: string;
|
|
32
|
+
}>;
|
|
26
33
|
stop(sessionKey: string): boolean;
|
|
27
34
|
steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
|
|
28
35
|
interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
|
package/dist/drivers/claude.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
9
9
|
resume: boolean;
|
|
10
10
|
tui: boolean;
|
|
11
11
|
fork: boolean;
|
|
12
|
+
rewind: boolean;
|
|
12
13
|
};
|
|
13
14
|
constructor(bin?: string);
|
|
14
15
|
tui(input: TuiInput): TuiSpec;
|
|
@@ -25,6 +26,8 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
25
26
|
}
|
|
26
27
|
export declare function claudeResumeArgs(sessionId: string, fork?: {
|
|
27
28
|
anchor?: string | null;
|
|
29
|
+
} | null, rewind?: {
|
|
30
|
+
anchor?: string | null;
|
|
28
31
|
} | null): string[];
|
|
29
32
|
export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
|
|
30
33
|
export declare function claudeBgHoldCapMs(): number;
|
package/dist/drivers/claude.js
CHANGED
|
@@ -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, fork: true };
|
|
12
|
+
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true, rewind: 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(...claudeResumeArgs(input.sessionId, input.fork));
|
|
46
|
+
args.push(...claudeResumeArgs(input.sessionId, input.fork, input.rewind));
|
|
47
47
|
if (input.systemPrompt)
|
|
48
48
|
args.push('--append-system-prompt', input.systemPrompt);
|
|
49
49
|
if (input.mcpConfigPath)
|
|
@@ -369,14 +369,19 @@ export class ClaudeDriver {
|
|
|
369
369
|
}
|
|
370
370
|
// The --resume flag family for one turn: plain resume appends to the session; a fork branches
|
|
371
371
|
// a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
|
|
372
|
-
// uuid (--resume-session-at)
|
|
373
|
-
|
|
372
|
+
// uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
|
|
373
|
+
// rebranches the SAME session in place (the transcript is a parentUuid tree — the dropped tip
|
|
374
|
+
// becomes a dead sibling and leaves the active path). Pure so tests pin the arg contract.
|
|
375
|
+
export function claudeResumeArgs(sessionId, fork, rewind) {
|
|
374
376
|
const args = ['--resume', sessionId];
|
|
375
377
|
if (fork) {
|
|
376
378
|
args.push('--fork-session');
|
|
377
379
|
if (fork.anchor)
|
|
378
380
|
args.push('--resume-session-at', fork.anchor);
|
|
379
381
|
}
|
|
382
|
+
else if (rewind?.anchor) {
|
|
383
|
+
args.push('--resume-session-at', rewind.anchor);
|
|
384
|
+
}
|
|
380
385
|
return args;
|
|
381
386
|
}
|
|
382
387
|
function claudeUsageOf(s) {
|
package/dist/drivers/codex.d.ts
CHANGED
|
@@ -19,8 +19,15 @@ export declare function captureCodexAgentMessage(item: any, s: CodexContentState
|
|
|
19
19
|
export declare function captureCodexReasoning(text: string, s: CodexContentState, emit: (e: DriverEvent) => void): void;
|
|
20
20
|
export declare function codexFinalText(s: CodexContentState): string;
|
|
21
21
|
export declare function codexFinalReasoning(s: CodexContentState): string;
|
|
22
|
+
export interface CodexLivenessOptions {
|
|
23
|
+
/** Silence after an accepted steer before the turn is healed (interrupt + replay). */
|
|
24
|
+
steerStallMs?: number;
|
|
25
|
+
/** Idle silence (no running tool, no pending HITL request) before the turn is force-closed. */
|
|
26
|
+
turnStallMs?: number;
|
|
27
|
+
}
|
|
22
28
|
export declare class CodexDriver implements AgentDriver {
|
|
23
29
|
private readonly bin;
|
|
30
|
+
private readonly liveness;
|
|
24
31
|
readonly id = "codex";
|
|
25
32
|
readonly capabilities: {
|
|
26
33
|
steer: boolean;
|
|
@@ -29,7 +36,7 @@ export declare class CodexDriver implements AgentDriver {
|
|
|
29
36
|
tui: boolean;
|
|
30
37
|
fork: boolean;
|
|
31
38
|
};
|
|
32
|
-
constructor(bin?: string);
|
|
39
|
+
constructor(bin?: string, liveness?: CodexLivenessOptions);
|
|
33
40
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
34
41
|
tui(input: TuiInput): TuiSpec;
|
|
35
42
|
listNativeSessions(opts: {
|
package/dist/drivers/codex.js
CHANGED
|
@@ -234,12 +234,47 @@ export function codexFinalText(s) {
|
|
|
234
234
|
export function codexFinalReasoning(s) {
|
|
235
235
|
return s.reasoning.trim() ? s.reasoning : s.thinkParts.join('\n\n');
|
|
236
236
|
}
|
|
237
|
+
// ── Turn liveness (steer race + silent-stall recovery) ──────────────────────────
|
|
238
|
+
// codex app-server has a turn-boundary race: a `turn/steer` that lands in the instant an
|
|
239
|
+
// item completes is ACCEPTED (recorded into the rollout) but never dispatched — the agent
|
|
240
|
+
// loop goes idle, no further notifications arrive, and `turn/completed` never fires, so the
|
|
241
|
+
// turn hangs forever while the process sits at 0% CPU (observed on codex-cli 0.144.x;
|
|
242
|
+
// same family as openai/codex#15714 / #23807). Two defenses below:
|
|
243
|
+
//
|
|
244
|
+
// 1. A successful steer is treated as ACCEPTED, NOT CONSUMED: it stays pending until a
|
|
245
|
+
// progress notification proves the loop picked it up. If the turn instead goes silent
|
|
246
|
+
// (or completes without consuming it), the driver heals in place — interrupt the wedged
|
|
247
|
+
// turn and restart it with the same input. The steered text is already in the thread
|
|
248
|
+
// history, so the worst false-positive cost is one duplicated user message; the turn
|
|
249
|
+
// keeps running under the same run()/task, invisible to upper layers.
|
|
250
|
+
// 2. A generic silence backstop: a turn with no notifications for a long stretch while
|
|
251
|
+
// nothing is visibly in flight (no running tool call, no server->client request awaiting
|
|
252
|
+
// a human) is declared stalled and force-closed, so the session ends as a visible error
|
|
253
|
+
// instead of spinning forever.
|
|
254
|
+
const CODEX_STEER_STALL_MS = 300_000; // matches codex core's own 300s stream watchdog
|
|
255
|
+
const CODEX_TURN_STALL_MS = 900_000; // long: silent thinking stretches are legitimate
|
|
256
|
+
// Notifications that prove the agent loop is making forward progress AFTER a steer.
|
|
257
|
+
// item/completed and tokenUsage are deliberately excluded — they can be the trailing edge
|
|
258
|
+
// of work that finished before the steer landed (the exact race signature). Progress that
|
|
259
|
+
// arrives within CODEX_STEER_CONSUMED_MS of the steer only refreshes the pending marker's
|
|
260
|
+
// clock rather than clearing it: the app-server can flush pre-acceptance stream output in
|
|
261
|
+
// the same write as the steer response, so near-simultaneous events are not proof the
|
|
262
|
+
// loop survived the injection. Progress beyond that window is.
|
|
263
|
+
const CODEX_STEER_CONSUMED_MS = 2_000;
|
|
264
|
+
const CODEX_PROGRESS_METHODS = new Set([
|
|
265
|
+
'turn/started', 'item/started', 'item/agentMessage/delta',
|
|
266
|
+
'item/reasoning/textDelta', 'item/reasoning/summaryTextDelta',
|
|
267
|
+
'item/commandExecution/outputDelta', 'item/fileChange/outputDelta',
|
|
268
|
+
'item/fileChange/patchUpdated', 'turn/plan/updated', 'rawResponseItem/completed',
|
|
269
|
+
]);
|
|
237
270
|
export class CodexDriver {
|
|
238
271
|
bin;
|
|
272
|
+
liveness;
|
|
239
273
|
id = 'codex';
|
|
240
274
|
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
|
|
241
|
-
constructor(bin = 'codex') {
|
|
275
|
+
constructor(bin = 'codex', liveness = {}) {
|
|
242
276
|
this.bin = bin;
|
|
277
|
+
this.liveness = liveness;
|
|
243
278
|
}
|
|
244
279
|
async run(input, ctx) {
|
|
245
280
|
// BYOK provider routing arrives as `-c key=value` overrides; pass them through so the
|
|
@@ -261,6 +296,15 @@ export class CodexDriver {
|
|
|
261
296
|
const deltaItems = new Set();
|
|
262
297
|
let lastTextItemId = null;
|
|
263
298
|
let steerRegistered = false;
|
|
299
|
+
// Liveness state (see the CODEX_STEER_STALL_MS block comment above).
|
|
300
|
+
const steerStallMs = this.liveness.steerStallMs ?? CODEX_STEER_STALL_MS;
|
|
301
|
+
const turnStallMs = this.liveness.turnStallMs ?? CODEX_TURN_STALL_MS;
|
|
302
|
+
let lastEventAt = Date.now();
|
|
303
|
+
let pendingSteer = null;
|
|
304
|
+
let pendingServerRequests = 0;
|
|
305
|
+
let healing = false;
|
|
306
|
+
let stalled = false;
|
|
307
|
+
let livenessTimer = null;
|
|
264
308
|
if (!srv.start())
|
|
265
309
|
return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
|
|
266
310
|
let settled = false;
|
|
@@ -320,23 +364,78 @@ export class CodexDriver {
|
|
|
320
364
|
state.sessionId = threadId;
|
|
321
365
|
ctx.emit({ type: 'session', sessionId: threadId });
|
|
322
366
|
}
|
|
367
|
+
// Heal a wedged/lost steer in place: (optionally) interrupt the dead turn, then restart
|
|
368
|
+
// it with the same input. Runs under the SAME run()/turnDone, so upper layers just see
|
|
369
|
+
// the turn continue. `healing` suppresses the interrupt's own turn/completed echo.
|
|
370
|
+
const heal = async (opts) => {
|
|
371
|
+
if (settled || healing || !pendingSteer)
|
|
372
|
+
return;
|
|
373
|
+
healing = true;
|
|
374
|
+
const replayInput = pendingSteer.input;
|
|
375
|
+
pendingSteer = null;
|
|
376
|
+
if (opts.interrupt && state.sessionId && state.turnId) {
|
|
377
|
+
await srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000);
|
|
378
|
+
}
|
|
379
|
+
if (settled) {
|
|
380
|
+
healing = false;
|
|
381
|
+
return;
|
|
382
|
+
} // raced with abort / process death
|
|
383
|
+
const resp = await srv.request('turn/start', {
|
|
384
|
+
threadId: state.sessionId,
|
|
385
|
+
input: replayInput,
|
|
386
|
+
model: input.model || undefined,
|
|
387
|
+
effort: input.effort || undefined,
|
|
388
|
+
});
|
|
389
|
+
if (settled) {
|
|
390
|
+
healing = false;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (resp.error) {
|
|
394
|
+
state.status = 'error';
|
|
395
|
+
state.error = `steer recovery failed: ${resp.error.message || 'turn/start failed'}`;
|
|
396
|
+
healing = false;
|
|
397
|
+
settle();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
state.turnId = resp.result?.turn?.id ?? state.turnId;
|
|
401
|
+
lastEventAt = Date.now();
|
|
402
|
+
healing = false;
|
|
403
|
+
};
|
|
323
404
|
// Codex emits no explicit compaction event, so track peak occupancy: a sharp
|
|
324
405
|
// mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
|
|
325
406
|
let compactPeakTokens = 0;
|
|
326
407
|
srv.onNotification((method, params) => {
|
|
327
408
|
if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
|
|
328
409
|
return;
|
|
410
|
+
lastEventAt = Date.now();
|
|
411
|
+
if (pendingSteer && CODEX_PROGRESS_METHODS.has(method)) {
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
if (now - pendingSteer.at >= CODEX_STEER_CONSUMED_MS)
|
|
414
|
+
pendingSteer = null; // loop provably alive post-steer
|
|
415
|
+
else
|
|
416
|
+
pendingSteer.progressAt = now; // maybe pre-acceptance flush — keep watching
|
|
417
|
+
}
|
|
329
418
|
switch (method) {
|
|
330
419
|
case 'turn/started':
|
|
331
420
|
state.turnId = params?.turn?.id ?? null;
|
|
332
421
|
if (!steerRegistered && state.turnId) {
|
|
333
422
|
steerRegistered = true;
|
|
334
423
|
ctx.registerSteer(async (prompt, attachments = []) => {
|
|
335
|
-
if (!state.sessionId || !state.turnId)
|
|
424
|
+
if (settled || !state.sessionId || !state.turnId)
|
|
336
425
|
return false;
|
|
337
|
-
const
|
|
338
|
-
|
|
426
|
+
const steerInput = buildTurnInput(prompt, attachments);
|
|
427
|
+
// Arm BEFORE sending — accepted, not yet consumed. Arming after the response
|
|
428
|
+
// would race the response's own microtask against progress notifications the
|
|
429
|
+
// server flushed in the same write, mis-arming against already-consumed steers.
|
|
430
|
+
// Back-to-back steers accumulate so a heal replays everything swallowed.
|
|
431
|
+
const armed = { input: [...(pendingSteer?.input ?? []), ...steerInput], at: Date.now(), progressAt: null };
|
|
432
|
+
pendingSteer = armed;
|
|
433
|
+
const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: steerInput }, 30_000);
|
|
434
|
+
if (r.error) {
|
|
435
|
+
if (pendingSteer === armed)
|
|
436
|
+
pendingSteer = null; // rejected — nothing to watch
|
|
339
437
|
return false;
|
|
438
|
+
}
|
|
340
439
|
state.turnId = r.result?.turnId ?? state.turnId;
|
|
341
440
|
return true;
|
|
342
441
|
});
|
|
@@ -466,11 +565,21 @@ export class CodexDriver {
|
|
|
466
565
|
}
|
|
467
566
|
case 'turn/completed': {
|
|
468
567
|
const turn = params?.turn || {};
|
|
568
|
+
applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
|
|
569
|
+
ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
|
|
570
|
+
if (healing)
|
|
571
|
+
break; // completion echo of the turn heal() just interrupted
|
|
572
|
+
if (pendingSteer && !pendingSteer.progressAt && !ctx.signal.aborted && (turn.status ?? 'completed') === 'completed') {
|
|
573
|
+
// The other face of the steer race: the turn finished without ever consuming
|
|
574
|
+
// the injected input. Replay it as a fresh turn instead of settling — the
|
|
575
|
+
// upper layers already dequeued the message on steer-ok, so settling here
|
|
576
|
+
// would silently drop it.
|
|
577
|
+
void heal({ interrupt: false });
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
469
580
|
state.status = turn.status ?? 'completed';
|
|
470
581
|
if (turn.error)
|
|
471
582
|
state.error = turn.error.message || turn.error.code || 'turn error';
|
|
472
|
-
applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
|
|
473
|
-
ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
|
|
474
583
|
settle();
|
|
475
584
|
break;
|
|
476
585
|
}
|
|
@@ -480,6 +589,7 @@ export class CodexDriver {
|
|
|
480
589
|
// accept approvals by default (parity with the legacy codex driver). Never throw —
|
|
481
590
|
// an unanswerable request degrades to an empty response, not a JSON-RPC error.
|
|
482
591
|
srv.onRequest(async (method, params, id) => {
|
|
592
|
+
pendingServerRequests++; // a request awaiting a human legitimately silences the turn
|
|
483
593
|
try {
|
|
484
594
|
if (method === 'item/tool/requestUserInput') {
|
|
485
595
|
const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
|
|
@@ -497,6 +607,9 @@ export class CodexDriver {
|
|
|
497
607
|
catch {
|
|
498
608
|
return {};
|
|
499
609
|
}
|
|
610
|
+
finally {
|
|
611
|
+
pendingServerRequests--;
|
|
612
|
+
}
|
|
500
613
|
});
|
|
501
614
|
const turnResp = await srv.request('turn/start', {
|
|
502
615
|
threadId: state.sessionId,
|
|
@@ -506,6 +619,35 @@ export class CodexDriver {
|
|
|
506
619
|
});
|
|
507
620
|
if (turnResp.error)
|
|
508
621
|
return { ok: false, text: state.text, error: turnResp.error.message || 'turn/start failed', stopReason: 'error', sessionId: state.sessionId };
|
|
622
|
+
// Liveness checker: heals a stalled steer, force-closes a silently dead turn. The
|
|
623
|
+
// silence clock only accumulates while nothing is visibly in flight — a running tool
|
|
624
|
+
// call or a server->client request parked on a human keeps the turn alive forever.
|
|
625
|
+
const checkEveryMs = Math.max(25, Math.min(5_000, Math.floor(Math.min(steerStallMs, turnStallMs) / 4)));
|
|
626
|
+
livenessTimer = setInterval(() => {
|
|
627
|
+
if (settled || healing)
|
|
628
|
+
return;
|
|
629
|
+
const now = Date.now();
|
|
630
|
+
if (pendingSteer) {
|
|
631
|
+
if (now - (pendingSteer.progressAt ?? pendingSteer.at) >= steerStallMs)
|
|
632
|
+
void heal({ interrupt: true });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const busy = pendingServerRequests > 0 || [...toolCalls.values()].some(c => c.status === 'running');
|
|
636
|
+
if (busy) {
|
|
637
|
+
lastEventAt = now;
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (now - lastEventAt < turnStallMs)
|
|
641
|
+
return;
|
|
642
|
+
stalled = true;
|
|
643
|
+
state.error = state.error || `codex app-server went silent mid-turn (no events for ${Math.round(turnStallMs / 1000)}s); closing the turn`;
|
|
644
|
+
if (state.sessionId && state.turnId) {
|
|
645
|
+
srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
|
|
646
|
+
}
|
|
647
|
+
else
|
|
648
|
+
settle();
|
|
649
|
+
}, checkEveryMs);
|
|
650
|
+
livenessTimer.unref?.();
|
|
509
651
|
await turnDone;
|
|
510
652
|
const usage = codexUsageOf(state);
|
|
511
653
|
const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
|
|
@@ -515,7 +657,7 @@ export class CodexDriver {
|
|
|
515
657
|
text: codexFinalText(state),
|
|
516
658
|
reasoning: finalReasoning || undefined,
|
|
517
659
|
error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
|
|
518
|
-
stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
|
|
660
|
+
stopReason: ctx.signal.aborted ? 'interrupted' : stalled ? 'stalled' : (state.status || 'end_turn'),
|
|
519
661
|
sessionId: state.sessionId,
|
|
520
662
|
// Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
|
|
521
663
|
anchor: state.turnId,
|
|
@@ -523,6 +665,8 @@ export class CodexDriver {
|
|
|
523
665
|
};
|
|
524
666
|
}
|
|
525
667
|
finally {
|
|
668
|
+
if (livenessTimer)
|
|
669
|
+
clearInterval(livenessTimer);
|
|
526
670
|
srv.kill();
|
|
527
671
|
}
|
|
528
672
|
}
|
package/dist/ports/defaults.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare class FsSessionStore implements SessionStore {
|
|
|
32
32
|
reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
|
|
33
33
|
appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
34
34
|
history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
35
|
+
truncateTurns(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
package/dist/ports/defaults.js
CHANGED
|
@@ -145,6 +145,26 @@ export class FsSessionStore {
|
|
|
145
145
|
}
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
|
+
// Rewrite turns.jsonl to the prefix through `throughTaskId` inclusive (in-place rewind). null
|
|
149
|
+
// clears the transcript; an unknown id keeps everything (no-op) so a stale cut can't wipe
|
|
150
|
+
// history. Atomic (temp file + rename) so a crash can't leave a half-written store.
|
|
151
|
+
async truncateTurns(agent, sessionId, throughTaskId) {
|
|
152
|
+
const all = await this.history(agent, sessionId);
|
|
153
|
+
let kept;
|
|
154
|
+
if (throughTaskId == null)
|
|
155
|
+
kept = [];
|
|
156
|
+
else {
|
|
157
|
+
const cut = all.findIndex((t) => t.taskId === throughTaskId);
|
|
158
|
+
if (cut < 0)
|
|
159
|
+
return; // unknown cut — leave the transcript untouched
|
|
160
|
+
kept = all.slice(0, cut + 1);
|
|
161
|
+
}
|
|
162
|
+
const p = this.turnsPath(agent, sessionId);
|
|
163
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
164
|
+
const tmp = `${p}.tmp-${process.pid}`;
|
|
165
|
+
fs.writeFileSync(tmp, kept.map((t) => JSON.stringify(t)).join('\n') + (kept.length ? '\n' : ''));
|
|
166
|
+
fs.renameSync(tmp, p);
|
|
167
|
+
}
|
|
148
168
|
}
|
|
149
169
|
/**
|
|
150
170
|
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
package/dist/runtime/hub.d.ts
CHANGED
|
@@ -46,6 +46,13 @@ export declare class Hub implements LoomIO {
|
|
|
46
46
|
forkSession(input: ForkSessionInput): Promise<{
|
|
47
47
|
sessionKey: string;
|
|
48
48
|
}>;
|
|
49
|
+
rewindSession(input: {
|
|
50
|
+
sessionKey: string;
|
|
51
|
+
atTaskId: string;
|
|
52
|
+
anchor?: string | null;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
}>;
|
|
49
56
|
private enqueue;
|
|
50
57
|
private runNow;
|
|
51
58
|
private promoteNext;
|
package/dist/runtime/hub.js
CHANGED
|
@@ -143,6 +143,52 @@ export class Hub {
|
|
|
143
143
|
this.deps.log?.(`[hub] forked ${input.fromSessionKey} -> ${agent}:${newId} (${mode}${anchor ? `, anchor ${anchor}` : ''}, ${kept.length}/${turns.length} turns)`);
|
|
144
144
|
return { sessionKey: makeSessionKey(agent, newId) };
|
|
145
145
|
}
|
|
146
|
+
// Rewind `sessionKey` IN PLACE to a turn boundary: drop the transcript after `atTaskId` (the
|
|
147
|
+
// last KEPT turn) and stamp a pendingRewind the next prompt() consumes (resume the SAME native
|
|
148
|
+
// session at the kept boundary, NO fork). The session id — managed AND native — is unchanged;
|
|
149
|
+
// only the dropped tip leaves the active path. Requires a rewind-capable driver, a store that
|
|
150
|
+
// can truncate, and a resolvable native anchor at the cut; otherwise throws so the caller can
|
|
151
|
+
// fall back (append on the same session, or a fork).
|
|
152
|
+
async rewindSession(input) {
|
|
153
|
+
const { agent, sessionId } = splitSessionKey(input.sessionKey);
|
|
154
|
+
const driver = this.deps.drivers.get(agent);
|
|
155
|
+
if (!driver)
|
|
156
|
+
throw new Error(`No driver registered for agent "${agent}"`);
|
|
157
|
+
if (!driver.capabilities?.rewind)
|
|
158
|
+
throw new Error(`agent "${agent}" has no in-place rewind`);
|
|
159
|
+
if (!this.deps.sessionStore.truncateTurns || !this.deps.sessionStore.history) {
|
|
160
|
+
throw new Error('session store cannot rewind (no truncateTurns/history)');
|
|
161
|
+
}
|
|
162
|
+
const rec = await this.deps.sessionStore.get(agent, sessionId);
|
|
163
|
+
if (!rec)
|
|
164
|
+
throw new Error(`rewind: session ${input.sessionKey} not found`);
|
|
165
|
+
const nativeId = rec.nativeSessionId || sessionId;
|
|
166
|
+
const turns = await this.deps.sessionStore.history(agent, sessionId).catch(() => []);
|
|
167
|
+
const cut = turns.findIndex((t) => t.taskId === input.atTaskId);
|
|
168
|
+
if (cut < 0)
|
|
169
|
+
throw new Error(`rewind point ${input.atTaskId} not found in ${input.sessionKey}`);
|
|
170
|
+
if (cut === turns.length - 1)
|
|
171
|
+
throw new Error(`rewind point ${input.atTaskId} is the tail — nothing to regenerate`);
|
|
172
|
+
const kept = turns.slice(0, cut + 1);
|
|
173
|
+
// Native rebranch boundary: explicit override → the kept tail's recorded anchor. No anchor =
|
|
174
|
+
// the kept turn never recorded one (e.g. a native-only import) — can't rebranch, so refuse.
|
|
175
|
+
const anchor = input.anchor ?? kept[kept.length - 1]?.anchor ?? null;
|
|
176
|
+
if (!anchor)
|
|
177
|
+
throw new Error(`rewind: no native anchor at ${input.atTaskId} in ${input.sessionKey}`);
|
|
178
|
+
// Drop the tip from the managed transcript, then stamp the rewind intent. The native store is
|
|
179
|
+
// untouched here — the next dispatch rebranches it at `anchor` via AgentTurnInput.rewind.
|
|
180
|
+
await this.deps.sessionStore.truncateTurns(agent, sessionId, input.atTaskId);
|
|
181
|
+
const last = kept[kept.length - 1];
|
|
182
|
+
rec.model = last?.model ?? rec.model ?? null;
|
|
183
|
+
rec.effort = last?.effort ?? rec.effort ?? null;
|
|
184
|
+
rec.nativeSessionId = nativeId;
|
|
185
|
+
rec.runState = 'completed';
|
|
186
|
+
rec.runDetail = null;
|
|
187
|
+
rec.pendingRewind = { anchor };
|
|
188
|
+
await this.deps.sessionStore.save(rec);
|
|
189
|
+
this.deps.log?.(`[hub] rewound ${input.sessionKey} to ${input.atTaskId} (anchor ${anchor}, kept ${kept.length}/${turns.length} turns)`);
|
|
190
|
+
return { sessionKey: input.sessionKey };
|
|
191
|
+
}
|
|
146
192
|
enqueue(item) {
|
|
147
193
|
const q = this.waiting.get(item.sessionKey) ?? [];
|
|
148
194
|
q.push(item);
|
|
@@ -174,6 +220,9 @@ export class Hub {
|
|
|
174
220
|
// first turn keeps the intent, so the retry forks again). Native mode resumes the PARENT's
|
|
175
221
|
// native id with the fork flag; seed mode starts fresh and replays the copied transcript.
|
|
176
222
|
const pendingFork = rec && !rec.nativeSessionId ? rec.pendingFork ?? null : null;
|
|
223
|
+
// A rewind keeps the SAME native id (it resumes+rebranches this session), so — unlike a fork —
|
|
224
|
+
// it is NOT gated on a missing native id. Fork and rewind never coexist on one record.
|
|
225
|
+
const pendingRewind = rec?.pendingRewind ?? null;
|
|
177
226
|
const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
|
|
178
227
|
const model = injection?.model ?? input.model ?? null;
|
|
179
228
|
const effort = input.effort ?? null;
|
|
@@ -191,6 +240,7 @@ export class Hub {
|
|
|
191
240
|
const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted || pendingFork?.mode === 'seed');
|
|
192
241
|
let driverSessionId = rec?.nativeSessionId || (input.sessionKey ? sessionId : null);
|
|
193
242
|
let driverFork = null;
|
|
243
|
+
let driverRewind = null;
|
|
194
244
|
let driverPrompt = input.prompt;
|
|
195
245
|
if (pendingFork) {
|
|
196
246
|
if (pendingFork.mode === 'native' && pendingFork.parentNativeSessionId) {
|
|
@@ -204,11 +254,18 @@ export class Hub {
|
|
|
204
254
|
driverPrompt = `${seed}\n\n${input.prompt}`;
|
|
205
255
|
}
|
|
206
256
|
}
|
|
257
|
+
else if (pendingRewind) {
|
|
258
|
+
// In-place rewind: resume THIS session's own native id but rebranch at the kept boundary
|
|
259
|
+
// (no fork), so the dropped tip leaves the active path and the prompt regenerates from it.
|
|
260
|
+
driverSessionId = rec?.nativeSessionId || sessionId;
|
|
261
|
+
driverRewind = { anchor: pendingRewind.anchor };
|
|
262
|
+
}
|
|
207
263
|
const turnInput = {
|
|
208
264
|
prompt: driverPrompt,
|
|
209
265
|
attachments: input.attachments,
|
|
210
266
|
sessionId: driverSessionId,
|
|
211
267
|
fork: driverFork,
|
|
268
|
+
rewind: driverRewind,
|
|
212
269
|
workdir,
|
|
213
270
|
model, effort, systemPrompt,
|
|
214
271
|
env: spawn.env,
|
|
@@ -239,6 +296,20 @@ export class Hub {
|
|
|
239
296
|
this.deps.log?.(`[hub] pendingFork clear failed ${sessionKey}: ${e?.message || e}`);
|
|
240
297
|
}
|
|
241
298
|
}
|
|
299
|
+
// A rewind materialized (the turn ran and rebranched the native session) — consume the
|
|
300
|
+
// intent so a normal follow-up prompt appends to the regenerated tip instead of rewinding.
|
|
301
|
+
if (pendingRewind && result.sessionId) {
|
|
302
|
+
try {
|
|
303
|
+
const settled = await this.deps.sessionStore.get(agent, sessionId);
|
|
304
|
+
if (settled?.pendingRewind) {
|
|
305
|
+
settled.pendingRewind = null;
|
|
306
|
+
await this.deps.sessionStore.save(settled);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
this.deps.log?.(`[hub] pendingRewind clear failed ${sessionKey}: ${e?.message || e}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
242
313
|
// Persist the completed turn's final snapshot as a transcript entry. The runner's
|
|
243
314
|
// snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
|
|
244
315
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikiloom/kernel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
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",
|