agent-dag 3.15.0 → 3.16.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-CbYzt04Y.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-CqAgutJT.css">
43
+ <script type="module" crossorigin src="/assets/index-C7_uf54q.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-DPOZaaRr.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.15.0",
3
+ "version": "3.16.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": {
@@ -336,5 +336,15 @@
336
336
  "title": "🔕 The deck no longer puts anything in your settings.json",
337
337
  "body": "It used to add a hook there so Claude Code would play a sound when a turn ended. That entry is removed the first time you start this version, and any sound hook you wrote yourself is put back exactly where you had it. Nothing else in that file is touched. The deck makes the sound itself now — which is also why you finally hear one when a Codex turn ends, not just a Claude one."
338
338
  }
339
+ ],
340
+ "3.16.0": [
341
+ {
342
+ "title": "🤝 Pair two decks with one piece of text",
343
+ "body": "**Accounts ▸ Local network ▸ setup… ▸ make an invite.** Send the text however you already talk to them; they paste it and the two decks are paired. Nobody presses accept, and neither of you has to know which of your addresses the other can reach — the invite carries them all and their deck tries each one.\n\nDecks on the same router still find each other on their own and appear under **on this network right now**. An invite is for the case that does not work: another subnet, a VPN, a firewall.\n\nThe invite lasts 10 minutes and is used once. Anyone who gets hold of the text can pair with your deck until it runs out, so send it the way you would send anything else you would not want forwarded."
344
+ },
345
+ {
346
+ "title": "⚠️ Both machines have to be on this version",
347
+ "body": "The two decks no longer speak the old protocol, and there is no fallback: a 3.15 deck and a 3.16 deck cannot see each other at all. Update both.\n\nThe group passphrase is gone. If you had one set, that pairing is not carried over — make an invite and pair again. It was replaced because a passphrase that differs by one character produced a closed socket and no other symptom on either machine, and a secret is the one thing a panel must never print, so neither person could check theirs against the other's."
348
+ }
339
349
  ]
340
350
  }
@@ -54,7 +54,7 @@ export const DEFAULTS = Object.freeze({
54
54
  notifications: true,
55
55
  // LAN sync, off until somebody turns it on. `passphrase` is the only secret
56
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: "" }),
57
+ lan: Object.freeze({ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
58
58
  });
59
59
 
60
60
  /** The mode prefs.json is created with.
@@ -79,15 +79,29 @@ function normaliseLan(raw) {
79
79
  return {
80
80
  enabled: typeof src.enabled === "boolean" ? src.enabled : false,
81
81
  name: typeof src.name === "string" ? src.name : "",
82
- passphrase: typeof src.passphrase === "string" ? src.passphrase : "",
82
+ // THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
83
+ // which is why the write below names a mode rather than taking the umask's.
84
+ // It is an X25519 private key in base64 pkcs8; anything else is replaced on
85
+ // the next start rather than refused, because a corrupt key is not a thing
86
+ // anybody can act on and refusing to start would take the feature away.
87
+ secret: typeof src.secret === "string" ? src.secret : "",
83
88
  shared: strings(src.shared),
84
89
  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 : "",
90
+ // THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
91
+ // half: a fingerprint is a hash of it, so an entry without one cannot be
92
+ // checked against whatever answers at an address later, and an entry that
93
+ // cannot be checked is worse than no entry at all.
94
+ trusted: (Array.isArray(src.trusted) ? src.trusted : [])
95
+ .filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
96
+ .map(t => ({ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "" })),
97
+ // The port this deck listened on last time, so an address somebody typed on
98
+ // the other machine still works after a restart. It asked the OS for a new
99
+ // one every start, which is invisible while broadcast works and is exactly
100
+ // what does not work in the case the address field exists for.
101
+ //
102
+ // A PREFERENCE, never a requirement: a port already taken falls through to
103
+ // an OS-chosen one and this is rewritten. 0 means "none yet".
104
+ port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
91
105
  };
92
106
  }
93
107
 
@@ -168,18 +182,23 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
168
182
  /**
169
183
  * The preferences as a PAGE may see them.
170
184
  *
171
- * The passphrase never leaves this process. `GET /api/prefs` is readable by
185
+ * THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
172
186
  * anything that can reach the loopback port — which is the whole point of the
173
187
  * 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.
188
+ * either. A page has no use for the key: what it needs is this deck's
189
+ * fingerprint, which is a hash of the PUBLIC half and comes from `/api/lan`.
175
190
  *
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.
191
+ * The pinned public keys of trusted decks go the same way, for a smaller
192
+ * reason: they are not secret, and they are forty characters of base64 that no
193
+ * page draws. What a page shows is a name and a fingerprint.
178
194
  */
