agent-comms 8.15.9 → 8.15.11

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.
Files changed (189) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/server.d.ts.map +1 -1
  5. package/dist/bridges/user/web/server.js +44 -24
  6. package/dist/bridges/user/web/server.js.map +1 -1
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  10. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  14. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  15. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  16. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  17. package/dist/bridges/user/web/web-assets.js +67 -0
  18. package/dist/bridges/user/web/web-assets.js.map +1 -0
  19. package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
  20. package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
  21. package/dist/test/pi-manifest-packed.integration.test.js +68 -0
  22. package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
  23. package/package.json +8 -2
  24. package/src/account-cli.ts +195 -0
  25. package/src/account-join-cli.ts +191 -0
  26. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  27. package/src/bridges/cc-peer/bridge.ts +138 -0
  28. package/src/bridges/cc-peer/default-front.ts +58 -0
  29. package/src/bridges/cc-peer/front-controller.ts +160 -0
  30. package/src/bridges/cc-peer/front-relay.ts +191 -0
  31. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  32. package/src/bridges/cc-peer/front.ts +59 -0
  33. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  34. package/src/bridges/cc-peer/run.ts +100 -0
  35. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  36. package/src/bridges/claude-code/channel.ts +318 -0
  37. package/src/bridges/codex/tool.ts +132 -0
  38. package/src/bridges/mcp/server.ts +137 -0
  39. package/src/bridges/opencode/plugin.ts +130 -0
  40. package/src/bridges/pi/extension.ts +330 -0
  41. package/src/bridges/registry.ts +17 -0
  42. package/src/bridges/user/cli.ts +87 -0
  43. package/src/bridges/user/controller.ts +345 -0
  44. package/src/bridges/user/tui.ts +300 -0
  45. package/src/bridges/user/web/actions.ts +173 -0
  46. package/src/bridges/user/web/contract.ts +222 -0
  47. package/src/bridges/user/web/dist/bundle.js +49 -0
  48. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  49. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  50. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  51. package/src/bridges/user/web/dist/index.html +21 -0
  52. package/src/bridges/user/web/dist/manifest.json +26 -0
  53. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  54. package/src/bridges/user/web/dist/sw.js +1 -0
  55. package/src/bridges/user/web/event-publisher.ts +114 -0
  56. package/src/bridges/user/web/port-discovery.ts +49 -0
  57. package/src/bridges/user/web/reads.ts +35 -0
  58. package/src/bridges/user/web/router.ts +158 -0
  59. package/src/bridges/user/web/server.ts +782 -0
  60. package/src/bridges/user/web/vite.config.ts +71 -0
  61. package/src/bridges/user/web/web-access.ts +404 -0
  62. package/src/bridges/user/web/web-assets.ts +90 -0
  63. package/src/bridges/user/web/web-beacon.ts +69 -0
  64. package/src/bridges/user/web/web-console-static.ts +90 -0
  65. package/src/cli-main.ts +635 -0
  66. package/src/cli.ts +13 -0
  67. package/src/core/account-bundle.ts +264 -0
  68. package/src/core/account-join.ts +289 -0
  69. package/src/core/account-ledger-crypto.ts +154 -0
  70. package/src/core/account-ledger-store.ts +66 -0
  71. package/src/core/account-ledger.ts +385 -0
  72. package/src/core/agent-details.ts +216 -0
  73. package/src/core/agent-list-format.ts +97 -0
  74. package/src/core/agent-registry.ts +282 -0
  75. package/src/core/agent-table.ts +58 -0
  76. package/src/core/atomic-file.ts +46 -0
  77. package/src/core/bind-retry.ts +38 -0
  78. package/src/core/bridge-mesh.ts +176 -0
  79. package/src/core/bridge.ts +718 -0
  80. package/src/core/capability-ask.ts +162 -0
  81. package/src/core/capability-bubble-up.ts +237 -0
  82. package/src/core/comms-store.ts +130 -0
  83. package/src/core/connection-approval.ts +134 -0
  84. package/src/core/connection-code-tool.ts +92 -0
  85. package/src/core/connection-code.ts +296 -0
  86. package/src/core/coordinator-role.ts +224 -0
  87. package/src/core/data-directory.ts +32 -0
  88. package/src/core/data-frame-routing.ts +309 -0
  89. package/src/core/default-command.ts +12 -0
  90. package/src/core/delegation-policy.ts +41 -0
  91. package/src/core/delivery-engine.ts +649 -0
  92. package/src/core/device-membership-verification.ts +125 -0
  93. package/src/core/device-membership.ts +130 -0
  94. package/src/core/directory-admission.ts +155 -0
  95. package/src/core/discovery-mdns.ts +247 -0
  96. package/src/core/discovery-tailscale.ts +224 -0
  97. package/src/core/discovery.ts +303 -0
  98. package/src/core/display-name.ts +73 -0
  99. package/src/core/dm-grant-actions.ts +72 -0
  100. package/src/core/dm-send-delegation.ts +73 -0
  101. package/src/core/dm-token-verification.ts +76 -0
  102. package/src/core/election-sessions.ts +135 -0
  103. package/src/core/error-reporting.ts +85 -0
  104. package/src/core/first-contact.ts +242 -0
  105. package/src/core/gateway-trust-actions.ts +184 -0
  106. package/src/core/gateway-trust.ts +305 -0
  107. package/src/core/gossip-directory.ts +276 -0
  108. package/src/core/gossip-extensions.ts +123 -0
  109. package/src/core/group-proofs.ts +205 -0
  110. package/src/core/handshake.ts +354 -0
  111. package/src/core/hub-forwarding.ts +24 -0
  112. package/src/core/hub-link.ts +176 -0
  113. package/src/core/hub-session.ts +374 -0
  114. package/src/core/identity-store.ts +715 -0
  115. package/src/core/identity.ts +489 -0
  116. package/src/core/index.ts +65 -0
  117. package/src/core/issuer-grouping.ts +165 -0
  118. package/src/core/issuer-identity-file.ts +187 -0
  119. package/src/core/lan-web-actions.ts +34 -0
  120. package/src/core/lan-web-uis.ts +55 -0
  121. package/src/core/ledger-lock.ts +81 -0
  122. package/src/core/listener-registry.ts +133 -0
  123. package/src/core/loopback-address.ts +22 -0
  124. package/src/core/machine-identity.ts +77 -0
  125. package/src/core/machine-list-groups.ts +53 -0
  126. package/src/core/mcp-registry-retry.ts +69 -0
  127. package/src/core/mcp-registry-version.ts +18 -0
  128. package/src/core/membership-proof.ts +139 -0
  129. package/src/core/membership-proofs.ts +149 -0
  130. package/src/core/mesh-graph-trace-tool.ts +81 -0
  131. package/src/core/mesh-graph.ts +89 -0
  132. package/src/core/mesh-network-actions.ts +183 -0
  133. package/src/core/mesh-store-options.ts +23 -0
  134. package/src/core/mesh-store-shared.ts +134 -0
  135. package/src/core/mesh-store.ts +1114 -0
  136. package/src/core/name-claim.ts +145 -0
  137. package/src/core/naming-actions.ts +90 -0
  138. package/src/core/naming.ts +138 -0
  139. package/src/core/nanoid.ts +20 -0
  140. package/src/core/network-interfaces.ts +24 -0
  141. package/src/core/package-version.ts +34 -0
  142. package/src/core/peer-lifecycle.ts +377 -0
  143. package/src/core/peer-link-error.ts +41 -0
  144. package/src/core/peer-versions.ts +98 -0
  145. package/src/core/pending-connection.ts +53 -0
  146. package/src/core/petnames.ts +121 -0
  147. package/src/core/pgp-keyserver.ts +32 -0
  148. package/src/core/pid-lock-file.ts +122 -0
  149. package/src/core/push-manager.ts +76 -0
  150. package/src/core/random-id.ts +9 -0
  151. package/src/core/receiver-set.ts +42 -0
  152. package/src/core/relay-role.ts +182 -0
  153. package/src/core/relay-selection.ts +120 -0
  154. package/src/core/relay-server.ts +198 -0
  155. package/src/core/relay-uplink.ts +78 -0
  156. package/src/core/request-timeouts.ts +37 -0
  157. package/src/core/room-discovery.ts +48 -0
  158. package/src/core/room-lifecycle.ts +838 -0
  159. package/src/core/room-lookup.ts +41 -0
  160. package/src/core/room-messaging.ts +247 -0
  161. package/src/core/room-notice-log.ts +61 -0
  162. package/src/core/room-path.ts +84 -0
  163. package/src/core/room-protocol.ts +893 -0
  164. package/src/core/room-router.ts +180 -0
  165. package/src/core/room-token-verification.ts +85 -0
  166. package/src/core/room-wire-extensions.ts +60 -0
  167. package/src/core/self-name-claim.ts +67 -0
  168. package/src/core/send-outcome.ts +69 -0
  169. package/src/core/shutdown-signals.ts +57 -0
  170. package/src/core/stale-agent-checker.ts +108 -0
  171. package/src/core/store.ts +704 -0
  172. package/src/core/token-text.ts +40 -0
  173. package/src/core/tool.ts +853 -0
  174. package/src/core/transport-events.ts +73 -0
  175. package/src/core/transport-queries.ts +54 -0
  176. package/src/core/transport.ts +367 -0
  177. package/src/core/types.ts +550 -0
  178. package/src/core/user-identity.ts +290 -0
  179. package/src/core/vapid.ts +81 -0
  180. package/src/core/version-check.ts +147 -0
  181. package/src/core/version-report-actions.ts +79 -0
  182. package/src/core/web-beacon.ts +31 -0
  183. package/src/core/web-push.ts +322 -0
  184. package/src/core/wire-mesh-core-version.ts +25 -0
  185. package/src/core/wire-mesh-identity.ts +36 -0
  186. package/src/core/wire-mesh-transport-options.ts +82 -0
  187. package/src/core/wire-mesh-transport.ts +1007 -0
  188. package/src/core/wire-protocol.ts +153 -0
  189. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Petnames (agent-comms#345): the viewer's own labels for device-ids, the third naming tier beside a subject's self-asserted name and the short id. A petname lives only in the viewer's own storage and is never gossiped, never put in an advert and never sent to a peer, which is why it needs no trust decision and has no collision problem: it is nobody's claim but yours.
3
+ *
4
+ * Constructed with an identity location, the map is one JSON file per identity directory (~/.agent-comms/petnames.json by default), shared by every bridge the viewer runs on the machine, like the gateway trust file, and read afresh on every call, so a label set in one bridge is seen by the others at once. Without a location it is in memory only, which is what most tests use.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import {
10
+ resolveDataDir,
11
+ type DataDirectoryLocation,
12
+ } from "./data-directory.js";
13
+ import { writeFileAtomic } from "./atomic-file.js";
14
+ import { parseDisplayName } from "./display-name.js";
15
+ import { isDeviceIdHex } from "./room-path.js";
16
+ import { CommsError, requireDisplayName } from "./store.js";
17
+
18
+ /** Owner-only read and write: what a person calls their contacts is theirs. */
19
+ const PETNAMES_FILE_MODE = 0o600;
20
+ /** Owner-only directory, matching identity-store.ts's own base directory. */
21
+ const PETNAMES_DIR_MODE = 0o700;
22
+
23
+ export interface PetnamesLocation extends DataDirectoryLocation {
24
+ /** Directory override; defaults to ~/.agent-comms. */
25
+ dir?: string | undefined;
26
+ }
27
+
28
+ /** Narrows a caught value to Node's own errno-carrying Error subtype, so ENOENT can be told apart from every other read failure without an `as` assertion. */
29
+ function isErrnoException(value: unknown): value is NodeJS.ErrnoException {
30
+ return value instanceof Error && "code" in value;
31
+ }
32
+
33
+ /** The labels in a parsed petnames file: every entry keyed by a full device-id whose label parseDisplayName accepts, stored as it returns it. Any other entry (a hand-edited typo, a damaged value) is left out on its own, so it never costs the valid labels beside it. Throws when the file's top level is not a JSON object, since then no entry in it can be read at all. */
34
+ function labelsIn(file: string, parsed: unknown): Map<string, string> {
35
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
36
+ throw new Error(`${file} does not hold a JSON object of petnames`);
37
+ }
38
+ const labels = new Map<string, string>();
39
+ for (const [device, raw] of Object.entries(parsed)) {
40
+ if (!isDeviceIdHex(device) || typeof raw !== "string") continue;
41
+ const label = parseDisplayName(raw);
42
+ if (label !== undefined) labels.set(device, label);
43
+ }
44
+ return labels;
45
+ }
46
+
47
+ export class Petnames {
48
+ private readonly file: string | undefined;
49
+ private readonly memory = new Map<string, string>();
50
+
51
+ constructor(location?: Readonly<PetnamesLocation>) {
52
+ this.file =
53
+ location === undefined
54
+ ? undefined
55
+ : path.join(
56
+ resolveDataDir({ dir: location.dataDir ?? location.dir }),
57
+ "petnames.json",
58
+ );
59
+ }
60
+
61
+ /** Every label the viewer has set, keyed by lowercase device-id. A missing file means nothing is labelled yet. An entry that is not a usable label for a full device-id is left out (labelsIn). Throws when the file exists but cannot be read or is not a JSON object, rather than treating it as empty, since the next set or clear would then write that empty map over every label in it. */
62
+ list(): Map<string, string> {
63
+ if (this.file === undefined) return new Map(this.memory);
64
+ let raw: string;
65
+ try {
66
+ raw = fs.readFileSync(this.file, "utf-8");
67
+ } catch (err) {
68
+ if (isErrnoException(err) && err.code === "ENOENT") return new Map();
69
+ throw err;
70
+ }
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch (err) {
75
+ throw new Error(`${this.file} is not valid JSON`, { cause: err });
76
+ }
77
+ return labelsIn(this.file, parsed);
78
+ }
79
+
80
+ /** Labels deviceHex (a full device-id, hex, case-insensitive) as name, replacing any earlier label. Throws INVALID_DEVICE for anything but a full device-id, since a label keyed by a typo or a short id would apply to nothing, and INVALID_NAME for a name parseDisplayName refuses. Returns the label as stored. */
81
+ set(deviceHex: string, name: string): string {
82
+ const device = deviceHex.toLowerCase();
83
+ if (!isDeviceIdHex(device)) {
84
+ throw new CommsError(
85
+ `${JSON.stringify(deviceHex)} is not a device-id: pass the full 64-character hex id.`,
86
+ "INVALID_DEVICE",
87
+ );
88
+ }
89
+ const label = requireDisplayName(name, "A petname");
90
+ const labels = this.list();
91
+ labels.set(device, label);
92
+ this.save(labels);
93
+ return label;
94
+ }
95
+
96
+ /** Removes deviceHex's label. Returns whether there was one. */
97
+ clear(deviceHex: string): boolean {
98
+ const labels = this.list();
99
+ const removed = labels.delete(deviceHex.toLowerCase());
100
+ if (removed) this.save(labels);
101
+ return removed;
102
+ }
103
+
104
+ private save(labels: ReadonlyMap<string, string>): void {
105
+ if (this.file === undefined) {
106
+ this.memory.clear();
107
+ for (const [device, label] of labels) this.memory.set(device, label);
108
+ return;
109
+ }
110
+ fs.mkdirSync(path.dirname(this.file), {
111
+ recursive: true,
112
+ mode: PETNAMES_DIR_MODE,
113
+ });
114
+ // Atomic, so a bridge reading concurrently sees the old map or the new one, never a partial file.
115
+ writeFileAtomic(
116
+ this.file,
117
+ `${JSON.stringify(Object.fromEntries(labels), null, 2)}\n`,
118
+ PETNAMES_FILE_MODE,
119
+ );
120
+ }
121
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Fetches an armored OpenPGP public key from keys.openpgp.org's Verifying Key Server (VKS) HTTPS API by exact fingerprint (agent-comms#188) -- the convenience path a connection-code redeemer takes when they know the signer's fingerprint but weren't handed the armored key block directly.
3
+ *
4
+ * The keyserver is never a trust anchor here: the fingerprint the caller supplies is already the thing they independently trust (per connection-code.ts's own doc comment), and this function's only job is turning that fingerprint into bytes. It does not, by itself, prove the fetched key actually has that fingerprint -- ConnectionCodeLedger.redeem's own expectedFingerprint check (comparing the openpgp-computed fingerprint of whatever key actually verifies the signature against what the caller asked for) is what defends against a compromised or buggy keyserver returning the wrong key; this function only saves the caller a manual copy-paste when they already know the fingerprint but not the key text.
5
+ */
6
+
7
+ const KEYSERVER_BASE_URL = "https://keys.openpgp.org/vks/v1/by-fingerprint";
8
+
9
+ /** The minimal fetch shape this module actually calls -- a single URL argument returning a Response -- rather than the full `typeof fetch` (which also carries a `preconnect` static and an overload taking a Request/init object neither caller nor test needs), so a test's fake implementation isn't forced to shim unrelated fetch surface it never exercises. */
10
+ export type FetchLike = (url: string) => Promise<Response>;
11
+
12
+ /** Strips whitespace/colons and uppercases a fingerprint for the keyserver's own URL path convention -- mirrors connection-code.ts's normalizeFingerprint, kept separate since that one lowercases for comparison rather than uppercasing for a URL. */
13
+ function toKeyserverPathSegment(fingerprint: string): string {
14
+ return fingerprint.replace(/[\s:]+/g, "").toUpperCase();
15
+ }
16
+
17
+ /**
18
+ * Fetches the armored public key for `fingerprint` from keys.openpgp.org. Throws if the lookup fails (not found, network error, non-2xx response) -- there is no fallback value, since a caller who asked for this fingerprint by name needs to know definitively whether it was found, not silently proceed with nothing.
19
+ */
20
+ export async function fetchPgpPublicKeyByFingerprint(
21
+ fingerprint: string,
22
+ fetchImpl: FetchLike = fetch,
23
+ ): Promise<string> {
24
+ const segment = toKeyserverPathSegment(fingerprint);
25
+ const response = await fetchImpl(`${KEYSERVER_BASE_URL}/${segment}`);
26
+ if (!response.ok) {
27
+ throw new Error(
28
+ `Keyserver lookup for fingerprint ${segment} failed: HTTP ${String(response.status)}`,
29
+ );
30
+ }
31
+ return await response.text();
32
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * A lock file holding the PID of the process that claimed it: the exclusivity guard for a bridge's identity slot (identity-store.ts) and for appends to the machine's account ledger (ledger-lock.ts). A lock whose recorded process has died is stale and may be taken over, so a holder that crashed never wedges the lock.
3
+ *
4
+ * Every claim writes the PID plus a random nonce, so two claims never leave identical content even when a PID is reused. Removing a stale lock is the one step that could delete a newer, live claim by mistake (the stale file can be released and re-claimed between reading it and removing it), so it happens only under a second, takeover lock file, and only when the lock still holds exactly the content that was read and judged stale. A live holder releasing its own lock never needs the takeover lock: only its own claim can be at that name while it holds it.
5
+ */
6
+
7
+ import { randomBytes } from "node:crypto";
8
+ import * as fs from "node:fs";
9
+
10
+ /** Random bytes in each claim's content, enough that two claims never collide. */
11
+ const CLAIM_NONCE_BYTES = 16;
12
+
13
+ export function isPidAlive(pid: number): boolean {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function readLockContent(lockFile: string): string | undefined {
23
+ try {
24
+ return fs.readFileSync(lockFile, "utf-8");
25
+ } catch (err) {
26
+ if (isErrorCode(err, "ENOENT")) return undefined;
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ function pidOf(content: string): number | undefined {
32
+ const pid = Number.parseInt(content.trim(), 10);
33
+ return Number.isInteger(pid) ? pid : undefined;
34
+ }
35
+
36
+ /** Read the PID holding the lock, or undefined when absent or unreadable. */
37
+ export function readLockPid(lockFile: string): number | undefined {
38
+ const content = readLockContent(lockFile);
39
+ return content === undefined ? undefined : pidOf(content);
40
+ }
41
+
42
+ function isErrorCode(err: unknown, code: string): boolean {
43
+ if (!(err instanceof Error) || !("code" in err)) return false;
44
+ return err.code === code;
45
+ }
46
+
47
+ /**
48
+ * Atomically claims `file` for this process via write-temp-then-hardlink: link() is POSIX-guaranteed atomic and exclusive (it fails with EEXIST if the target name already exists), and because the temp file's content is fully written before the link is created, the file's content can never be observed incomplete the instant its name exists, unlike a bare open(O_CREAT|O_EXCL) followed by a separate write(). Returns false when the name is already taken.
49
+ */
50
+ function claimExclusive(file: string): boolean {
51
+ const tmpFile = `${file}.${String(process.pid)}.tmp`;
52
+ fs.writeFileSync(
53
+ tmpFile,
54
+ `${String(process.pid)}\n${randomBytes(CLAIM_NONCE_BYTES).toString("hex")}\n`,
55
+ "utf-8",
56
+ );
57
+ try {
58
+ fs.linkSync(tmpFile, file);
59
+ return true;
60
+ } catch (err) {
61
+ if (!isErrorCode(err, "EEXIST")) throw err;
62
+ return false;
63
+ } finally {
64
+ fs.rmSync(tmpFile, { force: true });
65
+ }
66
+ }
67
+
68
+ /** Whether content names a process that can no longer be holding the lock: one that has died, this process itself (which claims a lock only after releasing it, so a lock naming it is left over from before), or no readable PID at all. */
69
+ function isStale(content: string): boolean {
70
+ const pid = pidOf(content);
71
+ return pid === undefined || pid === process.pid || !isPidAlive(pid);
72
+ }
73
+
74
+ /** Removes `file` if it still holds exactly `content`, the content a caller read and judged stale. Any newer claim has different content (each carries its own nonce), so it is left alone. */
75
+ function removeIfUnchanged(file: string, content: string): void {
76
+ if (readLockContent(file) === content) fs.rmSync(file, { force: true });
77
+ }
78
+
79
+ /**
80
+ * Removes lockFile when it still holds `staleContent`, under the takeover lock so that no other process removes a stale lock at the same moment, which is what makes reading the content and removing the file one step. Returns false when another process holds the takeover lock: it is removing the same stale lock, and the caller retries afterwards. A takeover lock left behind by a process that died inside this function is itself removed the same way, without a takeover lock of its own, so two processes clearing that doubly crashed state at the same instant could both go on to remove lockFile; both would still see the same stale content there, so neither removes a live claim.
81
+ */
82
+ function removeStaleLock(lockFile: string, staleContent: string): boolean {
83
+ const takeoverFile = `${lockFile}.takeover`;
84
+ if (!claimExclusive(takeoverFile)) {
85
+ const takeover = readLockContent(takeoverFile);
86
+ if (takeover !== undefined && isStale(takeover)) {
87
+ removeIfUnchanged(takeoverFile, takeover);
88
+ }
89
+ return false;
90
+ }
91
+ try {
92
+ removeIfUnchanged(lockFile, staleContent);
93
+ return true;
94
+ } finally {
95
+ fs.rmSync(takeoverFile, { force: true });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Attempts to claim lockFile for this process. Returns the current holder when a different live process holds it. A lock found missing after a failed claim was released in between, so it is claimed again rather than treated as stale; a stale lock (see isStale) is taken over only through removeStaleLock, so a claim another process made after the stale one was read is never deleted. Losing any of these races to another process reports that process as the holder.
101
+ */
102
+ export function tryAcquireLock(
103
+ lockFile: string,
104
+ ): { acquired: true } | { acquired: false; heldBy: number | undefined } {
105
+ if (claimExclusive(lockFile)) return { acquired: true };
106
+ const content = readLockContent(lockFile);
107
+ if (content !== undefined && !isStale(content)) {
108
+ return { acquired: false, heldBy: pidOf(content) };
109
+ }
110
+ if (content !== undefined && !removeStaleLock(lockFile, content)) {
111
+ return { acquired: false, heldBy: readLockPid(lockFile) };
112
+ }
113
+ if (claimExclusive(lockFile)) return { acquired: true };
114
+ return { acquired: false, heldBy: readLockPid(lockFile) };
115
+ }
116
+
117
+ /** Removes lockFile if this process holds it; a lock another PID has since taken over is left alone. The release half of tryAcquireLock. */
118
+ export function releaseLockFile(lockFile: string): void {
119
+ if (readLockPid(lockFile) === process.pid) {
120
+ fs.rmSync(lockFile, { force: true });
121
+ }
122
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Push subscription manager — tracks browser push subscriptions and
3
+ * dispatches Web Push notifications when agents are offline.
4
+ *
5
+ * The PushManager is a standalone module — MeshStore doesn't know about it.
6
+ * The web server bridge checks whether the target agent's WebSocket is
7
+ * disconnected and falls back to push when a subscription exists.
8
+ */
9
+
10
+ import type { VapidKeys } from "./vapid.js";
11
+ import { generateVapidKeys } from "./vapid.js";
12
+ import {
13
+ sendWebPush,
14
+ type PushSubscription,
15
+ type PushPayload,
16
+ } from "./web-push.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // PushManager
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export class PushManager {
23
+ private readonly vapidKeys: VapidKeys;
24
+ private readonly subject: string;
25
+ private readonly subscriptions = new Map<string, PushSubscription>();
26
+
27
+ constructor(subject = "mailto:agent-comms@localhost") {
28
+ this.vapidKeys = generateVapidKeys();
29
+ this.subject = subject;
30
+ }
31
+
32
+ /**
33
+ * Register a push subscription for a given agent ID.
34
+ * Overwrites any existing subscription for the same agent.
35
+ */
36
+ addSubscription(agentId: string, subscription: PushSubscription): void {
37
+ this.subscriptions.set(agentId, subscription);
38
+ }
39
+
40
+ /**
41
+ * Remove a push subscription.
42
+ */
43
+ removeSubscription(agentId: string): void {
44
+ this.subscriptions.delete(agentId);
45
+ }
46
+
47
+ /**
48
+ * Check whether a push subscription exists for the given agent.
49
+ */
50
+ hasSubscription(agentId: string): boolean {
51
+ return this.subscriptions.has(agentId);
52
+ }
53
+
54
+ /**
55
+ * Send a push notification to a subscribed agent.
56
+ *
57
+ * No-ops silently if no subscription exists — callers should check
58
+ * hasSubscription() first if they need to distinguish the two cases.
59
+ */
60
+ async sendPush(
61
+ agentId: string,
62
+ payload: Readonly<PushPayload>,
63
+ ): Promise<void> {
64
+ const subscription = this.subscriptions.get(agentId);
65
+ if (!subscription) return;
66
+
67
+ await sendWebPush(subscription, payload, this.vapidKeys, this.subject);
68
+ }
69
+
70
+ /**
71
+ * Get the VAPID public key for the PWA to use in `pushManager.subscribe()`.
72
+ */
73
+ getPublicKey(): string {
74
+ return this.vapidKeys.publicKey;
75
+ }
76
+ }
@@ -0,0 +1,9 @@
1
+ /** 16 random bytes (128 bits) is the conventional size for an unguessable identifier, matching a UUIDv4's own random payload. */
2
+ const RANDOM_ID_BYTE_LENGTH = 16;
3
+
4
+ /** Generates a random identifier: several of core/room's own fields (token-id, message-id) are defined as an arbitrary-length bstr, and {@link RANDOM_ID_BYTE_LENGTH} random bytes is the conventional size for an unguessable identifier, matching a UUIDv4's own random payload. */
5
+ export function randomId(): Uint8Array<ArrayBuffer> {
6
+ const bytes = new Uint8Array(RANDOM_ID_BYTE_LENGTH);
7
+ crypto.getRandomValues(bytes);
8
+ return bytes;
9
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ReceiverSet: the observers a MeshStore fans one of its events out to (agent-comms#293). A store has a single assignable callback per event (onDelivery, onPatch), so the bridge that owns the store and anything else serving from it, the web UI, would silence each other by assigning last. The callback keeps working, and a ReceiverSet adds any number of observers alongside it.
3
+ */
4
+
5
+ /** A callback for an event whose payload is Args. */
6
+ export type Receiver<Args extends unknown[]> = (
7
+ ...args: Args
8
+ ) => void | Promise<void>;
9
+
10
+ export class ReceiverSet<Args extends unknown[]> {
11
+ private readonly receivers = new Set<Receiver<Args>>();
12
+
13
+ /** Adds an observer and returns a function that removes it. */
14
+ add(receiver: Receiver<Args>): () => void {
15
+ this.receivers.add(receiver);
16
+ return () => {
17
+ this.receivers.delete(receiver);
18
+ };
19
+ }
20
+
21
+ /**
22
+ * The single function to call for an event: the store's own assignable callback, when it has one, and then every observer, or undefined while there is neither, so a caller can still tell nobody is listening. A receiver that throws or rejects is passed to onError and does not stop the others.
23
+ */
24
+ handler(
25
+ primary: Receiver<Args> | undefined,
26
+ onError: (error: unknown) => void,
27
+ ): ((...args: Args) => Promise<void>) | undefined {
28
+ if (primary === undefined && this.receivers.size === 0) return undefined;
29
+ return async (...args) => {
30
+ const all = [
31
+ ...(primary !== undefined ? [primary] : []),
32
+ ...this.receivers,
33
+ ];
34
+ const outcomes = await Promise.allSettled(
35
+ all.map(async (receive) => receive(...args)),
36
+ );
37
+ for (const outcome of outcomes) {
38
+ if (outcome.status === "rejected") onError(outcome.reason);
39
+ }
40
+ };
41
+ }
42
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * RelayRole: one transport's part in the relay set (agent-comms#342), split out of wire-mesh-transport.ts to keep it under the repo's max-lines cap. It holds the store's one hub session over the relay selection prefers (relay-selection.ts), and serves the relay role itself while it is eligible and holds an upstream session.
3
+ *
4
+ * Who serves: the store holding the elected coordinator role (agent-comms#341), and only while its hub session is to the configured public hub, its uplink. Tying serving to that single-holder role is what keeps one relay per machine: if every store holding an upstream session served, each would see the others' offers, move its own session onto one of them, lose its uplink, stop serving, and fall back to the public hub, over and over. For the same reason an eligible store always holds its session to the public hub and never selects another relay, so relays never chain.
5
+ */
6
+
7
+ import { readRelayOffer } from "wire-mesh-core/domain/relay-advert";
8
+ import { HubLink } from "./hub-link.js";
9
+ import type { HubSession } from "./hub-session.js";
10
+ import type { KnownDevices } from "./gossip-directory.js";
11
+ import type { ElectionSessions } from "./election-sessions.js";
12
+ import type { GatewayTrustReader } from "./gateway-trust.js";
13
+ import type { TransportEvents } from "./transport.js";
14
+ import { RelayServer } from "./relay-server.js";
15
+ import { selectRelay, type RelayOffer } from "./relay-selection.js";
16
+
17
+ export interface RelayRoleDeps {
18
+ /** The interface a served relay listens on (RelayServerOptions.host). */
19
+ listenHost: string;
20
+ /** This store's own device-id (hex). */
21
+ ownDeviceHex: string;
22
+ /** Every device this store has heard gossip from, with its latest advert: where relay offers are read from. */
23
+ knownDevices: Readonly<Pick<KnownDevices, "current">>;
24
+ /** The live machine-local sessions: a device with one is a full member of this store's local mesh. */
25
+ machineLocalPeers: Readonly<Pick<ElectionSessions, "hasPeer">>;
26
+ /** Gateway trust, whose reachability check admits a device learned through a hub. */
27
+ gatewayTrust: Readonly<Pick<GatewayTrustReader, "isReachable">>;
28
+ /** Re-sends this store's gossip, so the relay offer it carries changes at once and a new hub session is advertised straight away. */
29
+ readvertise: () => void;
30
+ events: Readonly<Pick<TransportEvents, "onError">>;
31
+ }
32
+
33
+ export class RelayRole {
34
+ private eligible = false;
35
+ private upstream = false;
36
+ private server: RelayServer | undefined;
37
+ /** Serialises start and stop, so a quick eligible/ineligible flip cannot leave two servers, or none where one should exist. */
38
+ private transition: Promise<void> = Promise.resolve();
39
+ /** Holds this store's one hub session over the selected relay, once joinHub has named the configured hub. */
40
+ private hubLink: HubLink | undefined;
41
+ /** The hub session whose connection a served relay takes as its uplink. */
42
+ private hub: HubSession | undefined;
43
+
44
+ constructor(private readonly deps: Readonly<RelayRoleDeps>) {}
45
+
46
+ /** Starts holding the hub session: over the relay selection prefers, with publicHubUrl (the configured hub) as the fallback and as the uplink an eligible store serves from. A second call does nothing. */
47
+ joinHub(
48
+ hub: HubSession,
49
+ publicHubUrl: string,
50
+ shouldConnect: () => boolean,
51
+ ): void {
52
+ if (this.hubLink !== undefined) return;
53
+ this.hub = hub;
54
+ this.hubLink = new HubLink({
55
+ hub,
56
+ selectUrl: (excluded) => this.selectUrl(publicHubUrl, excluded),
57
+ shouldConnect,
58
+ onConnected: (url) => {
59
+ this.setUpstream(url === publicHubUrl);
60
+ this.deps.readvertise();
61
+ },
62
+ onDisconnected: () => {
63
+ this.setUpstream(false);
64
+ },
65
+ onError: (error) => {
66
+ this.deps.events.onError?.(error);
67
+ },
68
+ });
69
+ this.hubLink.start();
70
+ }
71
+
72
+ /** Makes the hub session re-evaluate now whether it is wanted, and over which relay. */
73
+ reconsiderHub(): void {
74
+ this.hubLink?.reconsider();
75
+ }
76
+
77
+ /** The addresses this store currently offers relay service at, or undefined while it serves none. */
78
+ offer(): readonly string[] | undefined {
79
+ return this.server?.addresses();
80
+ }
81
+
82
+ /** How many clients the served relay has, or 0 while none is served. */
83
+ servedConnectionCount(): number {
84
+ return this.server === undefined ? 0 : this.server.connectionCount();
85
+ }
86
+
87
+ /** How many clients the served relay can route to, or 0 while none is served. See RelayServer.routableClientCount. */
88
+ routableClientCount(): number {
89
+ return this.server === undefined ? 0 : this.server.routableClientCount();
90
+ }
91
+
92
+ /** Whether this store may serve: set from the elected coordinator role. An eligible store moves its hub session back onto the uplink at once. */
93
+ setEligible(eligible: boolean): void {
94
+ this.eligible = eligible;
95
+ this.reconcile();
96
+ this.hubLink?.reconsider();
97
+ }
98
+
99
+ /** The URL to hold the hub session over: the public hub for an eligible store (its uplink), otherwise the best relay the policy finds, skipping any offer with an address in excluded (dials that just failed). */
100
+ selectUrl(publicHubUrl: string, excluded: ReadonlySet<string>): string {
101
+ if (this.eligible) return publicHubUrl;
102
+ return selectRelay({
103
+ offers: this.offers().filter((offer) =>
104
+ offer.addresses.every((address) => !excluded.has(address)),
105
+ ),
106
+ isTrusted: (deviceHex) => this.deps.gatewayTrust.isReachable(deviceHex),
107
+ ownDeviceHex: this.deps.ownDeviceHex,
108
+ publicHubUrl,
109
+ }).url;
110
+ }
111
+
112
+ /** Drops the hub session and stops serving, for good. */
113
+ async stop(): Promise<void> {
114
+ await this.hubLink?.stop();
115
+ this.eligible = false;
116
+ this.reconcile();
117
+ await this.transition;
118
+ }
119
+
120
+ /** Whether this store's hub session is currently held to the configured public hub. */
121
+ private setUpstream(upstream: boolean): void {
122
+ this.upstream = upstream;
123
+ this.reconcile();
124
+ }
125
+
126
+ private offers(): RelayOffer[] {
127
+ const offers: RelayOffer[] = [];
128
+ for (const [deviceHex, advert] of this.deps.knownDevices.current()) {
129
+ const addresses = readRelayOffer(advert);
130
+ if (addresses === undefined) continue;
131
+ offers.push({
132
+ deviceHex,
133
+ addresses,
134
+ machineLocal: this.deps.machineLocalPeers.hasPeer(deviceHex),
135
+ });
136
+ }
137
+ return offers;
138
+ }
139
+
140
+ /** Gives the served relay the hub session's connection as its uplink when it has none, which carries its clients to and from devices on the public hub. A session that was replaced since (the hub dropped it and it was redialled) has no uplink yet, so this runs on every reconcile and not only when the relay starts. */
141
+ private frontClients(server: RelayServer): void {
142
+ const hub = this.hub;
143
+ if (hub === undefined || hub.hasUplink) return;
144
+ if (hub.attachUplink((connection) => server.attachUplink(connection))) {
145
+ this.deps.readvertise();
146
+ }
147
+ }
148
+
149
+ private reconcile(): void {
150
+ this.transition = this.transition
151
+ .then(async () => {
152
+ const wanted = this.eligible && this.upstream;
153
+ if (wanted && this.server !== undefined) this.frontClients(this.server);
154
+ if (wanted && this.server === undefined) {
155
+ const server = new RelayServer({
156
+ host: this.deps.listenHost,
157
+ isAdmitted: (deviceHex) =>
158
+ this.deps.machineLocalPeers.hasPeer(deviceHex) ||
159
+ this.deps.gatewayTrust.isReachable(deviceHex),
160
+ onError: (error) => {
161
+ this.deps.events.onError?.(error);
162
+ },
163
+ });
164
+ await server.start();
165
+ this.server = server;
166
+ this.frontClients(server);
167
+ this.deps.readvertise();
168
+ } else if (!wanted && this.server !== undefined) {
169
+ const server = this.server;
170
+ this.server = undefined;
171
+ this.hub?.detachUplink();
172
+ this.deps.readvertise();
173
+ await server.stop();
174
+ }
175
+ })
176
+ .catch((error: unknown) => {
177
+ this.deps.events.onError?.(
178
+ error instanceof Error ? error : new Error(String(error)),
179
+ );
180
+ });
181
+ }
182
+ }