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.
@@ -26,8 +26,12 @@ import { claudeConfigDir } from "./claude-dir.mjs";
26
26
  import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
27
27
  import { PRODUCT } from "./brand.mjs";
28
28
  import { createBlockNotifier } from "./block-notify.mjs";
29
- import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
29
+ import { createActivity } from "./activity.mjs";
30
+ import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
31
+ import { createPresence } from "./presence.mjs";
32
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
30
33
  import { createEngine, defaultName } from "./lan-engine.mjs";
34
+ import { aboutThisDeck } from "./lan-about.mjs";
31
35
  import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
32
36
  import { run } from "./exec.mjs";
33
37
  import { notify as osNotify } from "./browser-react.mjs";
@@ -48,6 +52,13 @@ const RUNNING_VERSION = (() => {
48
52
  catch { return null; }
49
53
  })();
50
54
 
55
+ // The two facts the away-update waits on (awayUpdateTick): whether a turn is
56
+ // running, fed from pushEvent, and whether a tab is being looked at, fed from
57
+ // POST /api/presence. Up here rather than beside the tick because pushEvent
58
+ // can run before the module below it has finished evaluating.
59
+ const activity = createActivity();
60
+ const presence = createPresence();
61
+
51
62
  const MIME = {
52
63
  ".html": "text/html; charset=utf-8",
53
64
  ".js": "application/javascript; charset=utf-8",
@@ -3127,10 +3138,14 @@ function redactDeckToken(raw) {
3127
3138
  * same freshness every other cross-deck setting has.
3128
3139
  */
3129
3140
  let _prefs = { ...PREF_DEFAULTS };
3130
- // And LAN sync starts from the same read, so a deck that had it on comes back
3131
- // with it on. `applyLanPrefs` is defined below — this runs after module
3132
- // evaluation, which is what makes the forward reference fine.
3133
- readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
3141
+ // Read at import, so `consider` has its answer from the first event. LAN sync
3142
+ // used to start from this same read, and it must not: bin/deck.js imports this
3143
+ // module to ASK whether a deck is already up, and a launcher that then attaches
3144
+ // and exits had already bound the beacon and the sync port on the way in —
3145
+ // which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck` printed
3146
+ // above `deck already running`. The engine starts from the listen that
3147
+ // succeeds, in startServer, which is the only process that may hold a port.
3148
+ const _prefsRead = readPrefs().then(p => { _prefs = p; }).catch(() => {});
3134
3149
 
3135
3150
  const blockNotifier = createBlockNotifier({
3136
3151
  notify: osNotify,
@@ -3186,12 +3201,87 @@ async function handlePrefsWrite(req, res) {
3186
3201
  return send(res, 200, prefsPayload());
3187
3202
  }
3188
3203
 
3204
+ // ── updating while nobody is looking ────────────────────────────────────────
3205
+ //
3206
+ // auto-update.mjs has the why and the rules; this is the timer and the three
3207
+ // effects — install, restart, npx relaunch — each through the code a press
3208
+ // uses.
3209
+ let _awayTry = null;
3210
+ let _awayTimer = null;
3211
+ let _bootedAt = Date.now();
3212
+ // Until when a report that found nothing newer stands — see AWAY_RECHECK_MS.
3213
+ let _awayNothingUntil = 0;
3214
+
3215
+ /**
3216
+ * One tick of the away-update. Returns what it did, or null.
3217
+ *
3218
+ * Exported so a test can drive it without waiting out the boot grace — the one
3219
+ * input that is a policy rather than a fact, and so the only one it may pass.
3220
+ * Every clock is the real one: presence and activity are stamped with it.
3221
+ */
3222
+ export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3223
+ const now = Date.now();
3224
+ if (!awayGate({
3225
+ enabled: _prefs?.autoUpdate !== false,
3226
+ supervised: _onRestart != null && _canRestart,
3227
+ restarting: _restarting,
3228
+ sinceBootMs: now - _bootedAt,
3229
+ looking: presence.looking(now),
3230
+ busy: activity.busy(now),
3231
+ quietMs: activity.quietMs(now),
3232
+ graceMs,
3233
+ })) return null;
3234
+ // A standing "nothing newer", unless the clock has moved back past it.
3235
+ if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
3236
+ const su = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
3237
+ const report = await su.versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT });
3238
+ if (!report.notice) {
3239
+ _awayNothingUntil = now + AWAY_RECHECK_MS;
3240
+ return null;
3241
+ }
3242
+ const step = awayUpdateStep({
3243
+ notice: report.notice, mode: report.upgradeMode,
3244
+ installing: report.upgrade?.state === "running", lastTry: _awayTry, now,
3245
+ });
3246
+ if (!step.act) return null;
3247
+ // Asked again, because the lookup above can take seconds on a slow line and
3248
+ // somebody may have sat down, or started a turn, in them.
3249
+ const again = Date.now();
3250
+ if (presence.looking(again) || activity.busy(again) || _restarting) return null;
3251
+ _awayTry = { target: step.target, at: again };
3252
+ if (step.act === "install") {
3253
+ su.startUpgrade({ pkgRoot: PKG_ROOT });
3254
+ return step.act;
3255
+ }
3256
+ // handleRestart's own check for the npx path, for the same reason: the mode
3257
+ // comes from the install on disk, and a spec it cannot name is no relaunch.
3258
+ if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
3259
+ _restarting = true;
3260
+ handOffRestart(step.act === "npx" ? "npx" : null);
3261
+ return step.act;
3262
+ }
3263
+
3264
+ /** POST {tab, looking} — a tab saying whether it is being looked at. See
3265
+ * presence.mjs, and src/web/presence.ts for the sender. */
3266
+ async function handlePresence(req, res) {
3267
+ const raw = await readBody(req).catch(() => null);
3268
+ let body = null;
3269
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3270
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3271
+ const ok = presence.report(body.tab, body.looking === true, Date.now());
3272
+ return send(res, ok ? 200 : 400, { ok });
3273
+ }
3274
+
3189
3275
  // ── LAN sync ────────────────────────────────────────────────────────────────
3190
3276
  //
3191
3277
  // The engine is built once and told the settings; it opens and closes its own
3192
3278
  // sockets as those change. Nothing here touches a credential — see
3193
3279
  // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3194
3280
  const lanEngine = createEngine({
3281
+ // This deck's version and machine, for the decks it is paired with and
3282
+ // nobody else. RUNNING_VERSION rather than a fresh read, for the reason it
3283
+ // is read at import: it is what this process actually runs.
3284
+ about: aboutThisDeck({ version: RUNNING_VERSION }),
3195
3285
  readAccounts: async () => {
3196
3286
  const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3197
3287
  return fetchClaudeAccounts();
@@ -3324,6 +3414,9 @@ async function applyLanPrefs() {
3324
3414
  port: lan.port || 0,
3325
3415
  autoAsk: lan.autoAsk !== false,
3326
3416
  autoAccept: lan.autoAccept !== false,
3417
+ // Names somebody here gave other decks. The engine only hands them to
3418
+ // the page, so a change never restarts anything.
3419
+ aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
3327
3420
  });
3328
3421
  // Wholesale, so removing an address in the panel really stops it being
3329
3422
  // dialled rather than only taking the row away.
@@ -3461,6 +3554,21 @@ async function handleLanPeer(req, res) {
3461
3554
  return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
3462
3555
  case "unpair":
3463
3556
  return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
3557
+ // WHAT THIS DECK CALLS THAT ONE, and nobody else sees it. Keyed by the
3558
+ // fingerprint, so it follows the machine across a new address or a new
3559
+ // name of its own choosing. An empty name takes the alias away.
3560
+ //
3561
+ // The whole map is rebuilt from the one on disk rather than sent by the
3562
+ // page, so two tabs renaming two decks cannot undo each other.
3563
+ case "alias": {
3564
+ if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
3565
+ const name = cleanAlias(body.name);
3566
+ const next = { ...(_prefs?.lan?.aliases ?? {}) };
3567
+ if (name) next[fp] = name; else delete next[fp];
3568
+ _prefs = await writePrefs({ lan: { aliases: next } });
3569
+ await lanEngine.apply({ aliases: _prefs.lan.aliases });
3570
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3571
+ }
3464
3572
  default:
3465
3573
  return send(res, 400, { ok: false, reason: "unknown_action" });
3466
3574
  }
@@ -3468,8 +3576,21 @@ async function handleLanPeer(req, res) {
3468
3576
 
3469
3577
  /** Ask every peer now rather than at the next tick — the button beside the
3470
3578
  * list, for somebody who has just fixed a login on the other machine and does
3471
- * not want to wait a minute to see it arrive. */
3579
+ * not want to wait a minute to see it arrive.
3580
+ *
3581
+ * Or ONE peer, when the body names it: the `check now` in a deck's own
3582
+ * dialog. A deck that only calls in has no address here, and says so rather
3583
+ * than reporting a round that asked nobody. */
3472
3584
  async function handleLanSync(req, res) {
3585
+ const raw = await readBody(req).catch(() => null);
3586
+ let body = null;
3587
+ try { body = JSON.parse(raw ?? ""); } catch { /* the whole-list press sends nothing to read */ }
3588
+ const fp = body && typeof body.fp === "string" ? body.fp : null;
3589
+ if (fp) {
3590
+ const done = await lanEngine.roundOne(fp);
3591
+ if (done == null) return send(res, 409, { ok: false, reason: "no_address" });
3592
+ return send(res, 200, { ok: true, done, ...lanEngine.status() });
3593
+ }
3473
3594
  const done = await lanEngine.round();
3474
3595
  return send(res, 200, { ok: true, done, ...lanEngine.status() });
3475
3596
  }
@@ -3624,6 +3745,10 @@ function pushEvent(raw, source, opts = {}) {
3624
3745
  // know the other exists. block-notify.mjs holds the gates and the cooldown.
3625
3746
  blockNotifier.consider(raw, { clients: sseClients.size, replay: !!opts.replay });
3626
3747
 
3748
+ // Whether a turn is running, for the away-update. Not from a replay: the log
3749
+ // is history, and a turn it shows open is one that ended in another process.
3750
+ if (!opts.replay) activity.note(raw, evt.receivedAt);
3751
+
3627
3752
  if (persisting) {
3628
3753
  // Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
3629
3754
  // has to reach the file as one write — this used to be `appendFile`, which
@@ -4395,6 +4520,16 @@ async function handleRestart(req, res) {
4395
4520
  if (_deckReady) send(res, 200, { ok: true, mode });
4396
4521
  else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
4397
4522
  // Let the response flush before the listener goes away.
4523
+ handOffRestart(mode);
4524
+ }
4525
+
4526
+ /**
4527
+ * Hand a restart the latch has already admitted to the launcher — from a press
4528
+ * (handleRestart, once its answer is on its way) or from the away-update
4529
+ * (awayUpdateTick). The delay is the press's: its response has to leave before
4530
+ * the listener does.
4531
+ */
4532
+ function handOffRestart(mode) {
4398
4533
  setTimeout(() => {
4399
4534
  try { _onRestart(mode); }
4400
4535
  catch (err) {
@@ -5648,6 +5783,22 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5648
5783
  // `!== false` rather than a cast: a caller that omits the field means "yes",
5649
5784
  // which is how every embedder that predates this option keeps working.
5650
5785
  _providers = { claude: claude !== false, codex: codex !== false };
5786
+ // The away-update's clock. The boot grace counts from here, and the timer is
5787
+ // unref'd so it never keeps a process alive on its own. It does nothing until
5788
+ // the launcher has handed down a restart — see awayGate's `supervised`.
5789
+ _bootedAt = Date.now();
5790
+ clearInterval(_awayTimer);
5791
+ _awayTimer = setInterval(() => { awayUpdateTick().catch(() => {}); }, AWAY_TICK_MS);
5792
+ _awayTimer.unref?.();
5793
+ // The repair a paused Claude account used to wait on a `resume` press for:
5794
+ // handed to the roster read here, by the server that is actually running,
5795
+ // rather than wired at import — see repairStaleCopyWith.
5796
+ if (_providers.claude) {
5797
+ void Promise.all([
5798
+ import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href),
5799
+ cswapAdminModule(),
5800
+ ]).then(([accounts, admin]) => accounts.repairStaleCopyWith(admin.autoRecapture), () => {});
5801
+ }
5651
5802
  const removed = await sweepStaleDiscovery();
5652
5803
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
5653
5804
  if (persist) {
@@ -5725,6 +5876,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5725
5876
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
5726
5877
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
5727
5878
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
5879
+ if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
5728
5880
  if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
5729
5881
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
5730
5882
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
@@ -5845,6 +5997,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5845
5997
  if (codex) startCodexWatcher(workspace);
5846
5998
  // Both timers are unref'd, so this never holds the process open.
5847
5999
  startSystemMetrics();
6000
+ // LAN sync, from the prefs the import read — and only from here, so a
6001
+ // deck that had it on comes back with it on, and a launcher that only
6002
+ // asked the registry never binds a port it is about to walk away from.
6003
+ _prefsRead.then(() => applyLanPrefs()).catch(() => {});
5848
6004
  // Auto-switch resumes only if the user previously turned it on; the
5849
6005
  // module reads its own persisted flag and does nothing otherwise.
5850
6006
  cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
@@ -0,0 +1,119 @@
1
+ // What a paired deck says about itself: the version it runs and the machine it
2
+ // runs on.
3
+ //
4
+ // ONLY TO A DECK THAT IS PAIRED, AND SEALED. The beacon is shouted at the whole
5
+ // network and the hello is sent before anybody has proved anything, so neither
6
+ // carries this: which build of which operating system a machine runs is the
7
+ // first thing somebody hunting for a known-broken version would want, and
8
+ // nobody on the network is owed it. It rides the manifest exchange, which only
9
+ // happens between two decks that have each accepted the other — and it is
10
+ // sealed with that connection's key, because the frames around it are JSON in
11
+ // the clear on the wire.
12
+ //
13
+ // BOTH WAYS. The deck that dials sends its own card with the question, and the
14
+ // deck that answers sends its own with the answer. A deck that only calls in —
15
+ // paired, with no address this one can reach — is never asked anything, so the
16
+ // card it sends when it calls is the only way this deck ever learns what it is.
17
+ //
18
+ // OLDER DECKS SAY NOTHING, AND NOTHING BREAKS. The field is extra on a frame
19
+ // both versions already send: a deck from before this reads the accounts and
20
+ // never looks at the rest, and a card that is absent is drawn as absent.
21
+ import { readFileSync } from "node:fs";
22
+ import { release as osRelease } from "node:os";
23
+ import { open, seal } from "./lan-sync.mjs";
24
+
25
+ /**
26
+ * The operating system, in the words its owner would use.
27
+ *
28
+ * Node reports the KERNEL — `darwin 25.5.0`, `win32 10.0.26100` — and nobody
29
+ * calls their laptop Darwin 25. So the numbers are translated on the machine
30
+ * they describe, where the translation is certain, rather than on the one
31
+ * reading them. Every input is a parameter, so all three platforms are tested
32
+ * on any one of them.
33
+ */
34
+ export function osLabel({ platform, release = "", prettyName = "" } = {}) {
35
+ const [major, minor, build] = String(release).split(".").map(n => Number.parseInt(n, 10));
36
+ const dot = Number.isInteger(minor) ? `.${minor}` : "";
37
+ if (platform === "darwin") {
38
+ if (!Number.isInteger(major)) return "macOS";
39
+ // Darwin 20 was macOS 11 and every major after it was one more — until 25,
40
+ // which Apple shipped as macOS 26 to match the year.
41
+ if (major >= 25) return `macOS ${major + 1}${dot}`;
42
+ if (major >= 20) return `macOS ${major - 9}${dot}`;
43
+ // The 10.x years, where the kernel's major minus four is the second number.
44
+ return major >= 5 ? `macOS 10.${major - 4}` : "macOS";
45
+ }
46
+ if (platform === "win32") {
47
+ // Windows 11 kept the version 10.0 and moved only the build, and 22000 is
48
+ // the first one. From here it is the only way to tell the two apart.
49
+ if (major === 10 && Number.isInteger(build)) return build >= 22_000 ? "Windows 11" : "Windows 10";
50
+ return "Windows";
51
+ }
52
+ if (platform === "linux") return prettyName || "Linux";
53
+ return typeof platform === "string" && platform ? platform : "unknown";
54
+ }
55
+
56
+ /** The distribution's own name for itself, from the file every systemd-era
57
+ * distribution ships, with the fallback path the same spec names. Only ever
58
+ * read on Linux: the paths are Linux's and mean nothing anywhere else. */
59
+ export function linuxPrettyName(read = readFileSync) {
60
+ for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
61
+ try {
62
+ const line = /^PRETTY_NAME=(.*)$/m.exec(String(read(file, "utf8")))?.[1];
63
+ const name = line?.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
64
+ if (name) return name;
65
+ } catch { /* not this one */ }
66
+ }
67
+ return "";
68
+ }
69
+
70
+ /** This deck's own card. Built once, at start: nothing in it changes while the
71
+ * process lives, and an upgrade is a restart. */
72
+ export function aboutThisDeck({
73
+ version = null, platform = process.platform, release = osRelease(), arch = process.arch, read = readFileSync,
74
+ } = {}) {
75
+ return readAbout({
76
+ version,
77
+ os: osLabel({ platform, release, prettyName: platform === "linux" ? linuxPrettyName(read) : "" }),
78
+ arch,
79
+ });
80
+ }
81
+
82
+ /** One field of a card, or null. It came off the network, so it is a string of
83
+ * bounded length with no control characters — the rule cleanName applies to a
84
+ * deck's name, for the same reason: it reaches a terminal as well as a page.
85
+ * `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
86
+ function field(v, max, shape = null) {
87
+ if (typeof v !== "string") return null;
88
+ const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
89
+ const s = [...flat].slice(0, max).join("").trim();
90
+ return s && (!shape || shape.test(s)) ? s : null;
91
+ }
92
+
93
+ /** A card, or null when there is nothing in it worth drawing. */
94
+ export function readAbout(raw) {
95
+ if (!raw || typeof raw !== "object") return null;
96
+ const version = field(raw.version, 32, /^[0-9A-Za-z][0-9A-Za-z.+-]*$/);
97
+ const os = field(raw.os, 48);
98
+ const arch = field(raw.arch, 16, /^[0-9A-Za-z_]+$/);
99
+ return version || os || arch ? { version, os, arch } : null;
100
+ }
101
+
102
+ /** Bound to one connection AND one direction, so a card cannot be replayed
103
+ * back at the deck that sent it as if it were the other deck's. */
104
+ const aboutAad = (fromFp, toFp) => `${fromFp}->${toFp}|about`;
105
+
106
+ /** This deck's card, sealed for the deck on the other end of `key`. */
107
+ export function sealAbout(key, about, fromFp, toFp) {
108
+ if (!key || !about) return null;
109
+ return seal(key, JSON.stringify(about), aboutAad(fromFp, toFp));
110
+ }
111
+
112
+ /** The other deck's card, or null — for a deck that sent none, and for one
113
+ * whose seal does not open, which mean the same thing to the panel. */
114
+ export function openAbout(key, sealed, fromFp, toFp) {
115
+ if (!key || !sealed || typeof sealed !== "object") return null;
116
+ const text = open(key, sealed, aboutAad(fromFp, toFp));
117
+ if (text == null) return null;
118
+ try { return readAbout(JSON.parse(text)); } catch { return null; }
119
+ }
@@ -30,6 +30,7 @@
30
30
  import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
31
  import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
32
  import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
+ import { openAbout, sealAbout } from "./lan-about.mjs";
33
34
  import { randomBytes } from "node:crypto";
34
35
  import { hostname, networkInterfaces } from "node:os";
35
36
 
@@ -95,6 +96,20 @@ export function localAddresses(faces = networkInterfaces()) {
95
96
  return out;
96
97
  }
97
98
 
99
+ /**
100
+ * The accounts a peer's manifest listed, as the panel may keep them.
101
+ *
102
+ * It arrived from another machine, so it is read rather than trusted: strings
103
+ * where strings belong, a boolean for the verdict, and no more rows than a
104
+ * manifest may carry. What is kept is only what the deck's dialog draws.
105
+ */
106
+ export function offered(list) {
107
+ return (Array.isArray(list) ? list : [])
108
+ .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
109
+ .slice(0, 50)
110
+ .map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
111
+ }
112
+
98
113
  /**
99
114
  * One deck's LAN sync, from settings to a healed account.
100
115
  *
@@ -118,10 +133,13 @@ export function createEngine({
118
133
  * with. Nothing secret leaves, and it is still a test shouting at an office.
119
134
  */
120
135
  createSocket,
136
+ /** This deck's own card — its version and its machine — handed to every
137
+ * paired deck and to nobody else. See lan-about.mjs. */
138
+ about = null,
121
139
  } = {}) {
122
140
  let cfg = {
123
141
  enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
124
- autoAsk: true, autoAccept: true,
142
+ autoAsk: true, autoAccept: true, aliases: {},
125
143
  };
126
144
  let identity = null;
127
145
  let beacon = null;
@@ -166,6 +184,16 @@ export function createEngine({
166
184
  /** What the last round did, for the panel. Not a log: one line per peer, most
167
185
  * recent only, because "what happened" is a question about now. */
168
186
  const lastRound = new Map();
187
+ /** What each paired deck said about itself — version, operating system,
188
+ * architecture — keyed by fingerprint, most recent only. Filled from both
189
+ * directions: the manifest a deck answers with, and the question a deck that
190
+ * calls in asks. See lan-about.mjs. */
191
+ const aboutBy = new Map();
192
+ /** The accounts each paired deck offered in its last manifest, keyed by
193
+ * fingerprint. Kept APART from lastRound on purpose: a round that fails
194
+ * replaces that line, and the list a deck offered a minute ago is still the
195
+ * best answer to "what does it share" while it is unreachable. */
196
+ const offersBy = new Map();
169
197
  /** When the last round FINISHED, whatever it did or failed to do. The panel's
170
198
  * `↻` fires one on demand and the loop fires one on its own; a reader who
171
199
  * pressed it wants to know it happened, and a reader who did not wants to
@@ -212,6 +240,15 @@ export function createEngine({
212
240
  }));
213
241
  };
214
242
 
243
+ /** This deck's card for one connection, as a frame field — or nothing, for a
244
+ * deck built without one. Spread into the frame, so a deck from before this
245
+ * existed receives exactly the frame it always did plus one key it never
246
+ * reads. */
247
+ const cardFor = (key, toFp) => {
248
+ const sealed = sealAbout(key, about, identity.fp, toFp);
249
+ return sealed ? { about: sealed } : {};
250
+ };
251
+
215
252
  /** Frames from a deck that finished the handshake AND that somebody here has
216
253
  * accepted. Nothing reaches this before both, which is the whole point of
217
254
  * where the two checks sit. `ctx.key` is this connection's key and no other
@@ -222,8 +259,16 @@ export function createEngine({
222
259
  if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
223
260
  try {
224
261
  if (msg.t === "manifest") {
262
+ // THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
263
+ // that calls in ever says what it is: nothing here dials it, so nothing
264
+ // here ever asks. A seal that does not open is a deck that said nothing.
265
+ const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
266
+ if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
225
267
  const accounts = await localAccounts();
226
- return ctx.send({ t: "manifest", accounts: manifestFor(accounts, cfg.shared) });
268
+ return ctx.send({
269
+ t: "manifest", accounts: manifestFor(accounts, cfg.shared),
270
+ ...cardFor(ctx.key, ctx.peerFp),
271
+ });
227
272
  }
228
273
  if (msg.t === "want") {
229
274
  // A SECOND PROOF, for the one operation that moves a credential. The
@@ -313,13 +358,18 @@ export function createEngine({
313
358
 
314
359
  if (!trustedPeer(cfg.trusted, conn.peerFp)) {
315
360
  const { list, added } = addTrusted(cfg.trusted, {
316
- fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
361
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
317
362
  });
318
363
  if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
319
364
  }
320
365
 
321
- const theirs = await ask({ t: "manifest" });
366
+ // Our card goes with the question and theirs comes back with the answer
367
+ // — see lan-about.mjs for why it is here and nowhere earlier.
368
+ const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
322
369
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
370
+ const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
371
+ if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
372
+ offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
323
373
  const mine = await localAccounts();
324
374
  // Only accounts I have also ticked. Sharing is mutual by construction:
325
375
  // a peer cannot push an account at me that I never agreed to hold.
@@ -449,7 +499,7 @@ export function createEngine({
449
499
  // Somebody used the token. They are pinned, and the token is retired —
450
500
  // one that pairs twice is one worth stealing twice.
451
501
  onInviteUsed: entry => {
452
- const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
502
+ const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
453
503
  cfg = { ...cfg, trusted: list };
454
504
  invite = null;
455
505
  // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
@@ -612,7 +662,7 @@ export function createEngine({
612
662
  name: cfg.name, myPort: server.port(), code: inv.code,
613
663
  });
614
664
  const { list } = addTrusted(cfg.trusted, {
615
- fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
665
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
616
666
  });
617
667
  cfg = { ...cfg, trusted: list };
618
668
  this.addPeer(at.addr, at.port);
@@ -667,7 +717,7 @@ export function createEngine({
667
717
  onChange?.();
668
718
  return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
669
719
  }
670
- const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
720
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
671
721
  cfg = { ...cfg, trusted: list };
672
722
  pending.delete(fp);
673
723
  strangers.delete(fp);
@@ -724,6 +774,24 @@ export function createEngine({
724
774
  return true;
725
775
  },
726
776
  round,
777
+ /**
778
+ * One deck, now — the `check now` in that deck's own dialog.
779
+ *
780
+ * Found the way the list found it: heard on the network under its own
781
+ * fingerprint, or dialled at an address whose answer was that fingerprint.
782
+ * Null when it is neither, which is a deck that only calls in — nothing
783
+ * here holds an address for it, so there is nobody to dial.
784
+ *
785
+ * `roundAt` is left alone: it says when EVERY paired deck was last asked,
786
+ * and asking one of them does not make that true.
787
+ */
788
+ async roundOne(fp) {
789
+ if (!beacon || typeof fp !== "string" || !fp) return null;
790
+ const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
791
+ const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
792
+ const peer = heard ?? typed;
793
+ return peer ? roundWith(peer) : null;
794
+ },
727
795
  /** Dial this address on every round from now on. Returns false for an
728
796
  * address that is not one, rather than storing a row that can never
729
797
  * connect and reports an error every minute forever. */
@@ -758,6 +826,11 @@ export function createEngine({
758
826
  // which on a deck that has just started is the honest answer.
759
827
  checkedAt: roundAt,
760
828
  name: cfg.name,
829
+ // This deck's own card, so the panel can read a peer's version against
830
+ // it; and the names somebody here gave other decks, which the panel
831
+ // and the request dialog draw in place of the ones those decks chose.
832
+ about: about ?? null,
833
+ aliases: { ...(cfg.aliases ?? {}) },
761
834
  // Whether this deck asks on its own, and whether a request that
762
835
  // arrives is answered here or answered for you.
763
836
  autoAsk: !!cfg.autoAsk,
@@ -820,6 +893,15 @@ export function createEngine({
820
893
  had.met = had.met || row.met;
821
894
  if (row.name && !had.name) had.name = row.name;
822
895
  };
896
+ // WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
897
+ // the logins it offered last, and when somebody here said yes. All
898
+ // three are keyed by the fingerprint that proved itself, so both
899
+ // halves of a merged row read the same answer.
900
+ const card = id => ({
901
+ about: aboutBy.get(id) ?? null,
902
+ offers: offersBy.get(id) ?? null,
903
+ pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
904
+ });
823
905
  for (const p of [...beacon.peers.values(), ...manual.values()]) {
824
906
  if (!stillListed(p, now())) continue;
825
907
  const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
@@ -834,6 +916,7 @@ export function createEngine({
834
916
  met: !!met,
835
917
  paired: !!trustedPeer(cfg.trusted, id),
836
918
  last: lastRound.get(p.fp) ?? null,
919
+ ...card(id),
837
920
  });
838
921
  }
839
922
  // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
@@ -592,7 +592,11 @@ export function addTrusted(trusted, entry) {
592
592
  added: false,
593
593
  };
594
594
  }
595
- return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "" }], added: true };
595
+ // WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
596
+ // was already trusted keeps whatever date it had, and one pinned before this
597
+ // field existed keeps having none rather than being given today's.
598
+ const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
599
+ return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
596
600
  }
597
601
 
598
602
  /** Take one back out. Unpairing stops what has not happened yet and takes back
@@ -0,0 +1,40 @@
1
+ // Whether somebody is looking at the deck right now, as its tabs say.
2
+ //
3
+ // The server can tell whether a page is CONNECTED — `sseClients.size` — but not
4
+ // whether anybody is looking at one: a tab behind other windows keeps its
5
+ // stream open all day. The away-update (auto-update.mjs) needs the second fact,
6
+ // so each tab reports it (src/web/presence.ts), and a claim expires here unless
7
+ // it is renewed, because a tab that crashed or lost its network never gets to
8
+ // take it back.
9
+ export const PRESENCE_TTL_MS = 45_000;
10
+ /** More tabs than anybody opens on one deck. A bound because the ids arrive
11
+ * from pages; past it the oldest claim goes. */
12
+ const MAX_TABS = 32;
13
+ const TAB_ID = /^[\w-]{1,64}$/;
14
+
15
+ export function createPresence({ ttlMs = PRESENCE_TTL_MS } = {}) {
16
+ /** tab id → when it last said it was looking */
17
+ const seen = new Map();
18
+ return {
19
+ /** A tab saying whether it is being looked at. False for an id no page
20
+ * could have made, which the route answers with a 400. */
21
+ report(tab, looking, now) {
22
+ if (typeof tab !== "string" || !TAB_ID.test(tab)) return false;
23
+ seen.delete(tab);
24
+ if (looking) {
25
+ seen.set(tab, now);
26
+ while (seen.size > MAX_TABS) seen.delete(seen.keys().next().value);
27
+ }
28
+ return true;
29
+ },
30
+ /** Whether any tab's claim is still live. A claim stamped by a clock that
31
+ * has since moved either way is as expired as an old one. */
32
+ looking(now) {
33
+ for (const [tab, at] of seen) {
34
+ if (Math.abs(now - at) > ttlMs) seen.delete(tab);
35
+ else return true;
36
+ }
37
+ return false;
38
+ },
39
+ };
40
+ }