agent-dag 3.15.0 → 3.17.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-CSpWSOAU.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BwFExw1D.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.17.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,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.17.0": [
35
+ {
36
+ "title": "🔔 Pairing is two presses",
37
+ "body": "When another deck asks to pair, the question now arrives **over the deck** rather than in a row three sections down a panel: its name, its address, the fingerprint to compare, **Accept** or **Decline**. Escape means later — the request stays in the panel until you answer it.\n\nThe rest is one list. Every deck you are paired with, every deck on the network, and every one you turned away, each a row with its state under the name and one verb at the end — and a deck that cannot be reached now reads differently from one that is fine. Saying no also reaches the other machine now, instead of leaving it waiting forever; you can take it back.\n\nThe dialog behind **setup** is down to the two things it is really about: what this deck is called, and which logins it offers."
38
+ }
39
+ ],
34
40
  "3.15.0": [
35
41
  {
36
42
  "title": "🔗 Decks on one network heal each other's dead logins",
@@ -336,5 +342,15 @@
336
342
  "title": "🔕 The deck no longer puts anything in your settings.json",
337
343
  "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
344
  }
345
+ ],
346
+ "3.16.0": [
347
+ {
348
+ "title": "🤝 Pair two decks with one piece of text",
349
+ "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."
350
+ },
351
+ {
352
+ "title": "⚠️ Both machines have to be on this version",
353
+ "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."
354
+ }
339
355
  ]
340
356
  }
