@unagents/messenger-broker 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
2
+
3
+ /**
4
+ * Broker configuration. Every field can be given to `startBroker(options)`;
5
+ * anything left out falls back to the environment (`AGENT_MESSENGER_*`,
6
+ * same names and defaults as the JavaScript `legacy/src/config.js`).
7
+ */
8
+ interface BrokerOptions {
9
+ /** HTTP listen port (`AGENT_MESSENGER_PORT`, default 7901). `0` = random free port. */
10
+ port?: number;
11
+ /** HTTP bind address (`AGENT_MESSENGER_BIND`, default `0.0.0.0`). */
12
+ bind?: string;
13
+ /** Bearer token; `""` disables auth (`AGENT_MESSENGER_TOKEN`, default `""`). */
14
+ token?: string;
15
+ /** Broker id shown in `/health` and discovery replies (`AGENT_MESSENGER_BROKER_ID`, default hostname). */
16
+ id?: string;
17
+ /** State file (`AGENT_MESSENGER_STATE`, default `~/.agent-messenger/broker.json`). */
18
+ stateFile?: string;
19
+ /** UDP discovery responder on/off (`AGENT_MESSENGER_DISCOVERY`, `"off"` disables; default on). */
20
+ discovery?: boolean;
21
+ /** UDP discovery port (`AGENT_MESSENGER_DISCOVERY_PORT`, default 7902). */
22
+ discoveryPort?: number;
23
+ /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
+ discoveryAddr?: string;
25
+ /**
26
+ * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
+ * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
28
+ * undelivered messages there and exits. A permanent broker (default) never does this.
29
+ */
30
+ fallback?: boolean;
31
+ /** Fallback discovery interval in ms (`AGENT_MESSENGER_FALLBACK_INTERVAL_MS`, default 10000). */
32
+ fallbackIntervalMs?: number;
33
+ /** Called after a fallback broker has relayed its messages and closed itself (`main.ts` exits the process). */
34
+ onSupersede?: (url: string) => void;
35
+ /**
36
+ * Join the permanent-broker mesh (`AGENT_MESSENGER_MESH`, `"on"` enables; default off).
37
+ * Fallback brokers must leave this off: only brokers started with it take part.
38
+ */
39
+ mesh?: boolean;
40
+ /**
41
+ * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
+ * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
43
+ */
44
+ meshBrokers?: string[];
45
+ /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
+ meshIntervalMs?: number;
47
+ /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
+ meshPeerTtlMs?: number;
49
+ /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
+ configFile?: string;
51
+ }
52
+ type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
+ /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
54
+ declare const FALLBACK_ENV = "AGENT_MESSENGER_FALLBACK";
55
+ declare const FALLBACK_INTERVAL_ENV = "AGENT_MESSENGER_FALLBACK_INTERVAL_MS";
56
+ /** Directory of the default state file (`~/.agent-messenger`). Not exported by protocol. */
57
+ declare const STATE_DIR: string;
58
+ /** Default state file path (`~/.agent-messenger/broker.json`). Not exported by protocol. */
59
+ declare const DEFAULT_STATE_FILE: string;
60
+ /** Mesh environment variables. Broker-private (not in the protocol's `ENV`, which mirrors the legacy names). */
61
+ declare const MESH_ENV: {
62
+ readonly MESH: "AGENT_MESSENGER_MESH";
63
+ readonly INTERVAL_MS: "AGENT_MESSENGER_MESH_INTERVAL_MS";
64
+ readonly PEER_TTL_MS: "AGENT_MESSENGER_MESH_PEER_TTL_MS";
65
+ };
66
+ /** Read the broker-side environment exactly like `legacy/src/config.js`. */
67
+ declare function configFromEnv(env?: NodeJS.ProcessEnv): BrokerConfig;
68
+ /** `options` over environment over built-in defaults. */
69
+ declare function resolveConfig(options?: BrokerOptions): BrokerConfig;
70
+
71
+ /**
72
+ * On-disk shape of `~/.agent-messenger/broker.json` (written by core 0.4.0
73
+ * `legacy/src/broker.js`). Not part of the protocol package — broker-private.
74
+ */
75
+ interface BrokerStateFile {
76
+ peers: Peer[];
77
+ messages: Message[];
78
+ nextMsgId: number;
79
+ /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
+ pending?: PendingRelay[];
81
+ }
82
+ /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
+ interface PendingRelay extends RelayedMessage {
84
+ /** ISO 8601; pending items older than the TTL are dropped. */
85
+ queued_at: string;
86
+ }
87
+
88
+ /** Package version, reported by `/health` and discovery replies (inlined at build time). */
89
+ declare const VERSION: string;
90
+ interface BrokerHandle {
91
+ /** Stop HTTP + UDP, flush a pending state save. Idempotent. */
92
+ close(): Promise<void>;
93
+ /** Port the HTTP server listens on (actual port when `port: 0` was given). */
94
+ port: number;
95
+ /** Broker id (`AGENT_MESSENGER_BROKER_ID`, default hostname). */
96
+ id: string;
97
+ }
98
+ /**
99
+ * Start the broker daemon: load state, sweep stale peers, listen on HTTP
100
+ * (h3 + listhen) and answer UDP discovery probes.
101
+ *
102
+ * Unlike the JavaScript `startBroker()`, this does not install SIGINT /
103
+ * SIGTERM handlers — callers own the lifecycle through `close()`
104
+ * (`src/main.ts` does that for the standalone entry).
105
+ */
106
+ declare function startBroker(options?: BrokerOptions): Promise<BrokerHandle>;
107
+
108
+ export { DEFAULT_STATE_FILE, FALLBACK_ENV, FALLBACK_INTERVAL_ENV, MESH_ENV, STATE_DIR, VERSION, configFromEnv, resolveConfig, startBroker };
109
+ export type { BrokerConfig, BrokerHandle, BrokerOptions, BrokerStateFile };
@@ -0,0 +1,109 @@
1
+ import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
2
+
3
+ /**
4
+ * Broker configuration. Every field can be given to `startBroker(options)`;
5
+ * anything left out falls back to the environment (`AGENT_MESSENGER_*`,
6
+ * same names and defaults as the JavaScript `legacy/src/config.js`).
7
+ */
8
+ interface BrokerOptions {
9
+ /** HTTP listen port (`AGENT_MESSENGER_PORT`, default 7901). `0` = random free port. */
10
+ port?: number;
11
+ /** HTTP bind address (`AGENT_MESSENGER_BIND`, default `0.0.0.0`). */
12
+ bind?: string;
13
+ /** Bearer token; `""` disables auth (`AGENT_MESSENGER_TOKEN`, default `""`). */
14
+ token?: string;
15
+ /** Broker id shown in `/health` and discovery replies (`AGENT_MESSENGER_BROKER_ID`, default hostname). */
16
+ id?: string;
17
+ /** State file (`AGENT_MESSENGER_STATE`, default `~/.agent-messenger/broker.json`). */
18
+ stateFile?: string;
19
+ /** UDP discovery responder on/off (`AGENT_MESSENGER_DISCOVERY`, `"off"` disables; default on). */
20
+ discovery?: boolean;
21
+ /** UDP discovery port (`AGENT_MESSENGER_DISCOVERY_PORT`, default 7902). */
22
+ discoveryPort?: number;
23
+ /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
+ discoveryAddr?: string;
25
+ /**
26
+ * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
+ * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
28
+ * undelivered messages there and exits. A permanent broker (default) never does this.
29
+ */
30
+ fallback?: boolean;
31
+ /** Fallback discovery interval in ms (`AGENT_MESSENGER_FALLBACK_INTERVAL_MS`, default 10000). */
32
+ fallbackIntervalMs?: number;
33
+ /** Called after a fallback broker has relayed its messages and closed itself (`main.ts` exits the process). */
34
+ onSupersede?: (url: string) => void;
35
+ /**
36
+ * Join the permanent-broker mesh (`AGENT_MESSENGER_MESH`, `"on"` enables; default off).
37
+ * Fallback brokers must leave this off: only brokers started with it take part.
38
+ */
39
+ mesh?: boolean;
40
+ /**
41
+ * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
+ * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
43
+ */
44
+ meshBrokers?: string[];
45
+ /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
+ meshIntervalMs?: number;
47
+ /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
+ meshPeerTtlMs?: number;
49
+ /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
+ configFile?: string;
51
+ }
52
+ type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
+ /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
54
+ declare const FALLBACK_ENV = "AGENT_MESSENGER_FALLBACK";
55
+ declare const FALLBACK_INTERVAL_ENV = "AGENT_MESSENGER_FALLBACK_INTERVAL_MS";
56
+ /** Directory of the default state file (`~/.agent-messenger`). Not exported by protocol. */
57
+ declare const STATE_DIR: string;
58
+ /** Default state file path (`~/.agent-messenger/broker.json`). Not exported by protocol. */
59
+ declare const DEFAULT_STATE_FILE: string;
60
+ /** Mesh environment variables. Broker-private (not in the protocol's `ENV`, which mirrors the legacy names). */
61
+ declare const MESH_ENV: {
62
+ readonly MESH: "AGENT_MESSENGER_MESH";
63
+ readonly INTERVAL_MS: "AGENT_MESSENGER_MESH_INTERVAL_MS";
64
+ readonly PEER_TTL_MS: "AGENT_MESSENGER_MESH_PEER_TTL_MS";
65
+ };
66
+ /** Read the broker-side environment exactly like `legacy/src/config.js`. */
67
+ declare function configFromEnv(env?: NodeJS.ProcessEnv): BrokerConfig;
68
+ /** `options` over environment over built-in defaults. */
69
+ declare function resolveConfig(options?: BrokerOptions): BrokerConfig;
70
+
71
+ /**
72
+ * On-disk shape of `~/.agent-messenger/broker.json` (written by core 0.4.0
73
+ * `legacy/src/broker.js`). Not part of the protocol package — broker-private.
74
+ */
75
+ interface BrokerStateFile {
76
+ peers: Peer[];
77
+ messages: Message[];
78
+ nextMsgId: number;
79
+ /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
+ pending?: PendingRelay[];
81
+ }
82
+ /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
+ interface PendingRelay extends RelayedMessage {
84
+ /** ISO 8601; pending items older than the TTL are dropped. */
85
+ queued_at: string;
86
+ }
87
+
88
+ /** Package version, reported by `/health` and discovery replies (inlined at build time). */
89
+ declare const VERSION: string;
90
+ interface BrokerHandle {
91
+ /** Stop HTTP + UDP, flush a pending state save. Idempotent. */
92
+ close(): Promise<void>;
93
+ /** Port the HTTP server listens on (actual port when `port: 0` was given). */
94
+ port: number;
95
+ /** Broker id (`AGENT_MESSENGER_BROKER_ID`, default hostname). */
96
+ id: string;
97
+ }
98
+ /**
99
+ * Start the broker daemon: load state, sweep stale peers, listen on HTTP
100
+ * (h3 + listhen) and answer UDP discovery probes.
101
+ *
102
+ * Unlike the JavaScript `startBroker()`, this does not install SIGINT /
103
+ * SIGTERM handlers — callers own the lifecycle through `close()`
104
+ * (`src/main.ts` does that for the standalone entry).
105
+ */
106
+ declare function startBroker(options?: BrokerOptions): Promise<BrokerHandle>;
107
+
108
+ export { DEFAULT_STATE_FILE, FALLBACK_ENV, FALLBACK_INTERVAL_ENV, MESH_ENV, STATE_DIR, VERSION, configFromEnv, resolveConfig, startBroker };
109
+ export type { BrokerConfig, BrokerHandle, BrokerOptions, BrokerStateFile };
package/dist/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ import 'listhen';
2
+ import 'h3';
3
+ import '@unagents/messenger-protocol';
4
+ export { D as DEFAULT_STATE_FILE, F as FALLBACK_ENV, a as FALLBACK_INTERVAL_ENV, M as MESH_ENV, S as STATE_DIR, V as VERSION, c as configFromEnv, r as resolveConfig, s as startBroker } from './shared/messenger-broker.CY2SPlVR.mjs';
5
+ import 'node:os';
6
+ import 'node:path';
7
+ import 'defu';
8
+ import 'node:fs';
9
+ import 'consola';
10
+ import 'node:http';
11
+ import 'node:dgram';
@@ -0,0 +1,2 @@
1
+
2
+ export { };
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { };
package/dist/main.mjs ADDED
@@ -0,0 +1,22 @@
1
+ import { s as startBroker, l as log } from './shared/messenger-broker.CY2SPlVR.mjs';
2
+ import 'listhen';
3
+ import 'h3';
4
+ import '@unagents/messenger-protocol';
5
+ import 'node:os';
6
+ import 'node:path';
7
+ import 'defu';
8
+ import 'node:fs';
9
+ import 'consola';
10
+ import 'node:http';
11
+ import 'node:dgram';
12
+
13
+ const broker = await startBroker({
14
+ // fallback only: messages are relayed and the broker is closed, nothing left to do
15
+ onSupersede: () => process.exit(0)
16
+ });
17
+ const shutdown = () => {
18
+ log.info("shutting down");
19
+ broker.close().finally(() => process.exit(0));
20
+ };
21
+ process.once("SIGINT", shutdown);
22
+ process.once("SIGTERM", shutdown);
@@ -0,0 +1,842 @@
1
+ import { listen } from 'listhen';
2
+ import { createRouter, defineEventHandler, createApp, send, setResponseStatus, getRequestHeader, readRawBody, toNodeListener } from 'h3';
3
+ import { DEFAULT_BIND, ENV, DEFAULT_DISCOVERY_PORT, DEFAULT_HTTP_PORT, HEARTBEAT_TIMEOUT_MS, MESH_SYNC_ROUTE, HEALTH_ROUTE, BROKER_POST_ROUTES, RELAY_ROUTE, DISCOVERY_KEY, DISCOVERY_PROBE_VALUE, DISCOVERY_REPLY_VALUE } from '@unagents/messenger-protocol';
4
+ import { homedir, hostname, networkInterfaces } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+ import { defu } from 'defu';
7
+ import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
8
+ import { createConsola } from 'consola';
9
+ import { request } from 'node:http';
10
+ import { createSocket } from 'node:dgram';
11
+
12
+ const FALLBACK_ENV = "AGENT_MESSENGER_FALLBACK";
13
+ const FALLBACK_INTERVAL_ENV = "AGENT_MESSENGER_FALLBACK_INTERVAL_MS";
14
+ const DEFAULT_FALLBACK_INTERVAL_MS = 1e4;
15
+ const STATE_DIR = join(homedir(), ".agent-messenger");
16
+ const DEFAULT_STATE_FILE = join(STATE_DIR, "broker.json");
17
+ const MESH_ENV = {
18
+ MESH: "AGENT_MESSENGER_MESH",
19
+ INTERVAL_MS: "AGENT_MESSENGER_MESH_INTERVAL_MS",
20
+ PEER_TTL_MS: "AGENT_MESSENGER_MESH_PEER_TTL_MS"
21
+ };
22
+ function configFromEnv(env = process.env) {
23
+ return {
24
+ port: parseInt(env[ENV.PORT] ?? String(DEFAULT_HTTP_PORT), 10),
25
+ bind: env[ENV.BIND] ?? DEFAULT_BIND,
26
+ token: env[ENV.TOKEN] ?? "",
27
+ id: env[ENV.BROKER_ID] ?? hostname(),
28
+ stateFile: env[ENV.STATE] ?? DEFAULT_STATE_FILE,
29
+ discovery: (env[ENV.DISCOVERY] ?? "on").toLowerCase() !== "off",
30
+ discoveryPort: parseInt(env[ENV.DISCOVERY_PORT] ?? String(DEFAULT_DISCOVERY_PORT), 10),
31
+ discoveryAddr: env[ENV.DISCOVERY_ADDR] ?? "",
32
+ fallback: ["1", "true", "on", "yes"].includes((env[FALLBACK_ENV] ?? "").toLowerCase()),
33
+ fallbackIntervalMs: parseInt(env[FALLBACK_INTERVAL_ENV] ?? String(DEFAULT_FALLBACK_INTERVAL_MS), 10),
34
+ mesh: (env[MESH_ENV.MESH] ?? "off").toLowerCase() === "on",
35
+ meshBrokers: [],
36
+ meshIntervalMs: parseInt(env[MESH_ENV.INTERVAL_MS] ?? "5000", 10),
37
+ meshPeerTtlMs: parseInt(env[MESH_ENV.PEER_TTL_MS] ?? "300000", 10),
38
+ configFile: env[ENV.CONFIG] ?? join(STATE_DIR, "config.json")
39
+ };
40
+ }
41
+ function resolveConfig(options = {}) {
42
+ return defu(options, configFromEnv());
43
+ }
44
+
45
+ const log = createConsola({ stdout: process.stderr, stderr: process.stderr }).withTag(
46
+ "agent-messenger broker"
47
+ );
48
+
49
+ class BrokerState {
50
+ constructor(file) {
51
+ this.file = file;
52
+ }
53
+ peers = /* @__PURE__ */ new Map();
54
+ messages = [];
55
+ nextMsgId = 1;
56
+ pending = [];
57
+ /** Mesh: when each origin broker last synced with us (ms epoch, memory only). Replicated peers live as long as this is fresh. */
58
+ originSeen = /* @__PURE__ */ new Map();
59
+ saveTimer = null;
60
+ /** Read the snapshot if present. A missing or unreadable file starts empty. */
61
+ load() {
62
+ try {
63
+ const raw = JSON.parse(readFileSync(this.file, "utf8"));
64
+ for (const p of raw.peers ?? []) {
65
+ this.peers.set(p.id, p);
66
+ if (p.origin) this.originSeen.set(p.origin, Date.now());
67
+ }
68
+ this.messages = raw.messages ?? [];
69
+ this.nextMsgId = raw.nextMsgId ?? 1;
70
+ this.pending = raw.pending ?? [];
71
+ log.info(`Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s))`);
72
+ } catch {
73
+ }
74
+ }
75
+ /** Snapshot to disk after 200 ms; repeated calls inside the window coalesce. */
76
+ scheduleSave() {
77
+ if (this.saveTimer) return;
78
+ this.saveTimer = setTimeout(() => {
79
+ this.saveTimer = null;
80
+ this.saveNow();
81
+ }, 200);
82
+ }
83
+ /** Write the snapshot immediately (also cancels a pending debounced save). */
84
+ saveNow() {
85
+ if (this.saveTimer) {
86
+ clearTimeout(this.saveTimer);
87
+ this.saveTimer = null;
88
+ }
89
+ try {
90
+ mkdirSync(dirname(this.file), { recursive: true });
91
+ const tmp = `${this.file}.tmp`;
92
+ const snapshot = {
93
+ peers: [...this.peers.values()],
94
+ messages: this.messages,
95
+ nextMsgId: this.nextMsgId,
96
+ ...this.pending.length ? { pending: this.pending } : {}
97
+ };
98
+ writeFileSync(tmp, JSON.stringify(snapshot, null, 1));
99
+ renameSync(tmp, this.file);
100
+ } catch (e) {
101
+ log.warn(`Save failed: ${e.message}`);
102
+ }
103
+ }
104
+ /** True when a debounced save is still pending. */
105
+ get savePending() {
106
+ return this.saveTimer !== null;
107
+ }
108
+ }
109
+
110
+ const PENDING_RELAY_TTL_MS = 60 * 60 * 1e3;
111
+ const DEFAULT_MESH_PEER_TTL_MS = 3e5;
112
+ const MESH_KEY_MEMORY = 2e3;
113
+ class BrokerHandlers {
114
+ constructor(state, opts = {}) {
115
+ this.state = state;
116
+ this.opts = opts;
117
+ }
118
+ recentMeshKeys = /* @__PURE__ */ new Set();
119
+ // --- peer bookkeeping ---
120
+ /** Cutoff `last_seen` (ISO) below which a peer counts as stale. */
121
+ static cutoff() {
122
+ return new Date(Date.now() - HEARTBEAT_TIMEOUT_MS).toISOString();
123
+ }
124
+ /**
125
+ * Direct peers: `last_seen` within the heartbeat timeout. Replicated peers
126
+ * (mesh): their origin broker synced within `meshPeerTtlMs`; `last_seen` is
127
+ * the origin's clock and is not compared.
128
+ */
129
+ isAlive(p, cutoff) {
130
+ if (p.origin) {
131
+ const seen = this.state.originSeen.get(p.origin) ?? 0;
132
+ return Date.now() - seen < (this.opts.meshPeerTtlMs ?? DEFAULT_MESH_PEER_TTL_MS);
133
+ }
134
+ return p.last_seen >= cutoff;
135
+ }
136
+ /** Number of peers seen within `HEARTBEAT_TIMEOUT_MS` (for `/health`). */
137
+ alivePeerCount() {
138
+ const cutoff = BrokerHandlers.cutoff();
139
+ return [...this.state.peers.values()].filter((p) => this.isAlive(p, cutoff)).length;
140
+ }
141
+ cleanStalePeers() {
142
+ const cutoff = BrokerHandlers.cutoff();
143
+ let removed = 0;
144
+ for (const p of [...this.state.peers.values()]) {
145
+ if (!this.isAlive(p, cutoff)) {
146
+ this.removePeer(p.id);
147
+ removed++;
148
+ }
149
+ }
150
+ if (removed) {
151
+ log.info(`Cleaned ${removed} stale peer(s)`);
152
+ this.state.scheduleSave();
153
+ }
154
+ const expiry = new Date(Date.now() - PENDING_RELAY_TTL_MS).toISOString();
155
+ const kept = this.state.pending.filter((r) => r.queued_at >= expiry);
156
+ if (kept.length !== this.state.pending.length) {
157
+ log.info(`Dropped ${this.state.pending.length - kept.length} expired relayed message(s)`);
158
+ this.state.pending = kept;
159
+ this.state.scheduleSave();
160
+ }
161
+ }
162
+ /** A peer that registered here (not a mesh replica). Only those can heartbeat / rename. */
163
+ localPeer(id) {
164
+ const p = this.state.peers.get(id);
165
+ return p && !p.origin ? p : void 0;
166
+ }
167
+ removePeer(id) {
168
+ this.state.peers.delete(id);
169
+ this.state.messages = this.state.messages.filter((m) => !(m.to_id === id && m.delivered === 0));
170
+ }
171
+ /** Resolve a peer by id or unique name. */
172
+ resolvePeer(to) {
173
+ const byId = this.state.peers.get(to);
174
+ if (byId) return { peer: byId };
175
+ const byName = [...this.state.peers.values()].filter((p) => p.name && p.name === to);
176
+ if (byName.length === 1) return { peer: byName[0] };
177
+ if (byName.length > 1) {
178
+ return {
179
+ error: `Name "${to}" is ambiguous: ${byName.map((p) => `${p.id}@${p.hostname}`).join(", ")}`
180
+ };
181
+ }
182
+ return { error: `Peer "${to}" not found (no id or name matches)` };
183
+ }
184
+ static generateId() {
185
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
186
+ let id = "";
187
+ for (let i = 0; i < 8; i++) id += chars[Math.floor(Math.random() * chars.length)];
188
+ return id;
189
+ }
190
+ // --- routes ---
191
+ register(body) {
192
+ for (const p of [...this.state.peers.values()]) {
193
+ if (p.pid === body.pid && p.hostname === body.hostname) this.removePeer(p.id);
194
+ }
195
+ const id = BrokerHandlers.generateId();
196
+ const now = (/* @__PURE__ */ new Date()).toISOString();
197
+ this.state.peers.set(id, {
198
+ id,
199
+ name: body.name ?? "",
200
+ pid: body.pid,
201
+ hostname: body.hostname ?? "",
202
+ cwd: body.cwd ?? "",
203
+ git_root: body.git_root ?? null,
204
+ tty: body.tty ?? null,
205
+ summary: body.summary ?? "",
206
+ registered_at: now,
207
+ last_seen: now
208
+ });
209
+ this.deliverPending(this.state.peers.get(id));
210
+ this.state.scheduleSave();
211
+ return { id };
212
+ }
213
+ heartbeat(body) {
214
+ const p = this.localPeer(body.id);
215
+ if (!p) return { ok: false, error: "unknown peer (re-register)" };
216
+ p.last_seen = (/* @__PURE__ */ new Date()).toISOString();
217
+ this.state.scheduleSave();
218
+ return { ok: true };
219
+ }
220
+ setSummary(body) {
221
+ const p = this.localPeer(body.id);
222
+ if (!p) return { ok: false, error: "unknown peer" };
223
+ p.summary = body.summary ?? "";
224
+ this.state.scheduleSave();
225
+ return { ok: true };
226
+ }
227
+ setName(body) {
228
+ const p = this.localPeer(body.id);
229
+ if (!p) return { ok: false, error: "unknown peer" };
230
+ p.name = body.name ?? "";
231
+ this.deliverPending(p);
232
+ this.state.scheduleSave();
233
+ return { ok: true };
234
+ }
235
+ listPeers(body) {
236
+ const cutoff = BrokerHandlers.cutoff();
237
+ let list = [...this.state.peers.values()].filter((p) => this.isAlive(p, cutoff));
238
+ switch (body.scope) {
239
+ case "host":
240
+ if (body.hostname) list = list.filter((p) => p.hostname === body.hostname);
241
+ break;
242
+ case "directory":
243
+ list = list.filter((p) => p.cwd === body.cwd);
244
+ break;
245
+ case "repo":
246
+ list = body.git_root ? list.filter((p) => p.git_root === body.git_root) : list.filter((p) => p.cwd === body.cwd);
247
+ break;
248
+ }
249
+ if (body.exclude_id) list = list.filter((p) => p.id !== body.exclude_id);
250
+ return list.map(({ origin: _origin, ...p }) => p);
251
+ }
252
+ sendMessage(body) {
253
+ const resolved = this.resolvePeer(body.to);
254
+ if (resolved.error !== void 0) return { ok: false, error: resolved.error };
255
+ const { peer } = resolved;
256
+ const mode = body.mode ?? "pull";
257
+ if (mode !== "pull" && mode !== "push" && mode !== "ping") return { ok: false, error: `invalid mode: ${String(mode)}` };
258
+ const msg = {
259
+ id: this.state.nextMsgId++,
260
+ from_id: body.from_id ?? "cli",
261
+ from_name: body.from_name ?? "",
262
+ to_id: peer.id,
263
+ text: body.text ?? "",
264
+ mode,
265
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
266
+ delivered: 0,
267
+ acknowledged: 0
268
+ };
269
+ this.state.messages.push(msg);
270
+ this.state.scheduleSave();
271
+ if (peer.origin) this.opts.onRemoteMessage?.();
272
+ return { ok: true, to_id: peer.id, to_name: peer.name, message_id: msg.id };
273
+ }
274
+ pollMessages(body) {
275
+ const out = this.state.messages.filter((m) => m.to_id === body.id && m.delivered === 0);
276
+ for (const m of out) m.delivered = 1;
277
+ if (out.length) this.state.scheduleSave();
278
+ return { messages: out };
279
+ }
280
+ acknowledgeMessages(body) {
281
+ const ids = new Set(body.message_ids ?? []);
282
+ const before = this.state.messages.length;
283
+ this.state.messages = this.state.messages.filter((m) => !(m.to_id === body.id && ids.has(m.id)));
284
+ if (this.state.messages.length !== before) this.state.scheduleSave();
285
+ return { ok: true };
286
+ }
287
+ getBufferedMessages(body) {
288
+ return {
289
+ messages: this.state.messages.filter(
290
+ (m) => m.to_id === body.id && m.delivered === 1 && m.acknowledged === 0
291
+ )
292
+ };
293
+ }
294
+ unregister(body) {
295
+ this.removePeer(body.id);
296
+ this.state.scheduleSave();
297
+ return { ok: true };
298
+ }
299
+ // --- relay from a fallback broker ---
300
+ /** Queue a message for `peerId` exactly like `sendMessage` does. */
301
+ enqueue(peerId, m) {
302
+ this.state.messages.push({
303
+ id: this.state.nextMsgId++,
304
+ from_id: m.from_id,
305
+ from_name: m.from_name,
306
+ to_id: peerId,
307
+ text: m.text,
308
+ mode: m.mode ?? "pull",
309
+ sent_at: m.sent_at,
310
+ delivered: 0,
311
+ acknowledged: 0
312
+ });
313
+ }
314
+ /** Give `peer` the relayed messages that were waiting for its name. */
315
+ deliverPending(peer) {
316
+ if (!peer.name || this.state.pending.length === 0) return;
317
+ const mine = (r) => r.to_name === peer.name && (!r.to_hostname || r.to_hostname === peer.hostname);
318
+ const hits = this.state.pending.filter(mine);
319
+ if (hits.length === 0) return;
320
+ for (const r of hits) this.enqueue(peer.id, r);
321
+ this.state.pending = this.state.pending.filter((r) => !mine(r));
322
+ log.info(`Delivered ${hits.length} relayed message(s) to ${peer.name} (${peer.id})`);
323
+ }
324
+ /**
325
+ * `/relay-messages`: undelivered messages of a fallback broker. Peer ids are broker-local, so the
326
+ * recipient is addressed by name (and hostname). A recipient that is not registered yet (its client
327
+ * moves over only after the fallback exits) gets the message when it registers.
328
+ */
329
+ relayMessages(body) {
330
+ const res = { ok: true, delivered: 0, queued: 0, dropped: 0 };
331
+ const cutoff = BrokerHandlers.cutoff();
332
+ for (const m of body.messages ?? []) {
333
+ if (!m.to_name) {
334
+ res.dropped++;
335
+ continue;
336
+ }
337
+ let cands = [...this.state.peers.values()].filter((p) => p.name === m.to_name && this.isAlive(p, cutoff));
338
+ if (m.to_hostname && cands.some((p) => p.hostname === m.to_hostname)) cands = cands.filter((p) => p.hostname === m.to_hostname);
339
+ if (cands.length === 1) {
340
+ this.enqueue(cands[0].id, m);
341
+ res.delivered++;
342
+ } else {
343
+ this.state.pending.push({ ...m, queued_at: (/* @__PURE__ */ new Date()).toISOString() });
344
+ res.queued++;
345
+ }
346
+ }
347
+ this.state.scheduleSave();
348
+ log.info(`Relay: ${res.delivered} delivered, ${res.queued} queued, ${res.dropped} dropped`);
349
+ return res;
350
+ }
351
+ // --- mesh (permanent broker sync) ---
352
+ /** Live peers that registered with this broker directly. */
353
+ meshLocalPeers() {
354
+ const cutoff = BrokerHandlers.cutoff();
355
+ return [...this.state.peers.values()].filter((p) => !p.origin && this.isAlive(p, cutoff));
356
+ }
357
+ /** Undelivered messages addressed to peers replicated from `origin` (to be forwarded there). */
358
+ meshOutbox(origin) {
359
+ return this.state.messages.filter((m) => {
360
+ if (m.delivered !== 0) return false;
361
+ return this.state.peers.get(m.to_id)?.origin === origin;
362
+ });
363
+ }
364
+ /** Drop messages the other broker took over. */
365
+ meshDrop(ids) {
366
+ if (!ids.length) return;
367
+ const gone = new Set(ids);
368
+ this.state.messages = this.state.messages.filter((m) => !gone.has(m.id));
369
+ this.state.scheduleSave();
370
+ }
371
+ /** Replace everything held for `origin` by `peers` and note that `origin` is alive. */
372
+ meshApplyPeers(origin, peers) {
373
+ const now = (/* @__PURE__ */ new Date()).toISOString();
374
+ this.state.originSeen.set(origin, Date.now());
375
+ const incoming = /* @__PURE__ */ new Set();
376
+ for (const p of peers) {
377
+ const local = this.state.peers.get(p.id);
378
+ if (local && !local.origin) continue;
379
+ const moved = [...this.state.peers.values()].some((q) => !q.origin && q.pid === p.pid && q.hostname === p.hostname);
380
+ if (moved) continue;
381
+ incoming.add(p.id);
382
+ this.state.peers.set(p.id, { ...p, origin, last_seen: now });
383
+ }
384
+ for (const p of [...this.state.peers.values()]) {
385
+ if (p.origin === origin && !incoming.has(p.id)) this.removePeer(p.id);
386
+ }
387
+ this.state.scheduleSave();
388
+ }
389
+ /** Take over messages forwarded by broker `from`; returns the sender-side ids to drop (duplicates count as taken). */
390
+ meshAccept(from, messages) {
391
+ const accepted = [];
392
+ for (const m of messages) {
393
+ const target = this.state.peers.get(m.to_id);
394
+ if (!target || target.origin) continue;
395
+ const key = `${from}:${m.id}`;
396
+ const dup = this.recentMeshKeys.has(key) || this.state.messages.some((x) => x.mesh_key === key);
397
+ if (!dup) {
398
+ this.state.messages.push({ ...m, id: this.state.nextMsgId++, delivered: 0, acknowledged: 0, mesh_key: key });
399
+ this.recentMeshKeys.add(key);
400
+ if (this.recentMeshKeys.size > MESH_KEY_MEMORY) this.recentMeshKeys.delete(this.recentMeshKeys.values().next().value);
401
+ }
402
+ accepted.push(m.id);
403
+ }
404
+ if (accepted.length) this.state.scheduleSave();
405
+ return accepted;
406
+ }
407
+ /** Inbound `/mesh/sync`. */
408
+ meshSync(body) {
409
+ if (!body.from) throw new Error("mesh sync: missing from");
410
+ if (body.from === this.opts.id) return { id: body.from, peers: [], accepted: [] };
411
+ this.meshApplyPeers(body.from, body.peers ?? []);
412
+ const accepted = this.meshAccept(body.from, body.messages ?? []);
413
+ return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted };
414
+ }
415
+ /** Route table in `BROKER_POST_ROUTES` order. */
416
+ routes() {
417
+ return {
418
+ "/register": (b) => this.register(b),
419
+ "/heartbeat": (b) => this.heartbeat(b),
420
+ "/set-summary": (b) => this.setSummary(b),
421
+ "/set-name": (b) => this.setName(b),
422
+ "/list-peers": (b) => this.listPeers(b),
423
+ "/send-message": (b) => this.sendMessage(b),
424
+ "/poll-messages": (b) => this.pollMessages(b),
425
+ "/acknowledge-messages": (b) => this.acknowledgeMessages(b),
426
+ "/get-buffered-messages": (b) => this.getBufferedMessages(b),
427
+ "/unregister": (b) => this.unregister(b)
428
+ };
429
+ }
430
+ }
431
+
432
+ class Mesh {
433
+ constructor(opts) {
434
+ this.opts = opts;
435
+ }
436
+ timer = null;
437
+ running = false;
438
+ again = false;
439
+ stopped = false;
440
+ links = /* @__PURE__ */ new Map();
441
+ /** url -> id of the broker that answered there */
442
+ origins = /* @__PURE__ */ new Map();
443
+ start() {
444
+ log.info(`mesh on (id ${this.opts.id}, every ${this.opts.intervalMs}ms)`);
445
+ this.timer = setInterval(() => void this.syncOnce(), this.opts.intervalMs);
446
+ this.timer.unref();
447
+ void this.syncOnce();
448
+ }
449
+ stop() {
450
+ this.stopped = true;
451
+ if (this.timer) clearInterval(this.timer);
452
+ this.timer = null;
453
+ }
454
+ /** Sync soon (coalesced). */
455
+ kick() {
456
+ if (this.running) this.again = true;
457
+ else setImmediate(() => void this.syncOnce());
458
+ }
459
+ /** One round against every listed broker. Never throws. */
460
+ async syncOnce() {
461
+ if (this.running) {
462
+ this.again = true;
463
+ return;
464
+ }
465
+ this.running = true;
466
+ try {
467
+ do {
468
+ this.again = false;
469
+ await Promise.all(this.opts.urls().map((url) => this.syncWith(url)));
470
+ } while (this.again && !this.stopped);
471
+ } finally {
472
+ this.running = false;
473
+ }
474
+ }
475
+ setLink(url, state, detail = "") {
476
+ const prev = this.links.get(url);
477
+ this.links.set(url, state);
478
+ if (prev === state) return;
479
+ if (state === "up") log.info(`mesh: ${url} ${prev === "down" ? "is back, synced" : "joined"}`);
480
+ else if (state === "down") log.warn(`mesh: ${url} unreachable${detail ? ` (${detail})` : ""}; will keep retrying`);
481
+ else if (state === "unsupported") log.warn(`mesh: ${url} does not take part in the mesh (${detail})`);
482
+ else if (state === "self") log.info(`mesh: ${url} is this broker, skipped`);
483
+ }
484
+ async syncWith(url) {
485
+ if (this.links.get(url) === "self" || this.stopped) return;
486
+ const { handlers, id } = this.opts;
487
+ const origin = this.origins.get(url);
488
+ const req = {
489
+ from: id,
490
+ peers: handlers.meshLocalPeers(),
491
+ messages: origin ? handlers.meshOutbox(origin) : []
492
+ };
493
+ let res;
494
+ try {
495
+ const headers = { "Content-Type": "application/json" };
496
+ if (this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
497
+ const r = await postJson(`${url}${MESH_SYNC_ROUTE}`, headers, JSON.stringify(req), this.opts.timeoutMs ?? 3e3);
498
+ if (r.status === 404) return this.setLink(url, "unsupported", "HTTP 404");
499
+ if (r.status < 200 || r.status >= 300) return this.setLink(url, "down", `HTTP ${r.status}`);
500
+ res = JSON.parse(r.body);
501
+ } catch (e) {
502
+ return this.setLink(url, "down", e.message);
503
+ }
504
+ if (!res.id) return this.setLink(url, "unsupported", "no broker id in answer");
505
+ if (res.id === id) return this.setLink(url, "self");
506
+ const learned = this.origins.get(url) !== res.id;
507
+ this.origins.set(url, res.id);
508
+ handlers.meshApplyPeers(res.id, res.peers ?? []);
509
+ handlers.meshDrop(res.accepted ?? []);
510
+ if (learned && handlers.meshOutbox(res.id).length) this.again = true;
511
+ this.setLink(url, "up");
512
+ }
513
+ }
514
+ function readMeshBrokers(configFile) {
515
+ try {
516
+ const parsed = JSON.parse(readFileSync(configFile, "utf8"));
517
+ const list = Array.isArray(parsed.brokers) ? parsed.brokers : parsed.broker ? [parsed.broker] : [];
518
+ const out = [];
519
+ for (const s of list) {
520
+ if (typeof s !== "string" || !s.trim()) continue;
521
+ try {
522
+ out.push(normalizeBrokerUrl(s));
523
+ } catch {
524
+ log.warn(`mesh: ignoring unparsable broker entry "${s}"`);
525
+ }
526
+ }
527
+ return out;
528
+ } catch {
529
+ return [];
530
+ }
531
+ }
532
+ function normalizeBrokerUrl(s) {
533
+ s = s.trim();
534
+ if (!/^https?:\/\//.test(s)) s = `http://${s}`;
535
+ const u = new URL(s);
536
+ if (!u.port) u.port = String(DEFAULT_HTTP_PORT);
537
+ return u.origin;
538
+ }
539
+ function postJson(url, headers, body, timeoutMs) {
540
+ return new Promise((resolve, reject) => {
541
+ const req = request(url, { method: "POST", agent: false, headers: { ...headers, "Content-Length": Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
542
+ const chunks = [];
543
+ res.on("data", (d) => chunks.push(d));
544
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }));
545
+ res.on("error", reject);
546
+ });
547
+ req.on("timeout", () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
548
+ req.on("error", reject);
549
+ req.end(body);
550
+ });
551
+ }
552
+
553
+ function createBrokerApp(opts) {
554
+ const routes = opts.handlers.routes();
555
+ const router = createRouter();
556
+ router.get(HEALTH_ROUTE, defineEventHandler(() => ({
557
+ status: "ok",
558
+ version: opts.version,
559
+ id: opts.id,
560
+ port: opts.port(),
561
+ peers: opts.handlers.alivePeerCount(),
562
+ ...opts.fallback ? { fallback: true } : {}
563
+ })));
564
+ const post = (route, run) => router.post(route, defineEventHandler(async (event) => {
565
+ if (!authorized(event, opts.token)) return unauthorized(event);
566
+ try {
567
+ return run(await readJson(event));
568
+ } catch (e) {
569
+ setResponseStatus(event, 500);
570
+ return { error: e.message };
571
+ }
572
+ }));
573
+ for (const route of BROKER_POST_ROUTES) post(route, (body) => routes[route](body));
574
+ post(RELAY_ROUTE, (body) => opts.handlers.relayMessages(body));
575
+ if (opts.mesh) post(MESH_SYNC_ROUTE, (body) => opts.handlers.meshSync(body));
576
+ const app = createApp();
577
+ app.use(router);
578
+ app.use(defineEventHandler(async (event) => {
579
+ if (event.method !== "POST") {
580
+ return send(event, `agent-messenger broker ${opts.version}
581
+ `, "text/plain");
582
+ }
583
+ if (!authorized(event, opts.token)) return unauthorized(event);
584
+ setResponseStatus(event, 404);
585
+ return { error: "not found" };
586
+ }));
587
+ return app;
588
+ }
589
+ function authorized(event, token) {
590
+ return !token || getRequestHeader(event, "authorization") === `Bearer ${token}`;
591
+ }
592
+ function unauthorized(event) {
593
+ setResponseStatus(event, 401);
594
+ return { error: "unauthorized" };
595
+ }
596
+ async function readJson(event) {
597
+ const raw = await readRawBody(event, "utf8");
598
+ return raw ? JSON.parse(raw) : {};
599
+ }
600
+
601
+ function startDiscoveryResponder(opts) {
602
+ const disco = createSocket({ type: "udp4", reuseAddr: true });
603
+ disco.on("error", (e) => log.warn(`discovery responder error: ${e.message}`));
604
+ disco.on("message", (msg, rinfo) => {
605
+ try {
606
+ if (JSON.parse(msg.toString())[DISCOVERY_KEY] !== DISCOVERY_PROBE_VALUE) return;
607
+ } catch {
608
+ return;
609
+ }
610
+ const reply = Buffer.from(JSON.stringify(opts.reply()));
611
+ disco.send(reply, rinfo.port, rinfo.address, () => {
612
+ });
613
+ });
614
+ const bindAddr = opts.addr && !opts.addr.endsWith(".255") ? opts.addr : "0.0.0.0";
615
+ return new Promise((resolve) => {
616
+ disco.once("error", () => resolve(disco));
617
+ disco.bind(opts.port, bindAddr, () => {
618
+ try {
619
+ disco.setBroadcast(true);
620
+ } catch {
621
+ }
622
+ log.info(`discovery responder on udp ${bindAddr}:${opts.port} (id: ${opts.reply().id})`);
623
+ resolve(disco);
624
+ });
625
+ });
626
+ }
627
+ function probeTargets(addr) {
628
+ if (addr) return [addr];
629
+ const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
630
+ for (const list of Object.values(networkInterfaces())) {
631
+ for (const i of list ?? []) {
632
+ if (i.family !== "IPv4" || i.internal || !i.netmask) continue;
633
+ const ip = i.address.split(".").map(Number);
634
+ const mask = i.netmask.split(".").map(Number);
635
+ targets.add(ip.map((o, k) => o & mask[k] | ~mask[k] & 255).join("."));
636
+ }
637
+ }
638
+ return [...targets];
639
+ }
640
+ function localAddresses() {
641
+ const out = /* @__PURE__ */ new Set(["127.0.0.1"]);
642
+ for (const list of Object.values(networkInterfaces())) {
643
+ for (const i of list ?? []) if (i.family === "IPv4") out.add(i.address);
644
+ }
645
+ return out;
646
+ }
647
+ function probeBrokers(opts) {
648
+ return new Promise((resolve) => {
649
+ const sock = createSocket({ type: "udp4" });
650
+ const found = /* @__PURE__ */ new Map();
651
+ let settled = false;
652
+ const finish = () => {
653
+ if (settled) return;
654
+ settled = true;
655
+ clearTimeout(timer);
656
+ try {
657
+ sock.close();
658
+ } catch {
659
+ }
660
+ resolve([...found.values()]);
661
+ };
662
+ const timer = setTimeout(finish, opts.timeoutMs);
663
+ sock.on("error", finish);
664
+ sock.on("message", (msg, rinfo) => {
665
+ try {
666
+ const m = JSON.parse(msg.toString());
667
+ if (m[DISCOVERY_KEY] !== DISCOVERY_REPLY_VALUE || !m.port) return;
668
+ const url = `http://${rinfo.address}:${m.port}`;
669
+ if (!found.has(url)) {
670
+ found.set(url, { url, host: rinfo.address, port: m.port, id: m.id ?? rinfo.address, version: m.version ?? "", fallback: m.fallback === true });
671
+ }
672
+ } catch {
673
+ }
674
+ });
675
+ sock.bind(0, () => {
676
+ try {
677
+ sock.setBroadcast(true);
678
+ } catch {
679
+ }
680
+ const probe = Buffer.from(JSON.stringify({ [DISCOVERY_KEY]: DISCOVERY_PROBE_VALUE }));
681
+ for (const t of probeTargets(opts.addr)) sock.send(probe, opts.port, t, () => {
682
+ });
683
+ });
684
+ });
685
+ }
686
+
687
+ const TIMEOUT_MS = 5e3;
688
+ async function post(url, path, token, body) {
689
+ const headers = { "Content-Type": "application/json" };
690
+ if (token) headers.Authorization = `Bearer ${token}`;
691
+ return fetch(`${url}${path}`, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS) });
692
+ }
693
+ async function relayUndelivered(state, url, token) {
694
+ const items = [];
695
+ let dropped = 0;
696
+ for (const m of state.messages) {
697
+ if (m.delivered !== 0) continue;
698
+ const peer = state.peers.get(m.to_id);
699
+ if (!peer?.name) {
700
+ dropped++;
701
+ continue;
702
+ }
703
+ items.push({
704
+ from_id: m.from_id,
705
+ from_name: m.from_name,
706
+ to_name: peer.name,
707
+ to_hostname: peer.hostname,
708
+ text: m.text,
709
+ mode: m.mode ?? "pull",
710
+ sent_at: m.sent_at
711
+ });
712
+ }
713
+ let sent = 0;
714
+ if (items.length) {
715
+ const res = await post(url, RELAY_ROUTE, token, { messages: items });
716
+ if (res.ok) {
717
+ const r = await res.json();
718
+ sent = r.delivered + r.queued;
719
+ dropped += r.dropped;
720
+ } else if (res.status === 404) {
721
+ for (const it of items) {
722
+ const r = await post(url, "/send-message", token, { to: it.to_name, from_id: it.from_id, from_name: it.from_name, text: it.text, mode: it.mode });
723
+ if (r.ok && (await r.json()).ok) sent++;
724
+ else dropped++;
725
+ }
726
+ } else {
727
+ throw new Error(`relay to ${url} failed: ${res.status} ${await res.text()}`);
728
+ }
729
+ }
730
+ state.messages = state.messages.filter((m) => m.delivered !== 0);
731
+ state.scheduleSave();
732
+ log.info(`relayed ${sent} message(s) to ${url} (${dropped} dropped: recipient without name or refused)`);
733
+ return { sent, dropped };
734
+ }
735
+
736
+ const version = "0.9.0";
737
+ const pkg = {
738
+ version: version};
739
+
740
+ const VERSION = pkg.version;
741
+ const CLEAN_INTERVAL_MS = 3e4;
742
+ async function startBroker(options = {}) {
743
+ const { onSupersede, ...rest } = options;
744
+ const cfg = resolveConfig(rest);
745
+ const state = new BrokerState(cfg.stateFile);
746
+ state.load();
747
+ let mesh = null;
748
+ const handlers = new BrokerHandlers(state, {
749
+ id: cfg.id,
750
+ meshPeerTtlMs: cfg.meshPeerTtlMs,
751
+ onRemoteMessage: () => mesh?.kick()
752
+ });
753
+ handlers.cleanStalePeers();
754
+ const cleaner = setInterval(() => handlers.cleanStalePeers(), CLEAN_INTERVAL_MS);
755
+ cleaner.unref();
756
+ let port = cfg.port;
757
+ const app = createBrokerApp({
758
+ handlers,
759
+ version: VERSION,
760
+ id: cfg.id,
761
+ port: () => port,
762
+ token: cfg.token,
763
+ fallback: cfg.fallback,
764
+ mesh: cfg.mesh
765
+ });
766
+ let listener;
767
+ try {
768
+ listener = await listen(toNodeListener(app), {
769
+ port: cfg.port,
770
+ hostname: cfg.bind,
771
+ showURL: false,
772
+ autoClose: false,
773
+ // `isProd` makes listhen fail on a busy port instead of silently picking another one.
774
+ // For port 0 we need listhen's random-port path, which `isProd` would disable.
775
+ isProd: cfg.port !== 0
776
+ });
777
+ } catch (e) {
778
+ clearInterval(cleaner);
779
+ throw e;
780
+ }
781
+ port = listener.address.port;
782
+ const reply = () => ({ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE, id: cfg.id, port, version: VERSION, ...cfg.fallback ? { fallback: true } : {} });
783
+ let disco = null;
784
+ if (cfg.discovery) {
785
+ disco = await startDiscoveryResponder({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, reply });
786
+ }
787
+ if (cfg.mesh) {
788
+ const fixed = cfg.meshBrokers.map(normalizeBrokerUrl);
789
+ mesh = new Mesh({
790
+ id: cfg.id,
791
+ token: cfg.token,
792
+ handlers,
793
+ urls: () => fixed.length ? fixed : readMeshBrokers(cfg.configFile),
794
+ intervalMs: cfg.meshIntervalMs
795
+ });
796
+ mesh.start();
797
+ }
798
+ log.info(`v${VERSION} id=${cfg.id} listening on ${cfg.bind}:${port} (state: ${cfg.stateFile})`);
799
+ if (cfg.fallback) log.info(`FALLBACK mode: looking for another broker every ${cfg.fallbackIntervalMs} ms (discovery ${cfg.discovery ? "on" : "off"})`);
800
+ log.info(cfg.token ? "token auth enabled" : "WARNING: no AGENT_MESSENGER_TOKEN set \u2014 broker is open");
801
+ let closed = false;
802
+ let fallbackTimer = null;
803
+ const close = async () => {
804
+ if (closed) return;
805
+ closed = true;
806
+ clearInterval(cleaner);
807
+ if (fallbackTimer) clearInterval(fallbackTimer);
808
+ mesh?.stop();
809
+ if (disco) {
810
+ await new Promise((resolve) => disco.close(() => resolve()));
811
+ }
812
+ await listener.close();
813
+ if (state.savePending) state.saveNow();
814
+ };
815
+ if (cfg.fallback && cfg.discovery) {
816
+ const mine = localAddresses();
817
+ let busy = false;
818
+ const lookForBroker = async () => {
819
+ if (busy || closed) return;
820
+ busy = true;
821
+ try {
822
+ const found = await probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, timeoutMs: 1e3 });
823
+ const other = found.find((b) => !b.fallback && !(b.port === port && mine.has(b.host)));
824
+ if (!other || closed) return;
825
+ log.info(`fallback: found broker ${other.id} at ${other.url} \u2014 relaying messages and exiting`);
826
+ await relayUndelivered(state, other.url, cfg.token);
827
+ await close();
828
+ log.info("fallback: done");
829
+ onSupersede?.(other.url);
830
+ } catch (e) {
831
+ log.warn(`fallback: ${e.message} \u2014 will retry`);
832
+ } finally {
833
+ busy = false;
834
+ }
835
+ };
836
+ fallbackTimer = setInterval(() => void lookForBroker(), cfg.fallbackIntervalMs);
837
+ fallbackTimer.unref();
838
+ }
839
+ return { close, port, id: cfg.id };
840
+ }
841
+
842
+ export { DEFAULT_STATE_FILE as D, FALLBACK_ENV as F, MESH_ENV as M, STATE_DIR as S, VERSION as V, FALLBACK_INTERVAL_ENV as a, configFromEnv as c, log as l, resolveConfig as r, startBroker as s };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@unagents/messenger-broker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.0",
4
+ "description": "agent-messenger broker: HTTP daemon that tracks peers and routes messages between Claude Code sessions.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/unagents/agent-messenger.git",
10
+ "directory": "packages/broker"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.mts",
15
+ "import": "./dist/index.mjs"
16
+ },
17
+ "./main": "./dist/main.mjs"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "build": "unbuild",
27
+ "typecheck": "tsgo -p tsconfig.json",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@unagents/messenger-protocol": "0.9.0",
32
+ "consola": "^3.4.2",
33
+ "defu": "^6.1.7",
34
+ "h3": "^1.15.11",
35
+ "listhen": "^1.10.1"
36
+ }
37
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.