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
@@ -28,7 +28,7 @@ import { StringDecoder } from "node:string_decoder";
28
28
  import { renameWithRetry } from "./atomic-write.mjs";
29
29
  import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
30
30
  import { accountKey } from "./lan-sync.mjs";
31
- import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
31
+ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap, swapProjectsRoot } from "./swap-log.mjs";
32
32
  // The rules the live transcript scan counts a line's usage by, so this rollup
33
33
  // and a session's card cannot disagree about which lines billed anything, nor
34
34
  // about the speed they were billed at — see transcript-scan.mjs.
@@ -43,7 +43,7 @@ import {
43
43
  * a per-account total is never quietly inflated by work that is not that
44
44
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
45
45
  export const UNATTRIBUTED = "\u0000unattributed";
46
- const STATE_VERSION = 7;
46
+ const STATE_VERSION = 8;
47
47
 
48
48
  /** Where the tally and cursors live, beside cswap-auto's own state. */
49
49
  export function statePath(home = homedir()) {
@@ -372,11 +372,11 @@ async function listTranscripts(roots) {
372
372
  if (seen.has(path)) continue;
373
373
  seen.add(path);
374
374
  const parts = relative(root, dir).split(/[\\/]/).filter(Boolean);
375
- found.push({ path, slug: parts[0] ?? "", depth: parts.length });
375
+ found.push({ root, path, slug: parts[0] ?? "", depth: parts.length });
376
376
  }
377
377
  }
378
378
  found.sort((a, b) => a.depth - b.depth);
379
- return new Map(found.map(f => [f.path, f.slug]));
379
+ return new Map(found.map(f => [f.path, f]));
380
380
  }
381
381
 
382
382
  /** Drop day buckets older than the retention window and prune the empty
@@ -494,6 +494,8 @@ export function createProjectRollup({
494
494
  clearInterval: clearIv = clearInterval,
495
495
  } = {}) {
496
496
  let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {} };
497
+ let canonicalRoots = [];
498
+ let gapRoots = [];
497
499
  let timer = null;
498
500
  let running = false;
499
501
  let dirty = false;
@@ -502,20 +504,29 @@ export function createProjectRollup({
502
504
  async function load() {
503
505
  if (loaded) return;
504
506
  loaded = true;
507
+ canonicalRoots = [...new Set(await Promise.all(roots.map(r => swapProjectsRoot(r))))];
508
+ gapRoots = canonicalRoots;
505
509
  try {
506
510
  const disk = JSON.parse(await readFile(stateFile, "utf8"));
511
+ gapRoots = [...new Set([...canonicalRoots, ...(Array.isArray(disk?.sourceRoots) ? disk.sourceRoots.filter(r => typeof r === "string") : [])])];
507
512
  if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
508
513
  state = disk;
509
514
  state.folders ??= {};
510
515
  state.requests ??= {};
511
- } else if (disk && [1, 2, 3, 4, 5, 6].includes(disk.version)) {
516
+ if (JSON.stringify(disk.sourceRoots) !== JSON.stringify(canonicalRoots)) {
517
+ state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {},
518
+ lastAlive: disk.lastAlive };
519
+ dirty = true;
520
+ }
521
+ } else if (disk && [1, 2, 3, 4, 5, 6, 7].includes(disk.version)) {
512
522
  // Version 1 counted every API content block; version 2 keyed each row
513
523
  // by the line's own cwd, a row per folder the agent cd'd into (#1278);
514
524
  // version 3 kept no speed, so every fast turn read at the standard
515
525
  // rate; version 4 billed a subagent's request from its streaming
516
526
  // snapshot, most of its output and its speed missing (#1883); version
517
527
  // 5 billed a resumed subagent's request again each time its file
518
- // restated its history; version 6 kept no per-request context tiers.
528
+ // restated its history; version 6 kept no per-request context tiers;
529
+ // version 7 charged independent transcript roots to the same account.
519
530
  // None of them can be re-keyed, since it no
520
531
  // longer knows which transcript a count came from. Keep the heartbeat
521
532
  // so a restart gap remains fenced, but rebuild the tally from
@@ -524,6 +535,27 @@ export function createProjectRollup({
524
535
  dirty = true;
525
536
  }
526
537
  } catch { /* first run: empty state */ }
