ccdeck 3.41.3 → 3.43.0

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.
Files changed (48) hide show
  1. package/README.md +38 -18
  2. package/bin/deck.js +4 -0
  3. package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
  4. package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
  5. package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
  6. package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
  7. package/dist/web/assets/app-9p127oSR.js +310 -0
  8. package/dist/web/assets/app-DsHSufBt.css +1 -0
  9. package/dist/web/assets/index-Bkx27-wy.js +1 -0
  10. package/dist/web/index.html +18 -4
  11. package/package.json +1 -1
  12. package/release-notes.json +40 -0
  13. package/src/server/account-choice.mjs +198 -0
  14. package/src/server/account-notify.mjs +15 -8
  15. package/src/server/account-routes.mjs +6 -0
  16. package/src/server/activity.mjs +11 -3
  17. package/src/server/agent-activity.mjs +33 -10
  18. package/src/server/browser-watch-decks.mjs +3 -2
  19. package/src/server/browser-watch.mjs +3 -4
  20. package/src/server/claude-recovery.mjs +150 -0
  21. package/src/server/codex-enrichment.mjs +11 -3
  22. package/src/server/codex-lineage.mjs +29 -0
  23. package/src/server/codex-recovery.mjs +83 -0
  24. package/src/server/codex-translate.mjs +24 -0
  25. package/src/server/codex-watch.mjs +116 -12
  26. package/src/server/cswap-auto-choice.mjs +296 -0
  27. package/src/server/cswap-auto-loop.mjs +18 -2
  28. package/src/server/cswap-auto-readers.mjs +5 -2
  29. package/src/server/cswap-auto.mjs +7 -1
  30. package/src/server/deck-prefs.mjs +5 -1
  31. package/src/server/event-pipeline.mjs +6 -3
  32. package/src/server/evicted-enrichment.mjs +2 -0
  33. package/src/server/feedback-form.mjs +1 -0
  34. package/src/server/index.mjs +13 -0
  35. package/src/server/pinned-build.mjs +5 -0
  36. package/src/server/reports-routes.mjs +31 -12
  37. package/src/server/request-gates.mjs +2 -0
  38. package/src/server/tool-audit-operations.mjs +112 -0
  39. package/src/server/tool-audit-report.mjs +115 -0
  40. package/src/server/tool-audit-routes.mjs +38 -0
  41. package/src/server/tool-audit.mjs +126 -0
  42. package/src/server/traffic-radar-config.mjs +8 -1
  43. package/dist/web/assets/BrowserWatchModal-6TctCbc4.js +0 -1
  44. package/dist/web/assets/RadioPanel-ZDDwNYnL.js +0 -1
  45. package/dist/web/assets/TrafficRadar-lfG5ekUR.js +0 -1
  46. package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
  47. package/dist/web/assets/index-BgSnAnP6.css +0 -1
  48. package/dist/web/assets/index-C80Hp_2s.js +0 -307
@@ -18,8 +18,8 @@ import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
18
18
  // reads a rollout's lines by, the ones the translation reads — see
19
19
  // codex-translate.mjs.
20
20
  import {
21
- codexSessionApproval, codexSessionModel,
22
- responseItemModel, sessionMeta, taskStartedWindow, tokenCountInfo,
21
+ codexSessionApproval, codexSessionEffort, codexSessionModel,
22
+ responseItemModel, sessionMeta, taskStartedWindow, tokenCountInfo, turnContextEffort,
23
23
  } from "./codex-translate.mjs";
24
24
  // The bounded chunk reads the Claude transcripts use — see jsonl-chunks.mjs.
25
25
  import { readByteRange } from "./jsonl-chunks.mjs";
