ccdeck 3.43.3 → 3.44.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 (82) hide show
  1. package/README.md +74 -20
  2. package/THIRD_PARTY_NOTICES.md +43 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +60 -16
  9. package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +40 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +30 -6
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-copies.mjs +43 -5
  47. package/src/server/lan-deck.mjs +3 -0
  48. package/src/server/lan-engine.mjs +7 -2
  49. package/src/server/lan-manifest.mjs +2 -2
  50. package/src/server/lan-serve.mjs +17 -2
  51. package/src/server/lan-sync.mjs +5 -5
  52. package/src/server/lan-wire.mjs +3 -3
  53. package/src/server/login-guard.mjs +162 -0
  54. package/src/server/login-service.mjs +3 -0
  55. package/src/server/pinned-build.mjs +5 -0
  56. package/src/server/prefs-routes.mjs +4 -0
  57. package/src/server/process-command.mjs +4 -1
  58. package/src/server/reports.mjs +7 -1
  59. package/src/server/request-gates.mjs +7 -0
  60. package/src/server/session-read-gate.mjs +5 -3
  61. package/src/server/session-summary.mjs +281 -0
  62. package/src/server/skill-hook.mjs +162 -0
  63. package/src/server/stop-continue-gate.d.mts +8 -0
  64. package/src/server/stop-continue-gate.mjs +77 -0
  65. package/src/server/stop-continue.mjs +544 -0
  66. package/src/server/stop-failure.d.mts +10 -0
  67. package/src/server/stop-failure.mjs +97 -0
  68. package/src/server/swap-log.mjs +19 -10
  69. package/src/server/tool-audit-report.mjs +5 -3
  70. package/src/server/tool-audit-routes.mjs +5 -3
  71. package/src/server/tool-audit-skills.mjs +82 -0
  72. package/src/server/tool-audit.mjs +53 -11
  73. package/src/server/traffic-radar-capture.mjs +10 -1
  74. package/src/server/traffic-radar-monitor.mjs +4 -2
  75. package/src/server/traffic-radar-routes.mjs +13 -0
  76. package/src/server/traffic-radar-service.mjs +133 -0
  77. package/src/server/traffic-radar-wire.mjs +4 -3
  78. package/src/server/usage-day.mjs +1 -1
  79. package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
  80. package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
  81. package/dist/web/assets/app-DsHSufBt.css +0 -1
  82. package/dist/web/assets/app-HpFVXK_g.js +0 -310
@@ -10,6 +10,7 @@
10
10
  // (event-pipeline.mjs) hands every live event to maybeResolveCodex, and
11
11
  // session-tracking.mjs forgets a session through forgetCodexSession. The
12
12
  // bodies are unchanged.
13
+ import { codexCostState, codexCostGap, foldCodexCost, codexCostBuckets } from "./codex-cost-usage.mjs";
13
14
  import { stat } from "node:fs/promises";
14
15
  import { join } from "node:path";
15
16
  import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
@@ -48,6 +49,7 @@ import { sessionReadGate } from "./session-read-gate.mjs";
48
49
  const codexRolloutPathBySid = new Map();
49
50
  const CODEX_READ_THROTTLE_MS = 2500;
50
51
  const codexUsageReads = sessionReadGate(CODEX_READ_THROTTLE_MS);
52
+ const codexUsageRequests = new Map();
51
53
  // How many of the newest day directories a lookup reads when it is not owed
52
54
  // the whole tree: the bound listRecentCodexRollouts keeps for the watcher.
53
55
  const CODEX_RECENT_DAY_DIRS = 2;
