ccdeck 3.42.0 → 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 (37) hide show
  1. package/README.md +35 -17
  2. package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
  3. package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
  4. package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
  6. package/dist/web/assets/app-9p127oSR.js +310 -0
  7. package/dist/web/assets/app-DsHSufBt.css +1 -0
  8. package/dist/web/assets/index-Bkx27-wy.js +1 -0
  9. package/dist/web/index.html +5 -2
  10. package/package.json +1 -1
  11. package/release-notes.json +22 -0
  12. package/src/server/account-choice.mjs +198 -0
  13. package/src/server/account-notify.mjs +15 -8
  14. package/src/server/account-routes.mjs +6 -0
  15. package/src/server/activity.mjs +11 -3
  16. package/src/server/claude-recovery.mjs +150 -0
  17. package/src/server/codex-enrichment.mjs +11 -3
  18. package/src/server/codex-lineage.mjs +29 -0
  19. package/src/server/codex-recovery.mjs +83 -0
  20. package/src/server/codex-translate.mjs +24 -0
  21. package/src/server/codex-watch.mjs +113 -9
  22. package/src/server/cswap-auto-choice.mjs +296 -0
  23. package/src/server/cswap-auto-loop.mjs +18 -2
  24. package/src/server/cswap-auto-readers.mjs +5 -2
  25. package/src/server/cswap-auto.mjs +7 -1
  26. package/src/server/deck-prefs.mjs +5 -1
  27. package/src/server/event-pipeline.mjs +4 -3
  28. package/src/server/evicted-enrichment.mjs +2 -0
  29. package/src/server/index.mjs +9 -0
  30. package/src/server/pinned-build.mjs +4 -0
  31. package/src/server/traffic-radar-config.mjs +8 -1
  32. package/dist/web/assets/BrowserWatchModal-TdSBv7_T.js +0 -1
  33. package/dist/web/assets/RadioPanel-bJc004IK.js +0 -1
  34. package/dist/web/assets/TrafficRadar-pRTWTOpJ.js +0 -1
  35. package/dist/web/assets/UsageHistoryModal-BBAEDRj9.js +0 -1
  36. package/dist/web/assets/index-BPLXy9SO.css +0 -1
  37. 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