@@ -227,6 +227,8 @@ export async function readCodexRollout(path) {
227
227
  let model = null;
228
228
  let contextWindow = null;
229
229
  let cwd = null;
230
+ // Undefined until a turn_context is read: "no turn yet" is not "no effort".
231
+ let effort;
230
232
  for (const line of text.split("\n")) {
231
233
  if (!line) continue;
232
234
  let obj;
@@ -245,9 +247,11 @@ export async function readCodexRollout(path) {
245
247
  // Fallback model source — response items carry the model id.
246
248
  const itemModel = responseItemModel(obj);
247
249
  if (itemModel !== null) model = itemModel;
250
+ // The newest turn's effort (#2155), as the rollout watcher reads it.
251
+ if (obj?.type === "turn_context") effort = turnContextEffort(obj.payload);
248
252
  }
249
253
  if (!lastUsage && !model && !contextWindow) return null;
250
- return { usage: lastUsage, model, contextWindow, cwd };
254
+ return { usage: lastUsage, model, contextWindow, cwd, effort };
251
255
  } catch {
252
256
  return null;
253
257
  }
@@ -275,6 +279,9 @@ function maybeResolveCodex(payload) {
275
279
  hook_event_name: "ModelObserved",
276
280
  session_id: sid,
277
281
  model: r.model,
282
+ // A hook-delivered session has no watcher to spread it, so it rides
283
+ // here, beside the model it qualifies.
284
+ ...(r.effort !== undefined ? { reasoning_effort: r.effort } : {}),
278
285
  }, "internal");
279
286
  }
