agent-dag 3.14.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-CinWEMD2.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-Xyt_GsRj.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.14.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": {
@@ -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",
@@ -326,5 +336,15 @@
326
336
  "title": "🔕 The deck no longer puts anything in your settings.json",
327
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."
328
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
+ }
329
349
  ]
330
350
  }
@@ -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,60 @@ 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: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
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
+ // 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 : "",
88
+ shared: strings(src.shared),
89
+ manual: strings(src.manual),
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,
105
+ };
106
+ }
54
107
 
55
108
  /** Coerce whatever is on disk into a whole, known-shaped prefs object.
56
109
  *
@@ -60,7 +113,10 @@ export const DEFAULTS = Object.freeze({ notifications: true });
60
113
  * a setting nothing here can see, which reads as support and is not. */
61
114
  export function normalise(raw) {
62
115
  const src = raw && typeof raw === "object" ? raw : {};
63
- return { notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications };
116
+ return {
117
+ notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
118
+ lan: normaliseLan(src.lan),
119
+ };
64
120
  }
65
121
 
66
122
  /** What is on disk, or the defaults. A corrupt or absent file is not an error
@@ -97,10 +153,15 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
97
153
  // 500s through `guard`, and the notifications switch silently does not
98
154
  // stick. POSIX rename(2) has no such rule, which is why this shipped green.
99
155
  const mv = deps.rename ?? renameWithRetry;
100
- const next = normalise({ ...(await readPrefs(home, deps)), ...patch });
156
+ const prev = await readPrefs(home, deps);
157
+ // The LAN section merges rather than replaces, so a page toggling the
158
+ // switch does not have to send the passphrase back to keep it — and so
159
+ // nothing has to send a secret it was never given.
160
+ const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
161
+ const next = normalise(merged);
101
162
  await mk(prefsDir(home), { recursive: true });
102
163
  const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
103
- await write(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
164
+ await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
104
165
  await mv(tmp, prefsPath(home));
105
166
  return next;
106
167
  };
@@ -118,6 +179,28 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
118
179
  * the person who wrote the launch script are not always the same person, and
119
180
  * only one of them is making a claim about the machine.
120
181
  */