538
+ state.sourceRoots = canonicalRoots;
539
+ }
540
+
541
+ /** The swap-log entries that speak for any of `scope`, a list of canonical
542
+ * roots. An entry written before swaps recorded their projects root has
543
+ * none, and cannot prove which config directory it controlled — unless
544
+ * only one is known: a single configured root (two configured paths are two
545
+ * candidates, even when they resolve to one directory today), and no other
546
+ * root anywhere in the log. Then it can only have spoken for that root, and
547
+ * its history stays the account's, as it was before the upgrade. Otherwise
548
+ * it is left out, and the usage it covered stays unattributed. The log is
549
+ * the record asked because it only grows, so the answer cannot change back
550
+ * from one boot to the next. */
551
+ function timelineFor(timeline, scope) {
552
+ let legacyRoot = null;
553
+ if (new Set(roots.map(r => resolve(r))).size === 1) {
554
+ const known = new Set(canonicalRoots);
555
+ for (const e of timeline) if (e.projectsRoot !== undefined) known.add(e.projectsRoot);
556
+ if (known.size === 1) legacyRoot = canonicalRoots[0];
557
+ }
558
+ return timeline.filter(e => scope.includes(e.projectsRoot === undefined ? legacyRoot : e.projectsRoot));
527
559
  }
528
560
 
