agent-dag 3.20.0 → 3.21.1

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-Dr-bVaHM.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-DVj_M279.css">
43
+ <script type="module" crossorigin src="/assets/index-ByXJWM3T.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.20.0",
3
+ "version": "3.21.1",
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,12 @@
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
+ ],
34
40
  "3.20.0": [
35
41
  {
36
42
  "title": "🟢 The deck keeps running after you close the terminal",
@@ -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
+ }
@@ -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
+ }
@@ -504,6 +504,34 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
504
504
  return mine;
505
505
  }
506
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
+
507
535
  /**
508
536
  * The read itself, split out from the admission control above it so the guard is
509
537
  * readable as the four lines it is.
@@ -636,6 +664,10 @@ async function readRoster(now, gen) {
636
664
  // in as it anyway. The panel says so quietly instead of offering to log
637
665
  // them in again.
638
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,
639
671
  // Nothing has been collected for this account in half a day, and nothing
640
672
  // says why. Its own word because the two existing ones would both be
641
673
  // wrong: `error` claims a rejection that was never reported, and
@@ -1216,26 +1216,39 @@ export function removePromptMatches(line, num) {
1216
1216
  * refresh token dies it quarantines the row: no further collection is
1217
1217
  * attempted, so the numbers freeze and every Refresh re-reads a store that
1218
1218
  * cannot change. #721 fixed the sentence the panel said about that state; this
1219
- * 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.
1220
1221
  *
1221
1222
  * `cswap add` on an account already in the store is an idempotent credential
1222
1223
  * refresh — registerSignedIn above says so in its own words: "No new slot means
1223
1224
  * the account was already managed and cswap refreshed its credentials in
1224
- * place." It captures whatever is signed in RIGHT NOW, which for the active
1225
- * slot is the account the row belongs to, with the working credentials the user
1226
- * already has. Nobody is signed in or out, no browser opens, and the active
1227
- * 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.
1228
1228
  *
1229
- * It only ever repairs the ACTIVE slot, because "what is signed in right now"
1230
- * 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.
1231
1236
  */
1232
- export async function recaptureActive() {
1237
+ export async function recaptureActive({ expect = null } = {}) {
1233
1238
  return withStoreLock(async () => {
1234
- // Who is actually signed in, asked before and after, because `cswap add`
1235
- // captures the live credentials and this is the one check that the thing it
1236
- // captured is the thing the user meant.
1239
+ // Who is signed in right now: `cswap add` captures THAT, whoever it is.
1237
1240
  const before = await currentIdentity();
1238
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
+ }
1239
1252
 
1240
1253
  const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
1241
1254
  if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
@@ -1259,6 +1272,69 @@ export async function recaptureActive() {
1259
1272
  });
1260
1273
  }
1261
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
+
1262
1338
  export async function removeAccount(num) {
1263
1339
  const n = Number(num);
1264
1340
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
@@ -58,6 +58,12 @@ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.j
58
58
  */
59
59
  export const DEFAULTS = Object.freeze({
60
60
  notifications: true,
61
+ // Whether the deck may update itself: restart into code already on disk once
62
+ // it is idle, and — while nobody is looking — install a newer release and
63
+ // restart into that (auto-update.mjs). ON, because this is the banner's
64
+ // `auto when idle`, which defaulted on as a localStorage key; it moved here so
65
+ // the server can read it with no page open.
66
+ autoUpdate: true,
61
67
  // LAN sync, off until somebody turns it on. `passphrase` is the only secret
62
68
  // this file has ever held, which is why the write below now names a mode.
63
69
  lan: Object.freeze({
@@ -74,6 +80,9 @@ export const DEFAULTS = Object.freeze({
74
80
  // the dialog that turns the feature on prints them.
75
81
  autoAsk: true,
76
82
  autoAccept: true,
83
+ // What somebody HERE calls another deck, keyed by its fingerprint. The name
84
+ // a deck gives itself is its owner's to choose; this is the other half.
85
+ aliases: Object.freeze({}),
77
86
  }),
78
87
  });
79
88
 
@@ -90,6 +99,40 @@ export const DEFAULTS = Object.freeze({
90
99
  * window between the two, which is exactly when a secret is in it. */
91
100
  export const PREFS_MODE = 0o600;
92
101
 
102
+ /** The longest name somebody here may give another deck. The same order as a
103
+ * deck's own name, and short enough to stay one line in the panel's column. */
104
+ export const ALIAS_MAX = 48;
105
+
106
+ /** A name somebody typed for another deck, cleaned — or "" for one with
107
+ * nothing left in it, which is how an alias is taken away. Control characters
108
+ * go the way cleanName sends them: this string reaches a terminal as well as a
109
+ * page. */
110
+ export function cleanAlias(raw) {
111
+ if (typeof raw !== "string") return "";
112
+ const flat = raw.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
113
+ return [...flat].slice(0, ALIAS_MAX).join("").trim();
114
+ }
115
+
116
+ /** A fingerprint an alias may be kept under. Real ones are `abc-def-012-345`;
117
+ * what matters here is only that a typed address's placeholder — which has a
118
+ * colon in it and names no deck — can never be one, and that nothing exotic
119
+ * becomes a key in an object this file writes back to disk. */
120
+ export function isAliasKey(fp) {
121
+ return typeof fp === "string" && /^[A-Za-z0-9-]{1,64}$/.test(fp);
122
+ }
123
+
124
+ /** The alias map, coerced. A page writes it; a hand-edited file can hold
125
+ * anything. Capped, because nothing legitimate is anywhere near it. */
126
+ function aliasesFrom(raw) {
127
+ const out = {};
128
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
129
+ for (const [fp, name] of Object.entries(raw).slice(0, 256)) {
130
+ const clean = cleanAlias(name);
131
+ if (isAliasKey(fp) && clean) out[fp] = clean;
132
+ }
133
+ return out;
134
+ }
135
+
93
136
  /** One LAN section, coerced. Unknown keys dropped like everything else here,
94
137
  * and the two lists forced to arrays of strings — they arrive from a page and
95
138
  * are then compared against account keys and dialled as addresses. */
@@ -113,7 +156,13 @@ function normaliseLan(raw) {
113
156
  // cannot be checked is worse than no entry at all.
114
157
  trusted: (Array.isArray(src.trusted) ? src.trusted : [])
115
158
  .filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
116
- .map(t => ({ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "" })),
159
+ .map(t => ({
160
+ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "",
161
+ // When somebody here said yes. Absent on every pin made before this was
162
+ // kept, and absent is drawn as "before" rather than guessed.
163
+ ...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
164
+ })),
165
+ aliases: aliasesFrom(src.aliases),
117
166
  // The port this deck listened on last time, so an address somebody typed on
118
167
  // the other machine still works after a restart. It asked the OS for a new
119
168
  // one every start, which is invisible while broadcast works and is exactly
@@ -145,6 +194,7 @@ export function normalise(raw) {
145
194
  const src = raw && typeof raw === "object" ? raw : {};
146
195
  return {
147
196
  notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
197
+ autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
148
198
  lan: normaliseLan(src.lan),
149
199
  };
150
200
  }