- 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
@@ -0,0 +1,296 @@
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 the decks this one is paired with say 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. */
152
+ async function accountsOnOtherDecks() {
153
+ try {
154
+ const { lanEngine } = await import("./lan-deck.mjs");
155
+ const peers = lanEngine.status()?.peers ?? [];
156
+ return new Set(peers.map(p => p?.offers?.current?.key).filter(k => typeof k === "string"));
157
+ } catch {
158
+ return new Set();
159
+ }
160
+ }
161
+
162
+ /** The name claude-swap gives an account's `cswap run` profile after its
163
+ * slot number: the email, NFC, with anything but ASCII letters, digits and
164
+ * `._-` turned into `_` (claude_swap/session.py, slugify_email). */
165
+ export function sessionSlug(email) {
166
+ return [...String(email ?? "").normalize("NFC")].map(ch => (/^[A-Za-z0-9._-]$/.test(ch) ? ch : "_")).join("");
167
+ }
168
+
169
+ /**
170
+ * The accounts a `cswap run` session is working on, by key. Such a session
171
+ * runs Claude Code on the account's own copy of the login; the engine never
172
+ * activates one of them, because two copies of one login race for every
173
+ * renewal. `cswap switch`, which is what a move here runs, only warns — so
174
+ * the deck asks the same question first.
175
+ *
176
+ * Claude Code leaves one record per running instance in the profile's
177
+ * `sessions/<pid>.json`; one counts while its process is running. A pid used
178
+ * again by something else reads as live too, which only leaves that move to
179
+ * the engine.
180
+ */
181
+ async function accountsInCswapRun(accounts) {
182
+ const live = new Set();
183
+ for (const a of accounts) {
184
+ if (a.num == null || !a.email) continue;
185
+ const dir = join(backupRoot(), "sessions", `${a.num}-${sessionSlug(a.email)}`, "sessions");
186
+ let names = [];
187
+ try { names = (await readdir(dir)).filter(n => /^\d+\.json$/.test(n)); } catch { continue; }
188
+ for (const name of names) {
189
+ let pid = null;
190
+ try { pid = Number(JSON.parse(await readFile(join(dir, name), "utf8"))?.pid); } catch {}
191
+ if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { live.add(a.key); break; }
192
+ }
193
+ }
194
+ return live;
195
+ }
196
+
197
+ const numberOr = (v, fallback) => {
198
+ const n = Number(v);
199
+ return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
200
+ };
201
+
202
+ /** One side of a move, in the shape claude-swap's own `switch` event has. */
203
+ const refOf = a => (a ? { number: a.num, email: a.email ?? null } : null);
204
+
205
+ /**
206
+ * Ask the two rules, and move when they say so. Under the store mutex, from
207
+ * the tick (cswap-auto-loop.mjs).
208
+ *
209
+ * Returns `{ result, accounts }`: `result` is a tick in the shape summarise
210
+ * gives the engine's — so the caches, the swap log and the notification treat
211
+ * the move as any other — or null for "run the engine"; `accounts` is the
212
+ * roster it decided on, which noteEngineTick needs to name where the engine
213
+ * went, and null when nothing is set.
214
+ */
215
+ export async function chooseBeforeEngine({ now = Date.now() } = {}) {
216
+ const choice = await readChoice();
217
+ if (choice.next === "room" && !choice.home) return { result: null, accounts: null };
218
+ const roster = await fetchClaudeAccounts();
219
+ if (!roster?.ok || !Array.isArray(roster.accounts)) return { result: null, accounts: null };
220
+ const accounts = roster.accounts.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
221
+ const active = accounts.find(a => a.active) ?? null;
222
+
223
+ const byHand = active != null && choice.lastActive != null && choice.lastActive !== active.key;
224
+ const manual = choice.manual || byHand;
225
+ const goHome = choice.goHome && active?.key !== choice.home?.key;
226
+ // WHAT THIS TICK LEARNED, WRITTEN OVER WHAT IS THERE NOW. The panel's writes
227
+ // do not wait for the store lock, so "Set as home" or a new rule can land
228
+ // while the awaits below run. `manual` and `goHome` were worked out from the
229
+ // choice as it was read, so they are only written back over that same
230
+ // choice — over a newer one they would undo what the person just set.
231
+ // Every write replaces the held object, which is what tells the two apart.
232
+ const keep = c => (c === choice
233
+ ? { ...c, manual, goHome, lastActive: active?.key ?? c.lastActive }
234
+ : { ...c, lastActive: active?.key ?? c.lastActive });
235
+
236
+ const cfg = (await readCswapConfig()) ?? {};
237
+ const threshold = numberOr(cfg["autoswitch.threshold"]?.value, 90);
238
+ const models = countedModels(cfg["autoswitch.model"]?.value);
239
+ const engine = await engineState(accounts);
240
+ const pick = chooseAccount({
241
+ accounts,
242
+ choice: { next: choice.next, home: choice.home?.key ?? null, manual, goHome },
243
+ threshold,
244
+ models,
245
+ busy: new Set([...await accountsOnOtherDecks(), ...await accountsInCswapRun(accounts)]),
246
+ quarantined: engine.quarantined,
247
+ sinceMove: now - Math.max(choice.lastMoveAt, engine.lastSwitchAt),
248
+ cooldownMs: numberOr(cfg["autoswitch.cooldownSeconds"]?.value, 300) * 1000,
249
+ optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
250
+ now,
251
+ });
252
+ if (!pick) {
253
+ await updateChoice(keep);
254
+ return { result: null, accounts };
255
+ }
256
+ const moved = await switchClaudeAccount(pick.to.num);
257
+ if (!moved.ok) {
258
+ await updateChoice(keep);
259
+ return { result: null, accounts };
260
+ }
261
+ // The move happened, so where it left the user and when are true whatever
262
+ // was set meanwhile; a home still to be gone to is the current choice's.
263
+ await updateChoice(c => ({
264
+ ...c, manual: false, goHome: (c === choice ? goHome : c.goHome) && pick.to.key !== c.home?.key,
265
+ lastActive: pick.to.key, lastMoveAt: now,
266
+ }));
267
+ return {
268
+ result: {
269
+ ok: true,
270
+ event: "switch",
271
+ switched: true,
272
+ trigger: pick.trigger,
273
+ reason: null,
274
+ detail: null,
275
+ from: refOf(active),
276
+ to: refOf(pick.to),
277
+ active: refOf(active),
278
+ threshold,
279
+ // What the notification reads, in the shape claude-swap's poll gives:
280
+ // room per account, and the windows of each by label.
281
+ headroom: Object.fromEntries(accounts.map(a => [String(a.num), a.headroom ?? null])),
282
+ windows: Object.fromEntries(accounts.map(a => [String(a.num),
283
+ Object.fromEntries((a.lanes ?? []).map(l => [l.label, l.pct]))])),
284
+ resetAt: pick.resetAt ?? null,
285
+ },
286
+ accounts,
287
+ };
288
+ }
289
+
290
+ /** The engine switched in a tick this step had looked at: the account it left
291
+ * the user on is the tick's pick, not one made by hand. */
292
+ export async function noteEngineTick(result, accounts) {
293
+ if (!accounts || !result?.switched) return;
294
+ const to = accounts.find(a => String(a.num) === String(result.to?.number));
295
+ await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
296
+ }