agent-peers 0.1.0 → 0.3.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.
@@ -1,14 +1,56 @@
1
- import { lstatSync } from "node:fs";
1
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { isAbsolute, join } from "node:path";
4
- import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join } from "node:path";
5
4
  import { VERSION } from "./version.mjs";
6
5
  import { claudeHome, peersHome } from "./wire.mjs";
6
+ import { loggingConfig } from "./logging.mjs";
7
+ import { daemonSocket } from "./app-server.mjs";
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
 
10
- // Inspect metadata only: no configuration writes, daemon startup or model turns.
11
- export function diagnose() {
11
+ function daemonStat(path) {
12
+ const link = lstatSync(path);
13
+ if (!link.isSymbolicLink()) return link;
14
+ if (process.getuid && link.uid !== process.getuid()) throw new Error("daemon socket link must belong to this user");
15
+ // Codex publishes its short runtime socket through an owned symlink. Only this
16
+ // daemon endpoint permits indirection; peer registry/socket rules stay strict.
17
+ let target;
18
+ try { target = realpathSync(path); }
19
+ catch (error) {
20
+ if (error.code === "ENOENT") throw Object.assign(new Error(readlinkSync(path)), { code: "STALE_LINK" });
21
+ throw error;
22
+ }
23
+ for (const directory of [dirname(path), dirname(target)]) {
24
+ const stat = lstatSync(directory);
25
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022)) {
26
+ throw new Error("daemon socket link and target require owned directories without shared write access");
27
+ }
28
+ }
29
+ return lstatSync(target);
30
+ }
31
+
32
+ const START_DAEMON = "start it with `codex app-server daemon start`, or open a Codex session";
33
+
34
+ // One handshake and a loaded-thread read: a socket file can outlive its server, for example
35
+ // across a reboot. Nothing is logged, started or loaded.
36
+ async function daemonAnswers(path) {
37
+ const { AppServer } = await import("./app-server.mjs");
38
+ const app = new AppServer(undefined, { connectTimeoutMs: 2000, handshakeTimeoutMs: 2000, log: () => {} });
39
+ try {
40
+ const result = await app.request("thread/loaded/list", {}, 3000);
41
+ const count = Array.isArray(result?.data) ? `${result.data.length}${result.nextCursor ? "+" : ""}` : "unknown";
42
+ return { status: "ok", message: `${path} (answering; ${count} loaded threads)` };
43
+ } catch (error) {
44
+ const reason = error?.code === "ECONNREFUSED" ? "connection refused" : String(error?.code || error?.message || "no answer");
45
+ const configured = process.env.AGENT_PEERS_CODEX_APP_SERVER ? "the configured app-server" : "Codex's shared daemon";
46
+ return { status: "warning", message: `${path} is not answering (${reason}); ${configured} is not running` +
47
+ (process.env.AGENT_PEERS_CODEX_APP_SERVER ? "" : `: ${START_DAEMON}`) };
48
+ } finally { app.close(); }
49
+ }
50
+
51
+ // Inspect metadata and ask the app-server one read-only question: no configuration writes,
52
+ // daemon startup or model turns.
53
+ export async function diagnose() {
12
54
  const checks = [];
13
55
  const add = (name, status, message) => checks.push({ name, status, message });
14
56
  add("node", Number(process.versions.node.split(".")[0]) >= 22 ? "ok" : "error", process.version);
@@ -20,23 +62,30 @@ export function diagnose() {
20
62
  const paths = [
21
63
  ["claude registry", join(claudeHome(), "sessions"), "directory", false],
22
64
  ["peer registry", peersHome(), "directory", true],
23
- ["codex daemon", process.env.AGENT_PEERS_CODEX_APP_SERVER ||
24
- join(process.env.CODEX_HOME || join(homedir(), ".codex"), "app-server-control", "app-server-control.sock"), "socket", false],
65
+ ["codex daemon", daemonSocket(), "socket", false],
25
66
  ];
26
67
  for (const [name, path, kind, privateMode] of paths) {
27
68
  if (!isAbsolute(path)) { add(name, "error", `expected an absolute path: ${path}`); continue; }
28
69
  try {
29
- const stat = lstatSync(path);
70
+ const stat = kind === "socket" ? daemonStat(path) : lstatSync(path);
30
71
  if (stat.isSymbolicLink() || (kind === "socket" ? !stat.isSocket() : !stat.isDirectory())) {
31
72
  add(name, "error", `${path} must be a ${kind}, not a symlink or another file type`);
32
73
  } else if ((process.getuid && stat.uid !== process.getuid()) || (stat.mode & (privateMode ? 0o077 : 0o022))) {
33
74
  add(name, "error", `${path} must belong to this user and have safe permissions`);
75
+ } else if (kind === "socket") {
76
+ const live = await daemonAnswers(path);
77
+ add(name, live.status, live.message);
34
78
  } else add(name, "ok", path);
35
79
  } catch (error) {
36
- if (error.code === "ENOENT") add(name, "warning", `${path} is absent; start the corresponding agent to use it`);
80
+ if (error.code === "STALE_LINK") add(name, "warning", `${path} links to a missing socket (${error.message}); Codex's shared daemon is not running: ${START_DAEMON}`);
81
+ else if (error.code === "ENOENT") add(name, "warning", `${path} is absent; start the corresponding agent to use it`);
37
82
  else add(name, "error", `${path}: ${error.message}`);
38
83
  }
39
84
  }
40
- add("compatibility", "warning", "Socket presence is not a delivery check. Codex must use its shared daemon; Hermes replies require the documented route APIs. See the compatibility guide.");
41
- return { version: VERSION, ok: !checks.some((c) => c.status === "error"), checks };
85
+ const logging = loggingConfig();
86
+ add("logging", logging.enabled && !logging.valid ? "error" : "ok", logging.enabled
87
+ ? `enabled; ${logging.mode}: ${logging.location}${logging.valid ? "" : "; expected an absolute normalized path"}`
88
+ : `disabled; set AGENT_PEERS_DEBUG=1 to capture future events in ${logging.location}`);
89
+ add("compatibility", "warning", "An answering daemon is not a delivery check. Codex threads are reachable only on the app-server agent-peers is pointed at; Hermes replies require the documented route APIs. See the compatibility guide.");
90
+ return { version: VERSION, ok: !checks.some((c) => c.status === "error"), logging, checks };
42
91
  }
package/src/discovery.mjs CHANGED
@@ -40,18 +40,47 @@ export function codexDiscovery(peers, threads, self) {
40
40
  return [...rows.values()];
41
41
  }
42
42
 
43
+ // Explain a route mismatch without sending anything or treating a nickname as a bridge
44
+ // address. Match against all discovered peers so an abbreviation cannot silently select
45
+ // one native agent when another peer has a matching name.
46
+ export function nativeRouteError(to, peers) {
47
+ if (typeof to !== "string" || !to || to.startsWith("uds:")) return null;
48
+ const exact = peers.filter((p) => p.name === to || p.name === `codex:${to}` || p.name === `claude:${to}` ||
49
+ p.native?.target === to || p.threadId === to);
50
+ const matches = exact.length ? exact : peers.filter((p) => p.name.includes(to));
51
+ if (matches.length !== 1) return null;
52
+ const p = matches[0];
53
+ if (p.native?.reachable !== true || !p.native.target || p.bridge) return null;
54
+ const subagent = p.native.target !== "/root";
55
+ let message = `${JSON.stringify(p.name)} is a native Codex ${subagent ? "subagent" : "agent"} without a bridge route; ` +
56
+ `send_peer cannot address it. Use native ${p.native.tool} ${JSON.stringify({ target: p.native.target })}.`;
57
+ if (p.status === "idle") {
58
+ message += subagent
59
+ ? ` It is idle; use native followup_task ${JSON.stringify({ target: p.native.target })} to start a turn; send_message only queues a message.`
60
+ : " Native send_message does not start an idle turn for the root agent.";
61
+ }
62
+ if (subagent) message += " Direct app-server input, including codex_tui.send_message_to_thread, is unsupported for subagents.";
63
+ return message;
64
+ }
65
+
43
66
  export function formatCodexPeer(p) {
44
67
  const native = p.native.reachable === true
45
68
  ? `yes — ${p.native.tool} ${JSON.stringify({ target: p.native.target })}`
46
69
  : `${p.native.reachable === false ? "no" : "unknown"} (${p.native.reason})`;
47
- const bridge = p.bridge ? `${p.bridge.tool} ${JSON.stringify({ to: p.bridge.to })}` : "none (use native messaging)";
48
- return `${p.name} ${p.status ?? ""} cwd=${p.cwd}\n native: ${native}\n bridge: ${bridge}`;
70
+ const subagent = p.native.reachable === true && p.native.target !== "/root";
71
+ const bridge = p.bridge ? `${p.bridge.tool} ${JSON.stringify({ to: p.bridge.to })}`
72
+ : subagent ? "none (use native messaging; send_peer and direct app-server input cannot target this subagent)"
73
+ : p.native.reachable === true ? "none (use native messaging)" : "none (check list_agents for a native target)";
74
+ const wake = subagent && p.status === "idle"
75
+ ? `\n start idle turn: followup_task ${JSON.stringify({ target: p.native.target })} (send_message only queues a message)` : "";
76
+ return `${p.name} ${p.status ?? ""} cwd=${p.cwd}\n native: ${native}\n bridge: ${bridge}${wake}`;
49
77
  }
50
78
 
51
79
  // The CLI is also used outside Codex, where there is no caller thread to compare.
52
80
  // Name the platform explicitly rather than implying Claude's SendMessage is a Codex tool.
53
81
  export function formatSessionPeer(p) {
54
- return `${p.name} ${p.status ?? ""} cwd=${p.cwd}\n` +
82
+ return `${p.name} ${p.status ?? ""}${p.reachable === false ? " stale" : ""} cwd=${p.cwd}\n` +
83
+ (p.reachable === false ? " stale: the socket refused a connection; the record is kept\n" : "") +
55
84
  ` Claude native: SendMessage ${JSON.stringify({ to: p.address })}\n` +
56
85
  ` Codex bridge: send_peer ${JSON.stringify({ to: p.address })}`;
57
86
  }
package/src/guidance.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  // Codex's session guidance; the same preference appears in the Claude setup snippet.
2
2
  export const coordinationGuidance =
3
- "Before messaging, weigh its value against token cost and distraction; keep it concise and avoid repetitive or low-value chatter. " +
4
- "Prefer coordination around scope, decisions, and handoffs. " +
5
- "Routine progress updates can usually wait; use your judgment about when a discussion or reply would help.";
3
+ "Keep coordination useful and brief. Send what's new; link existing detail rather than repeating it. " +
4
+ "Reply when it adds value.";
@@ -6,6 +6,7 @@ import { chmodSync, lstatSync, rmSync } from "node:fs";
6
6
  import { createServer } from "node:net";
7
7
  import { dirname, join } from "node:path";
8
8
  import { AppServer } from "./app-server.mjs";
9
+ import { errorFields, logEvent, logIdentity } from "./logging.mjs";
9
10
  import {
10
11
  MAX_LINE, MAX_SOCKET_PATH, address, allPeers, messageLine, parseEnvelope, peerSocket, post, readLines,
11
12
  } from "./wire.mjs";
@@ -14,6 +15,11 @@ const BUCKET = 30, REFILL_PER_S = 0.5, DEDUP_MS = 30_000, IDLE_MS = 600_000;
14
15
  const QUEUE_MAX = 50, TOTAL_QUEUE_MAX = 200, SOURCES_MAX = 256, SESSIONS_MAX = 256;
15
16
  const IPC_QUEUE_MAX = 128, IPC_WRITE_TIMEOUT_MS = 5000;
16
17
  const logger = (message) => console.error(`[hermes-bridge ${process.pid}] ${message}`);
18
+ const event = (name, fields) => logEvent("hermes-bridge", name, fields);
19
+ const safeError = (error) => {
20
+ const { reason, errorCode } = errorFields(error);
21
+ return errorCode && errorCode !== "unknown" ? `${reason} (${errorCode})` : reason;
22
+ };
17
23
 
18
24
  function requiredString(value, field, max = MAX_LINE) {
19
25
  if (typeof value !== "string" || !value.trim() || value.length > max) {
@@ -53,7 +59,39 @@ export class HermesBridge {
53
59
  this.#log = log;
54
60
  }
55
61
 
62
+ #introduced = new Set();
63
+
64
+ #firstContact(key) {
65
+ if (this.#introduced.has(key)) return false;
66
+ if (this.#introduced.size >= 4096) this.#introduced.clear();
67
+ this.#introduced.add(key);
68
+ return true;
69
+ }
70
+
56
71
  async request(method, params = {}) {
72
+ const operationId = randomUUID(), started = performance.now();
73
+ const fields = {
74
+ operationId, method: ["list_peers", "send_peer", "close_session"].includes(method) ? method : "unknown",
75
+ ...(typeof params?.session === "string" ? { sessionId: logIdentity(params.session) } : {}),
76
+ ...(typeof params?.to === "string" ? { targetId: logIdentity(params.to) } : {}),
77
+ ...(typeof params?.message === "string" ? { bytes: Buffer.byteLength(params.message) } : {}),
78
+ };
79
+ event("tool.request", fields);
80
+ try {
81
+ const result = await this.#request(method, params, operationId);
82
+ event("tool.result", {
83
+ ...fields, durationMs: performance.now() - started,
84
+ outcome: result.note || result.status && result.status !== "delivered" ? "warning" : "success",
85
+ ...(Array.isArray(result.peers) ? { bridgeCount: result.peers.length } : {}),
86
+ });
87
+ return result;
88
+ } catch (error) {
89
+ event("tool.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ async #request(method, params, operationId) {
57
95
  if (this.#closed) throw new Error("Hermes bridge is closed");
58
96
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error("params must be an object");
59
97
  if (method === "list_peers") return this.#listPeers();
@@ -63,7 +101,7 @@ export class HermesBridge {
63
101
  const previous = this.#locks.get(session) ?? Promise.resolve();
64
102
  const operation = previous.catch(() => {}).then(async () => {
65
103
  if (this.#closed) throw new Error("Hermes bridge is closed");
66
- if (method === "send_peer") return this.#send(session, params);
104
+ if (method === "send_peer") return this.#send(session, params, operationId);
67
105
  const routes = [...this.#routes.values()].filter((route) => route.session === session);
68
106
  await Promise.all(routes.map((route) => this.#retire(route)));
69
107
  return { closed: routes.length > 0 };
@@ -137,12 +175,18 @@ export class HermesBridge {
137
175
  buckets: new Map(), recent: new Map(), pending: new Map(), queued: 0, chain: Promise.resolve(),
138
176
  };
139
177
  route.server = createServer((conn) => {
140
- if (route.closed || route.connections.size >= QUEUE_MAX) return conn.destroy();
178
+ if (route.closed || route.connections.size >= QUEUE_MAX) {
179
+ event("receive.dropped", { sessionId: logIdentity(session), reason: route.closed ? "closed" : "queue_full" });
180
+ return conn.destroy();
181
+ }
141
182
  route.connections.add(conn);
142
183
  conn.once("close", () => route.connections.delete(conn));
143
184
  readLines(conn, (line) => this.#receive(route, line));
144
185
  });
145
- route.server.on("error", (error) => this.#log(`inbox error: ${error.message}`));
186
+ route.server.on("error", (error) => {
187
+ event("inbox.error", { sessionId: logIdentity(session), ...errorFields(error) });
188
+ this.#log(`inbox error: ${safeError(error)}`);
189
+ });
146
190
  try {
147
191
  await new Promise((resolve, reject) => {
148
192
  route.server.once("error", reject);
@@ -152,6 +196,7 @@ export class HermesBridge {
152
196
  route.owner = lstatSync(socket);
153
197
  chmodSync(socket, 0o600);
154
198
  this.#routes.set(key, route);
199
+ event("inbox.bound", { sessionId: logIdentity(session), targetId: logIdentity(route.address) });
155
200
  } catch (error) {
156
201
  route.closed = true;
157
202
  for (const conn of route.connections) conn.destroy();
@@ -163,7 +208,30 @@ export class HermesBridge {
163
208
  return route;
164
209
  }
165
210
 
166
- async #send(session, params) {
211
+ async #send(session, params, operationId) {
212
+ const started = performance.now(), messageId = randomUUID();
213
+ const fields = {
214
+ operationId, messageId: logIdentity(messageId), sessionId: logIdentity(session),
215
+ ...(typeof params.to === "string" ? { targetId: logIdentity(params.to) } : {}),
216
+ ...(typeof params.message === "string" ? { bytes: Buffer.byteLength(params.message) } : {}),
217
+ };
218
+ event("send.started", fields);
219
+ try {
220
+ const result = await this.#post(session, params, messageId, fields);
221
+ const status = result.status;
222
+ event("send.result", {
223
+ ...fields, durationMs: performance.now() - started,
224
+ outcome: status === "held" ? "held" : status === "delivered" ? "receipt_delivered" : status ? "rejected" : "inbox_written",
225
+ receipt: Boolean(status),
226
+ });
227
+ return result;
228
+ } catch (error) {
229
+ event("send.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ async #post(session, params, messageId, fields) {
167
235
  const name = requiredString(params.name, "name", 80);
168
236
  if (!/^hermes:[A-Za-z0-9:_-]+$/.test(name)) throw new Error("name must identify a Hermes profile as hermes:<profile>");
169
237
  const to = requiredString(params.to, "to", 512);
@@ -173,11 +241,12 @@ export class HermesBridge {
173
241
  if (!targetSocket) throw new Error("the peer registry contains an invalid inbox address; call list_peers again");
174
242
  const route = await this.#bind(session, name, dirname(targetSocket));
175
243
  if (target.address === route.address) throw new Error("that address is this conversation's own inbox");
176
- // Claude labels every external envelope as another Claude session. Correct that frame.
177
- const body = target.kind === "codex" ? message : `${message}\n\n` +
178
- `(From ${name}, a Hermes profile with a private reply inbox. Use your peer messaging tool ` +
179
- `to reply to the sender address. Claude's notify_when_idle is unavailable for this peer.)`;
180
- const line = messageLine({ from: route.address, fromName: name, body });
244
+ // Claude labels every external envelope as another Claude session. Correct that frame once
245
+ // per conversation and recipient: the recipient keeps it in context.
246
+ const body = target.kind === "claude" && this.#firstContact(`${session}\0${target.address}`)
247
+ ? `${message}\n\n(Hermes profile; notify_when_idle unavailable.)`
248
+ : message;
249
+ const line = { ...messageLine({ from: route.address, fromName: name, body }), msg_id: messageId };
181
250
  let finish, timer;
182
251
  const receipt = new Promise((resolve) => {
183
252
  finish = resolve;
@@ -185,12 +254,14 @@ export class HermesBridge {
185
254
  timer = setTimeout(() => resolve(undefined), 1500);
186
255
  });
187
256
  try {
257
+ const started = performance.now();
188
258
  await post(targetSocket, line);
259
+ event("message.send", { ...fields, outcome: "inbox_written", durationMs: performance.now() - started });
189
260
  const status = await receipt;
190
261
  const text = status === "held"
191
262
  ? `Delivered to ${target.name}'s inbox; its session is holding it for its user's approval.`
192
263
  : status && status !== "delivered" ? `Not delivered to ${target.name}: ${status}.`
193
- : `Delivered to ${target.name}'s inbox. Any reply arrives in this Hermes conversation.`;
264
+ : `Delivered to ${target.name}'s inbox.`;
194
265
  return { text, address: route.address, message_id: line.msg_id, ...(status ? { status } : {}) };
195
266
  } finally {
196
267
  clearTimeout(timer);
@@ -203,42 +274,60 @@ export class HermesBridge {
203
274
  const now = Date.now();
204
275
  for (const [key, bucket] of route.buckets) if (now - bucket.at > IDLE_MS) route.buckets.delete(key);
205
276
  for (const [key, time] of route.recent) if (now - time > DEDUP_MS) route.recent.delete(key);
206
- if (!route.buckets.has(from) && route.buckets.size >= SOURCES_MAX) return false;
277
+ if (!route.buckets.has(from) && route.buckets.size >= SOURCES_MAX) return "rate_limit";
207
278
  const bucket = route.buckets.get(from) ?? { tokens: BUCKET, at: now };
208
279
  bucket.tokens = Math.min(BUCKET, bucket.tokens + (now - bucket.at) / 1000 * REFILL_PER_S);
209
280
  bucket.at = now;
210
281
  route.buckets.set(from, bucket);
211
282
  const bodyKey = createHash("sha256").update(from).update("\0").update(body).digest("hex");
212
283
  const idKey = `${from}\0${id}`;
213
- if (bucket.tokens < 1 || route.recent.has(bodyKey) || route.recent.has(idKey) ||
214
- route.queued >= QUEUE_MAX || this.#queued >= TOTAL_QUEUE_MAX) return false;
284
+ if (route.recent.has(bodyKey) || route.recent.has(idKey)) return "duplicate";
285
+ if (bucket.tokens < 1) return "rate_limit";
286
+ if (route.queued >= QUEUE_MAX || this.#queued >= TOTAL_QUEUE_MAX) return "queue_full";
215
287
  bucket.tokens--;
216
288
  route.recent.set(bodyKey, now);
217
289
  route.recent.set(idKey, now);
218
- return true;
290
+ return undefined;
219
291
  }
220
292
 
221
293
  #receive(route, line) {
222
- if (route.closed || !line || typeof line !== "object") return;
294
+ const sessionId = logIdentity(route.session);
295
+ const drop = (reason, fields = {}) => event("receive.dropped", { sessionId, reason, ...fields });
296
+ if (route.closed) return drop("closed");
297
+ if (!line || typeof line !== "object") return drop("malformed");
223
298
  if (line.type === "control" && line.action === "peer_message_status") {
224
- if (typeof line.status === "string" && line.status.length <= 128) route.pending.get(line.orig_msg_id)?.(line.status);
299
+ if (typeof line.status !== "string" || !line.status.length || line.status.length > 128 ||
300
+ typeof line.orig_msg_id !== "string") return drop("malformed");
301
+ route.pending.get(line.orig_msg_id)?.(line.status);
225
302
  return;
226
303
  }
227
- if (line.type !== "user" || typeof line.message?.content !== "string") return;
304
+ if (line.type !== "user" || typeof line.message?.content !== "string") return drop("malformed");
228
305
  const env = parseEnvelope(line.message.content);
229
- if (!env || !peerSocket(env.from) || env.from === route.address || (line.from && line.from !== env.from)) return;
306
+ if (!env || !peerSocket(env.from) || env.from === route.address || (line.from && line.from !== env.from)) return drop("malformed");
230
307
  const id = typeof line.msg_id === "string" && line.msg_id.length <= 256 && line.msg_id ? line.msg_id : randomUUID();
231
- if (!this.#admit(route, env.from, env.body, id)) return;
308
+ const fields = { sessionId, messageId: logIdentity(id), targetId: logIdentity(env.from), bytes: Buffer.byteLength(env.body) };
309
+ const notification = { method: "peer_message", params: {
310
+ session: route.session, message_id: id, from: env.from,
311
+ from_name: /^[A-Za-z0-9:_ .-]{1,80}$/.test(env.fromName ?? "") ? env.fromName : "an unnamed peer",
312
+ message: env.body,
313
+ } };
314
+ // The host reads at most MAX_LINE per line and restarts the bridge on a longer one; a frame
315
+ // that fits the inbox can exceed that once re-encoded with session and message IDs.
316
+ if (Buffer.byteLength(JSON.stringify(notification)) + 1 > MAX_LINE) return drop("message_too_large", fields);
317
+ const reason = this.#admit(route, env.from, env.body, id);
318
+ if (reason) return drop(reason, fields);
232
319
  route.queued++;
233
320
  this.#queued++;
321
+ event("receive.queued", { ...fields, queueDepth: route.queued });
322
+ const started = performance.now();
234
323
  route.chain = route.chain.then(async () => {
235
- if (route.closed) return;
236
- await this.#emit({ method: "peer_message", params: {
237
- session: route.session, message_id: id, from: env.from,
238
- from_name: /^[A-Za-z0-9:_ .-]{1,80}$/.test(env.fromName ?? "") ? env.fromName : "an unnamed peer",
239
- message: env.body,
240
- } });
241
- }).catch((error) => this.#log(`incoming message delivery failed: ${error.message}`))
324
+ if (route.closed) return drop("closed", fields);
325
+ await this.#emit(notification);
326
+ event("delivery.result", { ...fields, outcome: "emitted_to_host", durationMs: performance.now() - started });
327
+ }).catch((error) => {
328
+ event("delivery.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
329
+ this.#log(`incoming message delivery failed: ${safeError(error)}`);
330
+ })
242
331
  .finally(() => { route.queued--; this.#queued--; });
243
332
  }
244
333
 
@@ -253,7 +342,7 @@ export class HermesBridge {
253
342
  const current = lstatSync(route.socket);
254
343
  owned = current.dev === route.owner.dev && current.ino === route.owner.ino;
255
344
  } catch (error) {
256
- if (error.code !== "ENOENT") this.#log(`cannot inspect inbox ownership: ${error.message}`);
345
+ if (error.code !== "ENOENT") this.#log(`cannot inspect inbox ownership: ${safeError(error)}`);
257
346
  }
258
347
  if (owned) {
259
348
  // Node closes and unlinks a Unix listener by pathname; do not close it if somebody
@@ -270,6 +359,7 @@ export class HermesBridge {
270
359
  }
271
360
  route.buckets.clear();
272
361
  route.recent.clear();
362
+ event("inbox.closed", { sessionId: logIdentity(route.session), targetId: logIdentity(route.address), queueDepth: route.queued });
273
363
  }
274
364
 
275
365
  close() {
@@ -292,6 +382,7 @@ export class HermesBridge {
292
382
  export function runHermesBridge() {
293
383
  const write = (message) => new Promise((resolve, reject) => {
294
384
  const timer = setTimeout(() => {
385
+ event("bridge.error", { reason: "timeout" });
295
386
  reject(new Error("bridge output timed out"));
296
387
  void stop(1);
297
388
  }, IPC_WRITE_TIMEOUT_MS);
@@ -312,7 +403,8 @@ export function runHermesBridge() {
312
403
  try {
313
404
  await bridge.close();
314
405
  } catch (error) {
315
- logger(`bridge shutdown failed: ${error.message}`);
406
+ event("bridge.error", errorFields(error));
407
+ logger(`bridge shutdown failed: ${safeError(error)}`);
316
408
  code = 1;
317
409
  }
318
410
  process.exit(code);
@@ -326,6 +418,7 @@ export function runHermesBridge() {
326
418
  const raw = buffer.slice(0, newline);
327
419
  buffer = buffer.slice(newline + 1);
328
420
  if (Buffer.byteLength(raw) + 1 > MAX_LINE || queued >= IPC_QUEUE_MAX) {
421
+ event("bridge.error", { reason: queued >= IPC_QUEUE_MAX ? "queue_full" : "message_too_large", queueDepth: queued });
329
422
  logger("bridge input limit exceeded");
330
423
  void stop(1);
331
424
  return;
@@ -352,25 +445,28 @@ export function runHermesBridge() {
352
445
  const result = await bridge.request(request.method, request.params);
353
446
  await write({ id, result });
354
447
  } catch (error) {
448
+ if (id === null) event("bridge.error", { reason: "malformed" });
355
449
  await write({ id, error: { message: error.message } });
356
450
  }
357
- }).catch((error) => { logger(error.message); void stop(1); }).finally(() => queued--);
451
+ }).catch((error) => { event("bridge.error", errorFields(error)); logger(safeError(error)); void stop(1); }).finally(() => queued--);
358
452
  }
359
453
  if (Buffer.byteLength(buffer) >= MAX_LINE) {
454
+ event("bridge.error", { reason: "message_too_large" });
360
455
  logger("bridge input line too large");
361
456
  void stop(1);
362
457
  }
363
458
  });
364
459
  process.stdin.on("end", () => {
365
460
  if (buffer.trim()) {
461
+ event("bridge.error", { reason: "malformed" });
366
462
  logger("incomplete bridge input at EOF");
367
463
  void stop(1);
368
464
  } else void chain.finally(() => stop());
369
465
  });
370
- process.stdin.on("error", () => { void stop(1); });
371
- process.stdout.on("error", () => { void stop(1); });
466
+ process.stdin.on("error", (error) => { event("bridge.error", errorFields(error)); void stop(1); });
467
+ process.stdout.on("error", (error) => { event("bridge.error", errorFields(error)); void stop(1); });
372
468
  process.once("SIGINT", () => { void stop(); });
373
469
  process.once("SIGTERM", () => { void stop(); });
374
- process.on("uncaughtException", (error) => { logger(error.message); void stop(1); });
470
+ process.on("uncaughtException", (error) => { event("bridge.error", errorFields(error)); logger(safeError(error)); void stop(1); });
375
471
  return bridge;
376
472
  }