agent-dag 3.19.0 β†’ 3.21.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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-DTbVC1d9.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-CE-8EKi6.css">
43
+ <script type="module" crossorigin src="/assets/index-G0ntHhBn.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BYMVIG6f.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.19.0",
3
+ "version": "3.21.0",
4
4
  "description": "Live deck of Claude Code and Codex agents β€” watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,18 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "3.21.0": [
35
+ {
36
+ "title": "πŸ–ΌοΈ The deck explains itself in pictures",
37
+ "body": "🎬 First run opens an eight-picture tour instead of the changelog β€” who is waiting on you, the tree, a node's inside, cost and quota, this machine, accounts, local network, how to start. `Take the tour` on an empty canvas brings it back.\n\nπŸ“‘ **Local network** has a guide: a card while it is off, `How it works` under an empty list, four pictures ending on `Turn it on`. A machine's row opens its own dialog β€” address, version and system, the logins it offers, and a name of your own for it. Setup ends on Done.\n\nπŸ”„ The deck updates itself when nobody is looking: no tab focused, no agent mid-turn, thirty seconds quiet. The banner's `auto when idle` switch governs it.\n\nπŸ”‘ An account whose numbers were paused resumes on its own, and the panel is titled **Claude accounts** β€” a Codex login is not in it."
38
+ }
39
+ ],
40
+ "3.20.0": [
41
+ {
42
+ "title": "🟒 The deck keeps running after you close the terminal",
43
+ "body": "`ccdeck` prints the same boot report and gives you the prompt back. The deck stays up, and starts again when you log in.\n\nπŸ›‘ **`ccdeck --stop` is the off switch now** β€” Ctrl+C only cancels a start that is still printing. `--status` says what is running, `--logs` shows what the deck wrote while you were away, and `--foreground` holds the terminal the way every version before this one did.\n\nπŸ” Typing `ccdeck` beside a deck that is already up opens **that deck's tab** instead of building a second one. `ccdeck --stop --all` clears any older deck still running from before this version. An `npx` run cannot start at login β€” `npx ccdeck --install` puts it on your PATH and sets that up.\n\nπŸ”‘ An account that will not collect now says which of three things is wrong: **no stored login**, a **login that expired**, or a **keychain the deck cannot open** β€” with the fix beside it. One with no stored login is filled over the local network by a paired deck that has it.\n\nπŸ“‘ And decks find each other again on a machine with a VPN up: the beacon was going to an address that never left the machine, so this deck could hear colleagues and none of them could hear it."
44
+ }
45
+ ],
34
46
  "3.19.0": [
35
47
  {
36
48
  "title": "🟒 Local network: fixes and improvements",
@@ -0,0 +1,52 @@
1
+ // Whether any agent is mid-turn, from the hook stream alone.
2
+ //
3
+ // For the one decision the server takes on its own that must not land in the
4
+ // middle of a turn: restarting into a new version while nobody is looking
5
+ // (auto-update.mjs). A restart mid-turn drops the hook events fired during the
6
+ // gap, and the canvas shows those tools in flight until the stale sweeper
7
+ // reaps them β€” restart.ts states the rule for the page, which decides it from
8
+ // its reducer's `active` state.
9
+ //
10
+ // With no page open there is no reducer, so this keeps the smallest version of
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
13
+ // sitting at its prompt is not busy, and the idle reminder arrives after the
14
+ // turn it is about. An open session whose last event is older than
15
+ // OPEN_CAP_MS counts as gone rather than busy: a CLI that crashed never sends
16
+ // its Stop, and waiting on one forever would mean never updating.
17
+ export const OPEN_CAP_MS = 30 * 60_000;
18
+
19
+ const OPENS = new Set([
20
+ "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
21
+ ]);
22
+ const CLOSES = new Set(["Stop", "SessionEnd"]);
23
+
24
+ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
25
+ /** session id β†’ when its turn last produced an event */
26
+ const open = new Map();
27
+ let lastAt = 0;
28
+ return {
29
+ /** One hook payload, as pushEvent receives it. Anything else is ignored. */
30
+ note(raw, at) {
31
+ const name = raw?.hook_event_name;
32
+ if (!OPENS.has(name) && !CLOSES.has(name)) return;
33
+ if (at > lastAt) lastAt = at;
34
+ const id = raw?.session_id;
35
+ if (typeof id !== "string" || !id) return;
36
+ if (CLOSES.has(name)) open.delete(id);
37
+ else open.set(id, at);
38
+ },
39
+ /** Whether any turn is still running. */
40
+ busy(now) {
41
+ for (const [id, at] of open) {
42
+ if (Math.abs(now - at) > openCapMs) open.delete(id);
43
+ else return true;
44
+ }
45
+ return false;
46
+ },
47
+ /** How long since the last turn event of any session β€” Infinity before the first. */
48
+ quietMs(now) {
49
+ return lastAt ? Math.max(0, now - lastAt) : Infinity;
50
+ },
51
+ };
52
+ }
@@ -110,6 +110,25 @@ export function isPortValue(raw) {
110
110
  * C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
111
111
  * one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
112
112
  */
113
+ /**
114
+ * The flags that do a thing and exit, rather than starting a deck.
115
+ *
116
+ * bin/agent-dag.js reads this before it decides whether to detach, and that is
117
+ * the whole reason it exists as a list rather than as a condition written out
118
+ * at the call site: a one-shot that detached would print its answer into a log
119
+ * file and hand the terminal back empty. `ccdeck --version` detaching itself is
120
+ * the shape of the bug this prevents.
121
+ */
122
+ export const ONE_SHOT = Object.freeze([
123
+ "help", "version", "uninstall", "stop", "status", "logs",
124
+ "install", "installService", "uninstallService",
125
+ ]);
126
+
127
+ /** Is this a command line that answers and leaves? */
128
+ export function isOneShot(flags = {}) {
129
+ return ONE_SHOT.some((k) => flags[k] === true);
130
+ }
131
+
113
132
  export function parseArgs(args) {
114
133
  const out = { unknown: [], incomplete: [] };
115
134
  for (let i = 0; i < args.length; i++) {
@@ -140,6 +159,14 @@ export function parseArgs(args) {
140
159
  else if (a === "-v" || a === "--version") out.version = true;
141
160
  else if (a === "-p" || a === "--port") set("port", "a port number");
142
161
  else if (a === "--no-open") out.noOpen = true;
162
+ else if (a === "--new") out.new = true;
163
+ else if (a === "--foreground") out.foreground = true;
164
+ else if (a === "--stop") out.stop = true;
165
+ else if (a === "--status") out.status = true;
166
+ else if (a === "--logs") out.logs = true;
167
+ else if (a === "--install") out.install = true;
168
+ else if (a === "--install-service") out.installService = true;
169
+ else if (a === "--uninstall-service") out.uninstallService = true;
143
170
  else if (a === "--uninstall") out.uninstall = true;
144
171
  else if (a === "--workspace") set("workspace", "a path");
145
172
  else if (a === "--scope") out.scope = true;
@@ -0,0 +1,79 @@
1
+ // Updating the deck while nobody is looking at it.
2
+ //
3
+ // The update path had one automatic step, and it needed a page: the banner's
4
+ // `auto when idle` restarted the deck once an install had landed, and only in a
5
+ // tab somebody was looking at. An install itself never happened unless a person
6
+ // pressed `Update now`, and a deck with no tab open stayed on its old code for
7
+ // good. Since 3.20.0 the deck outlives its terminal and runs for days with no
8
+ // tab at all, which made that the common case.
9
+ //
10
+ // So the server does it, at the one moment it cannot interrupt anybody: no tab
11
+ // has focus (presence.mjs), no agent is mid-turn, and no turn has produced an
12
+ // event for AWAY_QUIET_MS (activity.mjs). The same switch governs it β€” the
13
+ // banner's `auto when idle`, kept in prefs.json as `autoUpdate` now so the
14
+ // server can read it with no page open. A person looking at the deck still gets
15
+ // the banner and its buttons; this is only for when they are not.
16
+ //
17
+ // Every effect goes through the code a press uses β€” startUpgrade, and the
18
+ // launcher's restart β€” so an unattended update cannot do anything a person
19
+ // could not have done from the banner. Pure rules here; index.mjs owns the
20
+ // timer and the effects.
21
+
22
+ /** Quiet before the deck may act: the page's IDLE_BEFORE_RESTART_MS, so the
23
+ * two paths agree on what idle means. */
24
+ export const AWAY_QUIET_MS = 30_000;
25
+
26
+ /** How long a deck that has just started waits before acting at all. A
27
+ * relaunch that came back on the version it left would otherwise try again a
28
+ * minute later, and again after that; this caps any such loop at one attempt
29
+ * per boot grace, under the launcher's own two-strike rule for npx
30
+ * (supervisor.mjs, upgradeAttempt). */
31
+ export const AWAY_BOOT_GRACE_MS = 5 * 60_000;
32
+
33
+ /** How long the same attempt is left alone after it was made: an install that
34
+ * failed, a fetch the launcher refused, a restart it could not run. A newer
35
+ * version is a different attempt and is not held back by this. */
36
+ export const AWAY_RETRY_MS = 30 * 60_000;
37
+
38
+ /** How often the server asks. The gate below is answered from memory; only
39
+ * when it passes does the tick read the version report, which touches disk. */
40
+ export const AWAY_TICK_MS = 60_000;
41
+
42
+ /** How long a report that found nothing newer is taken as the answer. The npm
43
+ * lookup behind it is hourly anyway, and the report is not free: on Windows
44
+ * upgradeBlock proves the npm prefix writable by creating a file in it, and a
45
+ * deck left alone overnight would otherwise do that once a minute. */
46
+ export const AWAY_RECHECK_MS = 5 * 60_000;
47
+
48
+ /**
49
+ * Whether the deck may consider updating itself right now. Every input is
50
+ * already in memory, so this runs every tick at no cost.
51
+ */
52
+ export function awayGate({
53
+ enabled, supervised, restarting, sinceBootMs, looking, busy, quietMs,
54
+ graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
55
+ }) {
56
+ return enabled === true && supervised === true && !restarting
57
+ && sinceBootMs >= graceMs && !looking && !busy && quietMs >= quietNeedMs;
58
+ }
59
+
60
+ /**
61
+ * What to do about the version report, once the gate has passed.
62
+ *
63
+ * restart the newer code is already on disk (an install landed, or a
64
+ * checkout was pulled) β€” the free half, as pickNotice says
65
+ * install `npm i -g` over a global install; the next tick restarts into it
66
+ * npx the launcher fetches the new version and hands it the port
67
+ * null nothing newer, an install already running, the same attempt
68
+ * made too recently, or a copy that only a person can update
69
+ */
70
+ export function awayUpdateStep({ notice, mode, installing, lastTry, now, retryMs = AWAY_RETRY_MS }) {
71
+ if (!notice || installing) return { act: null };
72
+ const target = `${notice.kind}:${notice.to}`;
73
+ // A clock that moved backwards counts as time elapsed, as everywhere else in
74
+ // the update path: a deck must not wait out a window it cannot measure.
75
+ if (lastTry?.target === target && now >= lastTry.at && now - lastTry.at < retryMs) return { act: null, target };
76
+ if (notice.kind === "restart") return { act: "restart", target };
77
+ if (notice.kind === "upgrade" && (mode === "install" || mode === "npx")) return { act: mode, target };
78
+ return { act: null, target };
79
+ }
@@ -151,6 +151,27 @@ function heldReading(now) {
151
151
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
152
152
  const STALE_AFTER_MS = 15 * 60_000;
153
153
 
154
+ /**
155
+ * How long a collector may produce nothing before that is a fault rather than a
156
+ * cadence.
157
+ *
158
+ * TWELVE HOURS, and the number is chosen from what the failure actually looks
159
+ * like rather than from taste. Measured on the machine that reported this: three
160
+ * accounts last collected 21 hours, 40 hours and 28 days ago, every one of them
161
+ * with `consecutiveFailures: 0` β€” because the thing stopping them was
162
+ * `keychain_unavailable`, which is claude-swap failing to OPEN the credential
163
+ * rather than having it rejected, and which never touches that counter.
164
+ *
165
+ * Long enough that a laptop closed overnight does not trip it. Short enough that
166
+ * an account nobody can read stops being advertised to the group as one they
167
+ * can. And the cost of being wrong is deliberately lopsided: a false "not
168
+ * collecting" asks a peer for a blob that `cswap import` then declines, because
169
+ * a plain import skips an account that is present and healthy β€” while the false
170
+ * negative this replaces is an account that is never repaired by anything, ever,
171
+ * and is published to every paired deck as good.
172
+ */
173
+ const COLLECTION_STOPPED_AFTER_MS = 12 * 60 * 60_000;
174
+
154
175
  // Nudging the collector.
155
176
  //
156
177
  // Two throttles, because the cost of asking is not the cost of fetching. When
@@ -289,6 +310,94 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
289
310
  * Either way claude-swap decides whether a network call actually happens, and
290
311
  * this is throttled on top of that.
291
312
  */
313
+ /**
314
+ * What claude-swap says about each slot, in its own words.
315
+ *
316
+ * `usage.json` records numbers and a failure COUNTER; `cswap list --json`
317
+ * records a per-slot VERDICT, and the two answer different questions. Measured
318
+ * on the machine this was written for, at one instant, for the same account:
319
+ *
320
+ * usage.json -> consecutiveFailures: 0, lastError: null
321
+ * cswap list -> usageStatus: "no_credentials"
322
+ *
323
+ * Three verdicts, three different things for a person to do β€” `no_credentials`
324
+ * is an account to receive or re-add, `relogin_required` is one to sign into,
325
+ * `keychain_unavailable` is not about the account at all but about the process
326
+ * asking. The counter can tell none of them apart, and for two of the three it
327
+ * reads zero.
328
+ *
329
+ * WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
330
+ * `cswap list` when a collection is due, and threw the output away. It now asks
331
+ * for `--json` and keeps the verdicts. Still not awaited by anyone β€” the nudge
332
+ * stays synchronous for its callers β€” and still one child at a time.
333
+ */
334
+ let _verdicts = { at: 0, byNum: {} };
335
+ /** Stale after this, because a verdict that outlives its cause is worse than no
336
+ * verdict: "no credentials" under an account somebody has since signed into is
337
+ * a sentence that sends them to fix what is already fixed. */
338
+ const VERDICT_TTL_MS = 10 * 60_000;
339
+ /** One `cswap list --json` at a time. A collection can take a while on a cold
340
+ * network, and a nudge landing inside one must not start a second. */
341
+ let _verdictInFlight = false;
342
+
343
+ /**
344
+ * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
345
+ *
346
+ * The cached verdicts are ten minutes old at worst, which is right for a label
347
+ * and wrong for a decision that writes a credential: somebody who signed in two
348
+ * minutes ago still reads as `no_credentials` there, and acting on that would
349
+ * replace the login they just created with a peer's.
350
+ *
351
+ * Matched on IDENTITY rather than on the slot number, because a number is a
352
+ * position in one machine's store and the caller is holding an account.
353
+ *
354
+ * Returns null when the question cannot be answered β€” no claude-swap, a refusal,
355
+ * an account it does not know. Null is not `no_credentials`, and the one caller
356
+ * treats it as "do not act".
357
+ */
358
+ export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
359
+ const want = String(email ?? "").trim().toLowerCase();
360
+ if (!want) return null;
361
+ try {
362
+ const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
363
+ if (!out?.ok) return null;
364
+ const d = JSON.parse(out.stdout);
365
+ // The same freshly-read answer feeds the cache the panel draws from, since
366
+ // it cost a subprocess either way.
367
+ const byNum = readVerdicts(out.stdout);
368
+ if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
369
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
370
+ if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
371
+ if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
372
+ return typeof a?.usageStatus === "string" ? a.usageStatus : null;
373
+ }
374
+ return null;
375
+ } catch { return null; }
376
+ }
377
+
378
+ /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
379
+ function verdictFor(num, now) {
380
+ if (now - _verdicts.at > VERDICT_TTL_MS) return null;
381
+ const v = _verdicts.byNum[String(num)];
382
+ return typeof v === "string" && v !== "" ? v : null;
383
+ }
384
+
385
+ /** Read the verdicts out of a `cswap list --json` payload. Tolerant by
386
+ * construction: this is another tool's output, and a shape we do not recognise
387
+ * means no verdicts rather than a thrown boot. */
388
+ export function readVerdicts(stdout) {
389
+ try {
390
+ const d = JSON.parse(stdout);
391
+ const out = {};
392
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
393
+ if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
394
+ out[String(a.number)] = a.usageStatus;
395
+ }
396
+ }
397
+ return out;
398
+ } catch { return {}; }
399
+ }
400
+
292
401
  function nudgeCollector(rows, slots, now, activeNum) {
293
402
  // Did the last ask accomplish anything? Cheap proxy: the newest collection
294
403
  // timestamp in the store.
@@ -307,12 +416,35 @@ function nudgeCollector(rows, slots, now, activeNum) {
307
416
  if (!due && !freshen) return;
308
417
 
309
418
  _lastNudge = now;
310
- const args = due ? ["list"] : ["auto", "--once", "--dry-run", "--json"];
419
+ // The due path asks for JSON and KEEPS it β€” see _verdicts. The dry-run path
420
+ // stays detached: it is the engine's own collect pass, its JSON is a different
421
+ // shape, and nothing here reads it.
422
+ if (!due) {
423
+ cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
424
+ return;
425
+ }
426
+ if (_verdictInFlight) return;
427
+ _verdictInFlight = true;
311
428
  // Fire-and-forget: this function is deliberately synchronous so callers never
312
- // wait on it, and resolving the binary is the only async part.
313
- cswapBin().then(bin => runDetached(bin, args)).catch(() => {});
429
+ // wait on it, and resolving the binary is the only async part. `run` rather
430
+ // than `runDetached` only so the output can be read; the caller is no more
431
+ // aware of it than before.
432
+ cswapBin()
433
+ .then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
434
+ .then(out => {
435
+ const byNum = out?.ok ? readVerdicts(out.stdout) : {};
436
+ // Replaced whole rather than merged. A slot that has gone away must not
437
+ // keep the verdict it had when it was last seen.
438
+ if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
439
+ })
440
+ .catch(() => {})
441
+ .finally(() => { _verdictInFlight = false; });
314
442
  }
315
443
 
444
+ /** Long enough for a cold collection over a slow network, short enough that a
445
+ * wedged claude-swap does not hold a child for the rest of the day. */
446
+ const VERDICT_TIMEOUT_MS = 90_000;
447
+
316
448
  async function readJson(path) {
317
449
  try {
318
450
  const parsed = JSON.parse(await readFile(path, "utf8"));
@@ -372,6 +504,34 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
372
504
  return mine;
373
505
  }
374
506
 
507
+ // ── the repair nobody should have to press ───────────────────────────────────
508
+ //
509
+ // A `stale-copy` row β€” claude-swap's stored copy of the signed-in account's
510
+ // login was rejected, while the login itself works β€” has exactly one repair,
511
+ // and it asks the user nothing: re-capture the copy from the login they already
512
+ // have (cswap-admin's recaptureActive). It was a button, `resume`, which asked a
513
+ // person to confirm the only answer there is. So the read that finds the state
514
+ // starts the repair, and the row carries how it is going instead of a button.
515
+ //
516
+ // HANDED IN, NOT IMPORTED. The repair writes a credential into claude-swap's
517
+ // store, and this module is read by dozens of tests against fixture stores. The
518
+ // server hands it in when it starts listening (index.mjs) and nothing else does,
519
+ // so no test run can reach `cswap add` through a read.
520
+ let _repairStaleCopy = null;
521
+
522
+ /** Called with `{ num, email, now }` for a `stale-copy` row, and returns that
523
+ * row's `repair`. Null unregisters. */
524
+ export function repairStaleCopyWith(fn) {
525
+ _repairStaleCopy = typeof fn === "function" ? fn : null;
526
+ }
527
+
528
+ /** The registered repair's state for one row. A repair that throws costs the
529
+ * row its state, never the read. */
530
+ function repairFor(num, email, now) {
531
+ if (!_repairStaleCopy) return null;
532
+ try { return _repairStaleCopy({ num, email, now }) ?? null; } catch { return null; }
533
+ }
534
+
375
535
  /**
376
536
  * The read itself, split out from the admission control above it so the guard is
377
537
  * readable as the four lines it is.
@@ -449,7 +609,9 @@ async function readRoster(now, gen) {
449
609
 
450
610
  const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
451
611
  const isActive = String(seq.activeAccountNumber) === num;
452
- const trouble = authTrouble(row, { matches, isActive, identity, email: acct.email });
612
+ const trouble = authTrouble(row, {
613
+ matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
614
+ });
453
615
 
454
616
  const lanes = [
455
617
  lane("five_hour", "5h", good?.five_hour),
@@ -502,6 +664,21 @@ async function readRoster(now, gen) {
502
664
  // in as it anyway. The panel says so quietly instead of offering to log
503
665
  // them in again.
504
666
  staleCopy: trouble?.kind === "stale-copy",
667
+ // How the deck's own repair of that is going β€” `{ state: "running" }` or
668
+ // `{ state: "failed", reason, retryAt }` β€” and null on every other row.
669
+ // Asking is what starts it; see repairStaleCopyWith.
670
+ repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
671
+ // Nothing has been collected for this account in half a day, and nothing
672
+ // says why. Its own word because the two existing ones would both be
673
+ // wrong: `error` claims a rejection that was never reported, and
674
+ // `staleCopy` promises the user is signed in as it, which is only
675
+ // knowable for the active account.
676
+ stopped: trouble?.kind === "stopped",
677
+ // claude-swap's own verdict for this slot, when there is a fresh one:
678
+ // "no_credentials", "relogin_required", "keychain_unavailable", … It is
679
+ // what turns "not collecting" into a sentence with a next step in it, and
680
+ // it is null on every machine where the collector has not been asked yet.
681
+ collector: verdictFor(num, now),
505
682
  });
506
683
  }
507
684
 
@@ -541,8 +718,24 @@ async function readRoster(now, gen) {
541
718
  * evidence of anything, so the stored verdict stands: refusing to show a real
542
719
  * expiry because a subprocess failed is the opposite mistake.
543
720
  */
544
- export function authTrouble(row, { matches, isActive, identity, email } = {}) {
545
- if (!matches || !((row?.consecutiveFailures ?? 0) > 0)) return null;
721
+ export function authTrouble(row, {
722
+ matches, isActive, identity, email,
723
+ fetchedAt = null, now = Date.now(), stoppedAfterMs = COLLECTION_STOPPED_AFTER_MS,
724
+ } = {}) {
725
+ if (!matches) return null;
726
+ const failing = (row?.consecutiveFailures ?? 0) > 0;
727
+ // A COLLECTOR THAT HAS PRODUCED NOTHING IN HALF A DAY IS FAILING, whatever
728
+ // its counter says β€” see COLLECTION_STOPPED_AFTER_MS. `consecutiveFailures`
729
+ // counts rejections, and the failure found on the reporting machine was not a
730
+ // rejection: `cswap list` answered `usageStatus: keychain_unavailable` for
731
+ // three accounts whose counters all read zero.
732
+ //
733
+ // `fetchedAt == null` is deliberately NOT this. That is an account nobody has
734
+ // ever collected β€” usually one added a minute ago β€” and the panel already has
735
+ // a word for it. Calling a new account broken is a worse first impression than
736
+ // saying nothing.
737
+ const stopped = !failing && fetchedAt != null && now - fetchedAt > stoppedAfterMs;
738
+ if (!failing && !stopped) return null;
546
739
 
547
740
  const signedInHere = isActive
548
741
  && identity
@@ -555,6 +748,12 @@ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
555
748
  // fix under a red badge, and it must not offer to sign them in again.
556
749
  return { kind: "stale-copy", error: null };
557
750
  }
751
+ // A SILENCE, NOT A DIAGNOSIS. All that is known is that nothing has been
752
+ // collected for half a day; the reason lives in claude-swap and may be a dead
753
+ // login, a keychain it cannot open, or a machine that was off. Putting
754
+ // `invalid_grant` on it would be inventing evidence, and the panel would then
755
+ // offer "sign in again" for a problem that may not be a sign-in at all.
756
+ if (stopped) return { kind: "stopped", error: null };
558
757
  return { kind: "auth", error: row.lastError ?? "error" };
559
758
  }
560
759
 
@@ -987,6 +987,23 @@ export async function shareAccounts(nums) {
987
987
  }
988
988
 
989
989
 
990
+ /**
991
+ * Did anything actually arrive in the store?
992
+ *
993
+ * THREE STATES MEAN YES AND THEY ARE NOT INTERCHANGEABLE. `imported` is a slot
994
+ * that did not exist; `healed` and `updated` are one that did and was rewritten,
995
+ * which is what a forced import produces and what `added` β€” counting `imported`
996
+ * alone β€” reports as nothing having happened. A caller that asked for a replace
997
+ * and read `added` would conclude its own success had failed.
998
+ *
999
+ * `present` is the decline: claude-swap kept what it had. `failed` is the
1000
+ * refusal. Neither is an arrival.
1001
+ */
1002
+ export function landed(results) {
1003
+ return (Array.isArray(results) ? results : [])
1004
+ .some(r => r?.state === "imported" || r?.state === "healed" || r?.state === "updated");
1005
+ }
1006
+
990
1007
  /**
991
1008
  * The identities a bundle carries, or `[]` when it cannot be read.
992
1009
  *
@@ -1199,26 +1216,39 @@ export function removePromptMatches(line, num) {
1199
1216
  * refresh token dies it quarantines the row: no further collection is
1200
1217
  * attempted, so the numbers freeze and every Refresh re-reads a store that
1201
1218
  * cannot change. #721 fixed the sentence the panel said about that state; this
1202
- * is the button that ends it.
1219
+ * is what ends it β€” started by the deck itself (autoRecapture, below) the
1220
+ * moment a read finds the state, and still reachable from the admin route.
1203
1221
  *
1204
1222
  * `cswap add` on an account already in the store is an idempotent credential
1205
1223
  * refresh β€” registerSignedIn above says so in its own words: "No new slot means
1206
1224
  * the account was already managed and cswap refreshed its credentials in
1207
- * place." It captures whatever is signed in RIGHT NOW, which for the active
1208
- * slot is the account the row belongs to, with the working credentials the user
1209
- * already has. Nobody is signed in or out, no browser opens, and the active
1210
- * account does not change.
1225
+ * place." It captures whatever is signed in RIGHT NOW, with the working
1226
+ * credentials the user already has. Nobody is signed in or out, no browser
1227
+ * opens, and the active account does not change.
1211
1228
  *
1212
- * It only ever repairs the ACTIVE slot, because "what is signed in right now"
1213
- * is the only thing `cswap add` can see. The panel offers it nowhere else.
1229
+ * WHICH IS WHY IT ASKS WHO THAT IS FIRST. "Whatever is signed in" is the active
1230
+ * slot's account only while the two agree. Somebody who signed in as another
1231
+ * account in a terminal has made it a different one, and `cswap add` would then
1232
+ * refresh β€” or add β€” that account instead. Behind a press that was a narrow
1233
+ * race; with the deck starting it on its own it is the one thing that must be
1234
+ * ruled out. So the capture runs only when the CLI's account is the active
1235
+ * slot's, and, when the caller names an account, that one too.
1214
1236
  */
1215
- export async function recaptureActive() {
1237
+ export async function recaptureActive({ expect = null } = {}) {
1216
1238
  return withStoreLock(async () => {
1217
- // Who is actually signed in, asked before and after, because `cswap add`
1218
- // captures the live credentials and this is the one check that the thing it
1219
- // captured is the thing the user meant.
1239
+ // Who is signed in right now: `cswap add` captures THAT, whoever it is.
1220
1240
  const before = await currentIdentity();
1221
1241
  if (!before?.email) return { ok: false, reason: "not_signed_in" };
1242
+ const who = before.email.toLowerCase();
1243
+ const store = await readStore();
1244
+ const activeNum = String(store.activeNum);
1245
+ const activeEmail = String(store.emails[activeNum] ?? "").toLowerCase();
1246
+ // One address under two organizations is two accounts to claude-swap, so a
1247
+ // matching email in another org is still somebody else's slot.
1248
+ const sameOrg = !before.orgId || !store.orgs[activeNum] || before.orgId === store.orgs[activeNum];
1249
+ if (who !== activeEmail || !sameOrg || (expect != null && who !== String(expect).toLowerCase())) {
1250
+ return { ok: false, reason: "not_active_account", email: before.email };
1251
+ }
1222
1252
 
1223
1253
  const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
1224
1254
  if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
@@ -1242,6 +1272,69 @@ export async function recaptureActive() {
1242
1272
  });
1243
1273
  }
1244
1274
 
1275
+ // ── the same repair, without the press ──────────────────────────────────────
1276
+
1277
+ /**
1278
+ * How long one account is left alone after the deck re-captured it on its own.
1279
+ *
1280
+ * Ten minutes: past claude-swap's longest ordinary poll interval, so a capture
1281
+ * that took has been collected before this could fire again, and one that did
1282
+ * not β€” a collector that still cannot read the login β€” costs one `claude auth
1283
+ * status` and one `cswap add` per ten minutes rather than one per panel poll.
1284
+ */
1285
+ const AUTO_RECAPTURE_RETRY_MS = 10 * 60_000;
1286
+
1287
+ /** Emails whose automatic attempt is under way. Per account rather than one
1288
+ * flag: after an auto-switch a second account can pause while the first's
1289
+ * attempt still waits on the store lock, and it must not borrow that attempt's
1290
+ * `running`. The lock serialises the attempts themselves. */
1291
+ const _autoRunning = new Set();
1292
+ /** Lower-cased email β†’ the last automatic attempt: when, and why it failed. */
1293
+ const _autoTried = new Map();
1294
+
1295
+ /**
1296
+ * What the deck's own re-capture of a paused account should do now, given its
1297
+ * last attempt. Pure; exported for tests.
1298
+ *
1299
+ * running an attempt is under way
1300
+ * start none has been made, or the last is old enough to try again
1301
+ * failed one was made inside the window and the row is still paused;
1302
+ * `reason` is why it failed, or null when the capture itself held
1303
+ */
1304
+ export function autoRecaptureState(last, now, { inFlight = false, retryMs = AUTO_RECAPTURE_RETRY_MS } = {}) {
1305
+ if (inFlight) return { state: "running" };
1306
+ if (!last || now - last.at >= retryMs) return { state: "start" };
1307
+ return { state: "failed", reason: last.reason ?? null, retryAt: last.at + retryMs };
1308
+ }
1309
+
1310
+ /**
1311
+ * Re-capture a paused account because a read found it paused β€” the repair the
1312
+ * `resume` button used to wait for.
1313
+ *
1314
+ * Synchronous for its caller, which is building a roster row: it starts the
1315
+ * attempt and returns the state to draw. At most one attempt per account under
1316
+ * way and one per AUTO_RECAPTURE_RETRY_MS, always for the account the row names,
1317
+ * which recaptureActive checks against who is signed in inside the store lock.
1318
+ */
1319
+ export function autoRecapture({ email, now = Date.now() } = {}) {
1320
+ const key = String(email ?? "").trim().toLowerCase();
1321
+ if (!key) return null;
1322
+ const next = autoRecaptureState(_autoTried.get(key), now, { inFlight: _autoRunning.has(key) });
1323
+ if (next.state !== "start") return next;
1324
+ _autoRunning.add(key);
1325
+ _autoTried.set(key, { at: now, reason: null });
1326
+ recaptureActive({ expect: key })
1327
+ .then(
1328
+ r => { if (!r?.ok) _autoTried.set(key, { at: now, reason: r?.reason ?? "failed" }); },
1329
+ () => { _autoTried.set(key, { at: now, reason: "failed" }); },
1330
+ )
1331
+ // And the roster forgets the row it drew as `running`, so the next read β€”
1332
+ // forced or not β€” says how the attempt went, instead of the cache or a
1333
+ // forced read's one-minute floor holding `resuming…` over a failure.
1334
+ .finally(() => { _autoRunning.delete(key); invalidateClaudeAccountsCache(); });
1335
+ return { state: "running" };
1336
+ }
1337
+
1245
1338
  export async function removeAccount(num) {
1246
1339
  const n = Number(num);
1247
1340
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };