super-dsh 0.1.5 → 0.1.9

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 (79) hide show
  1. package/client/index.js +1010 -345
  2. package/cordis.patch.yml +22 -5
  3. package/package.json +8 -3
  4. package/superd-hub/dist/a2a-delivery.d.ts +189 -0
  5. package/superd-hub/dist/a2a-delivery.js +240 -0
  6. package/superd-hub/dist/a2a-host.d.ts +76 -0
  7. package/superd-hub/dist/a2a-host.js +145 -0
  8. package/superd-hub/dist/a2a-ingest.d.ts +31 -0
  9. package/superd-hub/dist/a2a-ingest.js +636 -0
  10. package/superd-hub/dist/a2a-log.d.ts +17 -0
  11. package/superd-hub/dist/a2a-log.js +42 -0
  12. package/superd-hub/dist/a2a-row.d.ts +65 -0
  13. package/superd-hub/dist/a2a-row.js +162 -0
  14. package/superd-hub/dist/a2a-service.d.ts +357 -0
  15. package/superd-hub/dist/a2a-service.js +980 -0
  16. package/superd-hub/dist/a2a-wire.d.ts +220 -0
  17. package/superd-hub/dist/a2a-wire.js +305 -0
  18. package/superd-hub/dist/agent-ids.d.ts +10 -0
  19. package/superd-hub/dist/agent-ids.js +68 -0
  20. package/superd-hub/dist/agent-roster.d.ts +32 -17
  21. package/superd-hub/dist/agent-roster.js +54 -15
  22. package/superd-hub/dist/agent-status.d.ts +119 -0
  23. package/superd-hub/dist/agent-status.js +386 -0
  24. package/superd-hub/dist/client/mesh-api.d.ts +134 -0
  25. package/superd-hub/dist/client/mesh-api.js +219 -0
  26. package/superd-hub/dist/client/mesh-groups.d.ts +63 -0
  27. package/superd-hub/dist/client/mesh-groups.js +66 -0
  28. package/superd-hub/dist/client/roster.d.ts +48 -0
  29. package/superd-hub/dist/client/roster.js +96 -0
  30. package/superd-hub/dist/exec-pty-channel.d.ts +58 -0
  31. package/superd-hub/dist/exec-pty-channel.js +162 -0
  32. package/superd-hub/dist/exec-pty-framing.d.ts +32 -0
  33. package/superd-hub/dist/exec-pty-framing.js +81 -0
  34. package/superd-hub/dist/exec-pty-rpc.d.ts +21 -0
  35. package/superd-hub/dist/exec-pty-rpc.js +128 -0
  36. package/superd-hub/dist/exec-pty-session.d.ts +76 -0
  37. package/superd-hub/dist/exec-pty-session.js +234 -0
  38. package/superd-hub/dist/exec.d.ts +56 -0
  39. package/superd-hub/dist/exec.js +225 -0
  40. package/superd-hub/dist/fusion-transport.d.ts +23 -21
  41. package/superd-hub/dist/fusion-transport.js +12 -40
  42. package/superd-hub/dist/gateway.d.ts +39 -2
  43. package/superd-hub/dist/gateway.js +62 -3
  44. package/superd-hub/dist/hub-tool.d.ts +174 -0
  45. package/superd-hub/dist/hub-tool.js +464 -0
  46. package/superd-hub/dist/index.d.ts +18 -4
  47. package/superd-hub/dist/index.js +19 -3
  48. package/superd-hub/dist/machines.d.ts +45 -0
  49. package/superd-hub/dist/machines.js +125 -0
  50. package/superd-hub/dist/mesh-federate.d.ts +138 -0
  51. package/superd-hub/dist/mesh-federate.js +223 -0
  52. package/superd-hub/dist/mesh-gateway.d.ts +67 -0
  53. package/superd-hub/dist/mesh-gateway.js +408 -0
  54. package/superd-hub/dist/mesh-link.d.ts +274 -0
  55. package/superd-hub/dist/mesh-link.js +441 -0
  56. package/superd-hub/dist/mesh-rpc.d.ts +123 -0
  57. package/superd-hub/dist/mesh-rpc.js +250 -0
  58. package/superd-hub/dist/mux-client.d.ts +71 -0
  59. package/superd-hub/dist/mux-client.js +281 -0
  60. package/superd-hub/dist/pack-version.d.ts +2 -0
  61. package/superd-hub/dist/pack-version.js +30 -0
  62. package/superd-hub/dist/roster.d.ts +10 -0
  63. package/superd-hub/dist/roster.js +9 -1
  64. package/superd-hub/dist/rpc.d.ts +18 -2
  65. package/superd-hub/dist/rpc.js +34 -4
  66. package/superd-hub/dist/spawn-world.js +3 -1
  67. package/superd-hub/dist/switch-core.d.ts +98 -21
  68. package/superd-hub/dist/switch-core.js +123 -28
  69. package/superd-hub/dist/system-head.d.ts +22 -12
  70. package/superd-hub/dist/system-head.js +100 -84
  71. package/superd-hub/dist/toast-core.d.ts +43 -0
  72. package/superd-hub/dist/toast-core.js +63 -0
  73. package/superd-hub/dist/wire-endpoints.d.ts +264 -0
  74. package/superd-hub/dist/wire-endpoints.js +180 -0
  75. package/superd-hub/dist/world-join.js +8 -3
  76. package/superd-hub/dist/world-mount.d.ts +27 -0
  77. package/superd-hub/dist/world-mount.js +67 -0
  78. package/superd-hub/lib/client/index.js +1010 -345
  79. package/superd-hub/package.json +11 -2
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Machine registry (mesh P1, spec §3.1/§3.3): the persisted identity of
3
+ * THIS machine (`self`, owner-minted m4 per D4) plus the remote machine
4
+ * rows created by pairing.
5
+ *
6
+ * Pure module — no cordis dependencies. All state lives in
7
+ * `<home>/superd/machines.json` (0600, atomic tmp+rename writes); every
8
+ * mutation is a read-modify-write of the whole file with no in-process
9
+ * cache, which is sufficient at P1 machine counts. A missing or
10
+ * unreadable file reads as the empty shape (`self.id === ''` = not yet
11
+ * minted); `ensureSelf` mints lazily on first need and never re-mints.
12
+ */
13
+ import { randomInt } from 'node:crypto';
14
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
17
+ const MINT_MAX_ATTEMPTS = 32;
18
+ function machinesPath(home) {
19
+ return join(home, 'superd', 'machines.json');
20
+ }
21
+ function emptyMachines() {
22
+ return { self: { id: '', label: '' }, remotes: [] };
23
+ }
24
+ function isMachineRow(v) {
25
+ if (typeof v !== 'object' || v === null)
26
+ return false;
27
+ const r = v;
28
+ return (typeof r.id === 'string' && typeof r.name === 'string' && typeof r.label === 'string' &&
29
+ typeof r.origin === 'string' && typeof r.enabled === 'boolean' && typeof r.addedAt === 'number' &&
30
+ (r.token === undefined || typeof r.token === 'string') &&
31
+ (r.cookie === undefined || typeof r.cookie === 'string') &&
32
+ (r.execEnabled === undefined || r.execEnabled === true || r.execEnabled === false));
33
+ }
34
+ /** Mint a random short id over [a-z0-9], re-minting on collision with `taken`; loud after 32 attempts. */
35
+ export function mintShortId(len, taken) {
36
+ const used = new Set(taken);
37
+ for (let attempt = 0; attempt < MINT_MAX_ATTEMPTS; attempt++) {
38
+ let id = '';
39
+ for (let i = 0; i < len; i++)
40
+ id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
41
+ if (!used.has(id))
42
+ return id;
43
+ }
44
+ throw new Error(`machines: short-id space exhausted after ${MINT_MAX_ATTEMPTS} attempts (len=${len})`);
45
+ }
46
+ /** Read the registry; a missing or unreadable/corrupt file degrades to the empty shape (never throws). */
47
+ export function loadMachines(home) {
48
+ let raw;
49
+ try {
50
+ raw = readFileSync(machinesPath(home), 'utf8');
51
+ }
52
+ catch {
53
+ return emptyMachines();
54
+ }
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return emptyMachines();
61
+ }
62
+ if (typeof parsed !== 'object' || parsed === null)
63
+ return emptyMachines();
64
+ const o = parsed;
65
+ const self = o.self;
66
+ if (typeof self !== 'object' || self === null)
67
+ return emptyMachines();
68
+ const s = self;
69
+ if (typeof s.id !== 'string' || typeof s.label !== 'string' || !Array.isArray(o.remotes)) {
70
+ return emptyMachines();
71
+ }
72
+ return { self: { id: s.id, label: s.label }, remotes: o.remotes.filter(isMachineRow) };
73
+ }
74
+ /** Persist the whole registry: recursive mkdir, tmp write, chmod 0600, atomic rename. */
75
+ export function saveMachines(home, file) {
76
+ if (typeof file.self?.id !== 'string' || typeof file.self?.label !== 'string' || !Array.isArray(file.remotes)) {
77
+ throw new TypeError('machines: save rejected — file must be {self: {id, label}, remotes: []}');
78
+ }
79
+ for (const r of file.remotes) {
80
+ if (typeof r?.id !== 'string' || r.id === '' || typeof r.name !== 'string' || r.name === '' ||
81
+ typeof r.origin !== 'string' || r.origin === '') {
82
+ throw new TypeError('machines: save rejected — row id/name/origin must be non-empty strings');
83
+ }
84
+ }
85
+ const dir = join(home, 'superd');
86
+ mkdirSync(dir, { recursive: true });
87
+ const target = machinesPath(home);
88
+ const tmp = `${target}.tmp`;
89
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
90
+ chmodSync(tmp, 0o600);
91
+ renameSync(tmp, target);
92
+ }
93
+ /** Lazily mint + persist the self identity; an already-minted self is returned unchanged. */
94
+ export function ensureSelf(home, fallbackLabel) {
95
+ const file = loadMachines(home);
96
+ if (file.self.id !== '')
97
+ return file.self;
98
+ const taken = new Set([...file.remotes.map((r) => r.id)]);
99
+ const self = { id: mintShortId(4, taken), label: fallbackLabel };
100
+ saveMachines(home, { self, remotes: file.remotes });
101
+ return self;
102
+ }
103
+ /** Snapshot copy of the remote rows — callers can hold it without aliasing the store. */
104
+ export function listRemotes(home) {
105
+ return loadMachines(home).remotes.map((r) => ({ ...r }));
106
+ }
107
+ /** Insert or overwrite (same id) one remote row; full read-modify-write persistence. */
108
+ export function upsertRemote(home, row) {
109
+ const file = loadMachines(home);
110
+ const idx = file.remotes.findIndex((r) => r.id === row.id);
111
+ if (idx === -1)
112
+ file.remotes.push({ ...row });
113
+ else
114
+ file.remotes[idx] = { ...row };
115
+ saveMachines(home, file);
116
+ }
117
+ /** Drop one remote row; returns false when the id was not present. */
118
+ export function removeRemote(home, id) {
119
+ const file = loadMachines(home);
120
+ const remotes = file.remotes.filter((r) => r.id !== id);
121
+ if (remotes.length === file.remotes.length)
122
+ return false;
123
+ saveMachines(home, { self: file.self, remotes });
124
+ return true;
125
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Federation registrar (machine-mesh P2 Task 3): adopts a paired machine's
3
+ * PROBED roster into the LOCAL registries, so a remote agent is addressable
4
+ * and visible exactly like a local world:
5
+ *
6
+ * targets.ts `registerForeignTarget({key: a6, gateway})` — the a6-keyed
7
+ * wire gateway (Task 2's `createRemoteGateway`), so the `/hub`
8
+ * family demuxes `?agent=<a6>` to the peer's `/hub` family
9
+ * carrying the remote RUNTIME key (`?agent=codex` at the peer).
10
+ * The peer's NATIVE agent row federates the same way — its
11
+ * row carries a minted a6 plus `key:'native'`, and its gateway
12
+ * is built with the NATIVE face (Task 2's `face` option): the
13
+ * peer's ctx0 `/api` surface, no demux key — exactly how a
14
+ * browser ON the peer consumes its native DSH (the `/hub`
15
+ * family would 404 `?agent=native` as foreign-only).
16
+ * roster.ts one row per agent: `{key: a6, label: '<Label> · <machine
17
+ * name>', machine: m4, ready, failed}` — `machine` set marks
18
+ * the row federated; `agentLinks` projects it into the UI rows.
19
+ *
20
+ * The a6 IS the local registration key (spec §3.2): runtime keys are NOT
21
+ * reused across machines (a remote `codex` would collide with — and silently
22
+ * replace — the local codex world), while a6s are owner-minted 6-char ids
23
+ * adopted verbatim. EVERY row federates, the peer's native included — the
24
+ * face selector is `agent.key === 'native'`, never an a6 test (only
25
+ * `'native'` is pre-screened as a hard reserve). Cross-machine a6 collision
26
+ * (two peers minting the same id, p ≈ 36⁻¹² per pair) and a6-vs-local-key
27
+ * collision (p ≈ 36⁻⁶ per adoption) are accepted at P2 machine counts — the
28
+ * registry replaces last-write-wins, same ruling as spec §3.1's m4 collision
29
+ * roughness.
30
+ *
31
+ * ## Import direction (frozen): mesh-federate → {mesh-link, mesh-gateway,
32
+ * ## roster, targets} — NEVER the reverse. mesh-link emits machine lifecycle
33
+ * ## events (`onMeshFederationEvent`: federated / degraded / defederated);
34
+ * ## this module subscribes at wiring time ({@link startMeshFederation},
35
+ * ## called once from gateway.ts apply). mesh-rpc's DELETE path announces
36
+ * ## removals through mesh-link's {@link emitMachineRemoved} — no route-module
37
+ * ## federation logic anywhere.
38
+ *
39
+ * ## Gateway memo — one `RemoteGateway` per (m4, a6), built by the wiring's
40
+ * ## factory, keyed by a6. Reused across probe events (identity-stable: a
41
+ * ## reprobe does not rebuild a live gateway); rebuilt when the peer facts
42
+ * ## change (origin moved, remote runtime key re-minted); disposed and
43
+ * ## forgotten on defederate. The cookie is NOT memoized — the gateway reads
44
+ * ## it per call through `cookieOf(m4, home)` (jar-fronted), so a re-minted
45
+ * ## cookie is picked up by the NEXT call with zero rebuilds.
46
+ *
47
+ * ## Degrade semantics (spec §6.3): a failed probe KEEPS the machine's rows
48
+ * ## and flips `ready` false — visible but not servable. Defederate (machine
49
+ * ## removal) and stale-convergence (an agent vanished from the peer's
50
+ * ## roster) are the only row-removing paths.
51
+ */
52
+ import { type MuxClientSocket } from './mesh-gateway.js';
53
+ import { type GatewayFace } from './targets.js';
54
+ /** One remote agent as adopted from a probe — mesh-link's `ProbeAgent` shape
55
+ * (kept structural so callers and tests can pass plain objects). */
56
+ export interface FederatedAgent {
57
+ /** The remote-owner-minted a6 — the LOCAL registration key. */
58
+ id: string;
59
+ /** The remote RUNTIME key (`codex`, `omp`, …) — the peer's `?agent=` demux
60
+ * key on the wire; `'native'` selects the NATIVE face (peer ctx0 `/api`, no
61
+ * demux key) in the wiring's gateway factory. An EMPTY key cannot address
62
+ * anything at the peer; {@link gatewayFor} falls back to the a6 there
63
+ * (T3-M1) rather than shipping `?agent=` on the wire. */
64
+ key: string;
65
+ label: string;
66
+ ready: boolean;
67
+ failed: boolean;
68
+ }
69
+ /** The machine half of a federation fact (m4 + display name for label suffixes). */
70
+ export interface FederatedMachine {
71
+ id: string;
72
+ name: string;
73
+ }
74
+ /** Wiring deps: the hub home (cookie row fallback) + test seams for the wire. */
75
+ export interface FederationWireDeps {
76
+ home: string;
77
+ fetchImpl?: typeof fetch;
78
+ wsFactory?: (url: string, headers: Record<string, string>) => MuxClientSocket;
79
+ }
80
+ /**
81
+ * Register one machine's probed agents — IDEMPOTENT replace per a6: a row
82
+ * whose key/label/machine/ready/failed all match the live registry is left
83
+ * untouched (zero roster churn on repeated probes); a changed row is
84
+ * unregistered then re-registered (`registerAgent` throws on duplicate keys —
85
+ * federate always unregisters first, so repeated probes never throw).
86
+ * Agents with an empty id (unkeyable) are skipped; EVERY keyed row
87
+ * federates — the peer's native row included (its `key:'native'` selects the
88
+ * native gateway face; see {@link gatewayFor}).
89
+ * @param machine - the peer's identity (m4 + name; the name lands in every label suffix).
90
+ * @param agents - the probe's agent snapshot (a6 `id`, remote `key`, label, ready, failed).
91
+ * @param gatewayOf - gateway factory receiving the FULL agent row (the wiring
92
+ * derives the face from `agent.key`; tests inject fakes) — called once per
93
+ * (re)registration and once per converged-row target re-arm.
94
+ */
95
+ export declare function federateAgents(machine: FederatedMachine, agents: FederatedAgent[], gatewayOf: (agent: FederatedAgent) => GatewayFace): void;
96
+ /**
97
+ * Drop EVERY federated row of one machine (mesh-rpc DELETE / unpair): roster
98
+ * rows, target registrations, and the memoized wire gateways (disposed —
99
+ * in-flight streams fail `gateway/disposed` per Task 2's contract).
100
+ */
101
+ export declare function defederateMachine(m4: string): void;
102
+ /**
103
+ * Roster convergence: unregister the machine's rows whose a6s are ABSENT from
104
+ * the peer's latest probe (the agent was removed/renamed remotely) — live
105
+ * agents are left to {@link federateAgents}.
106
+ * @param m4 - the machine id.
107
+ * @param liveA6s - the a6 set of the latest successful probe.
108
+ */
109
+ export declare function defederateStaleMachine(m4: string, liveA6s: ReadonlySet<string>): void;
110
+ /**
111
+ * Degrade one machine's rows after a failed probe (unreachable peer or
112
+ * auth-expired cookie): rows are KEPT, `ready` flipped false, `failed`
113
+ * untouched — visible but not servable (spec §6.3; the orchestra downgrade).
114
+ * Rows already degraded are left alone (no roster churn); local rows are
115
+ * never touched.
116
+ */
117
+ export declare function degradeMachine(m4: string): void;
118
+ /**
119
+ * The federated gateway of one a6 — the `/hub` demux's federation-table step
120
+ * (exact a6 match; the same pair `federateAgents` registers into the shared
121
+ * target registry, so the two sources never disagree). Undefined for local
122
+ * labels and unknown/defederated a6s.
123
+ */
124
+ export declare function federatedGatewayOf(a6: string): GatewayFace | undefined;
125
+ /**
126
+ * Wire the registrar to mesh-link's machine lifecycle events (called ONCE at
127
+ * plugin activation — gateway.ts apply):
128
+ *
129
+ * - `federated` → {@link federateAgents} (converge onto the probe snapshot)
130
+ * + {@link defederateStaleMachine} (drop vanished agents);
131
+ * - `degraded` → {@link degradeMachine} (rows kept, ready=false);
132
+ * - `defederated` → {@link defederateMachine} (mesh-rpc DELETE).
133
+ *
134
+ * Re-probing a machine after a hub restart rebuilds its federation from the
135
+ * probe alone — the events are the single rebuild path.
136
+ * @returns the disposer (unsubscribes; registered rows are left as-is).
137
+ */
138
+ export declare function startMeshFederation(deps: FederationWireDeps): () => void;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Federation registrar (machine-mesh P2 Task 3): adopts a paired machine's
3
+ * PROBED roster into the LOCAL registries, so a remote agent is addressable
4
+ * and visible exactly like a local world:
5
+ *
6
+ * targets.ts `registerForeignTarget({key: a6, gateway})` — the a6-keyed
7
+ * wire gateway (Task 2's `createRemoteGateway`), so the `/hub`
8
+ * family demuxes `?agent=<a6>` to the peer's `/hub` family
9
+ * carrying the remote RUNTIME key (`?agent=codex` at the peer).
10
+ * The peer's NATIVE agent row federates the same way — its
11
+ * row carries a minted a6 plus `key:'native'`, and its gateway
12
+ * is built with the NATIVE face (Task 2's `face` option): the
13
+ * peer's ctx0 `/api` surface, no demux key — exactly how a
14
+ * browser ON the peer consumes its native DSH (the `/hub`
15
+ * family would 404 `?agent=native` as foreign-only).
16
+ * roster.ts one row per agent: `{key: a6, label: '<Label> · <machine
17
+ * name>', machine: m4, ready, failed}` — `machine` set marks
18
+ * the row federated; `agentLinks` projects it into the UI rows.
19
+ *
20
+ * The a6 IS the local registration key (spec §3.2): runtime keys are NOT
21
+ * reused across machines (a remote `codex` would collide with — and silently
22
+ * replace — the local codex world), while a6s are owner-minted 6-char ids
23
+ * adopted verbatim. EVERY row federates, the peer's native included — the
24
+ * face selector is `agent.key === 'native'`, never an a6 test (only
25
+ * `'native'` is pre-screened as a hard reserve). Cross-machine a6 collision
26
+ * (two peers minting the same id, p ≈ 36⁻¹² per pair) and a6-vs-local-key
27
+ * collision (p ≈ 36⁻⁶ per adoption) are accepted at P2 machine counts — the
28
+ * registry replaces last-write-wins, same ruling as spec §3.1's m4 collision
29
+ * roughness.
30
+ *
31
+ * ## Import direction (frozen): mesh-federate → {mesh-link, mesh-gateway,
32
+ * ## roster, targets} — NEVER the reverse. mesh-link emits machine lifecycle
33
+ * ## events (`onMeshFederationEvent`: federated / degraded / defederated);
34
+ * ## this module subscribes at wiring time ({@link startMeshFederation},
35
+ * ## called once from gateway.ts apply). mesh-rpc's DELETE path announces
36
+ * ## removals through mesh-link's {@link emitMachineRemoved} — no route-module
37
+ * ## federation logic anywhere.
38
+ *
39
+ * ## Gateway memo — one `RemoteGateway` per (m4, a6), built by the wiring's
40
+ * ## factory, keyed by a6. Reused across probe events (identity-stable: a
41
+ * ## reprobe does not rebuild a live gateway); rebuilt when the peer facts
42
+ * ## change (origin moved, remote runtime key re-minted); disposed and
43
+ * ## forgotten on defederate. The cookie is NOT memoized — the gateway reads
44
+ * ## it per call through `cookieOf(m4, home)` (jar-fronted), so a re-minted
45
+ * ## cookie is picked up by the NEXT call with zero rebuilds.
46
+ *
47
+ * ## Degrade semantics (spec §6.3): a failed probe KEEPS the machine's rows
48
+ * ## and flips `ready` false — visible but not servable. Defederate (machine
49
+ * ## removal) and stale-convergence (an agent vanished from the peer's
50
+ * ## roster) are the only row-removing paths.
51
+ */
52
+ import { createRemoteGateway } from './mesh-gateway.js';
53
+ import { cookieOf, onMeshFederationEvent } from './mesh-link.js';
54
+ import { registerAgent, unregisterAgent, listAgents, setReady } from './roster.js';
55
+ import { registerForeignTarget, unregisterForeignTarget, getTarget } from './targets.js';
56
+ const gatewayMemo = new Map();
57
+ /** Roster rows of one machine (its a6s), from the live registry. */
58
+ function rowsOf(m4) {
59
+ return listAgents().filter((e) => (e.machine ?? 'local') === m4);
60
+ }
61
+ /** Unregister one federated a6 from both registries + drop its memo gateway. */
62
+ function dropA6(a6) {
63
+ if (listAgents().some((e) => e.key === a6))
64
+ unregisterAgent(a6); // guard: unregister no-ops still emit roster churn
65
+ unregisterForeignTarget(a6);
66
+ const memo = gatewayMemo.get(a6);
67
+ if (memo !== undefined) {
68
+ gatewayMemo.delete(a6);
69
+ memo.gateway.dispose();
70
+ }
71
+ }
72
+ /**
73
+ * Register one machine's probed agents — IDEMPOTENT replace per a6: a row
74
+ * whose key/label/machine/ready/failed all match the live registry is left
75
+ * untouched (zero roster churn on repeated probes); a changed row is
76
+ * unregistered then re-registered (`registerAgent` throws on duplicate keys —
77
+ * federate always unregisters first, so repeated probes never throw).
78
+ * Agents with an empty id (unkeyable) are skipped; EVERY keyed row
79
+ * federates — the peer's native row included (its `key:'native'` selects the
80
+ * native gateway face; see {@link gatewayFor}).
81
+ * @param machine - the peer's identity (m4 + name; the name lands in every label suffix).
82
+ * @param agents - the probe's agent snapshot (a6 `id`, remote `key`, label, ready, failed).
83
+ * @param gatewayOf - gateway factory receiving the FULL agent row (the wiring
84
+ * derives the face from `agent.key`; tests inject fakes) — called once per
85
+ * (re)registration and once per converged-row target re-arm.
86
+ */
87
+ export function federateAgents(machine, agents, gatewayOf) {
88
+ for (const agent of agents) {
89
+ if (agent.id === '')
90
+ continue;
91
+ const desired = {
92
+ key: agent.id,
93
+ label: `${agent.label} · ${machine.name}`,
94
+ machine: machine.id,
95
+ ready: agent.ready === true,
96
+ failed: agent.failed === true,
97
+ };
98
+ const existing = listAgents().find((e) => e.key === agent.id);
99
+ if (existing !== undefined
100
+ && existing.label === desired.label
101
+ && existing.ready === desired.ready
102
+ && (existing.failed ?? false) === desired.failed
103
+ && (existing.machine ?? 'local') === machine.id) {
104
+ // Converged — but never trust a half-state: the roster row may survive
105
+ // a target that something else dropped. Re-arm the registration only.
106
+ if (getTarget(agent.id) === undefined)
107
+ registerForeignTarget({ key: agent.id, gateway: gatewayOf(agent) });
108
+ continue;
109
+ }
110
+ if (existing !== undefined)
111
+ unregisterAgent(agent.id); // defeats registerAgent's duplicate-key throw
112
+ unregisterForeignTarget(agent.id); // explicit replace, not map-overwrite
113
+ registerForeignTarget({ key: agent.id, gateway: gatewayOf(agent) });
114
+ registerAgent(desired);
115
+ }
116
+ }
117
+ /**
118
+ * Drop EVERY federated row of one machine (mesh-rpc DELETE / unpair): roster
119
+ * rows, target registrations, and the memoized wire gateways (disposed —
120
+ * in-flight streams fail `gateway/disposed` per Task 2's contract).
121
+ */
122
+ export function defederateMachine(m4) {
123
+ for (const row of rowsOf(m4))
124
+ dropA6(row.key);
125
+ // Belt: memo entries whose roster rows vanished independently still die with the machine.
126
+ for (const [a6, memo] of [...gatewayMemo]) {
127
+ if (memo.machineId === m4)
128
+ dropA6(a6);
129
+ }
130
+ }
131
+ /**
132
+ * Roster convergence: unregister the machine's rows whose a6s are ABSENT from
133
+ * the peer's latest probe (the agent was removed/renamed remotely) — live
134
+ * agents are left to {@link federateAgents}.
135
+ * @param m4 - the machine id.
136
+ * @param liveA6s - the a6 set of the latest successful probe.
137
+ */
138
+ export function defederateStaleMachine(m4, liveA6s) {
139
+ for (const row of rowsOf(m4)) {
140
+ if (!liveA6s.has(row.key))
141
+ dropA6(row.key);
142
+ }
143
+ }
144
+ /**
145
+ * Degrade one machine's rows after a failed probe (unreachable peer or
146
+ * auth-expired cookie): rows are KEPT, `ready` flipped false, `failed`
147
+ * untouched — visible but not servable (spec §6.3; the orchestra downgrade).
148
+ * Rows already degraded are left alone (no roster churn); local rows are
149
+ * never touched.
150
+ */
151
+ export function degradeMachine(m4) {
152
+ for (const row of rowsOf(m4)) {
153
+ if (row.ready !== false)
154
+ setReady(row.key, false);
155
+ }
156
+ }
157
+ /**
158
+ * The federated gateway of one a6 — the `/hub` demux's federation-table step
159
+ * (exact a6 match; the same pair `federateAgents` registers into the shared
160
+ * target registry, so the two sources never disagree). Undefined for local
161
+ * labels and unknown/defederated a6s.
162
+ */
163
+ export function federatedGatewayOf(a6) {
164
+ return gatewayMemo.get(a6)?.gateway;
165
+ }
166
+ /** The wiring's gateway factory: memoized per a6, rebuilt on peer-fact
167
+ * change (origin, remote runtime key, or face flip). The FACE is selected
168
+ * from the agent ROW — `key === 'native'` → the peer's ctx0 `/api` surface,
169
+ * anything else → the `/hub` family with the runtime-key demux. */
170
+ function gatewayFor(event, agent, deps) {
171
+ const a6 = agent.id;
172
+ const face = agent.key === 'native' ? 'native' : 'foreign';
173
+ // T3-M1: an empty remote key must not ride the wire as `?agent=` (the
174
+ // nullish class miss — '' is not nullish); fall back to the a6 so the
175
+ // registration stays addressable and the wrong key fails LOUDLY at the
176
+ // peer (its demux 404s the unknown key) instead of silently here.
177
+ const agentKey = agent.key !== '' ? agent.key : agent.id;
178
+ const memo = gatewayMemo.get(a6);
179
+ if (memo !== undefined && memo.machineId === event.machineId && memo.origin === event.origin && memo.agentKey === agentKey && memo.face === face) {
180
+ return memo.gateway;
181
+ }
182
+ if (memo !== undefined) {
183
+ gatewayMemo.delete(a6);
184
+ memo.gateway.dispose();
185
+ }
186
+ const gateway = createRemoteGateway({
187
+ origin: event.origin,
188
+ agentKey,
189
+ face,
190
+ cookie: () => cookieOf(event.machineId, deps.home),
191
+ fetchImpl: deps.fetchImpl,
192
+ wsFactory: deps.wsFactory,
193
+ });
194
+ gatewayMemo.set(a6, { gateway, machineId: event.machineId, origin: event.origin, agentKey, face });
195
+ return gateway;
196
+ }
197
+ /**
198
+ * Wire the registrar to mesh-link's machine lifecycle events (called ONCE at
199
+ * plugin activation — gateway.ts apply):
200
+ *
201
+ * - `federated` → {@link federateAgents} (converge onto the probe snapshot)
202
+ * + {@link defederateStaleMachine} (drop vanished agents);
203
+ * - `degraded` → {@link degradeMachine} (rows kept, ready=false);
204
+ * - `defederated` → {@link defederateMachine} (mesh-rpc DELETE).
205
+ *
206
+ * Re-probing a machine after a hub restart rebuilds its federation from the
207
+ * probe alone — the events are the single rebuild path.
208
+ * @returns the disposer (unsubscribes; registered rows are left as-is).
209
+ */
210
+ export function startMeshFederation(deps) {
211
+ return onMeshFederationEvent((event) => {
212
+ if (event.type === 'federated') {
213
+ federateAgents({ id: event.machineId, name: event.machineName }, event.agents, (agent) => gatewayFor(event, agent, deps));
214
+ defederateStaleMachine(event.machineId, new Set(event.agents.map((a) => a.id)));
215
+ }
216
+ else if (event.type === 'degraded') {
217
+ degradeMachine(event.machineId);
218
+ }
219
+ else {
220
+ defederateMachine(event.machineId);
221
+ }
222
+ });
223
+ }
@@ -0,0 +1,67 @@
1
+ import type { MuxClientSocket } from './mux-client.js';
2
+ import type { GatewayFace } from './targets.js';
3
+ export type { MuxClientSocket } from './mux-client.js';
4
+ /**
5
+ * The default ws seam: connect one `ws` WebSocket to `url` with `headers`
6
+ * riding the upgrade request (the pairing Cookie), presented as T1's
7
+ * {@link MuxClientSocket}.
8
+ *
9
+ * The adapter is crash-proof by construction (fix round 1):
10
+ * - an `'error'` listener is ALWAYS registered: an unreachable peer
11
+ * (ECONNREFUSED) or a rejected upgrade (non-101, e.g. a dead cookie's
12
+ * HTTP 401) emits `'error'` with no listener of its own — an uncaught
13
+ * exception that kills the hub process. The listener is a no-op: ws
14
+ * guarantees `'close'` follows `'error'`, and the close path already
15
+ * classifies (in-flight streams fail `gateway/socket-closed`) and retires
16
+ * the connection for redial. WS-lane auth failure therefore classifies
17
+ * as a socket cut post-fix — acceptable: the next POST-lane call is the
18
+ * one that reports `gateway/auth-expired`.
19
+ * - `ws` THROWS on sends made while CONNECTING, and its post-close send
20
+ * bookkeeping can itself throw in teardown windows, so every send is
21
+ * guarded: pre-open sends queue (flushed in order on 'open'), post-close
22
+ * sends are swallowed — the stream is dead and the close path owns the
23
+ * teardown. WS-level pings stay at the WS layer: `on('message')` fires
24
+ * for text data frames only, so protocol-level ping/pong NEVER surfaces
25
+ * as text.
26
+ */
27
+ export declare function defaultWsFactory(url: string, headers: Record<string, string>): MuxClientSocket;
28
+ /** Options for {@link createRemoteGateway}. */
29
+ export interface RemoteGatewayOptions {
30
+ /** The peer hub origin (`http(s)://host[:port]`; the mux lane maps it to ws(s)). */
31
+ origin: string;
32
+ /** The remote agent's demux key (`?agent=` on both lanes; ignored by the native face). */
33
+ agentKey: string;
34
+ /** The machine's pairing cookie, read per call (the jar fronting `MachineRow.cookie`). */
35
+ cookie: () => string;
36
+ /** Fetch override (tests); defaults to `globalThis.fetch`. */
37
+ fetchImpl?: typeof fetch;
38
+ /** Socket factory override (tests); defaults to {@link defaultWsFactory}. */
39
+ wsFactory?: (url: string, headers: Record<string, string>) => MuxClientSocket;
40
+ /** Which peer surface to address (see the header's face table): 'foreign' =
41
+ * the `/hub` family with the `?agent=` demux (default); 'native' = ctx0's
42
+ * own `/api` surface, no demux key — how a browser ON the peer consumes its
43
+ * native DSH. */
44
+ face?: 'foreign' | 'native';
45
+ /** Idle ms with zero live streams before the ws closes (default 30 s; test hook for short real timers). */
46
+ idleCloseMs?: number;
47
+ }
48
+ /** A wire-form gateway: a {@link GatewayFace} plus the carrier call shape and a hard close. */
49
+ export interface RemoteGateway extends GatewayFace {
50
+ /**
51
+ * CarrierGatewayFace shape — the canonical order `worldMuxOf` calls with
52
+ * (`(endpoint, payload, uplink, peer, signal, control)`); the uplink is
53
+ * bridged into the wire upstream lane.
54
+ */
55
+ openWireStream(endpoint: string, payload: unknown, uplink: AsyncIterable<unknown> | Iterable<unknown>, peer: undefined, signal: AbortSignal, control: AbortController): AsyncIterable<unknown>;
56
+ /** GatewayFace shape — no uplink lane; the stream's uplink half-closes immediately. */
57
+ openWireStream(endpoint: string, payload: unknown, signal: AbortSignal): AsyncIterable<unknown>;
58
+ /** Hard-close the shared connection now (machine unpair); in-flight streams fail `gateway/disposed`. */
59
+ dispose(): void;
60
+ }
61
+ /**
62
+ * Build one remote agent's wire gateway toward a paired hub's `/hub` family.
63
+ * @param options - see {@link RemoteGatewayOptions}.
64
+ * @returns the {@link RemoteGateway} (a GatewayFace; register it with
65
+ * `registerForeignTarget` — callers cannot tell it from a local world gateway).
66
+ */
67
+ export declare function createRemoteGateway(options: RemoteGatewayOptions): RemoteGateway;