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.
- package/README.md +38 -18
- package/bin/deck.js +4 -0
- package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
- package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
- package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
- package/dist/web/assets/app-9p127oSR.js +310 -0
- package/dist/web/assets/app-DsHSufBt.css +1 -0
- package/dist/web/assets/index-Bkx27-wy.js +1 -0
- package/dist/web/index.html +18 -4
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-choice.mjs +198 -0
- package/src/server/account-notify.mjs +15 -8
- package/src/server/account-routes.mjs +6 -0
- package/src/server/activity.mjs +11 -3
- package/src/server/agent-activity.mjs +33 -10
- package/src/server/browser-watch-decks.mjs +3 -2
- package/src/server/browser-watch.mjs +3 -4
- package/src/server/claude-recovery.mjs +150 -0
- package/src/server/codex-enrichment.mjs +11 -3
- package/src/server/codex-lineage.mjs +29 -0
- package/src/server/codex-recovery.mjs +83 -0
- package/src/server/codex-translate.mjs +24 -0
- package/src/server/codex-watch.mjs +116 -12
- package/src/server/cswap-auto-choice.mjs +296 -0
- package/src/server/cswap-auto-loop.mjs +18 -2
- package/src/server/cswap-auto-readers.mjs +5 -2
- package/src/server/cswap-auto.mjs +7 -1
- package/src/server/deck-prefs.mjs +5 -1
- package/src/server/event-pipeline.mjs +6 -3
- package/src/server/evicted-enrichment.mjs +2 -0
- package/src/server/feedback-form.mjs +1 -0
- package/src/server/index.mjs +13 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/reports-routes.mjs +31 -12
- package/src/server/request-gates.mjs +2 -0
- package/src/server/tool-audit-operations.mjs +112 -0
- package/src/server/tool-audit-report.mjs +115 -0
- package/src/server/tool-audit-routes.mjs +38 -0
- package/src/server/tool-audit.mjs +126 -0
- package/src/server/traffic-radar-config.mjs +8 -1
- package/dist/web/assets/BrowserWatchModal-6TctCbc4.js +0 -1
- package/dist/web/assets/RadioPanel-ZDDwNYnL.js +0 -1
- package/dist/web/assets/TrafficRadar-lfG5ekUR.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
- package/dist/web/assets/index-BgSnAnP6.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|