ccdeck 3.42.0 → 3.43.1
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 +35 -17
- package/dist/web/assets/BrowserWatchModal-1On-pA6T.js +1 -0
- package/dist/web/assets/RadioPanel-DkXAgxKR.js +1 -0
- package/dist/web/assets/TrafficRadar-DWilzdA3.js +1 -0
- package/dist/web/assets/UsageHistoryModal-BW9jTi6E.js +1 -0
- package/dist/web/assets/app-ByCusSHX.js +310 -0
- package/dist/web/assets/app-DsHSufBt.css +1 -0
- package/dist/web/assets/index-CzIBjYsB.js +1 -0
- package/dist/web/index.html +5 -2
- package/package.json +1 -1
- package/release-notes.json +24 -1
- 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/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 +113 -9
- package/src/server/cswap-auto-choice.mjs +303 -0
- package/src/server/cswap-auto-loop.mjs +33 -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 +4 -3
- package/src/server/evicted-enrichment.mjs +2 -0
- package/src/server/index.mjs +9 -0
- package/src/server/pinned-build.mjs +4 -0
- package/src/server/traffic-radar-config.mjs +8 -1
- package/dist/web/assets/BrowserWatchModal-TdSBv7_T.js +0 -1
- package/dist/web/assets/RadioPanel-bJc004IK.js +0 -1
- package/dist/web/assets/TrafficRadar-pRTWTOpJ.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BBAEDRj9.js +0 -1
- package/dist/web/assets/index-BPLXy9SO.css +0 -1
- package/dist/web/assets/index-DzDnLqDX.js +0 -307
|
@@ -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.
|
|
@@ -313,6 +316,7 @@ function emitCodexEvent(payload, persist, toolAuditAt) {
|
|
|
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) {
|
|
@@ -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
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The deck's own step in an auto-switch tick, taken before claude-swap's
|
|
2
|
+
// engine: the home account and "soonest weekly reset" rules
|
|
3
|
+
// (account-choice.mjs), carried out, and what they need remembered between
|
|
4
|
+
// ticks.
|
|
5
|
+
//
|
|
6
|
+
// NOTHING SET, NOTHING DONE. With the next account left on "most room" and no
|
|
7
|
+
// home, a tick never reads anything here beyond this file and goes straight to
|
|
8
|
+
// the engine — the behaviour every deck had before these rules existed.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS REMEMBERED, per machine, in auto-choice.json beside prefs.json:
|
|
11
|
+
//
|
|
12
|
+
// next "room" | "weekly"
|
|
13
|
+
// home { key, email, orgUuid } or null — an identity, never a slot:
|
|
14
|
+
// slots are reused and differ between machines (lan-copies.mjs)
|
|
15
|
+
// manual the account in use was picked by hand: the panel's Switch, the
|
|
16
|
+
// terminal, anything that was not a tick. The moves nobody needs
|
|
17
|
+
// leave it alone until it fills.
|
|
18
|
+
// goHome a home was just set, so the next tick may take the user there
|
|
19
|
+
// lastActive the account the last tick left the user on, which is how a
|
|
20
|
+
// switch made by hand is told from one a tick made
|
|
21
|
+
// lastMoveAt when a tick last moved the account, for the cooldowns
|
|
22
|
+
//
|
|
23
|
+
// The ticks run one at a time under the store mutex; the panel's two writes
|
|
24
|
+
// do not, so every write here goes through one chain.
|
|
25
|
+
import { readFile, readdir, mkdir } from "node:fs/promises";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
28
|
+
import { writeFileAtomic } from "./atomic-write.mjs";
|
|
29
|
+
import { backupRoot, fetchClaudeAccounts, slotNumber, switchClaudeAccount } from "./claude-accounts.mjs";
|
|
30
|
+
import { readCswapConfig } from "./cswap-auto-readers.mjs";
|
|
31
|
+
import { accountKey } from "./lan-copies.mjs";
|
|
32
|
+
import { isProcessAlive } from "./deck-probe.mjs";
|
|
33
|
+
import { chooseAccount, countedModels, NEXT_CHOICES } from "./account-choice.mjs";
|
|
34
|
+
|
|
35
|
+
/** Where the choice is kept: beside prefs.json, in the deck's own data. */
|
|
36
|
+
export function choicePath(home = deckDataDir()) {
|
|
37
|
+
return join(home, "auto-choice.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const text = v => (typeof v === "string" && v ? v : null);
|
|
41
|
+
|
|
42
|
+
/** Whatever is on disk, as a whole and known-shaped choice. */
|
|
43
|
+
export function normaliseChoice(raw) {
|
|
44
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
45
|
+
const home = src.home && typeof src.home === "object" && text(src.home.email)
|
|
46
|
+
? { key: accountKey(src.home.email, src.home.orgUuid), email: src.home.email, orgUuid: text(src.home.orgUuid) }
|
|
47
|
+
: null;
|
|
48
|
+
return {
|
|
49
|
+
next: NEXT_CHOICES.includes(src.next) ? src.next : "room",
|
|
50
|
+
home,
|
|
51
|
+
manual: src.manual === true,
|
|
52
|
+
goHome: home != null && src.goHome === true,
|
|
53
|
+
lastActive: text(src.lastActive),
|
|
54
|
+
lastMoveAt: Number.isFinite(src.lastMoveAt) ? src.lastMoveAt : 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The choice as last read or written. This process is the file's only writer,
|
|
59
|
+
// so after the first read a tick asks memory, not the disk — which is what
|
|
60
|
+
// keeps a deck with nothing set paying nothing per tick.
|
|
61
|
+
let _held = null;
|
|
62
|
+
|
|
63
|
+
async function readChoice() {
|
|
64
|
+
if (_held) return _held;
|
|
65
|
+
try { _held = normaliseChoice(JSON.parse(await readFile(choicePath(), "utf8"))); } catch { _held = normaliseChoice(null); }
|
|
66
|
+
return _held;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let _writes = Promise.resolve();
|
|
70
|
+
/** Read, change and write the choice, one change at a time. A change that
|
|
71
|
+
* changes nothing writes nothing: most ticks only confirm where they are. */
|
|
72
|
+
function updateChoice(change) {
|
|
73
|
+
const done = _writes.then(async () => {
|
|
74
|
+
const before = await readChoice();
|
|
75
|
+
const next = normaliseChoice(change(before));
|
|
76
|
+
if (JSON.stringify(next) === JSON.stringify(before)) return before;
|
|
77
|
+
const path = choicePath();
|
|
78
|
+
await mkdir(dirname(path), { recursive: true });
|
|
79
|
+
await writeFileAtomic(path, `${JSON.stringify(next)}\n`);
|
|
80
|
+
_held = next;
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
_writes = done.catch(() => {});
|
|
84
|
+
return done;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What the panel draws: the strategy, and whose row carries the home mark. */
|
|
88
|
+
export async function choiceStatus() {
|
|
89
|
+
const c = await readChoice();
|
|
90
|
+
return { next: c.next, home: c.home ? { email: c.home.email, orgUuid: c.home.orgUuid } : null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "room" or "weekly". Choosing is a decision about the account in use too,
|
|
94
|
+
* so a pick made by hand no longer holds the new rule back. */
|
|
95
|
+
export async function setNextChoice(value) {
|
|
96
|
+
if (!NEXT_CHOICES.includes(value)) return { ok: false, reason: "bad_value" };
|
|
97
|
+
await updateChoice(c => ({ ...c, next: value, manual: false, lastActive: null }));
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make the account in slot `accountNum` the home, or clear it with null.
|
|
103
|
+
* Setting one is asking to be taken there: the next tick does, once the
|
|
104
|
+
* account is usable, even from an account picked by hand.
|
|
105
|
+
*/
|
|
106
|
+
export async function setHomeAccount(accountNum) {
|
|
107
|
+
if (accountNum === null) {
|
|
108
|
+
await updateChoice(c => ({ ...c, home: null, goHome: false }));
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
const num = slotNumber(accountNum);
|
|
112
|
+
if (num == null) return { ok: false, reason: "bad_account" };
|
|
113
|
+
const roster = await fetchClaudeAccounts();
|
|
114
|
+
const row = roster?.ok ? roster.accounts?.find(a => a.num === num) : null;
|
|
115
|
+
if (!row?.email) return { ok: false, reason: "bad_account" };
|
|
116
|
+
await updateChoice(c => ({ ...c, home: { email: row.email, orgUuid: row.orgUuid ?? null }, goHome: true, manual: false }));
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Auto-switch was just turned on: whatever happened while it was off is not
|
|
121
|
+
* a pick to protect, so the next tick starts from the account in use. */
|
|
122
|
+
export function forgetPicksByHand() {
|
|
123
|
+
return updateChoice(c => ({ ...c, manual: false, lastActive: null })).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What claude-swap's engine keeps in autoswitch_state.json that a move here
|
|
128
|
+
* has to respect: when it last switched, in ms or 0 — a move the deck makes
|
|
129
|
+
* does not start the engine's cooldown, so this one keeps both — and the
|
|
130
|
+
* accounts it has quarantined, by key.
|
|
131
|
+
*
|
|
132
|
+
* A quarantine is the slot's, with the email it held: the engine drops one
|
|
133
|
+
* whose slot now holds somebody else, so it is only this account's while the
|
|
134
|
+
* emails agree. One the engine would drop for a fresh login it drops on its
|
|
135
|
+
* next run, which every tick that does not move here is.
|
|
136
|
+
*/
|
|
137
|
+
async function engineState(accounts) {
|
|
138
|
+
let state = null;
|
|
139
|
+
try { state = JSON.parse(await readFile(join(backupRoot(), "autoswitch_state.json"), "utf8")); } catch {}
|
|
140
|
+
const at = Number(state?.lastSwitchAt);
|
|
141
|
+
const held = state?.quarantine && typeof state.quarantine === "object" ? state.quarantine : {};
|
|
142
|
+
const sameEmail = (x, y) => typeof x === "string" && typeof y === "string" && x.trim().toLowerCase() === y.trim().toLowerCase();
|
|
143
|
+
return {
|
|
144
|
+
lastSwitchAt: Number.isFinite(at) && at > 0 ? at * 1000 : 0,
|
|
145
|
+
quarantined: new Set(accounts.filter(a => sameEmail(held[String(a.num)]?.email, a.email)).map(a => a.key)),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The accounts paired decks have recently said they are working on.
|
|
150
|
+
* One login live on two machines races for every renewal (README, "One login
|
|
151
|
+
* live in one place"), so a tick never moves onto one of them. The LAN table
|
|
152
|
+
* retains old manifests for display after a peer goes quiet; those are not
|
|
153
|
+
* current reservations. Use the authenticated manifest's receipt time, not a
|
|
154
|
+
* beacon or a failed round, which cannot confirm the account still in use. */
|
|
155
|
+
async function accountsOnOtherDecks(now) {
|
|
156
|
+
try {
|
|
157
|
+
const [{ lanEngine }, { PRESENT_MS }] = await Promise.all([
|
|
158
|
+
import("./lan-deck.mjs"), import("./lan-sync.mjs"),
|
|
159
|
+
]);
|
|
160
|
+
const peers = lanEngine.status()?.peers ?? [];
|
|
161
|
+
return new Set(peers
|
|
162
|
+
.filter(p => p?.paired === true && Number.isFinite(p.offers?.at) && p.offers.at > 0 && now - p.offers.at < PRESENT_MS)
|
|
163
|
+
.map(p => p.offers.current?.key).filter(k => typeof k === "string"));
|
|
164
|
+
} catch {
|
|
165
|
+
return new Set();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The name claude-swap gives an account's `cswap run` profile after its
|
|
170
|
+
* slot number: the email, NFC, with anything but ASCII letters, digits and
|
|
171
|
+
* `._-` turned into `_` (claude_swap/session.py, slugify_email). */
|
|
172
|
+
export function sessionSlug(email) {
|
|
173
|
+
return [...String(email ?? "").normalize("NFC")].map(ch => (/^[A-Za-z0-9._-]$/.test(ch) ? ch : "_")).join("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The accounts a `cswap run` session is working on, by key. Such a session
|
|
178
|
+
* runs Claude Code on the account's own copy of the login; the engine never
|
|
179
|
+
* activates one of them, because two copies of one login race for every
|
|
180
|
+
* renewal. `cswap switch`, which is what a move here runs, only warns — so
|
|
181
|
+
* the deck asks the same question first.
|
|
182
|
+
*
|
|
183
|
+
* Claude Code leaves one record per running instance in the profile's
|
|
184
|
+
* `sessions/<pid>.json`; one counts while its process is running. A pid used
|
|
185
|
+
* again by something else reads as live too, which only leaves that move to
|
|
186
|
+
* the engine.
|
|
187
|
+
*/
|
|
188
|
+
async function accountsInCswapRun(accounts) {
|
|
189
|
+
const live = new Set();
|
|
190
|
+
for (const a of accounts) {
|
|
191
|
+
if (a.num == null || !a.email) continue;
|
|
192
|
+
const dir = join(backupRoot(), "sessions", `${a.num}-${sessionSlug(a.email)}`, "sessions");
|
|
193
|
+
let names = [];
|
|
194
|
+
try { names = (await readdir(dir)).filter(n => /^\d+\.json$/.test(n)); } catch { continue; }
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
let pid = null;
|
|
197
|
+
try { pid = Number(JSON.parse(await readFile(join(dir, name), "utf8"))?.pid); } catch {}
|
|
198
|
+
if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { live.add(a.key); break; }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return live;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const numberOr = (v, fallback) => {
|
|
205
|
+
const n = Number(v);
|
|
206
|
+
return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** One side of a move, in the shape claude-swap's own `switch` event has. */
|
|
210
|
+
const refOf = a => (a ? { number: a.num, email: a.email ?? null } : null);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Ask the two rules, and move when they say so. Under the store mutex, from
|
|
214
|
+
* the tick (cswap-auto-loop.mjs).
|
|
215
|
+
*
|
|
216
|
+
* Returns `{ result, accounts }`: `result` is a tick in the shape summarise
|
|
217
|
+
* gives the engine's — so the caches, the swap log and the notification treat
|
|
218
|
+
* the move as any other — or null for "run the engine"; `accounts` is the
|
|
219
|
+
* roster it decided on, which noteEngineTick needs to name where the engine
|
|
220
|
+
* went, and null when nothing is set.
|
|
221
|
+
*/
|
|
222
|
+
export async function chooseBeforeEngine({ now = Date.now() } = {}) {
|
|
223
|
+
const choice = await readChoice();
|
|
224
|
+
if (choice.next === "room" && !choice.home) return { result: null, accounts: null };
|
|
225
|
+
const roster = await fetchClaudeAccounts();
|
|
226
|
+
if (!roster?.ok || !Array.isArray(roster.accounts)) return { result: null, accounts: null };
|
|
227
|
+
const accounts = roster.accounts.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
|
|
228
|
+
const active = accounts.find(a => a.active) ?? null;
|
|
229
|
+
|
|
230
|
+
const byHand = active != null && choice.lastActive != null && choice.lastActive !== active.key;
|
|
231
|
+
const manual = choice.manual || byHand;
|
|
232
|
+
const goHome = choice.goHome && active?.key !== choice.home?.key;
|
|
233
|
+
// WHAT THIS TICK LEARNED, WRITTEN OVER WHAT IS THERE NOW. The panel's writes
|
|
234
|
+
// do not wait for the store lock, so "Set as home" or a new rule can land
|
|
235
|
+
// while the awaits below run. `manual` and `goHome` were worked out from the
|
|
236
|
+
// choice as it was read, so they are only written back over that same
|
|
237
|
+
// choice — over a newer one they would undo what the person just set.
|
|
238
|
+
// Every write replaces the held object, which is what tells the two apart.
|
|
239
|
+
const keep = c => (c === choice
|
|
240
|
+
? { ...c, manual, goHome, lastActive: active?.key ?? c.lastActive }
|
|
241
|
+
: { ...c, lastActive: active?.key ?? c.lastActive });
|
|
242
|
+
|
|
243
|
+
const cfg = (await readCswapConfig()) ?? {};
|
|
244
|
+
const threshold = numberOr(cfg["autoswitch.threshold"]?.value, 90);
|
|
245
|
+
const models = countedModels(cfg["autoswitch.model"]?.value);
|
|
246
|
+
const engine = await engineState(accounts);
|
|
247
|
+
const pick = chooseAccount({
|
|
248
|
+
accounts,
|
|
249
|
+
choice: { next: choice.next, home: choice.home?.key ?? null, manual, goHome },
|
|
250
|
+
threshold,
|
|
251
|
+
models,
|
|
252
|
+
busy: new Set([...await accountsOnOtherDecks(now), ...await accountsInCswapRun(accounts)]),
|
|
253
|
+
quarantined: engine.quarantined,
|
|
254
|
+
sinceMove: now - Math.max(choice.lastMoveAt, engine.lastSwitchAt),
|
|
255
|
+
cooldownMs: numberOr(cfg["autoswitch.cooldownSeconds"]?.value, 300) * 1000,
|
|
256
|
+
optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
|
|
257
|
+
now,
|
|
258
|
+
});
|
|
259
|
+
if (!pick) {
|
|
260
|
+
await updateChoice(keep);
|
|
261
|
+
return { result: null, accounts };
|
|
262
|
+
}
|
|
263
|
+
const moved = await switchClaudeAccount(pick.to.num);
|
|
264
|
+
if (!moved.ok) {
|
|
265
|
+
await updateChoice(keep);
|
|
266
|
+
return { result: null, accounts };
|
|
267
|
+
}
|
|
268
|
+
// The move happened, so where it left the user and when are true whatever
|
|
269
|
+
// was set meanwhile; a home still to be gone to is the current choice's.
|
|
270
|
+
await updateChoice(c => ({
|
|
271
|
+
...c, manual: false, goHome: (c === choice ? goHome : c.goHome) && pick.to.key !== c.home?.key,
|
|
272
|
+
lastActive: pick.to.key, lastMoveAt: now,
|
|
273
|
+
}));
|
|
274
|
+
return {
|
|
275
|
+
result: {
|
|
276
|
+
ok: true,
|
|
277
|
+
event: "switch",
|
|
278
|
+
switched: true,
|
|
279
|
+
trigger: pick.trigger,
|
|
280
|
+
reason: null,
|
|
281
|
+
detail: null,
|
|
282
|
+
from: refOf(active),
|
|
283
|
+
to: refOf(pick.to),
|
|
284
|
+
active: refOf(active),
|
|
285
|
+
threshold,
|
|
286
|
+
// What the notification reads, in the shape claude-swap's poll gives:
|
|
287
|
+
// room per account, and the windows of each by label.
|
|
288
|
+
headroom: Object.fromEntries(accounts.map(a => [String(a.num), a.headroom ?? null])),
|
|
289
|
+
windows: Object.fromEntries(accounts.map(a => [String(a.num),
|
|
290
|
+
Object.fromEntries((a.lanes ?? []).map(l => [l.label, l.pct]))])),
|
|
291
|
+
resetAt: pick.resetAt ?? null,
|
|
292
|
+
},
|
|
293
|
+
accounts,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The engine switched in a tick this step had looked at: the account it left
|
|
298
|
+
* the user on is the tick's pick, not one made by hand. */
|
|
299
|
+
export async function noteEngineTick(result, accounts) {
|
|
300
|
+
if (!accounts || !result?.switched) return;
|
|
301
|
+
const to = accounts.find(a => String(a.num) === String(result.to?.number));
|
|
302
|
+
await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
|
|
303
|
+
}
|