@@ -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) {
@@ -7,7 +7,7 @@ import { createServer, request as httpRequest } from "node:http";
7
7
  import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
8
8
  import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
9
9
  import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
- import { homedir } from "node:os";
10
+ import { homedir, networkInterfaces } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
13
13
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
@@ -18,7 +18,8 @@ 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";
21
+ import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
22
+ import { run } from "./exec.mjs";
22
23
  import { notify as osNotify } from "./browser-react.mjs";
23
24
  import { invokedName, renameNotice } from "./invoked-as.mjs";
24
25
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3255,12 +3256,37 @@ const lanEngine = createEngine({
3255
3256
  const out = await importAccount(blob);
3256
3257
  return !!out?.ok;
3257
3258
  },
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 => {
3259
+ // The deck's own long-term key, kept so a restart is the same deck rather
3260
+ // than a stranger to everybody who has paired with it. Written once, on the
3261
+ // first start that has none. It IS a secret — prefs.json is 0600 for this.
3262
+ // The port it actually got, kept so an address typed on the other machine
3263
+ // still reaches this deck after it restarts. Written only when it differs
3264
+ // from what is stored, so a normal start writes nothing.
3265
+ onPort: async port => {
3266
+ try { _prefs = await writePrefs({ lan: { port } }); }
3267
+ catch { /* the address field still works this session; next start re-pins */ }
3268
+ },
3269
+ // A deck was accepted or unpaired. Written straight through, because the
3270
+ // trusted list is the whole of who this deck will talk to and a list that
3271
+ // only existed in memory would drop every pairing on restart.
3272
+ onTrust: async trusted => {
3273
+ try { _prefs = await writePrefs({ lan: { trusted } }); }
3274
+ catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
3275
+ },
3276
+ // An address this deck must keep dialling: the far end of a pairing that
3277
+ // happened over an invite. Kept in prefs, because setPeers replaces the dial
3278
+ // list wholesale on every settings write and a row that lives only in memory
3279
+ // is a pairing that goes one-way at the next restart.
3280
+ onDial: async entry => {
3262
3281
  try {
3263
- _prefs = await writePrefs({ lan: { deckId: id } });
3282
+ const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3283
+ if (manual.includes(entry)) return;
3284
+ _prefs = await writePrefs({ lan: { manual: [...manual, entry] } });
3285
+ } catch { /* dialled this session; the next round re-adds it */ }
3286
+ },
3287
+ onIdentity: async secret => {
3288
+ try {
3289
+ _prefs = await writePrefs({ lan: { secret } });
3264
3290
  // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
3265
3291
  // detected, a new id was stored, and both decks kept broadcasting the old
3266
3292
  // one — so they stayed invisible to each other with a correct file on
@@ -3285,9 +3311,10 @@ async function applyLanPrefs() {
3285
3311
  await lanEngine.apply({
3286
3312
  enabled: !!lan.enabled,
3287
3313
  name: lan.name || defaultName(),
3288
- passphrase: lan.passphrase || "",
3314
+ secret: lan.secret || "",
3289
3315
  shared: Array.isArray(lan.shared) ? lan.shared : [],
3290
- deckId: lan.deckId || "",
3316
+ trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
3317
+ port: lan.port || 0,
3291
3318
  });
3292
3319
  // Wholesale, so removing an address in the panel really stops it being
3293
3320
  // dialled rather than only taking the row away.
@@ -3297,29 +3324,137 @@ async function applyLanPrefs() {
3297
3324
  }
3298
3325
  }
3299
3326
 
3327
+ // ── can other decks reach this one ──────────────────────────────────────────
3328
+ //
3329
+ // NEVER IN THE REQUEST. The panel polls this route, and the probe is a
3330
+ // PowerShell start-up — the better part of a second on a warm machine and
3331
+ // worse on a cold one. So the route answers with whatever the last probe said
3332
+ // and starts the next one behind it. The first poll after a deck starts
3333
+ // carries no verdict, which is correct rather than merely tolerable: at that
3334
+ // point the deck has not been listening long enough for an empty list to mean
3335
+ // anything either.
3336
+ //
3337
+ // STALE IS THE RIGHT DEFAULT HERE. What this measures — a network's category, a
3338
+ // firewall rule — changes when somebody changes it, which is a thing they do
3339
+ // while looking at the instructions this produced. Five minutes is far tighter
3340
+ // than that, and the panel's own reload picks up the change on the next poll.
3341
+ let reachSaid = null;
3342
+ let reachAt = 0;
3343
+ let reachBusy = false;
3344
+ const REACH_MS = 5 * 60_000;
3345
+
3346
+ function refreshReach() {
3347
+ if (reachBusy || process.platform !== "win32") return;
3348
+ if (reachAt && Date.now() - reachAt < REACH_MS) return;
3349
+ reachBusy = true;
3350
+ run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
3351
+ // The program path goes through the environment rather than into the
3352
+ // script, so a path with a quote in it cannot close the string it sits in.
3353
+ env: { ...process.env, CCDECK_EXE: process.execPath },
3354
+ timeout: 25_000,
3355
+ }).then(r => {
3356
+ reachSaid = reachability({
3357
+ platform: process.platform,
3358
+ probe: r?.ok ? readProbe(r.stdout) : null,
3359
+ aliases: localAliases(networkInterfaces()),
3360
+ exePath: process.execPath,
3361
+ });
3362
+ }).catch(() => {
3363
+ // A probe that did not run says nothing, which is the same answer as a
3364
+ // machine this cannot speak about. It is never an error the panel shows:
3365
+ // nobody asked for it.
3366
+ reachSaid = null;
3367
+ }).finally(() => {
3368
+ reachAt = Date.now();
3369
+ reachBusy = false;
3370
+ });
3371
+ }
3372
+
3300
3373
  /** What the panel draws: the switch, this deck's own name and address, and who
3301
3374
  * else is in the group. No passphrase, for the reason prefsPayload gives. */
3302
3375
  function handleLanStatus(req, res) {
3303
- return send(res, 200, { ok: true, ...lanEngine.status() });
3376
+ refreshReach();
3377
+ return send(res, 200, { ok: true, ...lanEngine.status(), reach: reachSaid });
3304
3378
  }
3305
3379
 
3306
3380
  /**
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.
3381
+ * Accept a deck, dismiss a request, or unpair one.
3382
+ *
3383
+ * ONE ROUTE, THREE VERBS, in the shape the accounts panel already uses for its
3384
+ * own multi-verb writes. Each one takes a fingerprint and nothing else: the key
3385
+ * being pinned comes from what this deck actually saw on the wire, never from
3386
+ * the page, so a page cannot pair this deck with a key nobody has met.
3387
+ */
3388
+ /**
3389
+ * Make an invite, put one away, or join on somebody else's.
3390
+ *
3391
+ * The token is the secret and it is never stored: minted on request, held in
3392
+ * memory until it expires or is used, and gone with the process. Nothing about
3393
+ * it reaches prefs.json, so a deck that restarts is offering nothing rather
3394
+ * than offering something its owner has forgotten they sent.
3320
3395
  */
3321
- function handleLanSuggest(req, res) {
3322
- return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
3396
+ async function handleLanInvite(req, res) {
3397
+ const raw = await readBody(req).catch(() => null);
3398
+ let body = null;
3399
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3400
+ switch (body?.action) {
3401
+ case "make": {
3402
+ const made = lanEngine.invite();
3403
+ if (!made) return send(res, 409, { ok: false, reason: "not_running" });
3404
+ // AFTER the spread, not before: status carries its own `invite` — the
3405
+ // token and its expiry, which is all a panel needs — and letting that
3406
+ // overwrite this one dropped the address list the caller asked for.
3407
+ return send(res, 200, { ok: true, ...lanEngine.status(), invite: made });
3408
+ }
3409
+ case "withdraw":
3410
+ lanEngine.withdraw();
3411
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3412
+ case "join": {
3413
+ const out = await lanEngine.join(body.token);
3414
+ if (!out.ok) return send(res, 200, { ok: false, ...out, ...lanEngine.status() });
3415
+ return send(res, 200, { ok: true, ...out, ...lanEngine.status() });
3416
+ }
3417
+ default:
3418
+ return send(res, 400, { ok: false, reason: "unknown_action" });
3419
+ }
3420
+ }
3421
+
3422
+ async function handleLanPeer(req, res) {
3423
+ const raw = await readBody(req).catch(() => null);
3424
+ let body = null;
3425
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3426
+ const fp = body && typeof body.fp === "string" ? body.fp : null;
3427
+ if (!fp) return send(res, 400, { ok: false, reason: "bad_request" });
3428
+ switch (body.action) {
3429
+ case "accept": {
3430
+ const added = lanEngine.accept(fp);
3431
+ if (!added) return send(res, 409, { ok: false, reason: "not_seen" });
3432
+ // A deck we only HEARD is not pinned, it is dialled — see accept. The
3433
+ // address has to reach prefs or the next write of the settings wipes it:
3434
+ // setPeers replaces the dial list wholesale, on purpose, so an address
3435
+ // that lives only in memory disappears the first time anything else is
3436
+ // saved.
3437
+ if (added.dialled) {
3438
+ const entry = `${added.addr}:${added.port}`;
3439
+ const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3440
+ if (!manual.includes(entry)) {
3441
+ try { _prefs = await writePrefs({ lan: { manual: [...manual, entry] } }); }
3442
+ catch { /* it is dialled this session; the next accept re-adds it */ }
3443
+ }
3444
+ }
3445
+ return send(res, 200, { ok: true, added, ...lanEngine.status() });
3446
+ }
3447
+ case "dismiss":
3448
+ return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
3449
+ // The undo for the one above. A deck that was told no stops asking; this is
3450
+ // how somebody who changed their mind lets it ask again.
3451
+ case "allow":
3452
+ return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
3453
+ case "unpair":
3454
+ return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
3455
+ default:
3456
+ return send(res, 400, { ok: false, reason: "unknown_action" });
3457
+ }
3323
3458
  }
3324
3459
 
3325
3460
  /** Ask every peer now rather than at the next tick — the button beside the
@@ -5558,7 +5693,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5558
5693
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
5559
5694
  if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
5560
5695
  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);
5696
+ if (req.method === "POST" && url.pathname === "/api/lan/peer") return guard(handleLanPeer(req, res), res);
5697
+ if (req.method === "POST" && url.pathname === "/api/lan/invite") return guard(handleLanInvite(req, res), res);
5562
5698
  if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
5563
5699
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
5564
5700
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());