179
195
  export function publicPrefs(prefs) {
180
196
  const p = normalise(prefs);
181
- const { passphrase, ...lan } = p.lan;
182
- return { ...p, lan: { ...lan, hasPassphrase: passphrase !== "" } };
197
+ const { secret, trusted, ...lan } = p.lan;
198
+ return {
199
+ ...p,
200
+ lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
201
+ };
183
202
  }
184
203
 
185
204
  export function notificationsOn(prefs, env = process.env) {
@@ -18,7 +18,6 @@ import { PRODUCT } from "./brand.mjs";
18
18
  import { createBlockNotifier } from "./block-notify.mjs";
19
19
  import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
20
20
  import { createEngine, defaultName } from "./lan-engine.mjs";
21
- import { suggestPassphrase } from "./lan-sync.mjs";
22
21
  import { notify as osNotify } from "./browser-react.mjs";
23
22
  import { invokedName, renameNotice } from "./invoked-as.mjs";
24
23
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3255,12 +3254,37 @@ const lanEngine = createEngine({
3255
3254
  const out = await importAccount(blob);
3256
3255
  return !!out?.ok;
3257
3256
  },
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 => {
3257
+ // The deck's own long-term key, kept so a restart is the same deck rather
3258
+ // than a stranger to everybody who has paired with it. Written once, on the
3259
+ // first start that has none. It IS a secret — prefs.json is 0600 for this.
3260
+ // The port it actually got, kept so an address typed on the other machine
3261
+ // still reaches this deck after it restarts. Written only when it differs
3262
+ // from what is stored, so a normal start writes nothing.
3263
+ onPort: async port => {
3264
+ try { _prefs = await writePrefs({ lan: { port } }); }
3265
+ catch { /* the address field still works this session; next start re-pins */ }
3266
+ },
3267
+ // A deck was accepted or unpaired. Written straight through, because the
3268
+ // trusted list is the whole of who this deck will talk to and a list that
3269
+ // only existed in memory would drop every pairing on restart.
3270
+ onTrust: async trusted => {
3271
+ try { _prefs = await writePrefs({ lan: { trusted } }); }
3272
+ catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
3273
+ },
3274
+ // An address this deck must keep dialling: the far end of a pairing that
3275
+ // happened over an invite. Kept in prefs, because setPeers replaces the dial
3276
+ // list wholesale on every settings write and a row that lives only in memory
3277
+ // is a pairing that goes one-way at the next restart.
3278
+ onDial: async entry => {
3279
+ try {
3280
+ const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3281
+ if (manual.includes(entry)) return;
3282
+ _prefs = await writePrefs({ lan: { manual: [...manual, entry] } });
3283
+ } catch { /* dialled this session; the next round re-adds it */ }
3284
+ },
3285
+ onIdentity: async secret => {
3262
3286
  try {
3263
- _prefs = await writePrefs({ lan: { deckId: id } });
3287
+ _prefs = await writePrefs({ lan: { secret } });
3264
3288
  // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
3265
3289
  // detected, a new id was stored, and both decks kept broadcasting the old
3266
3290
  // one — so they stayed invisible to each other with a correct file on
@@ -3285,9 +3309,10 @@ async function applyLanPrefs() {
3285
3309
  await lanEngine.apply({
3286
3310
  enabled: !!lan.enabled,
3287
3311
  name: lan.name || defaultName(),
3288
- passphrase: lan.passphrase || "",
3312
+ secret: lan.secret || "",
3289
3313
  shared: Array.isArray(lan.shared) ? lan.shared : [],
3290
- deckId: lan.deckId || "",
3314
+ trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
3315
+ port: lan.port || 0,
3291
3316
  });
3292
3317
  // Wholesale, so removing an address in the panel really stops it being
3293
3318
  // dialled rather than only taking the row away.
@@ -3304,22 +3329,79 @@ function handleLanStatus(req, res) {
3304
3329
  }
3305
3330
 
3306
3331
  /**
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.
3332
+ * Accept a deck, dismiss a request, or unpair one.
3333
+ *
3334
+ * ONE ROUTE, THREE VERBS, in the shape the accounts panel already uses for its
3335
+ * own multi-verb writes. Each one takes a fingerprint and nothing else: the key
3336
+ * being pinned comes from what this deck actually saw on the wire, never from
3337
+ * the page, so a page cannot pair this deck with a key nobody has met.
3338
+ */
3339
+ /**
3340
+ * Make an invite, put one away, or join on somebody else's.
3341
+ *
3342
+ * The token is the secret and it is never stored: minted on request, held in
3343
+ * memory until it expires or is used, and gone with the process. Nothing about
3344
+ * it reaches prefs.json, so a deck that restarts is offering nothing rather
3345
+ * than offering something its owner has forgotten they sent.
3320
3346
  */
3321
- function handleLanSuggest(req, res) {
3322
- return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
3347
+ async function handleLanInvite(req, res) {
3348
+ const raw = await readBody(req).catch(() => null);
3349
+ let body = null;
3350
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3351
+ switch (body?.action) {
3352
+ case "make": {
3353
+ const made = lanEngine.invite();
3354
+ if (!made) return send(res, 409, { ok: false, reason: "not_running" });
3355
+ // AFTER the spread, not before: status carries its own `invite` — the
3356
+ // token and its expiry, which is all a panel needs — and letting that
3357
+ // overwrite this one dropped the address list the caller asked for.
3358
+ return send(res, 200, { ok: true, ...lanEngine.status(), invite: made });
3359
+ }
3360
+ case "withdraw":
3361
+ lanEngine.withdraw();
3362
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3363
+ case "join": {
3364
+ const out = await lanEngine.join(body.token);
3365
+ if (!out.ok) return send(res, 200, { ok: false, ...out, ...lanEngine.status() });
3366
+ return send(res, 200, { ok: true, ...out, ...lanEngine.status() });
3367
+ }
3368
+ default:
3369
+ return send(res, 400, { ok: false, reason: "unknown_action" });
3370
+ }
3371
+ }
3372
+
3373
+ async function handleLanPeer(req, res) {
3374
+ const raw = await readBody(req).catch(() => null);
3375
+ let body = null;
3376
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3377
+ const fp = body && typeof body.fp === "string" ? body.fp : null;
3378
+ if (!fp) return send(res, 400, { ok: false, reason: "bad_request" });
3379
+ switch (body.action) {
3380
+ case "accept": {
3381
+ const added = lanEngine.accept(fp);
3382
+ if (!added) return send(res, 409, { ok: false, reason: "not_seen" });
3383
+ // A deck we only HEARD is not pinned, it is dialled — see accept. The
3384
+ // address has to reach prefs or the next write of the settings wipes it:
3385
+ // setPeers replaces the dial list wholesale, on purpose, so an address
3386
+ // that lives only in memory disappears the first time anything else is
3387
+ // saved.
3388
+ if (added.dialled) {
3389
+ const entry = `${added.addr}:${added.port}`;
3390
+ const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3391
+ if (!manual.includes(entry)) {
3392
+ try { _prefs = await writePrefs({ lan: { manual: [...manual, entry] } }); }
3393
+ catch { /* it is dialled this session; the next accept re-adds it */ }
3394
+ }
3395
+ }
3396
+ return send(res, 200, { ok: true, added, ...lanEngine.status() });
3397
+ }
3398
+ case "dismiss":
3399
+ return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
3400
+ case "unpair":
3401
+ return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
3402
+ default:
3403
+ return send(res, 400, { ok: false, reason: "unknown_action" });
3404
+ }
3323
3405
  }
3324
3406
 
3325
3407
  /** Ask every peer now rather than at the next tick — the button beside the
@@ -5558,7 +5640,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5558
5640
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
5559
5641
  if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
5560
5642
  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);
5643
+ if (req.method === "POST" && url.pathname === "/api/lan/peer") return guard(handleLanPeer(req, res), res);
5644
+ if (req.method === "POST" && url.pathname === "/api/lan/invite") return guard(handleLanInvite(req, res), res);
5562
5645
  if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
5563
5646
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
5564
5647
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());