529
561
  async function persist() {
@@ -541,7 +573,7 @@ export function createProjectRollup({
541
573
  } catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
542
574
  }
543
575
 
544
- async function foldFile(path, slug, timeline) {
576
+ async function foldFile(path, slug, timeline, root) {
545
577
  let st = null;
546
578
  try { st = await stat(path); } catch { return; }
547
579
  let offset = state.cursors[path] ?? 0;
@@ -552,7 +584,8 @@ export function createProjectRollup({
552
584
  // is the rare, safe side to err on.
553
585
  if (st.size < offset) { state.cursors[path] = st.size; delete state.requests[path]; dirty = true; return; }
554
586
  if (st.size <= offset) return;
555
- const from = { slug, folders: state.folders, requests: state.requests, path };
587
+ const folders = state.folders[root] ??= {};
588
+ const from = { slug, folders, requests: state.requests, path };
556
589
  let fh = null;
557
590
  try {
558
591
  fh = await open(path, "r");
@@ -574,19 +607,26 @@ export function createProjectRollup({
574
607
  // switched accounts by hand while nothing was recording. Written before
575
608
  // the seed so the order is stop → start.
576
609
  if (state.lastAlive && nowMs - state.lastAlive > GAP_MS) {
577
- await markGap(state.lastAlive + 1, swapLog);
610
+ for (const root of gapRoots) await markGap(state.lastAlive + 1, swapLog, root);
578
611
  }
579
- await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
612
+ await seedActive({ now, path: swapLog, root: storeRoot, projectsRoot: canonicalRoots[0] }); // anchor the current account, once, deduped
580
613
  const timeline = await readSwapLog(swapLog);
581
- const files = await listTranscripts(roots);
582
- for (const [path, slug] of files) await foldFile(path, slug, timeline);
614
+ const files = await listTranscripts(canonicalRoots);
615
+ // Each root is attributed only by the entries that speak for it; an
616
+ // unscoped legacy entry speaks for none unless its root is unambiguous
617
+ // (timelineFor).
618
+ const timelines = new Map(canonicalRoots.map(root => [root, timelineFor(timeline, [root])]));
619
+ for (const [path, { slug, root }] of files) await foldFile(path, slug, timelines.get(root), root);
583
620
  // Forget the cursors and request ledgers of files that are gone, and
584
621
  // the folders of project directories with no transcript left, so none of
585
622
  // the three maps grows forever.
586
623
  for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
587
624
  for (const p of Object.keys(state.requests)) if (!files.has(p)) { delete state.requests[p]; dirty = true; }
588
- const slugs = new Set(files.values());
589
- for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
625
+ for (const root of Object.keys(state.folders)) {
626
+ const slugs = new Set([...files.values()].filter(f => f.root === root).map(f => f.slug));
627
+ for (const s of Object.keys(state.folders[root])) if (!slugs.has(s)) { delete state.folders[root][s]; dirty = true; }
628
+ if (!slugs.size) delete state.folders[root];
629
+ }
590
630
  // Heartbeat: record that the deck was alive now, so a later boot can see
591
631
  // how long it was down. Cheap when nothing else changed — only bumped
592
632
  // (and so only persisted) once every HEARTBEAT_MS while idle.
@@ -607,7 +647,7 @@ export function createProjectRollup({
607
647
  async report(key, days) {
608
648
  await load();
609
649
  const timeline = await readSwapLog(swapLog);
610
- return reportFrom(state.tally, timeline, key, days, now());
650
+ return reportFrom(state.tally, timelineFor(timeline, canonicalRoots), key, days, now());
611
651
  },
612
652
  _state: () => state,
613
653
  };
@@ -25,6 +25,9 @@ import { readBody, send } from "./http-io.mjs";
25
25
  import { heldPrefs } from "./prefs-state.mjs";
26
26
  import { PRODUCT } from "./brand.mjs";
27
27
  import { incidentsFrom, withDismissed, withSignIn, withTidied } from "./account-origins.mjs";
28
+ // What a removal of the active account does after it lands (#2184). Pure and
29
+ // importless, so it puts nothing on the path to a listening socket.
30
+ import { removeLeavingLiveLogin } from "./login-guard.mjs";
28
31
 
29
32
  // Resolved the way pinned-build.mjs resolves it, from a file in the same
30
33
  // directory, so every lazy import below is the URL the pin has already evaluated.
@@ -52,7 +55,18 @@ export async function handleClaudeAccountSwitch(req, res) {
52
55
  try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
53
56
  if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
54
57
 
55
- const result = await switchClaudeAccount(parsed.account);
58
+ send(res, ...await switchAndTell(parsed.account, { switchClaudeAccount, invalidateClaudeAccountsCache }));
59
+ }
60
+
61
+ /**
62
+ * One switch somebody asked for, and everything that has to hear about it —
63
+ * as `[status, body]`. The switch route's, and the one a removal of an expired
64
+ * active login makes afterwards (removeLeavingLiveLogin), so both say it the
65
+ * same way. The two functions it needs from claude-accounts.mjs are the
66
+ * caller's, which has already imported it.
67
+ */
68
+ async function switchAndTell(account, { switchClaudeAccount, invalidateClaudeAccountsCache }) {
69
+ const result = await switchClaudeAccount(account);
56
70
  // The active account just moved; the next poll should see it immediately
57
71
  // rather than serving the pre-switch roster for another few seconds.
58
72
  invalidateClaudeAccountsCache();
@@ -73,10 +87,10 @@ export async function handleClaudeAccountSwitch(req, res) {
73
87
  // effort — a failed log write must never fail the switch — so it is not
74
88
  // awaited into the response.
75
89
  import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
76
- .then(({ recordSwap }) => recordSwap(parsed.account, "manual"))
90
+ .then(({ recordSwap }) => recordSwap(account, "manual"))
77
91
  .catch(() => {});
78
92
  }
79
- send(res, result.ok ? 200 : 400, result);
93
+ return [result.ok ? 200 : 400, result];
80
94
  }
81
95
 
82
96
  // The account-projects rollup, started once and shared. Its timer is unref'd
@@ -256,6 +270,33 @@ async function dismissReauth(raw) {
256
270
  return { ok: true };
257
271
  }
258
272
 
273
+ /**
274
+ * Remove an account — and when it was the active one and its login had expired,
275
+ * move Claude Code to a working account once it is gone (#2184).
276
+ *
277
+ * `cswap remove` leaves the live login where it is. That is right when the
278
+ * login works and wrong when it is the dead one: every session stays stopped,
279
+ * and nothing else will move them, since auto-switch does not touch a login
280
+ * claude-swap no longer manages. The roster is read BEFORE the removal, because
281
+ * after it the account is not on it to ask about; the move goes through the
282
+ * switch route's own path, guard and bookkeeping included. Like Auto-switch's
283
+ * moves, it never lands on an account claude-swap set aside or one a local
284
+ * `cswap run` session is using (slotsHeldBack).
285
+ */
286
+ async function removeAccount(admin, num) {
287
+ const accounts = await import(
288
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
289
+ );
290
+ return removeLeavingLiveLogin(num, {
291
+ roster: () => accounts.fetchClaudeAccounts({ force: true }),
292
+ remove: n => admin.removeAccount(n),
293
+ switchTo: async n => (await switchAndTell(n, accounts))[1],
294
+ heldBack: async rows => (await import(
295
+ pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto-choice.mjs")).href
296
+ )).slotsHeldBack(rows),
297
+ });
298
+ }
299
+
259
300
  // Reading the login's progress. The browser polls this while its dialog is
260
301
  // open, the same way the upgrade notice polls /api/version.
261
302
  export async function handleAccountLoginState(_req, res) {
@@ -302,7 +343,9 @@ export async function handleClaudeAccountAdmin(req, res) {
302
343
  result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
303
344
  break;
304
345
  }
305
- case "remove": result = await admin.removeAccount(parsed.account); break;
346
+ // #2184. Removing the ACTIVE account leaves Claude Code signed in as it;
347
+ // when that login is the expired one, a working account takes over after.
348
+ case "remove": result = await removeAccount(admin, parsed.account); break;
306
349
  // #721. Re-captures the active slot's credentials in place; see
307
350
  // recaptureActive for why this is not a login and cannot become one.
308
351
  case "recapture": result = await admin.recaptureActive(); break;
@@ -21,6 +21,9 @@ import {
21
21
  } from "./account-notify.mjs";
22
22
  import { deckDataDir } from "./deck-home.mjs";
23
23
  import { writeFileAtomic } from "./atomic-write.mjs";
24
+ // What a StopFailure says, and the words a notification names a session by.
25
+ import { stopFailureOf, switchCanCure } from "./stop-failure.mjs";
26
+ import { whoOf } from "./block-notify.mjs";
24
27
 
25
28
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
26
29
 
@@ -44,6 +47,10 @@ let _notifier = null;
44
47
  let _autoOn = false;
45
48
  let _timer = null;
46
49
  let _watching = null;
50
+ /** The auto-switch loop's "tick now", handed in by the loop as it loads
51
+ * (cswap-auto-loop.mjs `tickSoon`). Null until then, and on a deck that never
52
+ * loads it — one with no Claude to switch. */
53
+ let _tickSoon = null;
47
54
 
48
55
  /**
49
56
  * Hand the notifier its OS call, its product name and its switches. Called
@@ -75,6 +82,16 @@ function notifier() {
75
82
  return _notifier;
76
83
  }
77
84
 
85
+ /**
86
+ * For the suite: settles once every record save the notifier has started has
87
+ * finished. A save follows its notice and is an atomic write, whose rename
88
+ * Windows retries while a scanner holds the file, so no fixed pause after a
89
+ * tick is long enough to read the records back.
90
+ */
91
+ export function accountNotifySettled() {
92
+ return _notifier ? _notifier.settled() : Promise.resolve();
93
+ }
94
+
78
95
  /** The deck's auto-switch loop was switched on or off (cswap-auto-loop.mjs). */
79
96
  export function noteAutoSwitch(enabled) {
80
97
  _autoOn = enabled === true;
@@ -86,16 +103,43 @@ export function noteAutoSwitch(enabled) {
86
103
  * notification is raised: a switch somebody pressed never comes through here.
87
104
  * After a switch, claude-swap's roster is read for the two accounts' names and
88
105
  * keys — the store, which the deck already reads, rather than anything more out
89
- * of the tick's own output.
106
+ * of the tick's own output. `meta.startedAt` is when the tick began.
90
107
  */
91
- export function noteAutoTick(result) {
108
+ export function noteAutoTick(result, meta = {}) {
92
109
  const n = notifier();
93
110
  if (!n || !result) return Promise.resolve();
94
- return (result.switched ? storeAccounts() : Promise.resolve(null))
95
- .then(accounts => n.tick(result, accounts))
111
+ // A dead login with nowhere to go is named too, so its notice can say whose.
112
+ const named = result.switched || result.reason === "login-expired";
113
+ return (named ? storeAccounts() : Promise.resolve(null))
114
+ .then(accounts => n.tick(result, accounts, meta))
96
115
  .catch(() => {});
97
116
  }
98
117
 
118
+ /** The loop's way to be asked for a tick now. Called once, as it loads. */
119
+ export function connectEarlyTick(fn) {
120
+ _tickSoon = typeof fn === "function" ? fn : null;
121
+ }
122
+
123
+ /**
124
+ * A hook event, as pushEvent takes it in: when it is a turn that stopped on an
125
+ * expired login or a usage limit, the incident is counted for its notification
126
+ * and the auto-switch loop is asked to tick now rather than at its next
127
+ * interval. The loop coalesces the asks and does nothing while it is off, so
128
+ * ten sessions stopping in one second are one tick, and a deck with auto-switch
129
+ * off only counts.
130
+ *
131
+ * Every other event, and every stop that is not one of those two, returns at
132
+ * once — this is on the path every hook event takes. Never throws.
133
+ */
134
+ export function noteHookStop(raw, at = Date.now()) {
135
+ try {
136
+ const stop = stopFailureOf(raw, at);
137
+ if (!stop || !switchCanCure(stop.kind)) return;
138
+ notifier()?.stopped({ sessionId: raw.session_id, who: whoOf(raw), kind: stop.kind, at });
139
+ _tickSoon?.();
140
+ } catch { /* never the ingest path's problem */ }
141
+ }
142
+
99
143
  /** claude-swap's roster, number → `{ email, organizationUuid, alias }`, or null. */
100
144
  async function storeAccounts() {
101
145
  try {
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // With no page open there is no reducer, so this keeps the smallest version of
11
11
  // it: a session is open from the first event of a turn until the turn ends
12
- // (`Stop`, `SessionEnd`). SessionStart and Notification move nothing — a CLI
12
+ // (`Stop`, `StopFailure` — the Stop of a turn that ended on an API error —
13
+ // `SessionEnd`). SessionStart and Notification move nothing — a CLI
13
14
  // sitting at its prompt is not busy, and the idle reminder arrives after the
14
15
  // turn it is about. An open session whose last event is older than
15
16
  // OPEN_CAP_MS counts as gone rather than busy: a CLI that crashed never sends
@@ -26,7 +27,7 @@ export function shouldTrackActivity(raw, opts = {}) {
26
27
  const OPENS = new Set([
27
28
  ...RECOVERIES, "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
28
29
  ]);
29
- const CLOSES = new Set(["Stop", "SessionEnd"]);
30
+ const CLOSES = new Set(["Stop", "StopFailure", "SessionEnd"]);
30
31
 
31
32
  /** More turns open at once than any machine runs — the same figure as the
32
33
  * deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
@@ -161,17 +161,119 @@ export const ONE_SHOT = Object.freeze([
161
161
  "purge",
162
162
  ]);
163
163
 
164
+ /**
165
+ * The words the deck answers as commands (#2183), by every spelling it takes.
166
+ *
167
+ * Two kinds. A COMMAND is something the flags never did — read the accounts,
168
+ * switch one, list the sessions — and lands in `command`, answered by
169
+ * bin/cli/commands.js. A FLAG WORD is a flag that already existed under a
170
+ * second spelling: `ccdeck stop` is `ccdeck --stop`, set as that very flag, so
171
+ * the one-shot that answers it cannot tell the two apart and there is one
172
+ * implementation of each. `start` is the bare start, spelled out by people who
173
+ * type a verb first, and sets nothing at all.
174
+ *
175
+ * `swap` is a switch here, not claude-swap's "exchange two slots": that is the
176
+ * word the people asking for this used, and a switch is what they meant. Two
177
+ * account operands are answered with where the slot exchange lives instead
178
+ * (see bin/cli/commands.js).
179
+ */
180
+ export const COMMANDS = Object.freeze({
181
+ accounts: "accounts", account: "accounts", ls: "accounts",
182
+ switch: "switch", swap: "switch", use: "switch",
183
+ usage: "usage", quota: "usage",
184
+ sessions: "sessions", ps: "sessions",
185
+ open: "open",
186
+ });
187
+ const FLAG_WORDS = Object.freeze({
188
+ status: "status", stop: "stop", logs: "logs", help: "help", version: "version",
189
+ });
190
+ const BARE_START = "start";
191
+
192
+ /**
193
+ * The flags only a START reads. A line carrying one is a deck being started,
194
+ * whatever else is on it, and its bare words are what they always were —
195
+ * unknown options, named, and the deck starts — rather than commands.
196
+ *
197
+ * Two lines need that and neither can say so any other way. An unquoted path
198
+ * with a space in it: `--workspace /home/ana/Face Swap` is `--workspace
199
+ * /home/ana/Face` and a bare `Swap`, which is a switch to whoever reads words
200
+ * as commands. And the npx relaunch of an upgrade (npxRelaunchArgs in
201
+ * supervisor.mjs), which replays the user's own argv with `--no-open` added —
202
+ * an argv an older deck booted on, where `ccdeck myproj` was a warning and a
203
+ * start, and where a newer parser reading `myproj` as a misspelled command
204
+ * would end the deck it was upgrading.
205
+ */
206
+ const START_FLAGS = new Set([
207
+ "--no-open", "--new", "--foreground", "--at-login", "--workspace", "--scope",
208
+ "--no-persist", "--history", "--codex", "--no-codex", "--claude", "--no-claude", "--ref",
209
+ ]);
210
+
211
+ /**
212
+ * Could this token have been meant as a command?
213
+ *
214
+ * A word — letters, digits and dashes, starting with a letter. Anything with a
215
+ * separator, a dot or a tilde in it is a path, and a path in first position is
216
+ * the old mistake `ccdeck ~/proj`, reported the old way: an unknown option, and
217
+ * the deck still starts.
218
+ */
219
+ function wordShaped(token) {
220
+ return /^[a-z][a-z0-9-]*$/i.test(token);
221
+ }
222
+
164
223
  /** Is this a command line that answers and leaves? */
165
224
  export function isOneShot(flags = {}) {
225
+ // A command, and a word that was not one, answer and leave too: a command
226
+ // that detached would print its answer into deck.log, and a misspelled one
227
+ // that started a deck would leave a server behind on a machine reached over
228
+ // SSH, where there is no tab to show it in.
229
+ if (typeof flags.command === "string" || typeof flags.unknownCommand === "string") return true;
166
230
  return ONE_SHOT.some((k) => flags[k] === true);
167
231
  }
168
232
 
169
- export function parseArgs(args) {
233
+ /**
234
+ * `commands: false` reads every bare word the way the parser did before there
235
+ * were commands, which is how a RESPAWNED worker reads its argv (bin/deck.js):
236
+ * the line is one a deck already booted on — perhaps an older deck, for which
237
+ * `ccdeck serve` was a warning and a start — and a worker that answered it as
238
+ * a command would exit, and the supervisor would count each exit as a crash.
239
+ */
240
+ export function parseArgs(args, { commands = true } = {}) {
170
241
  const out = { unknown: [], incomplete: [] };
171
242
  // END OF OPTIONS. A bare `--` is the POSIX separator, and reporting it as an
172
- // unknown option warned about a token the user typed on purpose.
243
+ // unknown option warned about a token the user typed on purpose. After a
244
+ // command, what follows it is operands — `ccdeck switch -- -odd-alias` —
245
+ // which is the separator's whole purpose.
173
246
  const end = args.indexOf("--");
174
247
  const argv = end === -1 ? args : args.slice(0, end);
248
+ const afterEnd = end === -1 ? [] : args.slice(end + 1);
249
+ const words = commands && !argv.some(t => START_FLAGS.has(t.startsWith("--") ? t.split("=")[0] : t));
250
+ // A bare word, sorted by where it stands. Only the FIRST bare word on a line
251
+ // can be a command, and it can be one wherever it stands among the flags, so
252
+ // `ccdeck --json accounts` reads the same as `ccdeck accounts --json`; every
253
+ // bare word after it is one of its operands. A word-shaped token in first
254
+ // position that is not a command is a command misspelled — `ccdeck acounts` —
255
+ // and is refused rather than starting a deck. Any other bare word is what it
256
+ // always was: an unknown option, named, and the deck starts — so is every
257
+ // bare word on a start line (START_FLAGS) and behind a word that was not a
258
+ // command (`ccdeck ~/my swap`).
259
+ let bare = 0;
260
+ const positional = (token, first) => {
261
+ if (out.command) { out.operands.push(token); return; }
262
+ const word = String(token).toLowerCase();
263
+ const lead = bare++ === 0;
264
+ if (lead && word === BARE_START) return;
265
+ if (lead && words) {
266
+ if (Object.hasOwn(COMMANDS, word)) {
267
+ out.command = COMMANDS[word];
268
+ out.commandWord = word;
269
+ out.operands = [];
270
+ return;
271
+ }
272
+ if (Object.hasOwn(FLAG_WORDS, word)) { out[FLAG_WORDS[word]] = true; return; }
273
+ if (first && wordShaped(token)) { out.unknownCommand = token; return; }
274
+ }
275
+ out.unknown.push(token);
276
+ };
175
277
  for (let i = 0; i < argv.length; i++) {
176
278
  // `--port=4400`, split here so the dispatch below sees the two halves it
177
279
  // already knows how to read.
@@ -248,7 +350,15 @@ export function parseArgs(args) {
248
350
  // The ccdeck.dev page the command was copied from, for the first install
249
351
  // report (install-ref.mjs). A value that is not a ref is dropped there.
250
352
  else if (a === "--ref") set("ref", "a page name");
353
+ // What the commands read (#2183): machine-readable output on stdout, and
354
+ // the account with the most room rather than a named one.
355
+ else if (a === "--json") out.json = true;
356
+ else if (a === "--best") out.best = true;
357
+ else if (!looksLikeFlag(argv[i])) positional(argv[i], i === 0);
251
358
  else out.unknown.push(argv[i]); // the token as typed, `=` half included
252
359
  }
360
+ for (const token of afterEnd) {
361
+ if (out.command) out.operands.push(token);
362
+ }
253
363
  return out;
254
364
  }
@@ -0,0 +1,138 @@
1
+ // Auto-switch away from an active login that has died.
2
+ //
3
+ // WHY THE ENGINE CANNOT DO THIS UNDER THE DECK. claude-swap fails over only
4
+ // after the active account's usage has been unreadable for `unhealthyTicks`
5
+ // polls in a row (3 by default), and it counts them on the engine instance. The
6
+ // deck runs the engine as `cswap auto --once`, a new process per tick, so the
7
+ // count is 1 every time: each tick reports `active-usage-unknown 1/3` and the
8
+ // user stays on an account they can no longer use. Its idle hold has the same
9
+ // shape — `_idle_hold_since` starts again with every process, so its
10
+ // thirty-minute cap is never reached either. (claude-swap 0.26.0: cli.py builds
11
+ // a fresh AutoSwitchEngine for each --once run; autoswitch.py keeps both
12
+ // counters on it.)
13
+ //
14
+ // So the deck finishes that one decision itself, and nothing else: every other
15
+ // switch is still the engine's.
16
+ //
17
+ // WHAT COUNTS AS DEAD. Three answers from three places, all required:
18
+ // - the engine could not read the active account this tick;
19
+ // - claude-swap's stored copy of it is not alive (account-health.mjs);
20
+ // - the `claude` CLI itself says nobody is signed in. That is what Claude
21
+ // Code leaves behind once a refresh is refused: "Login expired · Please run
22
+ // /login" clears the saved credentials.
23
+ // Each alone has a known false alarm. An unreadable usage endpoint is a network
24
+ // blip; a dead stored copy beside a working session is #721's stale copy; and a
25
+ // signed-out CLI whose stored copy is alive is a /logout somebody meant, or one
26
+ // claude-swap can still repair. A CLI that could not be asked is no answer, so
27
+ // it never counts as signed out.
28
+ //
29
+ // WHERE IT GOES. An account claude-swap can still use, not disabled, under the
30
+ // threshold the tick ran with, and not one of the two the deck's own rules
31
+ // never land on either: set aside by claude-swap's engine (its refresh token is
32
+ // dead, the very thing being left) or in use by a local `cswap run` session.
33
+ // The freshest reading first, then the most headroom. A reading can be old —
34
+ // claude-swap collects idle accounts on its own schedule — and an old reading
35
+ // is still better than staying on a login that cannot answer at all; once the
36
+ // new account is active, the engine reads it and moves on by its own rules if
37
+ // it is full.
38
+ //
39
+ // AND THE DECK'S RULES. With a home or the weekly rule set, the loop answers a
40
+ // tick inside their cooldown without running the engine — except for an
41
+ // emergency, and an active account the engine could not read is one: it is
42
+ // the only way this failover hears of a dead login. The move it makes is
43
+ // recorded as the tick's (noteEngineTick), so the rules do not mistake it for
44
+ // a switch made by hand.
45
+ //
46
+ // HOW OFTEN. Once per FAILOVER_COOLDOWN_MS. A target whose login dies too is
47
+ // found the same way a tick later, and the wait keeps that from becoming a walk
48
+ // through every dead account in the store inside a minute.
49
+ import { fetchClaudeAccounts, switchClaudeAccount } from "./claude-accounts.mjs";
50
+ import { cliSignIn } from "./claude-identity.mjs";
51
+
52
+ /** The engine's two ways of saying it could not read the active account. */
53
+ export const LOST_ACTIVE_REASONS = new Set(["active-usage-unknown", "active-idle"]);
54
+
55
+ /** claude-swap's own cooldown after a switch, used here between failovers. */
56
+ export const FAILOVER_COOLDOWN_MS = 5 * 60_000;
57
+
58
+ /** What `failOver` names the switch, and the stuck tick, after. */
59
+ export const LOGIN_EXPIRED = "login-expired";
60
+
61
+ /** Whether a tick's answer could be a dead active login worth asking about. */
62
+ export function engineLostActive(tick) {
63
+ return tick?.ok === true && tick.event === "no-switch" && LOST_ACTIVE_REASONS.has(tick.reason);
64
+ }
65
+
66
+ /** The active roster row when its stored login is gone, else null. A stale
67
+ * copy is a working session the collector cannot see (#721), never this. */
68
+ export function deadActiveRow(accounts) {
69
+ const active = Array.isArray(accounts) ? accounts.find(a => a?.active) : null;
70
+ if (!active || active.staleCopy || active.alive !== false) return null;
71
+ return active;
72
+ }
73
+
74
+ /** The accounts a failover may land on, best first. `held` is the slots no
75
+ * move may land on whatever the rule (cswap-auto-choice.mjs slotsHeldBack):
76
+ * ones claude-swap's engine set aside, and ones a local `cswap run` session
77
+ * is using — the same two the deck's own rules skip. */
78
+ export function failoverTargets(accounts, threshold, held = new Set()) {
79
+ const limit = typeof threshold === "number" && Number.isFinite(threshold) ? threshold : 90;
80
+ return (Array.isArray(accounts) ? accounts : [])
81
+ .filter(a => a && !a.active && !a.disabled && a.alive === true && !a.staleCopy && !a.stopped
82
+ && !held.has(a.num)
83
+ && typeof a.headroom === "number" && 100 - a.headroom < limit)
84
+ .sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || b.headroom - a.headroom);
85
+ }
86
+
87
+ const refOf = a => ({ number: a.num, email: a.email ?? "" });
88
+
89
+ /**
90
+ * Finish a tick the engine could not finish: switch away from the active
91
+ * account when its login is dead, or say why not.
92
+ *
93
+ * Null when this tick is not about a dead login at all — the tick then stands
94
+ * as the engine reported it. Otherwise a tick-shaped answer to merge over it:
95
+ * a `switch` with `trigger: "login-expired"`, or a `no-switch` whose reason is
96
+ * `login-expired` and whose detail says what stood in the way.
97
+ *
98
+ * `before` runs inside the store lock, ahead of the switch, so the loop can
99
+ * stand down if it was turned off or a terminal engine started meanwhile.
100
+ * `heldBack(accounts)` names the slots no move may land on (failoverTargets);
101
+ * one it cannot read holds nothing back.
102
+ */
103
+ export async function failOver(tick, {
104
+ lastAt = null,
105
+ now = Date.now(),
106
+ before,
107
+ roster = () => fetchClaudeAccounts({ force: true }),
108
+ signIn = cliSignIn,
109
+ switchTo = switchClaudeAccount,
110
+ heldBack = async () => new Set(),
111
+ } = {}) {
112
+ if (!engineLostActive(tick)) return null;
113
+ const read = await roster().catch(() => null);
114
+ const dead = deadActiveRow(read?.ok ? read.accounts : null);
115
+ if (!dead) return null;
116
+ const cli = await signIn().catch(() => null);
117
+ if (cli?.loggedIn !== false) return null;
118
+
119
+ const from = refOf(dead);
120
+ if (lastAt != null && now - lastAt < FAILOVER_COOLDOWN_MS) {
121
+ return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "waiting after the last switch away from an expired login", from };
122
+ }
123
+ const held = await Promise.resolve().then(() => heldBack(read.accounts)).catch(() => null);
124
+ const [target] = failoverTargets(read.accounts, tick.threshold, held instanceof Set ? held : new Set());
125
+ if (!target) {
126
+ // `stuck`: the one answer the notifier says, and the panel shows.
127
+ return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "no other account is ready", stuck: true, from };
128
+ }
129
+ // Forced: the current login is the dead one, and Claude Code has already
130
+ // cleared it. A plain switch would first back that empty login up, and
131
+ // claude-swap refuses to (see switchClaudeAccount).
132
+ const r = await switchTo(target.num, { before, force: true });
133
+ if (r?.skipped) return { event: "skipped", reason: r.skipped };
134
+ if (!r?.ok) {
135
+ return { ok: false, event: "no-switch", reason: "failover_failed", detail: r?.output || r?.reason || "switch failed", from };
136
+ }
137
+ return { event: "switch", switched: true, trigger: LOGIN_EXPIRED, from, to: refOf(target) };
138
+ }