ccdeck 3.43.4 → 3.45.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 (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -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 +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -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,13 @@ 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";
27
+ // Which deck is on what, as Local network last heard it, and the identity rule
28
+ // its keys are made by (#2192).
29
+ import { elsewhereNow } from "./lan-elsewhere.mjs";
30
+ import { accountKey } from "./lan-copies.mjs";
24
31
 
25
32
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
26
33
 
@@ -39,11 +46,24 @@ export function recordsPath(home = deckDataDir()) {
39
46
  */
40
47
  export const WATCH_MS = 2 * 60_000;
41
48
 
49
+ /**
50
+ * How often the notifier asks Local network whether the account this deck is
51
+ * on is live on another deck too (#2192). A round is a minute, so asking more
52
+ * often finds nothing new; and with no deck on any account of this one's, the
53
+ * look is the engine's status and nothing else — the store is not read.
54
+ */
55
+ export const ELSEWHERE_WATCH_MS = 60_000;
56
+
42
57
  let _deps = null;
43
58
  let _notifier = null;
44
59
  let _autoOn = false;
45
60
  let _timer = null;
61
+ let _elsewhereTimer = null;
46
62
  let _watching = null;
63
+ /** The auto-switch loop's "tick now", handed in by the loop as it loads
64
+ * (cswap-auto-loop.mjs `tickSoon`). Null until then, and on a deck that never
65
+ * loads it — one with no Claude to switch. */
66
+ let _tickSoon = null;
47
67
 
48
68
  /**
49
69
  * Hand the notifier its OS call, its product name and its switches. Called
@@ -75,6 +95,16 @@ function notifier() {
75
95
  return _notifier;
76
96
  }
77
97
 
98
+ /**
99
+ * For the suite: settles once every record save the notifier has started has
100
+ * finished. A save follows its notice and is an atomic write, whose rename
101
+ * Windows retries while a scanner holds the file, so no fixed pause after a
102
+ * tick is long enough to read the records back.
103
+ */
104
+ export function accountNotifySettled() {
105
+ return _notifier ? _notifier.settled() : Promise.resolve();
106
+ }
107
+
78
108
  /** The deck's auto-switch loop was switched on or off (cswap-auto-loop.mjs). */
79
109
  export function noteAutoSwitch(enabled) {
80
110
  _autoOn = enabled === true;
@@ -86,16 +116,55 @@ export function noteAutoSwitch(enabled) {
86
116
  * notification is raised: a switch somebody pressed never comes through here.
87
117
  * After a switch, claude-swap's roster is read for the two accounts' names and
88
118
  * keys — the store, which the deck already reads, rather than anything more out
89
- * of the tick's own output.
119
+ * of the tick's own output. `meta.startedAt` is when the tick began.
90
120
  */
91
- export function noteAutoTick(result) {
121
+ export function noteAutoTick(result, meta = {}) {
92
122
  const n = notifier();
93
123
  if (!n || !result) return Promise.resolve();
94
- return (result.switched ? storeAccounts() : Promise.resolve(null))
95
- .then(accounts => n.tick(result, accounts))
124
+ // A dead login with nowhere to go is named too, so its notice can say whose.
125
+ const named = result.switched || result.reason === "login-expired";
126
+ return (named ? Promise.all([storeAccounts(), result.switched ? telemetryReader() : null]) : Promise.resolve([null, null]))
127
+ .then(([accounts, telemetryOf]) => n.tick(result, accounts, { ...meta, telemetryOf }))
96
128
  .catch(() => {});
97
129
  }
98
130
 
131
+ /** What each organization's settings were last seen to turn on, as the
132
+ * sentence a swap notice carries (org-telemetry.mjs), or null. */
133
+ async function telemetryReader() {
134
+ try {
135
+ const { knownOrgTelemetry, telemetryClause } = await import(pathToFileURL(join(PKG_ROOT, "src/server/org-telemetry.mjs")).href);
136
+ const records = await knownOrgTelemetry();
137
+ return org => telemetryClause(records?.[org]);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** The loop's way to be asked for a tick now. Called once, as it loads. */
144
+ export function connectEarlyTick(fn) {
145
+ _tickSoon = typeof fn === "function" ? fn : null;
146
+ }
147
+
148
+ /**
149
+ * A hook event, as pushEvent takes it in: when it is a turn that stopped on an
150
+ * expired login or a usage limit, the incident is counted for its notification
151
+ * and the auto-switch loop is asked to tick now rather than at its next
152
+ * interval. The loop coalesces the asks and does nothing while it is off, so
153
+ * ten sessions stopping in one second are one tick, and a deck with auto-switch
154
+ * off only counts.
155
+ *
156
+ * Every other event, and every stop that is not one of those two, returns at
157
+ * once — this is on the path every hook event takes. Never throws.
158
+ */
159
+ export function noteHookStop(raw, at = Date.now()) {
160
+ try {
161
+ const stop = stopFailureOf(raw, at);
162
+ if (!stop || !switchCanCure(stop.kind)) return;
163
+ notifier()?.stopped({ sessionId: raw.session_id, who: whoOf(raw), kind: stop.kind, at });
164
+ _tickSoon?.();
165
+ } catch { /* never the ingest path's problem */ }
166
+ }
167
+
99
168
  /** claude-swap's roster, number → `{ email, organizationUuid, alias }`, or null. */
100
169
  async function storeAccounts() {
101
170
  try {
@@ -155,6 +224,35 @@ export function watchOnce(providers) {
155
224
  return _watching;
156
225
  }
157
226
 
227
+ /**
228
+ * One look at whether the account this deck is on is one a paired deck says it
229
+ * is on too, handed to the notifier, which says it once per incident.
230
+ *
231
+ * The store is read only when some deck is on some account, and through the
232
+ * roster's own cache, which the panel and every LAN round already keep warm. A
233
+ * roster that could not be read says nothing and closes nothing: not knowing
234
+ * which account this deck is on is not the same as being on none.
235
+ */
236
+ export async function watchElsewhereOnce(now = Date.now()) {
237
+ const n = notifier();
238
+ if (!n) return;
239
+ try {
240
+ const on = elsewhereNow(now);
241
+ if (on.size === 0) return n.elsewhere(null, []);
242
+ const { fetchClaudeAccounts } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
243
+ const got = await fetchClaudeAccounts();
244
+ if (!got?.ok || !Array.isArray(got.accounts)) return;
245
+ const live = got.accounts.find(a => a?.active === true);
246
+ if (!live?.email) return n.elsewhere(null, []);
247
+ const key = accountKey(live.email, live.orgUuid);
248
+ const name = typeof live.alias === "string" && live.alias.trim() ? live.alias.trim() : live.email;
249
+ return n.elsewhere({ key, name }, on.get(key) ?? []);
250
+ } catch {
251
+ // The roster's own failures are its panel's to say; here there is nothing
252
+ // to compare this time.
253
+ }
254
+ }
255
+
158
256
  /**
159
257
  * Start reading quota on a timer, from startServer once the port is bound —
160
258
  * never at import, because a launcher imports the server module only to ask
@@ -166,4 +264,9 @@ export function startAccountWatch({ providers } = {}) {
166
264
  _timer.unref?.();
167
265
  const first = setTimeout(() => { watchOnce(providers).catch(() => {}); }, 15_000);
168
266
  first.unref?.();
267
+ // And Local network's word on the account this deck is on, on its own clock:
268
+ // it costs nothing while no deck is on anything, and it is said whichever of
269
+ // the quota switches is on.
270
+ _elsewhereTimer = setInterval(() => { watchElsewhereOnce().catch(() => {}); }, ELSEWHERE_WATCH_MS);
271
+ _elsewhereTimer.unref?.();
169
272
  }
@@ -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
  }