182
+ /**
183
+ * The preferences as a PAGE may see them.
184
+ *
185
+ * THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
186
+ * anything that can reach the loopback port — which is the whole point of the
187
+ * deck's own threat model, and is why the share envelope is not served there
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`.
190
+ *
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.
194
+ */
195
+ export function publicPrefs(prefs) {
196
+ const p = normalise(prefs);
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
+ };
202
+ }
203
+
121
204
  export function notificationsOn(prefs, env = process.env) {
122
205
  if (env[OFF_ENV] === "1") return false;
123
206
  return normalise(prefs).notifications;
@@ -16,7 +16,8 @@ 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";
20
21
  import { notify as osNotify } from "./browser-react.mjs";
21
22
  import { invokedName, renameNotice } from "./invoked-as.mjs";
22
23
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3169,7 +3170,10 @@ function redactDeckToken(raw) {
3169
3170
  * same freshness every other cross-deck setting has.
3170
3171
  */
3171
3172
  let _prefs = { ...PREF_DEFAULTS };
3172
- readPrefs().then(p => { _prefs = p; }).catch(() => {});
3173
+ // And LAN sync starts from the same read, so a deck that had it on comes back
3174
+ // with it on. `applyLanPrefs` is defined below — this runs after module
3175
+ // evaluation, which is what makes the forward reference fine.
3176
+ readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
3173
3177
 
3174
3178
  const blockNotifier = createBlockNotifier({
3175
3179
  notify: osNotify,
@@ -3197,7 +3201,11 @@ const blockNotifier = createBlockNotifier({
3197
3201
  function prefsPayload() {
3198
3202
  return {
3199
3203
  ok: true,
3200
- prefs: _prefs,
3204
+ // `publicPrefs`, never `_prefs`. This route answers anything that can reach
3205
+ // the loopback port, and the LAN group passphrase is in the stored object —
3206
+ // one field, and sending it here would put it in front of every page on the
3207
+ // machine. What goes out is whether one is set.
3208
+ prefs: publicPrefs(_prefs),
3201
3209
  notificationsAllowed: notificationsOn(_prefs),
3202
3210
  notificationsVetoed: notificationsVetoed(),
3203
3211
  };
@@ -3214,9 +3222,196 @@ async function handlePrefsWrite(req, res) {
3214
3222
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3215
3223
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3216
3224
  _prefs = await writePrefs(body);
3225
+ // The engine reads its settings from here rather than holding its own copy,
3226
+ // so turning the switch off in the panel really does stop the sockets rather
3227
+ // than only changing what the panel says.
3228
+ await applyLanPrefs();
3217
3229
  return send(res, 200, prefsPayload());
3218
3230
  }
3219
3231
 
3232
+ // ── LAN sync ────────────────────────────────────────────────────────────────
3233
+ //
3234
+ // The engine is built once and told the settings; it opens and closes its own
3235
+ // sockets as those change. Nothing here touches a credential — see
3236
+ // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3237
+ const lanEngine = createEngine({
3238
+ readAccounts: async () => {
3239
+ const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3240
+ return fetchClaudeAccounts();
3241
+ },
3242
+ exportAccount: async num => {
3243
+ const { shareAccounts } = await import("./cswap-admin.mjs");
3244
+ const out = await shareAccounts([String(num)]);
3245
+ return out?.ok ? out.blob : null;
3246
+ },
3247
+ importAccount: async blob => {
3248
+ const { importAccount } = await import("./cswap-admin.mjs");
3249
+ // NO `force`, ever, and this is the line where that promise is kept. A
3250
+ // plain import adds an account that is missing and replaces exactly one
3251
+ // claude-swap has quarantined; it SKIPS one that is present and healthy.
3252
+ // So a peer cannot overwrite a working credential of this deck's even by
3253
+ // lying about its own, because the flag that would allow it is not passed.
3254
+ const out = await importAccount(blob);
3255
+ return !!out?.ok;
3256
+ },
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 => {
3286
+ try {
3287
+ _prefs = await writePrefs({ lan: { secret } });
3288
+ // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
3289
+ // detected, a new id was stored, and both decks kept broadcasting the old
3290
+ // one — so they stayed invisible to each other with a correct file on
3291
+ // disk. `apply` restarts only when the id it is holding differs from the
3292
+ // one it is given, so this settles after one pass rather than looping.
3293
+ await applyLanPrefs();
3294
+ } catch { /* the next start picks it up; a shared id is the cost until then */ }
3295
+ },
3296
+ onError: (what, err) => {
3297
+ // Reported, never thrown. A machine with no route, a firewall that refuses
3298
+ // the bind, an interface that comes and goes with a VPN — none of them is a
3299
+ // reason for the deck to fall over.
3300
+ if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
3301
+ },
3302
+ });
3303
+
3304
+ /** Push whatever is in prefs at the engine. Called at boot and after every
3305
+ * write, so there is one source of truth and it is the file. */
3306
+ async function applyLanPrefs() {
3307
+ const lan = _prefs?.lan ?? {};
3308
+ try {
3309
+ await lanEngine.apply({
3310
+ enabled: !!lan.enabled,
3311
+ name: lan.name || defaultName(),
3312
+ secret: lan.secret || "",
3313
+ shared: Array.isArray(lan.shared) ? lan.shared : [],
3314
+ trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
3315
+ port: lan.port || 0,
3316
+ });
3317
+ // Wholesale, so removing an address in the panel really stops it being
3318
+ // dialled rather than only taking the row away.
3319
+ lanEngine.setPeers(lan.manual);
3320
+ } catch (err) {
3321
+ console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
3322
+ }
3323
+ }
3324
+
3325
+ /** What the panel draws: the switch, this deck's own name and address, and who
3326
+ * else is in the group. No passphrase, for the reason prefsPayload gives. */
3327
+ function handleLanStatus(req, res) {
3328
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3329
+ }
3330
+
3331
+ /**
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.
3346
+ */
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
+ }
3405
+ }
3406
+
3407
+ /** Ask every peer now rather than at the next tick — the button beside the
3408
+ * list, for somebody who has just fixed a login on the other machine and does
3409
+ * not want to wait a minute to see it arrive. */
3410
+ async function handleLanSync(req, res) {
3411
+ const done = await lanEngine.round();
3412
+ return send(res, 200, { ok: true, done, ...lanEngine.status() });
3413
+ }
3414
+
3220
3415
  function pushEvent(raw, source, opts = {}) {
3221
3416
  // First, before anything below can see it: the deck's own credential does not
3222
3417
  // belong in a store that is served without one. Every entry point to the
@@ -5444,6 +5639,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5444
5639
  // Machine state, not session state: sampled on the server's own timer and
5445
5640
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
5446
5641
  if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
5642
+ if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(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);
5645
+ if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
5447
5646
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
5448
5647
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
5449
5648
  // On demand only — the process list costs a subprocess on every platform,