@@ -211,6 +213,7 @@ export async function readCodexRollout(path) {
211
213
  const s = await stat(path);
212
214
  if (s.size === 0) return null;
213
215
  let text;
216
+ const pricing = codexCostState();
214
217
  if (s.size <= CODEX_HEAD_BYTES + CODEX_TAIL_BYTES) {
215
218
  text = await readByteRange(path, 0, s.size);
216
219
  } else {
@@ -221,7 +224,7 @@ export async function readCodexRollout(path) {
221
224
  // the loop below skips it.
222
225
  const head = await readByteRange(path, 0, CODEX_HEAD_BYTES);
223
226
  const tail = await readByteRange(path, s.size - CODEX_TAIL_BYTES, s.size);
224
- text = `${head}\n${tail}`;
227
+ text = `${head}\n__ccdeck_gap__\n${tail}`;
225
228
  }
226
229
  let lastUsage = null;
227
230
  let model = null;
@@ -230,9 +233,11 @@ export async function readCodexRollout(path) {
230
233
  // Undefined until a turn_context is read: "no turn yet" is not "no effort".
231
234
  let effort;
232
235
  for (const line of text.split("\n")) {
236
+ if (line === "__ccdeck_gap__") { codexCostGap(pricing); continue; }
233
237
  if (!line) continue;
234
238
  let obj;
235
239
  try { obj = JSON.parse(line); } catch { continue; }
240
+ foldCodexCost(pricing, obj);
236
241
  // One record is one of these at most: each asks the record's own type.
237
242
  const meta = sessionMeta(obj);
238
243
  if (meta) {
@@ -251,28 +256,47 @@ export async function readCodexRollout(path) {
251
256
  if (obj?.type === "turn_context") effort = turnContextEffort(obj.payload);
252
257
  }
253
258
  if (!lastUsage && !model && !contextWindow) return null;
254
- return { usage: lastUsage, model, contextWindow, cwd, effort };
259
+ const usageByModel = codexCostBuckets(pricing);
260
+ return { usage: lastUsage, model, contextWindow, cwd, effort,
261
+ ...(usageByModel ? { usageByModel } : {}) };
255
262
  } catch {
256
263
  return null;
257
264
  }
258
265
  }
259
266
 
260
- function maybeResolveCodex(payload) {
267
+ function maybeResolveCodex(payload, opts = {}) {
261
268
  if (!payload || typeof payload !== "object") return;
262
269
  if (payload.provider !== "codex") return;
263
270
  const sid = payload.session_id;
264
271
  if (!sid) return;
265
- codexUsageReads.run(sid, async () => {
272
+ let entry = codexUsageRequests.get(sid);
273
+ if (!entry) {
274
+ entry = { revision: 0, opts: {}, timer: null };
275
+ codexUsageRequests.set(sid, entry);
276
+ }
277
+ entry.revision++;
278
+ entry.opts = { persist: opts.persist };
279
+ runCodexUsageRead(sid, entry);
280
+ }
281
+
282
+ function runCodexUsageRead(sid, entry) {
283
+ if (codexUsageRequests.get(sid) !== entry) return;
284
+ const revision = entry.revision;
285
+ const emitOpts = entry.opts;
286
+ const started = codexUsageReads.run(sid, async () => {
266
287
  const path = await findCodexRolloutPath(sid);
267
- if (!path) return;
288
+ if (!path || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
268
289
  const r = await readCodexRollout(path);
269
- if (!r) return;
290
+ // Watcher activity, eviction or shutdown can supersede either await.
291
+ // Never let a delayed snapshot overwrite newer live observations.
292
+ if (!r || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
270
293
  if (r.usage) {
271
294
  pushEvent({
272
295
  hook_event_name: "UsageObserved",
273
296
  session_id: sid,
274
297
  usage: r.usage,
275
- }, "internal");
298
+ usageByModel: r.usageByModel ?? null,
299
+ }, "internal", emitOpts);
276
300
  }
277
301
  if (r.model) {
278
302
  pushEvent({
@@ -282,7 +306,7 @@ function maybeResolveCodex(payload) {
282
306
  // A hook-delivered session has no watcher to spread it, so it rides
283
307
  // here, beside the model it qualifies.
284
308
  ...(r.effort !== undefined ? { reasoning_effort: r.effort } : {}),
285
- }, "internal");
309
+ }, "internal", emitOpts);
286
310
  }
287
311
  if (r.contextWindow) {
288
312
  // Piggy-back on the model event with the window — reducer reads
@@ -292,9 +316,21 @@ function maybeResolveCodex(payload) {
292
316
  session_id: sid,
293
317
  model: r.model ?? undefined,
294
318
  model_context_window: r.contextWindow,
295
- }, "internal");
319
+ }, "internal", emitOpts);
296
320
  }
297
321
  });
322
+ if (started) {
323
+ clearTimeout(entry.timer);
324
+ entry.timer = null;
325
+ } else if (!entry.timer) {
326
+ // Keep demand until a read actually starts, including while another read
327
+ // is pending. At most one unref'd retry timer exists for this session.
328
+ entry.timer = setTimeout(() => {
329
+ entry.timer = null;
330
+ runCodexUsageRead(sid, entry);
331
+ }, CODEX_READ_THROTTLE_MS + 1);
332
+ entry.timer.unref?.();
333
+ }
298
334
  }
299
335
 
300
336
  /**
@@ -305,6 +341,8 @@ function maybeResolveCodex(payload) {
305
341
  function forgetCodexSession(sid) {
306
342
  codexRolloutPathBySid.delete(sid);
307
343
  codexUsageReads.forget(sid);
344
+ clearTimeout(codexUsageRequests.get(sid)?.timer);
345
+ codexUsageRequests.delete(sid);
308
346
  codexSessionModel.delete(sid);
309
347
  codexSessionApproval.delete(sid);
310
348
  codexSessionEffort.delete(sid);
@@ -316,3 +354,9 @@ function forgetCodexSession(sid) {
316
354
  // at each declaration, so the declarations read as they did where they came
317
355
  // from.
318
356
  export { findCodexRolloutPath, forgetCodexSession, maybeResolveCodex };
357
+
358
+ export function stopCodexEnrichment() {
359
+ for (const entry of codexUsageRequests.values()) clearTimeout(entry.timer);
360
+ codexUsageRequests.clear();
361
+ codexUsageReads.forgetAll();
362
+ }
@@ -0,0 +1,4 @@
1
+ export const AUTO_REVIEW_MODEL: string;
2
+ export const FREE_AUTO_REVIEW_TIER: string;
3
+ export const FREE_AUTO_REVIEW_FROM: number;
4
+ export function isFreeAutoReview(model: unknown, creator: unknown, at: unknown): boolean;
@@ -0,0 +1,11 @@
1
+ // OpenAI confirms ChatGPT auto-review checks are free:
2
+ // https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan
3
+ // The announcement was on Oct 6. Use the following UTC day conservatively;
4
+ // do not infer the exact historical billing cutover from the current policy.
5
+ export const AUTO_REVIEW_MODEL = "codex-auto-review";
6
+ export const FREE_AUTO_REVIEW_TIER = "chatgpt-auto-review-free";
7
+ export const FREE_AUTO_REVIEW_FROM = Date.parse("2026-10-07T00:00:00Z");
8
+ export function isFreeAutoReview(model, creator, at) {
9
+ return model === AUTO_REVIEW_MODEL && typeof creator === "string" && creator.trim().length > 0
10
+ && typeof at === "string" && Date.parse(at) >= FREE_AUTO_REVIEW_FROM;
11
+ }
@@ -0,0 +1,146 @@
1
+ // Which Codex skill a rollout line shows in use, for Skill activity (#2181).
2
+ //
3
+ // Codex writes no record of its own that a skill ran. Two things in a rollout
4
+ // say so anyway:
5
+ //
6
+ // - the `<skill>` message Codex injects when a person picks one with `$name`
7
+ // or /skills, which it writes only once the skill has loaded (explicit);
8
+ // - the model reading a skill's SKILL.md, or running one of its scripts, which
9
+ // is Codex's own rule for a skill it chose itself (detected).
10
+ //
11
+ // A detected use counts only for a skill in the catalog Codex gave that session
12
+ // (its `<skills_instructions>` message) and only when the command succeeded, so
13
+ // searching for SKILL.md files or reading a Claude skill is not a use. One use
14
+ // per turn and skill: the turn id is the row's key, which also lands a forked
15
+ // rollout's copy of its parent's turns on the rows the parent already wrote.
16
+ import { posix, win32 } from "node:path";
17
+ import { noteSkillUse } from "./tool-audit.mjs";
18
+ import { readByteRange } from "./jsonl-chunks.mjs";
19
+
20
+ // The catalog sits in the first lines of a rollout, a few tens of KB in.
21
+ const CATALOG_HEAD_BYTES = 512 * 1024;
22
+ const slashes = path => path.replace(/\\/g, "/");
23
+
24
+ /** The skills a `<skills_instructions>` message lists: SKILL.md path → name,
25
+ * and each skill's folder for its scripts. Null when the text has none. */
26
+ export function parseSkillCatalog(text) {
27
+ const start = typeof text === "string" ? text.indexOf("<skills_instructions>") : -1;
28
+ if (start < 0) return null;
29
+ const roots = new Map(), files = new Map(), dirs = [];
30
+ const end = text.indexOf("</skills_instructions>", start);
31
+ for (const line of text.slice(start, end < 0 ? undefined : end).split("\n")) {
32
+ const root = /^- `(r\d+)` = `(.+)`\s*$/.exec(line);
33
+ if (root) { roots.set(root[1], slashes(root[2]).replace(/\/+$/, "")); continue; }
34
+ const entry = /^- (\S+): .*\(file: (r\d+)\/([^()]*\/SKILL\.md)\)\s*$/.exec(line);
35
+ if (!entry || !roots.has(entry[2])) continue;
36
+ const file = `${roots.get(entry[2])}/${slashes(entry[3])}`;
37
+ files.set(file, entry[1]);
38
+ dirs.push([file.slice(0, -"SKILL.md".length), entry[1]]);
39
+ }
40
+ return files.size ? { files, dirs } : null;
41
+ }
42
+
43
+ /** The catalog a rollout began with, for a session this deck joined late. */
44
+ export async function readCodexSkillCatalog(path) {
45
+ try {
46
+ const head = await readByteRange(path, 0, CATALOG_HEAD_BYTES);
47
+ for (const line of head.split("\n")) {
48
+ if (!line.includes("<skills_instructions>")) continue;
49
+ return parseSkillCatalog(messageText(JSON.parse(line)?.payload));
50
+ }
51
+ } catch {}
52
+ return null;
53
+ }
54
+
55
+ function messageText(payload) {
56
+ if (payload?.type !== "message" || !Array.isArray(payload.content)) return "";
57
+ return payload.content.map(part => typeof part?.text === "string" ? part.text : "").join("\n");
58
+ }
59
+
60
+ // A rollout's paths are its own machine's, so their shape picks the rules, not
61
+ // the host reading them: `fileURLToPath` refuses a drive-less file URL on
62
+ // Windows, and the host's `resolve` would put its own drive letter in front.
63
+ const windowsPath = path => /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/.test(path);
64
+ const localPath = value => {
65
+ if (typeof value !== "string" || !value) return null;
66
+ if (!value.startsWith("file://")) return value;
67
+ try {
68
+ const { host, pathname } = new URL(value);
69
+ const path = decodeURIComponent(pathname);
70
+ return host ? `//${host}${path}` : /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
71
+ } catch { return null; }
72
+ };
73
+ const resolveIn = (cwd, path) => (windowsPath(cwd) ? win32 : posix).resolve(cwd, path);
74
+
75
+ /** A finished command that may have touched a skill: worth reading the catalog for. */
76
+ const commandItem = obj => {
77
+ const item = obj?.type === "event_msg" && obj.payload?.type === "item_completed" ? obj.payload.item : null;
78
+ return item?.type === "CommandExecution" && item.exit_code === 0 && Array.isArray(item.parsed_cmd) ? item : null;
79
+ };
80
+
81
+ /** Per-rollout state: the catalog (undefined until known, null for none), the
82
+ * turn the last `task_started` opened, and the turn-and-skill keys counted. */
83
+ export const codexSkillState = () => ({ catalog: undefined, turn: null, seen: new Set(), waiting: [], loading: false });
84
+
85
+ /**
86
+ * The skill uses one rollout line shows, as `{ key, name, origin, at }`.
87
+ * `skills` is the rollout's codexSkillState. A command names its own turn; the
88
+ * injected `<skill>` message names none and belongs to the turn just opened.
89
+ */
90
+ export function codexSkillUses(obj, skills) {
91
+ const pl = obj?.payload;
92
+ const at = typeof obj?.timestamp === "string" ? Date.parse(obj.timestamp) : undefined;
93
+ if (obj?.type === "event_msg" && pl?.type === "task_started" && typeof pl.turn_id === "string") { skills.turn = pl.turn_id; return []; }
94
+ const turn = (typeof pl?.turn_id === "string" ? pl.turn_id : skills.turn) ?? `at:${obj?.timestamp ?? ""}`;
95
+ const use = (name, origin) => {
96
+ const key = `${turn}|${name}`;
97
+ if (skills.seen.has(key)) return [];
98
+ if (skills.seen.size >= 256) skills.seen.clear();
99
+ skills.seen.add(key);
100
+ return [{ key, name, origin, at }];
101
+ };
102
+ if (obj?.type === "response_item" && pl?.type === "message") {
103
+ const text = messageText(pl);
104
+ if (pl.role === "developer" && text.includes("<skills_instructions>")) { skills.catalog = parseSkillCatalog(text); return []; }
105
+ if (pl.role !== "user") return [];
106
+ return [...text.matchAll(/<skill>\s*<name>([^<]+)<\/name>/g)].flatMap(m => use(m[1].trim(), "typed"));
107
+ }
108
+ const item = commandItem(obj);
109
+ if (!item || !skills.catalog) return [];
110
+ const cwd = localPath(item.cwd);
111
+ const found = [];
112
+ for (const parsed of item.parsed_cmd) {
113
+ if (parsed?.type === "read" && typeof parsed.path === "string") {
114
+ const name = skills.catalog.files.get(slashes(cwd ? resolveIn(cwd, parsed.path) : parsed.path));
115
+ if (name) found.push(name);
116
+ } else if (parsed?.type === "unknown" && typeof parsed.cmd === "string") {
117
+ const cmd = slashes(parsed.cmd);
118
+ for (const [dir, name] of skills.catalog.dirs) if (cmd.includes(`${dir}scripts/`)) found.push(name);
119
+ }
120
+ }
121
+ return [...new Set(found)].flatMap(name => use(name, "detected"));
122
+ }
123
+
124
+ /**
125
+ * Count the skills one rollout line shows. `state` is the watcher's cursor for
126
+ * the rollout: its session, cwd, subagent link and path. A command that may
127
+ * read a skill, in a session joined after its catalog went by, waits for the
128
+ * catalog to be read off the head of the file.
129
+ */
130
+ export function noteCodexSkills(obj, state, note = noteSkillUse) {
131
+ const skills = state.skills ??= codexSkillState();
132
+ const session = state.link?.parent_session_id ?? state.sid, cwd = state.cwd, subagent = !!state.link;
133
+ const send = uses => { for (const use of uses) note({ ...use, session, cwd, subagent }); };
134
+ if (skills.catalog === undefined && commandItem(obj) && state.path) {
135
+ skills.waiting.push(obj);
136
+ if (!skills.loading) {
137
+ skills.loading = true;
138
+ void readCodexSkillCatalog(state.path).then(catalog => {
139
+ if (skills.catalog === undefined) skills.catalog = catalog;
140
+ for (const line of skills.waiting.splice(0)) send(codexSkillUses(line, skills));
141
+ });
142
+ }
143
+ return;
144
+ }
145
+ send(codexSkillUses(obj, skills));
146
+ }
@@ -16,6 +16,7 @@ import { STOP, rolloutNameId, sidFromRolloutName, walkRolloutDays } from "./code
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.
18
18
  import { codexObjToPayload, codexSessionModel, sessionMeta } from "./codex-translate.mjs";
19
+ import { noteCodexSkills } from "./codex-skills.mjs";
19
20
  import { codexCwdInWorkspace, writesCodexLog } from "./log-election.mjs";
20
21
  // The one spelling of a rollout's cwd — see canonical-path.mjs.
21
22
  import { canonicalCwd } from "./canonical-path.mjs";
@@ -351,7 +352,7 @@ async function openCodexCursor(path, st, now, firstRun, recover = false) {
351
352
  // UsageObserved in the file to every tab, and re-appending the lot to
352
353
  // the shared events.jsonl if this deck is the elected writer (#981).
353
354
  const fresh = !codexSeenEver.has(path);
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 };
355
+ const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now, path };
355
356
  codexFileState.set(path, state);
356
357
  rememberCodexPath(path);
357
358
  if (firstRun) {
@@ -409,6 +410,8 @@ function emitCodexLines(state, consume, persist) {
409
410
  if (!line) continue;
410
411
  let obj;
411
412
  try { obj = JSON.parse(line); } catch { continue; }
413
+ // Skill activity reads the line itself: what says a skill ran is not an event.
414
+ noteCodexSkills(obj, state);
412
415
  const prevModel = codexSessionModel.get(state.sid);
413
416
  const payload = codexObjToPayload(obj, state.sid, state.cwd);
414
417
  const link = codexThreadLink(obj, state.sid, state.cwd);
@@ -486,7 +489,7 @@ async function openGrownOlderRollouts(now) {
486
489
  // Never `sawBeginning`, whatever this process read of the file before: the
487
490
  // lines before `was` are ones this deck has either drawn already or joined
488
491
  // too late for, and either way a SessionStart now would be a false one.
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 };
492
+ 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, path };
490
493
  codexFileState.set(path, state);
491
494
  rememberCodexPath(path);
492
495
  opened.push({ path, st, state });
@@ -181,6 +181,23 @@ async function accountsInCswapRun(accounts) {
181
181
  return live;
182
182
  }
183
183
 
184
+ /**
185
+ * The slots no move may land on, whatever the rule: the ones claude-swap's
186
+ * engine quarantined and the ones a local `cswap run` session is using — the
187
+ * two `landable` skips by key. For the failover away from an expired login
188
+ * (autoswitch-failover.mjs), which moves without asking the rules, so it
189
+ * never lands on another dead login or on a login a session already holds.
190
+ * Roster rows in, slot numbers out.
191
+ */
192
+ export async function slotsHeldBack(roster) {
193
+ const accounts = (Array.isArray(roster) ? roster : [])
194
+ .filter(a => a && a.num != null)
195
+ .map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
196
+ const engine = await engineState(accounts);
197
+ const busy = await accountsInCswapRun(accounts);
198
+ return new Set(accounts.filter(a => engine.quarantined.has(a.key) || busy.has(a.key)).map(a => a.num));
199
+ }
200
+
184
201
  const numberOr = (v, fallback) => {
185
202
  const n = Number(v);
186
203
  return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
@@ -294,3 +311,39 @@ export async function noteEngineTick(result, accounts) {
294
311
  const to = accounts.find(a => String(a.num) === String(result.to?.number));
295
312
  await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
296
313
  }
314
+
315
+ /**
316
+ * The two rules as `ccdeck switch --best` asks them (#2183): what
317
+ * account-pick.mjs bestAccount hands chooseAccount, read the way
318
+ * chooseBeforeEngine reads it — the threshold, the counted models and the
319
+ * engine's strategy from claude-swap's config, the accounts its engine
320
+ * quarantined and the ones a local `cswap run` session holds. Null when
321
+ * neither rule is set, before anything else is read: `--best` is then the
322
+ * engine's own choice, as every tick is.
323
+ *
324
+ * `choice` and `settings` are the deck's (GET /api/cswap-auto) when a deck
325
+ * answered, so the rules are the ones its panel shows; with no deck they are
326
+ * read here. Only reads: nothing in auto-choice.json or claude-swap's store
327
+ * changes, so it needs no lock and takes none.
328
+ */
329
+ export async function bestRules(rows, { choice = null, settings = null, now = Date.now() } = {}) {
330
+ const c = choice ?? await choiceStatus();
331
+ const next = c?.next === "weekly" ? "weekly" : "room";
332
+ const home = text(c?.home?.email) ? accountKey(c.home.email, c.home.orgUuid) : null;
333
+ if (next === "room" && !home) return null;
334
+ const cfg = (settings ?? await readCswapConfig()) ?? {};
335
+ const accounts = (Array.isArray(rows) ? rows : [])
336
+ .filter(a => a && a.num != null)
337
+ .map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
338
+ const engine = await engineState(accounts);
339
+ return {
340
+ next,
341
+ home,
342
+ threshold: numberOr(cfg["autoswitch.threshold"]?.value, 90),
343
+ models: countedModels(cfg["autoswitch.model"]?.value),
344
+ busy: await accountsInCswapRun(accounts),
345
+ quarantined: engine.quarantined,
346
+ optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
347
+ now,
348
+ };
349
+ }
@@ -18,11 +18,18 @@ import { invalidateQuotaCache } from "./quota.mjs";
18
18
  import { withStoreLock } from "./store-lock.mjs";
19
19
  // Told about every finished tick and about the switch itself, so a tick that
20
20
  // moved the account is said once on the desktop (account-notify.mjs). Both
21
- // doors return at once and never throw.
22
- import { noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
21
+ // doors return at once and never throw. The third is the other way round: how a
22
+ // session that stopped on a dead login asks for a tick now (tickSoon).
23
+ import { connectEarlyTick, noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
23
24
  // The owner's two rules — a home account, and the soonest weekly reset — asked
24
25
  // before the engine, inside the same lock. With neither set, nothing.
25
- import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick } from "./cswap-auto-choice.mjs";
26
+ import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick, slotsHeldBack } from "./cswap-auto-choice.mjs";
27
+ // The one decision the engine cannot reach under `--once`: leaving an active
28
+ // account whose login has died. See its header.
29
+ import { engineLostActive, failOver } from "./autoswitch-failover.mjs";
30
+ // And the automatic continue of stopped background sessions (#2193), which a
31
+ // tick that moved the account is the likeliest moment for. Returns at once.
32
+ import { noteContinueTick } from "./stop-continue.mjs";
26
33
  import { readFile, writeFile, mkdir } from "node:fs/promises";
27
34
  import { join } from "node:path";
28
35
  import { homedir } from "node:os";
@@ -55,7 +62,8 @@ function summarise(stdout) {
55
62
  // here throws away readings that cost a subprocess each.
56
63
  switched: events.some(e => e.event === "switch" && e.dryRun !== true),
57
64
  // Why the engine moved — "proactive", "at-limit", "failover" — beside the
58
- // deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs).
65
+ // deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs), and
66
+ // "login-expired", which the deck's failover writes here.
59
67
  trigger: action?.trigger ?? null,
60
68
  reason: action?.reason ?? null,
61
69
  detail: action?.detail ?? null,
@@ -121,9 +129,13 @@ async function runAutoTick() {
121
129
  // A deck-managed `cswap switch` does not advance the engine's own
122
130
  // lastSwitchAt. Its fallback must keep our cooldown too, or a manual
123
131
  // return to the full account can immediately escape the weekly rule
124
- // through the engine's most-room choice. Still allow emergency exits.
132
+ // through the engine's most-room choice. Still allow emergency exits:
133
+ // the engine's own at-limit and failover, and an active account it could
134
+ // not read, which may be a login that has died — the deck's failover
135
+ // judges that one after the tick (autoswitch-failover.mjs).
125
136
  const decision = summarise(preview.stdout);
126
- if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover") {
137
+ if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover"
138
+ && !engineLostActive({ ok: true, ...decision })) {
127
139
  return { chose: {
128
140
  ok: true, ...decision, event: "no-switch", switched: false,
129
141
  trigger: null, reason: "cooldown", from: null, to: null,
@@ -134,6 +146,7 @@ async function runAutoTick() {
134
146
  const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
135
147
  return { ...out, accounts: chose.accounts };
136
148
  });
149
+ _ruleAccounts = r.accounts ?? null;
137
150
  if (r.skipped) return { event: "skipped", reason: r.skipped };
138
151
  if (r.chose) return r.chose;
139
152
  // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
@@ -159,6 +172,11 @@ async function runAutoTick() {
159
172
  let _timer = null;
160
173
  let _lastTick = null;
161
174
  let _enabled = false;
175
+ // When the deck last switched away from a dead login, for failOver's cooldown.
176
+ let _lastFailoverAt = null;
177
+ // The roster the home/weekly rules decided on in this tick, or null when none
178
+ // is set: the failover's move is a tick's, and noteEngineTick records it so.
179
+ let _ruleAccounts = null;
162
180
  // Set the instant startLoop is entered and cleared when it settles, because
163
181
  // `_timer` cannot do that job: it is assigned AFTER an await, and the window in
164
182
  // between is what #537 was. See startLoop.
@@ -169,6 +187,9 @@ let _starting = false;
169
187
  let _restartWanted = false;
170
188
  // The tick in flight, so the interval can skip rather than stack. See tick.
171
189
  let _ticking = null;
190
+ // When the tick in flight began, so an early tick can tell whether the one
191
+ // running already started after it was asked for. See tickSoon.
192
+ let _tickStartedAt = 0;
172
193
  // What every tick waits for before it runs anything: the launcher's word that
173
194
  // the claude-swap it drives is not being installed or upgraded underneath it.
174
195
  // Settled by default, so a caller with nothing to wait for — a respawn, a test,
@@ -244,7 +265,25 @@ async function runTick() {
244
265
  _lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
245
266
  return;
246
267
  }
247
- const result = await runAutoTick();
268
+ let result = await runAutoTick();
269
+ // A tick that could not read the active account may be a login that has
270
+ // died, which the engine cannot leave under --once (autoswitch-failover.mjs).
271
+ // The switch asks both of this function's questions again inside the lock,
272
+ // for the same reason runAutoTick does (#1797).
273
+ const failover = await failOver(result, {
274
+ lastAt: _lastFailoverAt,
275
+ before: async () => (!_enabled ? "disabled" : (await externalAutoRunning()) ? "external-engine" : null),
276
+ heldBack: slotsHeldBack,
277
+ }).catch(() => null);
278
+ if (failover) {
279
+ result = { ...result, ...failover };
280
+ if (failover.switched) {
281
+ _lastFailoverAt = Date.now();
282
+ // Where the failover left the user is a tick's pick, not one made by
283
+ // hand, so the home/weekly rules and their cooldown go on from it.
284
+ await noteEngineTick(result, _ruleAccounts).catch(() => {});
285
+ }
286
+ }
248
287
  // Before `_lastTick`, not after. This is the only path in the deck that moves
249
288
  // the live account without a click behind it, so nothing else is in a position
250
289
  // to make the call — and `_lastTick` is what /api/cswap-auto reports, so
@@ -286,12 +325,63 @@ async function runTick() {
286
325
  function tick() {
287
326
  if (_ticking) return _ticking;
288
327
  // The notifier hears what every tick did, the ones that switched nothing
289
- // included: a threshold notice it is holding waits for exactly that answer.
328
+ // included: a threshold notice it is holding waits for exactly that answer,
329
+ // and a stopped notice for one that began after the stop.
290
330
  // Not awaited, and caught inside: a notification never delays a tick.
291
- _ticking = runTick().then(() => { noteAutoTick(_lastTick); }).finally(() => { _ticking = null; });
331
+ const startedAt = Date.now();
332
+ _tickStartedAt = startedAt;
333
+ _ticking = runTick().then(() => { noteAutoTick(_lastTick, { startedAt }); noteContinueTick(_lastTick); }).finally(() => { _ticking = null; });
292
334
  return _ticking;
293
335
  }
294
336
 
337
+ /** How long an early tick waits for the rest of a burst: sessions sharing a
338
+ * login stop within a second or two of each other. */
339
+ export const SOON_MS = 1_500;
340
+
341
+ /** The least time between two early ticks. The stops arrive on `/api/event`,
342
+ * which takes a post without the deck's token, so this is what keeps a loop
343
+ * of fake ones from running `cswap auto --once` back to back — the interval's
344
+ * own floor is fifteen seconds, and this is twice that. */
345
+ export const SOON_GAP_MS = 30_000;
346
+
347
+ let _soon = null;
348
+ let _soonAskedAt = -Infinity;
349
+
350
+ /**
351
+ * Run one tick now rather than at the next interval, because a session has
352
+ * just stopped on an expired login or a usage limit (account-watch.mjs
353
+ * `noteHookStop`). The same tick the interval runs, through the same guards,
354
+ * so nothing about what the engine decides changes — only when it is asked.
355
+ *
356
+ * COALESCED: the first ask arms one tick SOON_MS later and every ask until it
357
+ * runs joins it, so ten sessions stopping in the same second cause one tick;
358
+ * after it, asks inside SOON_GAP_MS are dropped, and the interval carries on.
359
+ * NEVER BESIDE A RUNNING TICK: one in flight is waited for, and is enough by
360
+ * itself when it began after the ask. Off, this does nothing.
361
+ *
362
+ * Returns whether a tick was armed, for the suite.
363
+ */
364
+ export function tickSoon() {
365
+ if (!_enabled || _soon) return false;
366
+ const askedAt = Date.now();
367
+ if (askedAt - _soonAskedAt < SOON_GAP_MS) return false;
368
+ _soonAskedAt = askedAt;
369
+ _soon = setTimeout(async () => {
370
+ _soon = null;
371
+ try {
372
+ if (_ticking) {
373
+ const covered = _tickStartedAt >= askedAt;
374
+ await _ticking;
375
+ if (covered) return;
376
+ }
377
+ if (_enabled) await tick();
378
+ } catch { /* a tick reports its own failures in _lastTick */ }
379
+ }, SOON_MS);
380
+ _soon.unref?.();
381
+ return true;
382
+ }
383
+ connectEarlyTick(tickSoon);
384
+
295
385
  /**
296
386
  * Start the deck-managed loop, at most once.
297
387
  *
@@ -354,6 +444,7 @@ async function startLoop() {
354
444
 
355
445
  function stopLoop() {
356
446
  if (_timer) { clearInterval(_timer); _timer = null; }
447
+ if (_soon) { clearTimeout(_soon); _soon = null; }
357
448
  }
358
449
 
359
450
  /** Put a new interval into effect: the timer goes, and a fresh read of the