agent-dag 3.14.0 → 3.15.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-CinWEMD2.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-Xyt_GsRj.css">
43
+ <script type="module" crossorigin src="/assets/index-CbYzt04Y.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-CqAgutJT.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.14.0",
3
+ "version": "3.15.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,16 @@
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.15.0": [
35
+ {
36
+ "title": "🔗 Decks on one network heal each other's dead logins",
37
+ "body": "A login dies on the machine that has not used it while the same account keeps working on the machine that has. The fix was a blob copied out of one deck and pasted into another. Now: **Accounts ▸ Local network**, the same passphrase on each deck — it offers you six words — and they find each other and repair themselves.\n\nA deck only **adds** an account it does not have, or **replaces one it has proven dead** — proven by Anthropic refusing the token, not guessed. A credential that works is never touched. The passphrase is the only gate; a stranger on the same wifi sees that a deck exists and nothing else."
38
+ },
39
+ {
40
+ "title": "⚠️ What sharing a login actually means",
41
+ "body": "**A login you share is a live one, and it cannot be taken back.** Turning the switch off stops what has not happened yet; a credential that has left your machine is gone, and the only real revocation is signing in again at Anthropic — which ends the session everywhere at once.\n\nSharing a Claude account may also be against Anthropic's terms. Your call; the deck says it once and does not nag."
42
+ }
43
+ ],
34
44
  "3.14.0": [
35
45
  {
36
46
  "title": "📊 The machine comes back beside the list that covers it",
@@ -464,6 +464,19 @@ async function readRoster(now, gen) {
464
464
  email: acct.email ?? null,
465
465
  alias: acct.alias ?? null,
466
466
  org: acct.organizationName ?? null,
467
+ // The other half of an account's identity, and the reason a slot number
468
+ // is not one: claude-swap keys on `(email, organizationUuid)` — the same
469
+ // email under two orgs is two accounts on purpose — and assigns slots
470
+ // max+1 per store, so the account that is 4 here is 2 on another machine.
471
+ // Surfaced for LAN sync, which has to match accounts across two stores
472
+ // that grew in a different order.
473
+ orgUuid: acct.organizationUuid ?? null,
474
+ // Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
475
+ // signed in. The two differ, and #721 is the whole argument: a
476
+ // `stale-copy` row means the live session is fine while the copy in the
477
+ // store is dead, and the copy is what a share would carry and what a
478
+ // peer's copy would heal. So both kinds of trouble read as not alive.
479
+ alive: trouble == null,
467
480
  active: String(seq.activeAccountNumber) === num,
468
481
  disabled: acct.disabled === true,
469
482
  lanes,
@@ -50,7 +50,46 @@ export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "pre
50
50
  * release notes describe; a switch that quietly turned an existing feature off
51
51
  * on upgrade would be a worse surprise than the noise it is meant to stop.
52
52
  */
53
- export const DEFAULTS = Object.freeze({ notifications: true });
53
+ export const DEFAULTS = Object.freeze({
54
+ notifications: true,
55
+ // LAN sync, off until somebody turns it on. `passphrase` is the only secret
56
+ // this file has ever held, which is why the write below now names a mode.
57
+ lan: Object.freeze({ enabled: false, name: "", passphrase: "", shared: [], manual: [], deckId: "" }),
58
+ });
59
+
60
+ /** The mode prefs.json is created with.
61
+ *
62
+ * It held nothing but booleans until LAN sync, and a booleans file at the
63
+ * umask default is unremarkable. A group passphrase is not: on a shared
64
+ * machine the default mode hands it to every other account on the box, and
65
+ * from it they can decrypt any credential that crosses the network.
66
+ *
67
+ * Passed to `writeFile` rather than applied with a follow-up chmod, and the
68
+ * difference is the whole point — claude-swap's transfer.py makes the same
69
+ * argument at length: a write-then-chmod leaves the file readable for the
70
+ * window between the two, which is exactly when a secret is in it. */
71
+ export const PREFS_MODE = 0o600;
72
+
73
+ /** One LAN section, coerced. Unknown keys dropped like everything else here,
74
+ * and the two lists forced to arrays of strings — they arrive from a page and
75
+ * are then compared against account keys and dialled as addresses. */
76
+ function normaliseLan(raw) {
77
+ const src = raw && typeof raw === "object" ? raw : {};
78
+ const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
79
+ return {
80
+ enabled: typeof src.enabled === "boolean" ? src.enabled : false,
81
+ name: typeof src.name === "string" ? src.name : "",
82
+ passphrase: typeof src.passphrase === "string" ? src.passphrase : "",
83
+ shared: strings(src.shared),
84
+ manual: strings(src.manual),
85
+ // A PUBLIC identifier, not a key and not a secret: it is in every beacon
86
+ // this deck broadcasts, and what authenticates is the passphrase. It is
87
+ // persisted so a restarted deck is recognised as the same one — without
88
+ // that, every restart adds a row to every peer's list that will never
89
+ // answer again, and the list is a graveyard within an afternoon.
90
+ deckId: typeof src.deckId === "string" && /^[0-9a-f]{12}$/.test(src.deckId) ? src.deckId : "",
91
+ };
92
+ }
54
93
 
55
94
  /** Coerce whatever is on disk into a whole, known-shaped prefs object.
56
95
  *
@@ -60,7 +99,10 @@ export const DEFAULTS = Object.freeze({ notifications: true });
60
99
  * a setting nothing here can see, which reads as support and is not. */
61
100
  export function normalise(raw) {
62
101
  const src = raw && typeof raw === "object" ? raw : {};
63
- return { notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications };
102
+ return {
103
+ notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
104
+ lan: normaliseLan(src.lan),
105
+ };
64
106
  }
65
107
 
66
108
  /** What is on disk, or the defaults. A corrupt or absent file is not an error
@@ -97,10 +139,15 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
97
139
  // 500s through `guard`, and the notifications switch silently does not
98
140
  // stick. POSIX rename(2) has no such rule, which is why this shipped green.
99
141
  const mv = deps.rename ?? renameWithRetry;
100
- const next = normalise({ ...(await readPrefs(home, deps)), ...patch });
142
+ const prev = await readPrefs(home, deps);
143
+ // The LAN section merges rather than replaces, so a page toggling the
144
+ // switch does not have to send the passphrase back to keep it — and so
145
+ // nothing has to send a secret it was never given.
146
+ const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
147
+ const next = normalise(merged);
101
148
  await mk(prefsDir(home), { recursive: true });
102
149
  const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
103
- await write(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
150
+ await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
104
151
  await mv(tmp, prefsPath(home));
105
152
  return next;
106
153
  };
@@ -118,6 +165,23 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
118
165
  * the person who wrote the launch script are not always the same person, and
119
166
  * only one of them is making a claim about the machine.
120
167
  */
168
+ /**
169
+ * The preferences as a PAGE may see them.
170
+ *
171
+ * The passphrase never leaves this process. `GET /api/prefs` is readable by
172
+ * anything that can reach the loopback port — which is the whole point of the
173
+ * deck's own threat model, and is why the share envelope is not served there
174
+ * either — so what goes out is whether one is set, not what it is.
175
+ *
176
+ * A boolean rather than a mask: `••••••••` in a field invites a page to send it
177
+ * back, and then the dots are the passphrase.
178
+ */
179
+ export function publicPrefs(prefs) {
180
+ const p = normalise(prefs);
181
+ const { passphrase, ...lan } = p.lan;
182
+ return { ...p, lan: { ...lan, hasPassphrase: passphrase !== "" } };
183
+ }
184
+
121
185
  export function notificationsOn(prefs, env = process.env) {
122
186
  if (env[OFF_ENV] === "1") return false;
123
187
  return normalise(prefs).notifications;
@@ -16,7 +16,9 @@ import { claudeConfigDir } from "./claude-dir.mjs";
16
16
  import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
17
17
  import { PRODUCT } from "./brand.mjs";
18
18
  import { createBlockNotifier } from "./block-notify.mjs";
19
- import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
19
+ import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
20
+ import { createEngine, defaultName } from "./lan-engine.mjs";
21
+ import { suggestPassphrase } from "./lan-sync.mjs";
20
22
  import { notify as osNotify } from "./browser-react.mjs";
21
23
  import { invokedName, renameNotice } from "./invoked-as.mjs";
22
24
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3169,7 +3171,10 @@ function redactDeckToken(raw) {
3169
3171
  * same freshness every other cross-deck setting has.
3170
3172
  */
3171
3173
  let _prefs = { ...PREF_DEFAULTS };
3172
- readPrefs().then(p => { _prefs = p; }).catch(() => {});
3174
+ // And LAN sync starts from the same read, so a deck that had it on comes back
3175
+ // with it on. `applyLanPrefs` is defined below — this runs after module
3176
+ // evaluation, which is what makes the forward reference fine.
3177
+ readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
3173
3178
 
3174
3179
  const blockNotifier = createBlockNotifier({
3175
3180
  notify: osNotify,
@@ -3197,7 +3202,11 @@ const blockNotifier = createBlockNotifier({
3197
3202
  function prefsPayload() {
3198
3203
  return {
3199
3204
  ok: true,
3200
- prefs: _prefs,
3205
+ // `publicPrefs`, never `_prefs`. This route answers anything that can reach
3206
+ // the loopback port, and the LAN group passphrase is in the stored object —
3207
+ // one field, and sending it here would put it in front of every page on the
3208
+ // machine. What goes out is whether one is set.
3209
+ prefs: publicPrefs(_prefs),
3201
3210
  notificationsAllowed: notificationsOn(_prefs),
3202
3211
  notificationsVetoed: notificationsVetoed(),
3203
3212
  };
@@ -3214,9 +3223,113 @@ async function handlePrefsWrite(req, res) {
3214
3223
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3215
3224
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3216
3225
  _prefs = await writePrefs(body);
3226
+ // The engine reads its settings from here rather than holding its own copy,
3227
+ // so turning the switch off in the panel really does stop the sockets rather
3228
+ // than only changing what the panel says.
3229
+ await applyLanPrefs();
3217
3230
  return send(res, 200, prefsPayload());
3218
3231
  }
3219
3232
 
3233
+ // ── LAN sync ────────────────────────────────────────────────────────────────
3234
+ //
3235
+ // The engine is built once and told the settings; it opens and closes its own
3236
+ // sockets as those change. Nothing here touches a credential — see
3237
+ // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3238
+ const lanEngine = createEngine({
3239
+ readAccounts: async () => {
3240
+ const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3241
+ return fetchClaudeAccounts();
3242
+ },
3243
+ exportAccount: async num => {
3244
+ const { shareAccounts } = await import("./cswap-admin.mjs");
3245
+ const out = await shareAccounts([String(num)]);
3246
+ return out?.ok ? out.blob : null;
3247
+ },
3248
+ importAccount: async blob => {
3249
+ const { importAccount } = await import("./cswap-admin.mjs");
3250
+ // NO `force`, ever, and this is the line where that promise is kept. A
3251
+ // plain import adds an account that is missing and replaces exactly one
3252
+ // claude-swap has quarantined; it SKIPS one that is present and healthy.
3253
+ // So a peer cannot overwrite a working credential of this deck's even by
3254
+ // lying about its own, because the flag that would allow it is not passed.
3255
+ const out = await importAccount(blob);
3256
+ return !!out?.ok;
3257
+ },
3258
+ // The deck's own public id, kept so a restart is the same deck rather than a
3259
+ // new row in every peer's list. Written once, on the first start that has
3260
+ // none — not a secret, and in every beacon this deck sends.
3261
+ onIdentity: async id => {
3262
+ try {
3263
+ _prefs = await writePrefs({ lan: { deckId: id } });
3264
+ // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
3265
+ // detected, a new id was stored, and both decks kept broadcasting the old
3266
+ // one — so they stayed invisible to each other with a correct file on
3267
+ // disk. `apply` restarts only when the id it is holding differs from the
3268
+ // one it is given, so this settles after one pass rather than looping.
3269
+ await applyLanPrefs();
3270
+ } catch { /* the next start picks it up; a shared id is the cost until then */ }
3271
+ },
3272
+ onError: (what, err) => {
3273
+ // Reported, never thrown. A machine with no route, a firewall that refuses
3274
+ // the bind, an interface that comes and goes with a VPN — none of them is a
3275
+ // reason for the deck to fall over.
3276
+ if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
3277
+ },
3278
+ });
3279
+
3280
+ /** Push whatever is in prefs at the engine. Called at boot and after every
3281
+ * write, so there is one source of truth and it is the file. */
3282
+ async function applyLanPrefs() {
3283
+ const lan = _prefs?.lan ?? {};
3284
+ try {
3285
+ await lanEngine.apply({
3286
+ enabled: !!lan.enabled,
3287
+ name: lan.name || defaultName(),
3288
+ passphrase: lan.passphrase || "",
3289
+ shared: Array.isArray(lan.shared) ? lan.shared : [],
3290
+ deckId: lan.deckId || "",
3291
+ });
3292
+ // Wholesale, so removing an address in the panel really stops it being
3293
+ // dialled rather than only taking the row away.
3294
+ lanEngine.setPeers(lan.manual);
3295
+ } catch (err) {
3296
+ console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
3297
+ }
3298
+ }
3299
+
3300
+ /** What the panel draws: the switch, this deck's own name and address, and who
3301
+ * else is in the group. No passphrase, for the reason prefsPayload gives. */
3302
+ function handleLanStatus(req, res) {
3303
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3304
+ }
3305
+
3306
+ /**
3307
+ * A strong group passphrase, for the field to open with.
3308
+ *
3309
+ * THE WHOLE FEATURE'S SECURITY IS THIS STRING — groupKey scrypts it and
3310
+ * everything else hangs off that — and until this route existed the field
3311
+ * opened empty under a placeholder reading "the same words on every deck",
3312
+ * which is an invitation to type something two people can both remember.
3313
+ * suggestPassphrase has been written, documented and tested since the first
3314
+ * commit of the feature and was reachable from nothing.
3315
+ *
3316
+ * Generated per request rather than folded into the status route, because the
3317
+ * status route is polled every five seconds and a suggestion that changed
3318
+ * under somebody's fingers while they read it out loud would be worse than no
3319
+ * suggestion at all.
3320
+ */
3321
+ function handleLanSuggest(req, res) {
3322
+ return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
3323
+ }
3324
+
3325
+ /** Ask every peer now rather than at the next tick — the button beside the
3326
+ * list, for somebody who has just fixed a login on the other machine and does
3327
+ * not want to wait a minute to see it arrive. */
3328
+ async function handleLanSync(req, res) {
3329
+ const done = await lanEngine.round();
3330
+ return send(res, 200, { ok: true, done, ...lanEngine.status() });
3331
+ }
3332
+
3220
3333
  function pushEvent(raw, source, opts = {}) {
3221
3334
  // First, before anything below can see it: the deck's own credential does not
3222
3335
  // belong in a store that is served without one. Every entry point to the
@@ -5444,6 +5557,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5444
5557
  // Machine state, not session state: sampled on the server's own timer and
5445
5558
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
5446
5559
  if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
5560
+ if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
5561
+ if (req.method === "GET" && url.pathname === "/api/lan/passphrase") return handleLanSuggest(req, res);
5562
+ if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
5447
5563
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
5448
5564
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
5449
5565
  // On demand only — the process list costs a subprocess on every platform,