280
287
  if (r.contextWindow) {
@@ -300,6 +307,7 @@ function forgetCodexSession(sid) {
300
307
  codexUsageReads.forget(sid);
301
308
  codexSessionModel.delete(sid);
302
309
  codexSessionApproval.delete(sid);
310
+ codexSessionEffort.delete(sid);
303
311
  }
304
312
 
305
313
  // What event-pipeline.mjs and session-tracking.mjs call besides readCodexRollout
@@ -0,0 +1,29 @@
1
+ // Read only explicit thread relationships. A model's prose is never ancestry.
2
+ export function codexThreadLink(record, sessionId, cwd) {
3
+ const text = value => typeof value === "string" && value.trim() ? value : null;
4
+ let child, parent, details;
5
+ if (record?.type === "session_meta") {
6
+ details = record.payload;
7
+ const spawn = details?.source?.subagent?.thread_spawn;
8
+ const direct = text(details?.parent_thread_id);
9
+ const inherited = text(spawn?.parent_thread_id);
10
+ if (direct && inherited && direct !== inherited) return null;
11
+ parent = direct ?? inherited;
12
+ child = text(details?.id) ?? text(sessionId);
13
+ details = { ...spawn, ...details };
14
+ } else if (record?.type === "event_msg") {
15
+ const payload = record.payload;
16
+ details = payload?.type === "sub_agent_activity" ? payload
17
+ : payload?.item?.type === "SubAgentActivity" ? payload.item : null;
18
+ if (details?.kind !== "started") return null;
19
+ child = text(details.agent_thread_id);
20
+ parent = text(sessionId);
21
+ }
22
+ if (!child || !parent || child === parent) return null;
23
+ return {
24
+ hook_event_name: "CodexThreadLinked", provider: "codex",
25
+ session_id: child, parent_session_id: parent, cwd,
26
+ agent_nickname: text(details?.agent_nickname),
27
+ agent_role: text(details?.agent_role) ?? text(details?.agent_type),
28
+ };
29
+ }
@@ -0,0 +1,83 @@
1
+ import { codexThreadLink } from "./codex-lineage.mjs";
2
+ import { turnContextEffort } from "./codex-translate.mjs";
3
+ import { stat } from "node:fs/promises";
4
+ import { readAppendedLines } from "./jsonl-chunks.mjs";
5
+
6
+ export const RECOVERY_TAIL_BYTES = 8 * 1024 * 1024;
7
+ export const RECOVERY_FILE_LIMIT = 128;
8
+ export const RECOVERY_SCAN_BYTES = 32 * 1024 * 1024;
9
+ export const RECOVERY_RECENT_MS = 24 * 60 * 60 * 1000;
10
+
11
+ /** A fixed prefix of an existing file. The live watcher resumes at offset. */
12
+ export async function readCodexRecovery(path, initial, sid, cwd, factsBefore = Infinity) {
13
+ const from = Math.max(0, initial.size - RECOVERY_TAIL_BYTES);
14
+ const cursor = { offset: from, midLine: from > 0 };
15
+ const result = { active: false, at: null, turnId: null, prompt: undefined,
16
+ model: undefined, usage: undefined, window: undefined, effort: undefined,
17
+ approval: undefined, contextTokens: undefined, offset: initial.size, certain: true, links: [] };
18
+ let pendingPrompt;
19
+ { // The entire bounded tail fits in one reader chunk; never reread a partial suffix.
20
+ const { text, advanced } = await readAppendedLines(path, cursor, initial.size);
21
+ if (!advanced && initial.size > from) result.certain = false;
22
+ let lineEnd = cursor.offset - Buffer.byteLength(text) - 1;
23
+ for (const line of text.split("\n")) {
24
+ lineEnd += Buffer.byteLength(line) + 1;
25
+ if (!line) continue;
26
+ let row;
27
+ try { row = JSON.parse(line); } catch { result.certain = false; continue; }
28
+ const link = codexThreadLink(row, sid, cwd);
29
+ if (link) result.links.push(link);
30
+ const p = row?.payload;
31
+ if (row?.type === "turn_context") {
32
+ if (typeof p?.model === "string") result.model = p.model;
33
+ result.effort = turnContextEffort(p);
34
+ if (typeof p?.approval_policy === "string") result.approval = p.approval_policy;
35
+ }
36
+ if (row?.type !== "event_msg" || !p) continue;
37
+ if (p.type === "task_started") {
38
+ if (!result.active || result.turnId !== p.turn_id) result.prompt = pendingPrompt;
39
+ pendingPrompt = undefined;
40
+ result.active = true;
41
+ result.turnId = typeof p.turn_id === "string" ? p.turn_id : null;
42
+ result.at = Date.parse(row.timestamp);
43
+ if (typeof p.model_context_window === "number") result.window = p.model_context_window;
44
+ } else if (p.type === "task_complete" || p.type === "turn_aborted") {
45
+ if (!p.turn_id || !result.turnId || p.turn_id === result.turnId) {
46
+ result.active = false;
47
+ result.prompt = pendingPrompt = undefined;
48
+ result.turnId = null;
49
+ result.at = Date.parse(row.timestamp);
50
+ }
51
+ } else if (p.type === "token_count") {
52
+ if (p.info?.total_token_usage) result.usage = p.info.total_token_usage;
53
+ if (typeof p.info?.last_token_usage?.total_tokens === "number") result.contextTokens = p.info.last_token_usage.total_tokens;
54
+ if (typeof p.info?.model_context_window === "number") result.window = p.info.model_context_window;
55
+ }
56
+ const prompt = p.type === "user_message" && typeof p.message === "string" ? p.message
57
+ : p.item?.type === "UserMessage" && Array.isArray(p.item.content)
58
+ ? p.item.content.map(part => typeof part?.text === "string" ? part.text : "").join("") : null;
59
+ if (prompt && lineEnd <= factsBefore) {
60
+ if (result.active) result.prompt = prompt;
61
+ else pendingPrompt = prompt;
62
+ }
63
+ }
64
+ }
65
+ result.offset = cursor.offset;
66
+ // An incomplete final line might be the completion. Keep it for the tail,
67
+ // but do not call this snapshot active until the evidence is complete.
68
+ if (cursor.offset !== initial.size) result.certain = false;
69
+ const current = await stat(path);
70
+ if (current.size !== initial.size || current.dev !== initial.dev || current.ino !== initial.ino) result.certain = false;
71
+ return result;
72
+ }
73
+
74
+ /** Repeated rollouts of a resumed thread must agree on the newest boundary. */
75
+ export function latestCodexRecovery(candidates) {
76
+ if (!candidates.length || candidates.some(c => !c.snapshot.certain)) return null;
77
+ if (candidates.length === 1) return candidates[0];
78
+ if (candidates.some(c => !Number.isFinite(c.snapshot.at))) return null;
79
+ const newest = Math.max(...candidates.map(c => c.snapshot.at));
80
+ const sameTime = candidates.filter(c => c.snapshot.at === newest);
81
+ if (sameTime.some(c => c.snapshot.active !== sameTime[0].snapshot.active)) return null;
82
+ return sameTime[sameTime.length - 1];
83
+ }
@@ -19,6 +19,10 @@ export const codexSessionModel = new Map(); // sid -> last model string
19
19
  // session. See codexObjToPayload for why this is read and what it is NOT used
20
20
  // for; #398 for why the deck holds it at all.
21
21
  export const codexSessionApproval = new Map(); // sid -> last approval_policy string
22
+ // sid -> the reasoning effort of the newest `turn_context`, or null when that
23
+ // record named none (#2155). Spread onto every payload as `reasoning_effort`,
24
+ // as approval_policy is, so the card says the effort the current turn runs at.
25
+ export const codexSessionEffort = new Map();
22
26
 
23
27
  // ─── What a rollout record states ─────────────────────────────────────────
24
28
  // The record shapes more than one reader takes a fact off: the translation
@@ -55,6 +59,19 @@ export function taskStartedWindow(obj) {
55
59
  : null;
56
60
  }
57
61
 
62
+ /**
63
+ * The reasoning effort a `turn_context` record says the turn runs at, or null
64
+ * when it names none (#2155). Codex writes it twice on every turn — `effort`,
65
+ * and again as `collaboration_mode.settings.reasoning_effort` — and the first
66
+ * is read first, the second only in its absence. Not a fixed set of words: the
67
+ * config reference lists `low` … `xhigh` and more, and says the levels depend
68
+ * on the model, so any short word is taken as written.
69
+ */
70
+ export function turnContextEffort(pl) {
71
+ const effort = pl?.effort ?? pl?.collaboration_mode?.settings?.reasoning_effort;
72
+ return typeof effort === "string" && /^[a-z][a-z0-9_-]{0,23}$/i.test(effort) ? effort : null;
73
+ }
74
+
58
75
  /** The model a `response_item` names, or null. */
59
76
  export function responseItemModel(obj) {
60
77
  const pl = obj && obj.payload;
@@ -93,6 +110,12 @@ export function codexObjToPayload(obj, sid, cwd) {
93
110
  // watcher reads. The reducer stamps it on the root beside `contextWindow`.
94
111
  const approval_policy = codexSessionApproval.get(sid);
95
112
  const base = { session_id: sid, cwd, provider: "codex", approval_policy };
113
+ // The effort, on the same terms — but sent as null once a turn_context named
114
+ // none, where approval_policy is left out: a policy that goes missing is a
115
+ // record that dropped a field, while a turn with no effort is a model that
116
+ // has none, and the chip must stop showing the last one. Before the first
117
+ // turn_context it is absent, which the page reads as "not known yet".
118
+ if (codexSessionEffort.has(sid)) base.reasoning_effort = codexSessionEffort.get(sid);
96
119
 
97
120
  // Track model from turn_context / response_item before mapping events.
98
121
  if (type === "turn_context") {
@@ -128,6 +151,7 @@ export function codexObjToPayload(obj, sid, cwd) {
128
151
  } else {
129
152
  codexSessionApproval.delete(sid);
130
153
  }
154
+ codexSessionEffort.set(sid, turnContextEffort(pl));
131
155
  return null;
132
156
  }
133
157
  const itemModel = responseItemModel(obj);
@@ -9,9 +9,9 @@
9
9
  // reads the ring or the SSE clients. index.mjs starts the watcher. The bodies
10
10
  // are unchanged.
11
11
  import { stat } from "node:fs/promises";
12
- import { join } from "node:path";
12
+ import { basename, join } from "node:path";
13
13
  import { PRODUCT } from "./brand.mjs";
14
- import { STOP, sidFromRolloutName, walkRolloutDays } from "./codex-dir.mjs";
14
+ import { STOP, rolloutNameId, sidFromRolloutName, walkRolloutDays } from "./codex-dir.mjs";
15
15
  // What one rollout line means as a hook payload, and the shape of the header
16
16
  // record that says whose rollout it is — see codex-translate.mjs. The watcher
17
17
  // below still decides which lines are read and where each one goes.
@@ -29,6 +29,9 @@ import { maybeResolveCodexMemory } from "./session-enrichment.mjs";
29
29
  // event-pipeline.mjs's pushEvent, reached without importing it — see
30
30
  // event-sink.mjs.
31
31
  import { pushEvent } from "./event-sink.mjs";
32
+ import { codexThreadLink } from "./codex-lineage.mjs";
33
+ import { readCodexRecovery, latestCodexRecovery, RECOVERY_FILE_LIMIT, RECOVERY_RECENT_MS, RECOVERY_SCAN_BYTES, RECOVERY_TAIL_BYTES } from "./codex-recovery.mjs";
34
+ import { heldPrefs, prefsRead } from "./prefs-state.mjs";
32
35
 
33
36
  // ─── Codex rollout watcher ────────────────────────────────────────────────
34
37
  // Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
@@ -219,7 +222,7 @@ async function readCodexHeader(path) {
219
222
  // workspace test below, the log election, the cwd on every event this
220
223
  // rollout produces — reads state.cwd, and this is the one place it is
221
224
  // read off disk. See canonicalCwd.
222
- return { sid, cwd: await canonicalCwd(meta.cwd) };
225
+ return { sid, cwd: await canonicalCwd(meta.cwd), link: codexThreadLink(obj, sid, meta.cwd) };
223
226
  }
224
227
  // The other rename the scan would otherwise retry in silence: a first
225
228
  // line that is whole but is not a session_meta will never become one.
@@ -244,8 +247,8 @@ async function readCodexHeader(path) {
244
247
  // write it to the log they share. The event is still buffered and broadcast —
245
248
  // every deck watching the session draws it — it is only the second copy on disk
246
249
  // that is dropped. See writesCodexLog in log-election.mjs.
247
- function emitCodexEvent(payload, persist) {
248
- pushEvent(payload, "codex", { persist });
250
+ function emitCodexEvent(payload, persist, toolAuditAt) {
251
+ pushEvent(payload, "codex", { persist, toolAuditAt });
249
252
  }
250
253
 
251
254
  /**
@@ -313,6 +316,7 @@ function emitCodexEvent(payload, persist) {
313
316
  function ensureCodexRoot(state, persist) {
314
317
  if (state.rootOpened) return;
315
318
  state.rootOpened = true;
319
+ if (state.link) emitCodexEvent({ ...state.link, cwd: state.cwd }, persist);
316
320
  if (!state.sawBeginning) return;
317
321
  emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
318
322
  }
@@ -325,7 +329,7 @@ function ensureCodexRoot(state, persist) {
325
329
  * skipping cursor is parked at its end; or this is the first scan, and the
326
330
  * file's history is skipped.
327
331
  */
328
- async function openCodexCursor(path, st, now, firstRun) {
332
+ async function openCodexCursor(path, st, now, firstRun, recover = false) {
329
333
  // New file — read the header for sid + cwd, then decide whether to
330
334
  // capture it. Skip files outside our workspace.
331
335
  const header = await readCodexHeader(path);
@@ -347,7 +351,7 @@ async function openCodexCursor(path, st, now, firstRun) {
347
351
  // UsageObserved in the file to every tab, and re-appending the lot to
348
352
  // the shared events.jsonl if this deck is the elected writer (#981).
349
353
  const fresh = !codexSeenEver.has(path);
350
- const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
354
+ const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
351
355
  codexFileState.set(path, state);
352
356
  rememberCodexPath(path);
353
357
  if (firstRun) {
@@ -379,6 +383,16 @@ async function openCodexCursor(path, st, now, firstRun) {
379
383
  // brace (#981).
380
384
  state.offset = st.size;
381
385
  state.sawBeginning = false;
386
+ if (recover) {
387
+ try {
388
+ state.recovery = await readCodexRecovery(path, st, state.sid, state.cwd);
389
+ state.recoveryObservedAt ??= now;
390
+ state.recoverySize = st.size;
391
+ state.recoveryBootSize = st.size;
392
+ state.offset = state.recovery.offset;
393
+ } catch { state.recovery = { certain: false }; }
394
+ return state;
395
+ }
382
396
  return null;
383
397
  }
384
398
  return state;
@@ -397,6 +411,11 @@ function emitCodexLines(state, consume, persist) {
397
411
  try { obj = JSON.parse(line); } catch { continue; }
398
412
  const prevModel = codexSessionModel.get(state.sid);
399
413
  const payload = codexObjToPayload(obj, state.sid, state.cwd);
414
+ const link = codexThreadLink(obj, state.sid, state.cwd);
415
+ if (link) {
416
+ ensureCodexRoot(state, persist);
417
+ emitCodexEvent(link, persist);
418
+ }
400
419
  // If the model changed (turn_context/response_item), surface it.
401
420
  const nowModel = codexSessionModel.get(state.sid);
402
421
  if (nowModel && nowModel !== prevModel) {
@@ -405,7 +424,7 @@ function emitCodexLines(state, consume, persist) {
405
424
  }
406
425
  if (payload) {
407
426
  ensureCodexRoot(state, persist);
408
- emitCodexEvent(payload, persist);
427
+ emitCodexEvent(payload, persist, typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : undefined);
409
428
  }
410
429
  }
411
430
  }
@@ -467,7 +486,7 @@ async function openGrownOlderRollouts(now) {
467
486
  // Never `sawBeginning`, whatever this process read of the file before: the
468
487
  // lines before `was` are ones this deck has either drawn already or joined
469
488
  // too late for, and either way a SessionStart now would be a false one.
470
- const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs };
489
+ const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, link: header.link, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs };
471
490
  codexFileState.set(path, state);
472
491
  rememberCodexPath(path);
473
492
  opened.push({ path, st, state });
@@ -497,7 +516,12 @@ export async function scanCodexNow() {
497
516
 
498
517
  async function scanRollouts(firstRun) {
499
518
  try {
519
+ if (firstRun) await prefsRead;
500
520
  const now = Date.now();
521
+ const recoverAtBoot = firstRun && heldPrefs.current().recoverCodexSessions !== false;
522
+ let recoveryBudget = RECOVERY_FILE_LIMIT;
523
+ let olderStatBudget = RECOVERY_FILE_LIMIT;
524
+ let recoveryBytes = RECOVERY_SCAN_BYTES;
501
525
  // Read at most once per scan, and only from the first rollout that actually
502
526
  // has new bytes: most ticks read nothing and must not pay a directory
503
527
  // listing for the answer to a question nothing is asking.
@@ -508,7 +532,9 @@ async function scanRollouts(firstRun) {
508
532
  // `null` rather than 0 for "never", so a clock that starts near the epoch,
509
533
  // or a faked one, cannot make boot look recent.
510
534
  const walkAll = codexOlderWalkedAt == null || Math.abs(now - codexOlderWalkedAt) >= CODEX_STATE_TTL_MS;
511
- const { files, older } = await listRecentCodexRollouts(walkAll);
535
+ const { files: recentFiles, older } = await listRecentCodexRollouts(walkAll);
536
+ const recent = new Set(recentFiles);
537
+ const files = recoverAtBoot ? [...recentFiles, ...older] : recentFiles;
512
538
  if (walkAll) { codexOlderWalkedAt = now; catalogOlderRollouts(older); }
513
539
  const listed = new Set(files);
514
540
  // Every rollout this tick has a reason to read, with its stat and cursor:
@@ -516,14 +542,20 @@ async function scanRollouts(firstRun) {
516
542
  // rollouts found grown. One pass reads them all below.
517
543
  const due = [];
518
544
  for (const path of files) {
519
- codexOlderRollouts.delete(path); // listed, so the listing's
545
+ if (!recent.has(path) && olderStatBudget-- <= 0) continue;
520
546
  let st;
521
547
  try { st = await stat(path); } catch { continue; }
548
+ const eligible = recoverAtBoot && now - st.mtimeMs <= RECOVERY_RECENT_MS;
549
+ if (!recent.has(path) && (!eligible || recoveryBudget <= 0)) continue;
550
+ codexOlderRollouts.delete(path);
522
551
  let state = codexFileState.get(path);
523
552
  if (state) state.seenAt = now;
524
553
 
525
554
  if (!state) {
526
- state = await openCodexCursor(path, st, now, firstRun);
555
+ const cost = Math.min(st.size, RECOVERY_TAIL_BYTES);
556
+ const recover = eligible && recoveryBudget > 0 && cost <= recoveryBytes;
557
+ if (recover) { recoveryBudget--; recoveryBytes -= cost; }
558
+ state = await openCodexCursor(path, st, now, firstRun, recover);
527
559
  if (!state) continue;
528
560
  }
529
561
  state.mtimeMs = st.mtimeMs;
@@ -542,6 +574,78 @@ async function scanRollouts(firstRun) {
542
574
  }
543
575
  due.push(...await openGrownOlderRollouts(now));
544
576
 
577
+ // Retry an unstable prefix only after its file changes. The live cursor
578
+ // stays behind an incomplete line and never replays skipped tool history.
579
+ let retryBudget = 16;
580
+ for (const entry of due) {
581
+ const { state, path, st } = entry;
582
+ const cost = Math.min(st.size, RECOVERY_TAIL_BYTES);
583
+ if (!firstRun && state.recovery && !state.recovery.certain && retryBudget > 0 && cost <= recoveryBytes && st.size !== state.recoverySize) {
584
+ recoveryBytes -= cost;
585
+ retryBudget--;
586
+ try {
587
+ state.recovery = await readCodexRecovery(path, st, state.sid, state.cwd, state.recoveryBootSize);
588
+ state.recoveryObservedAt ??= now;
589
+ // Keep the live cursor: bytes appended since boot are emitted below.
590
+ } catch { state.recovery = { certain: false }; }
591
+ state.recoverySize = st.size;
592
+ }
593
+ }
594
+ const bySession = new Map();
595
+ for (const entry of due) {
596
+ if (!entry.state.recovery) continue;
597
+ const candidates = bySession.get(entry.state.sid) ?? [];
598
+ candidates.push({ ...entry, snapshot: entry.state.recovery });
599
+ bySession.set(entry.state.sid, candidates);
600
+ }
601
+ const knownPathsBySession = new Map();
602
+ const unknownPaths = [];
603
+ if (bySession.size) for (const path of new Set([...files, ...codexOlderRollouts.keys(), ...codexFileState.keys()])) {
604
+ const sid = rolloutNameId(basename(path));
605
+ if (!sid) { unknownPaths.push(path); continue; }
606
+ const paths = knownPathsBySession.get(sid) ?? [];
607
+ paths.push(path); knownPathsBySession.set(sid, paths);
608
+ }
609
+ const selected = new Map();
610
+ for (const [sid, candidates] of bySession) {
611
+ // A resumed thread's unexamined rollout may contain a newer ending.
612
+ // Compare exact filename ids, including historical directories, before
613
+ // accepting a bounded selection. Unknown names make recovery uncertain.
614
+ const inspected = new Set(candidates.map(c => c.path));
615
+ const unseen = [...(knownPathsBySession.get(sid) ?? []), ...unknownPaths].some(path => !inspected.has(path));
616
+ const best = unseen ? null : latestCodexRecovery(candidates);
617
+ if (!best || !Number.isFinite(best.snapshot.at)) continue;
618
+ selected.set(sid, { candidates, best });
619
+ }
620
+ const recoveredLinks = [];
621
+ for (const { candidates, best } of selected.values()) {
622
+ const { state, snapshot } = best;
623
+ const signature = candidates.map(c => `${c.path}:${c.snapshot.offset}`).join("|");
624
+ if (candidates.every(c => c.state.recoveryEmitted === signature)) continue;
625
+ for (const c of candidates) c.state.recoveryEmitted = signature;
626
+ state.rootOpened = true;
627
+ pushEvent({
628
+ hook_event_name: "CodexSessionRecovered", provider: "codex",
629
+ session_id: state.sid, cwd: state.cwd, snapshot_observed_at: Math.min(...candidates.map(c => c.state.recoveryObservedAt ?? now)),
630
+ recovery_active: snapshot.active,
631
+ turn_started_at: snapshot.at, prompt: snapshot.prompt, model: snapshot.model,
632
+ usage: snapshot.usage, context_tokens: snapshot.contextTokens,
633
+ model_context_window: snapshot.window, approval_policy: snapshot.approval,
634
+ reasoning_effort: snapshot.effort,
635
+ }, "codex", { replay: true, persist: false });
636
+ if (snapshot.active) {
637
+ if (state.link) recoveredLinks.push({ ...state.link, cwd: state.cwd });
638
+ for (const link of snapshot.links ?? []) {
639
+ // Historical ancestry alone cannot establish that a child is active.
640
+ if (selected.get(link.session_id)?.best.snapshot.active) recoveredLinks.push(link);
641
+ }
642
+ }
643
+ // Keep terminal candidates too: a sibling rollout still being written
644
+ // must be reconciled against this ending on its next stable snapshot.
645
+ for (const c of candidates) c.state.recoverySize = c.st.size;
646
+ }
647
+ // Snapshots precede relationships so a child placeholder cannot overtake its facts.
648
+ for (const link of recoveredLinks) pushEvent(link, "codex", { replay: true, persist: false });
545
649
  for (const { path, st, state } of due) {
546
650
  if (state.skip) { state.offset = st.size; continue; }
547
651
  // A cursor sitting PAST the end of the file is a different thing from