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.
@@ -0,0 +1,293 @@
1
+ // Opt-in local diagnostics. Never pass message bodies, names, paths or raw errors.
2
+ // This module is independent of the transports so instrumentation cannot create
3
+ // connections, discover peers or interfere with their stdout protocols.
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, unlinkSync, writeSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, isAbsolute, join, normalize, parse } from "node:path";
8
+ import { VERSION } from "./version.mjs";
9
+
10
+ const FILE_BYTES = 1024 * 1024;
11
+ const TOTAL_BYTES = 16 * FILE_BYTES;
12
+ const FILE_COUNT = 32;
13
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
14
+ const MAX_LINE = 4096;
15
+ const uid = process.getuid?.();
16
+ const instanceId = randomUUID();
17
+ const installationId = createHash("sha256").update(new URL("../", import.meta.url).href).digest("hex").slice(0, 24);
18
+ const processFiles = new Map();
19
+ const lastPruned = new Map();
20
+ const reasons = new Set(["not_found", "ambiguous_target", "native_route_required", "subagent_input_forbidden", "timeout",
21
+ "connection_closed", "permission_denied", "invalid_input", "rate_limit", "unknown", "malformed", "duplicate", "queue_full",
22
+ "closed", "receipt_rejected", "self_send", "no_reply_route", "unloaded_thread", "unsafe_path", "replaced", "unsupported", "message_too_large",
23
+ "connection_limit", "sender_limit", "queue_limit", "queue_bytes_limit", "malformed_envelope", "inbox_replaced", "app_server_unavailable", "held", "delivered",
24
+ "rejected", "other_status", "unconfirmed", "idle_timeout", "frame_too_large", "invalid_json", "incomplete_frame"]);
25
+ const outcomes = new Set(["started", "succeeded", "failed", "success", "error", "warning", "held", "receipt_delivered",
26
+ "rejected", "inbox_written", "emitted_to_host", "receipt_received", "queued", "dropped", "app_server_input_accepted",
27
+ "skipped", "closed", "accepted", "bound", "unbound", "native_only", "degraded", "receipt_unavailable", "unreachable"]);
28
+ const methods = new Set(["list_peers", "send_peer", "close_session", "shutdown", "initialize", "initialized", "thread/loaded/list",
29
+ "thread/read", "thread/resume", "turn/start", "turn/steer", "mcpServerStatus/list", "unknown"]);
30
+ const codes = new Set(["ENOENT", "EACCES", "EPERM", "ELOOP", "ENOTDIR", "EISDIR", "ENOSPC", "EDQUOT", "EROFS", "EMFILE",
31
+ "ENFILE", "EPIPE", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ERR_SOCKET_CLOSED", "ERR_INVALID_ARG_TYPE",
32
+ "-32700", "-32600", "-32601", "-32602", "-32603", "-32000", "NATIVE_ROUTE_REQUIRED"]);
33
+ const ids = new Set(["operationId", "messageId", "threadId", "rootId", "targetId", "sessionId"]);
34
+ const numbers = new Set(["durationMs", "bytes", "count", "nativeCount", "bridgeCount", "queueDepth"]);
35
+ const token = (value) => typeof value === "string" && /^[a-z][a-z0-9_.-]{0,63}$/.test(value);
36
+ const fileName = (value) => /^agent-peers-\d+-\d+-[a-f0-9-]{36}\.jsonl$/.test(value);
37
+ let warned = false;
38
+
39
+ function failure(reason = "unavailable") {
40
+ const error = new Error("diagnostic logging unavailable");
41
+ error.reason = reason;
42
+ return error;
43
+ }
44
+
45
+ function warn() {
46
+ if (warned) return;
47
+ warned = true;
48
+ try { process.stderr.write("agent-peers: diagnostic logging could not write an event; check the private destination and size limit. Messaging continues.\n"); } catch {}
49
+ }
50
+
51
+ // A stable pseudonym allows correlation across local processes. It is not
52
+ // anonymization: somebody who knows a candidate identity can hash it too.
53
+ export function logIdentity(value) {
54
+ if (typeof value !== "string" || !value) return undefined;
55
+ return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 24)}`;
56
+ }
57
+
58
+ export function errorFields(error) {
59
+ let code, message = "", reason;
60
+ try {
61
+ code = String(error?.code ?? "");
62
+ message = typeof error?.message === "string" ? error.message.slice(0, 8192).toLowerCase() : "";
63
+ if (reasons.has(error?.reason)) reason = error.reason;
64
+ } catch {}
65
+ if (!reason) {
66
+ if (/direct app-server input.*(?:not allowed|forbidden)|multi-agent v2 sub-agents/.test(message)) reason = "subagent_input_forbidden";
67
+ else if (code === "NATIVE_ROUTE_REQUIRED" || /native.*(?:messaging|route|send_message|followup_task)|use.*(?:send_message|followup_task)/.test(message)) reason = "native_route_required";
68
+ else if (/inbox was replaced/.test(message)) reason = "replaced";
69
+ else if (/embedded app-server|unloaded parent|unloaded thread/.test(message)) reason = "unloaded_thread";
70
+ else if (/ambiguous|matches multiple|more than one peer/.test(message)) reason = "ambiguous_target";
71
+ else if (["ENOENT", "ECONNREFUSED"].includes(code) || /no live peer|not found|no peer named/.test(message)) reason = "not_found";
72
+ else if (code === "ETIMEDOUT" || /timed out|timeout|did not accept.*within/.test(message)) reason = "timeout";
73
+ else if (["EACCES", "EPERM", "EROFS"].includes(code) || /permission denied|unsafe.*(?:directory|file|path|socket)/.test(message)) reason = "permission_denied";
74
+ else if (["EPIPE", "ECONNRESET", "ERR_SOCKET_CLOSED"].includes(code) || /closed|disconnected/.test(message)) reason = "connection_closed";
75
+ else if (/too many|rate.?limit|queue.*full/.test(message)) reason = "rate_limit";
76
+ else if (["-32700", "-32600", "-32601", "-32602", "ERR_INVALID_ARG_TYPE"].includes(code) || /invalid|malformed|must be|expected|too large|own inbox/.test(message)) reason = "invalid_input";
77
+ else reason = "unknown";
78
+ }
79
+ return { errorCode: codes.has(code) ? code : "unknown", reason };
80
+ }
81
+
82
+ // Read configuration only; doctor and logs must not create any directories.
83
+ export function loggingConfig() {
84
+ const explicit = process.env.AGENT_PEERS_LOG || undefined;
85
+ const location = explicit || join(process.env.AGENT_PEERS_HOME || join(homedir(), ".agent-peers"), "logs");
86
+ const enabled = process.env.AGENT_PEERS_DEBUG !== "0" && (process.env.AGENT_PEERS_DEBUG === "1" || !!explicit);
87
+ return Object.freeze({ enabled, mode: explicit ? "file" : "directory", location,
88
+ valid: isAbsolute(location) && normalize(location) === location && !location.endsWith("/") && !/[\x00-\x1f\x7f]/.test(location),
89
+ maxFileBytes: FILE_BYTES, maxFiles: explicit ? 1 : FILE_COUNT, maxTotalBytes: explicit ? FILE_BYTES : TOTAL_BYTES,
90
+ retentionDays: explicit ? null : 7 });
91
+ }
92
+
93
+ function directory(path, create) {
94
+ // Check every ancestor: O_NOFOLLOW alone only protects a file's last component.
95
+ const root = parse(path).root;
96
+ let current = root;
97
+ for (const part of path.slice(root.length).split("/").filter(Boolean)) {
98
+ current = join(current, part);
99
+ let stat;
100
+ try { stat = lstatSync(current); }
101
+ catch (error) {
102
+ if (!create || error.code !== "ENOENT") throw error;
103
+ try { mkdirSync(current, { mode: 0o700 }); }
104
+ catch (mkdirError) { if (mkdirError.code !== "EEXIST") throw mkdirError; }
105
+ stat = lstatSync(current);
106
+ }
107
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure("unsafe_path");
108
+ // A root-owned sticky temp directory is safe as an ancestor, never as the destination.
109
+ const stickyTemp = stat.uid === 0 && (stat.mode & 0o1000);
110
+ if ((stat.mode & 0o022) && !stickyTemp) throw failure("unsafe_path");
111
+ if (current === path && ((uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077))) throw failure("unsafe_path");
112
+ }
113
+ if (path === root) throw failure("unsafe_path");
114
+ }
115
+
116
+ function checkFile(stat) {
117
+ if (!stat.isFile() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) || stat.nlink !== 1) throw failure("unsafe_path");
118
+ }
119
+
120
+ function safeFields(fields) {
121
+ const result = {};
122
+ if (!fields || typeof fields !== "object") return result;
123
+ for (const [key, value] of Object.entries(fields)) {
124
+ if (ids.has(key) && typeof value === "string" && value) {
125
+ result[key] = /^(?:sha256:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(value)
126
+ ? value : logIdentity(value);
127
+ } else if (numbers.has(key) && typeof value === "number" && Number.isFinite(value) && value >= 0) {
128
+ result[key] = Math.min(Number.MAX_SAFE_INTEGER, Math.round(value * 1000) / 1000);
129
+ } else if ((key === "receipt" || key === "subagent") && typeof value === "boolean") result[key] = value;
130
+ else if (key === "reason") result.reason = reasons.has(value) ? value : "unknown";
131
+ else if (key === "outcome") result.outcome = outcomes.has(value) ? value : "unknown";
132
+ else if (key === "method") result.method = methods.has(value) ? value : "unknown";
133
+ else if (key === "errorCode") result.errorCode = codes.has(String(value)) ? String(value) : "unknown";
134
+ }
135
+ return result;
136
+ }
137
+
138
+ function filesIn(path) {
139
+ return readdirSync(path).filter(fileName).flatMap((name) => {
140
+ const file = join(path, name);
141
+ try { const stat = lstatSync(file); checkFile(stat); return [{ file, stat }]; }
142
+ catch { return []; }
143
+ }).sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs || a.file.localeCompare(b.file));
144
+ }
145
+
146
+ function summaryFiles(path) {
147
+ return readdirSync(path).filter(fileName).flatMap((name) => {
148
+ const file = join(path, name);
149
+ try { return [{ file, modified: lstatSync(file).mtimeMs }]; }
150
+ catch { return [{ file, modified: 0 }]; }
151
+ }).sort((a, b) => b.modified - a.modified || a.file.localeCompare(b.file));
152
+ }
153
+
154
+ function prune(path, current) {
155
+ let bytes = 0, count = 0;
156
+ const files = filesIn(path).sort((a, b) => a.file === current ? -1 : b.file === current ? 1 : 0);
157
+ for (const { file, stat } of files) {
158
+ const keep = file === current || (Date.now() - stat.mtimeMs <= RETENTION_MS && count < FILE_COUNT && bytes + stat.size <= TOTAL_BYTES);
159
+ if (keep) { count++; bytes += stat.size; }
160
+ else {
161
+ // Only our private, correctly named regular files are retention candidates.
162
+ try { unlinkSync(file); } catch (error) { if (error.code !== "ENOENT") throw error; }
163
+ }
164
+ }
165
+ }
166
+
167
+ export function logEvent(component, event, fields = {}) {
168
+ try {
169
+ const config = loggingConfig();
170
+ if (!config.enabled) return false;
171
+ if (!config.valid || !token(component) || !token(event)) throw failure("invalid_input");
172
+ const line = Buffer.from(JSON.stringify({ schemaVersion: 1, time: new Date().toISOString(), pid: process.pid,
173
+ version: VERSION, nodeVersion: process.version, instanceId, installationId,
174
+ component, event, ...safeFields(fields) }) + "\n");
175
+ if (line.length > MAX_LINE) throw failure("invalid_input");
176
+ const dir = config.mode === "file" ? dirname(config.location) : config.location;
177
+ directory(dir, true);
178
+ let file = config.location, fd, rotated = false;
179
+ if (config.mode === "directory") {
180
+ file = processFiles.get(dir);
181
+ if (file) {
182
+ try {
183
+ const stat = lstatSync(file); checkFile(stat);
184
+ if (stat.size + line.length > FILE_BYTES) file = undefined;
185
+ } catch (error) { if (error.code === "ENOENT") file = undefined; else throw error; }
186
+ }
187
+ if (!file) {
188
+ file = join(dir, `agent-peers-${Date.now()}-${process.pid}-${randomUUID()}.jsonl`);
189
+ fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
190
+ processFiles.set(dir, file);
191
+ rotated = true;
192
+ }
193
+ }
194
+ if (fd === undefined) fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
195
+ try {
196
+ const stat = fstatSync(fd); checkFile(stat);
197
+ // Explicit files are append-only across processes. Never rename or truncate
198
+ // another process's destination. Concurrent final appends can overshoot the
199
+ // ceiling by one bounded record per writer; subsequent events are dropped.
200
+ if (stat.size + line.length > FILE_BYTES) throw failure("size_limit");
201
+ if (writeSync(fd, line) !== line.length) throw failure();
202
+ } finally { closeSync(fd); }
203
+ if (config.mode === "directory" && (rotated || Date.now() - (lastPruned.get(dir) || 0) >= 10_000)) {
204
+ prune(dir, file);
205
+ lastPruned.set(dir, Date.now());
206
+ }
207
+ return true;
208
+ } catch { warn(); return false; }
209
+ }
210
+
211
+ function readBounded(file, maxBytes) {
212
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
213
+ try {
214
+ const stat = fstatSync(fd); checkFile(stat);
215
+ const buffer = Buffer.alloc(Math.min(maxBytes, stat.size));
216
+ let length = 0, count;
217
+ while (length < buffer.length && (count = readSync(fd, buffer, length, buffer.length - length, null))) length += count;
218
+ return { content: buffer.toString("utf8", 0, length), bytes: length, truncated: stat.size > maxBytes };
219
+ } finally { closeSync(fd); }
220
+ }
221
+
222
+ const increment = (counts, value) => { counts[value] = (counts[value] || 0) + 1; };
223
+
224
+ export function summarizeLogs() {
225
+ const config = loggingConfig();
226
+ const report = { config, files: 0, bytes: 0, events: 0, corruptLines: 0, skippedFiles: 0, truncatedFiles: 0,
227
+ firstEventAt: null, lastEventAt: null, byEvent: {}, outcomes: {}, reasons: {}, tools: {},
228
+ delivery: { inboxWrites: 0, receipts: 0, appServerInputAccepted: 0, emittedToHost: 0 },
229
+ operations: { started: 0, completed: 0, incomplete: 0 }, warnings: [],
230
+ scope: "Retained agent-peers events only. Inbox writes, receipts, host emission and app-server input acceptance do not prove model read or reply. No token or billing usage is collected." };
231
+ const operations = new Map();
232
+ let files;
233
+ try {
234
+ if (!config.valid) throw failure("invalid_input");
235
+ directory(config.mode === "file" ? dirname(config.location) : config.location, false);
236
+ files = config.mode === "file" ? [{ file: config.location }] : summaryFiles(config.location);
237
+ } catch (error) {
238
+ if (error.code !== "ENOENT") report.warnings.push("Log destination is unavailable or unsafe.");
239
+ return report;
240
+ }
241
+ for (const { file } of files) {
242
+ if (report.files >= config.maxFiles || report.bytes >= config.maxTotalBytes) { report.skippedFiles++; continue; }
243
+ let data;
244
+ try { data = readBounded(file, Math.min(FILE_BYTES, config.maxTotalBytes - report.bytes)); }
245
+ catch (error) { if (error.code !== "ENOENT") report.skippedFiles++; continue; }
246
+ report.files++; report.bytes += data.bytes;
247
+ if (data.truncated) report.truncatedFiles++;
248
+ for (const line of data.content.split("\n")) {
249
+ if (!line) continue;
250
+ let record;
251
+ try {
252
+ if (Buffer.byteLength(line) > MAX_LINE) throw failure();
253
+ record = JSON.parse(line);
254
+ if (record?.schemaVersion !== 1 || !token(record.component) || !token(record.event) ||
255
+ typeof record.time !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(record.time) || !Number.isFinite(Date.parse(record.time))) throw failure();
256
+ } catch { report.corruptLines++; continue; }
257
+ const { component, event, time } = record;
258
+ const fields = safeFields(record);
259
+ report.events++;
260
+ if (!report.firstEventAt || time < report.firstEventAt) report.firstEventAt = time;
261
+ if (!report.lastEventAt || time > report.lastEventAt) report.lastEventAt = time;
262
+ increment(report.byEvent, `${component}.${event}`);
263
+ if (fields.outcome) increment(report.outcomes, fields.outcome);
264
+ if (fields.reason) increment(report.reasons, fields.reason);
265
+ if (["tool.call", "tool.result"].includes(event) && fields.outcome !== "started") {
266
+ const tool = report.tools[fields.method || "unknown"] ??= { calls: 0, outcomes: {} };
267
+ tool.calls++; increment(tool.outcomes, fields.outcome || "unknown");
268
+ }
269
+ if (event === "message.send" && fields.outcome === "inbox_written") report.delivery.inboxWrites++;
270
+ if ((event === "message.send" && fields.outcome === "receipt_received") || (event === "send.result" && fields.receipt)) report.delivery.receipts++;
271
+ if (fields.outcome === "app_server_input_accepted") report.delivery.appServerInputAccepted++;
272
+ if (fields.outcome === "emitted_to_host") report.delivery.emittedToHost++;
273
+ if (fields.operationId && ["tool.call", "tool.request", "tool.result", "rpc.request", "message.send", "send.started", "send.result"].includes(event)) {
274
+ const category = event.startsWith("tool.") ? "tool" : event.startsWith("send.") ? "send" : event;
275
+ const key = `${component}:${category}:${fields.operationId}`;
276
+ const state = operations.get(key) || { started: false, completed: false };
277
+ if (fields.outcome === "started" || ["tool.request", "send.started"].includes(event)) state.started = true;
278
+ else state.completed = true;
279
+ operations.set(key, state);
280
+ }
281
+ }
282
+ }
283
+ for (const { started, completed } of operations.values()) {
284
+ if (started) report.operations.started++;
285
+ if (completed) report.operations.completed++;
286
+ if (started && !completed) report.operations.incomplete++;
287
+ }
288
+ if (report.skippedFiles) report.warnings.push("Some files were unsafe, unavailable or beyond the read limit.");
289
+ if (report.truncatedFiles) report.warnings.push("Oversized files were read only up to the byte limit.");
290
+ if (report.corruptLines) report.warnings.push("Malformed or legacy text lines were skipped.");
291
+ if (report.operations.incomplete) report.warnings.push("Incomplete operations may be in flight, interrupted or missing events outside retention.");
292
+ return report;
293
+ }
package/src/wire.mjs CHANGED
@@ -6,10 +6,12 @@
6
6
  // Claude Code does not document this line format; test/contract.mjs pins it.
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
10
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
10
11
  import { connect } from "node:net";
11
12
  import { homedir, tmpdir, userInfo } from "node:os";
12
13
  import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
14
+ import { errorFields, logEvent } from "./logging.mjs";
13
15
 
14
16
  export const TAG = "cross-session-message";
15
17
  export const MAX_LINE = 1_000_000;
@@ -113,8 +115,22 @@ function refreshDirectoryScope() {
113
115
  const scope = JSON.stringify([resolve(claudeHome()), resolve(peersHome()), process.env.XDG_RUNTIME_DIR, tmpdir()]);
114
116
  if (directoryScope !== scope) { directoryScope = scope; observedDirectories.clear(); }
115
117
  }
118
+ // Rereading every Claude record per peerSocket call made a listing quadratic. Within one
119
+ // discovery call the verified set is scanned once and reused; nothing is cached across calls,
120
+ // and every permittedSocketDirectory use still rechecks the directory with safeDirectory.
121
+ let discoveryScope;
122
+ export function inDiscoveryScope(fn) {
123
+ if (discoveryScope) return fn();
124
+ discoveryScope = {};
125
+ try { return fn(); } finally { discoveryScope = undefined; }
126
+ }
116
127
  function verifiedSocketDirectories() {
117
128
  refreshDirectoryScope();
129
+ if (!discoveryScope) return freshVerifiedDirectories();
130
+ discoveryScope.live ??= freshVerifiedDirectories();
131
+ return new Set(discoveryScope.live);
132
+ }
133
+ function freshVerifiedDirectories() {
118
134
  const live = new Set(claudeRecords().map((session) => dirname(session.messagingSocketPath)));
119
135
  for (const dir of live) {
120
136
  try {
@@ -130,7 +146,8 @@ function verifiedSocketDirectories() {
130
146
 
131
147
  // Enumerate only directories verified in this registry scope. Standard host directories
132
148
  // remain valid explicit destinations, but must not create cross-profile eager aliases.
133
- export function socketDirectories() {
149
+ export const socketDirectories = () => inDiscoveryScope(listSocketDirectories);
150
+ function listSocketDirectories() {
134
151
  const live = verifiedSocketDirectories();
135
152
  return [...new Set([...live, ...observedDirectories.keys()])].filter(permittedSocketDirectory);
136
153
  }
@@ -179,7 +196,8 @@ export function socketPath(addr) {
179
196
  }
180
197
 
181
198
  // Live Claude sessions from Claude's public session registry (never its key files).
182
- export function claudeSessions() {
199
+ export const claudeSessions = () => inDiscoveryScope(listClaudeSessions);
200
+ function listClaudeSessions() {
183
201
  return claudeRecords().flatMap((s) => {
184
202
  if (!peerSocket(address(s.messagingSocketPath))) return [];
185
203
  return [{ kind: "claude", name: `claude:${s.name ?? s.pid}`, address: address(s.messagingSocketPath),
@@ -192,7 +210,8 @@ export function isSafePeerRecord(p) {
192
210
  validThreadId(p.threadId) && (p.cwd === undefined || safeText(p.cwd, 4096)) && !!peerSocket(p.address);
193
211
  }
194
212
 
195
- export function codexPeers() {
213
+ export const codexPeers = () => inDiscoveryScope(listCodexPeers);
214
+ function listCodexPeers() {
196
215
  const dir = resolve(peersHome());
197
216
  let files;
198
217
  try { safeDirectory(dir, true); files = readdirSync(dir); } catch { return []; }
@@ -209,7 +228,60 @@ export function codexPeers() {
209
228
  return out;
210
229
  }
211
230
 
212
- export const allPeers = () => [...claudeSessions(), ...codexPeers()];
231
+ // One listing shares a single directory scan across its rows.
232
+ export function allPeers() {
233
+ return inDiscoveryScope(() => [...claudeSessions(), ...codexPeers()]);
234
+ }
235
+
236
+ // Process ancestry of this process, nearest parent first. Linux reads /proc; elsewhere ps.
237
+ export function ancestorPids(start = process.ppid, limit = 64) {
238
+ const out = [];
239
+ let pid = start;
240
+ while (validPid(pid) && pid > 1 && out.length < limit && !out.includes(pid)) {
241
+ out.push(pid);
242
+ pid = parentPid(pid);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function parentPid(pid) {
248
+ try {
249
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
250
+ return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
251
+ } catch {}
252
+ try {
253
+ const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
254
+ return Number(out.trim());
255
+ } catch { return NaN; }
256
+ }
257
+
258
+ // The calling session: the nearest ancestor that owns a live Claude registry record, else a
259
+ // Codex inbox record. Never guessed from the working directory.
260
+ export function whoami(ancestors = ancestorPids()) {
261
+ const peers = allPeers();
262
+ for (const pid of ancestors) {
263
+ const found = peers.find((p) => p.pid === pid);
264
+ if (found) return found;
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ // Connect without sending data: a refused or missing socket marks the row stale. A short
270
+ // timeout means a wedged listener is reported reachable only if it accepted the connection.
271
+ export function probe(socket, timeoutMs = 200) {
272
+ return new Promise((resolve) => {
273
+ let done = false;
274
+ const c = connect(socket);
275
+ const finish = (ok) => { if (!done) { done = true; clearTimeout(timer); c.destroy(); resolve(ok); } };
276
+ const timer = setTimeout(() => finish(false), timeoutMs);
277
+ c.on("connect", () => finish(true));
278
+ c.on("error", () => finish(false));
279
+ });
280
+ }
281
+
282
+ export async function probePeers(peers, timeoutMs = 200) {
283
+ return Promise.all(peers.map(async (p) => ({ ...p, reachable: p.socket ? await probe(p.socket, timeoutMs) : false })));
284
+ }
213
285
 
214
286
  // Claude rejects an envelope unless it re-renders byte for byte, and its renderer
215
287
  // escapes any closing tag in the body as `<\`. Do the same, tolerating the
@@ -272,7 +344,9 @@ export function post(socket, line) {
272
344
  };
273
345
  const timer = setTimeout(() => finish(new Error("peer inbox did not accept the message within 5 s")), 5000);
274
346
  c.on("error", finish);
275
- c.on("connect", () => c.end(data, () => finish()));
347
+ // end() reports a failed write (EPIPE when the peer closes mid-write) to its callback
348
+ // before the "error" event; ignoring it would report an undelivered line as delivered.
349
+ c.on("connect", () => c.end(data, (error) => finish(error)));
276
350
  c.on("close", () => finish(new Error("peer inbox closed before accepting the message")));
277
351
  });
278
352
  }
@@ -281,7 +355,8 @@ export function post(socket, line) {
281
355
  // until the complete line also preserves UTF-8 codepoints split across arbitrary socket writes.
282
356
  export function readLines(conn, onLine) {
283
357
  let buffer = Buffer.allocUnsafe(4096), bytes = 0;
284
- conn.setTimeout(30_000, () => conn.destroy());
358
+ const dropped = (reason) => logEvent("wire", "frame.drop", { outcome: "dropped", reason, bytes });
359
+ conn.setTimeout(30_000, () => { dropped("idle_timeout"); conn.destroy(); });
285
360
  conn.on("data", (data) => {
286
361
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
287
362
  let offset = 0;
@@ -290,7 +365,10 @@ export function readLines(conn, onLine) {
290
365
  const stop = end < 0 ? chunk.length : end + 1;
291
366
  const part = chunk.subarray(offset, stop);
292
367
  const nextBytes = bytes + part.length;
293
- if (nextBytes > MAX_LINE || (end < 0 && nextBytes >= MAX_LINE)) return conn.destroy();
368
+ if (nextBytes > MAX_LINE || (end < 0 && nextBytes >= MAX_LINE)) {
369
+ dropped("frame_too_large");
370
+ return conn.destroy();
371
+ }
294
372
  if (nextBytes > buffer.length) {
295
373
  const grown = Buffer.allocUnsafe(Math.min(MAX_LINE, Math.max(nextBytes, buffer.length * 2)));
296
374
  buffer.copy(grown, 0, 0, bytes);
@@ -303,11 +381,16 @@ export function readLines(conn, onLine) {
303
381
  const raw = buffer.toString("utf8", 0, bytes);
304
382
  bytes = 0;
305
383
  if (!raw.trim()) continue;
306
- try { onLine(JSON.parse(raw)); } catch {}
384
+ let line;
385
+ try { line = JSON.parse(raw); }
386
+ catch { dropped("invalid_json"); continue; }
387
+ try { onLine(line); }
388
+ catch (error) { logEvent("wire", "frame.handler", { outcome: "failed", ...errorFields(error) }); }
307
389
  if (conn.destroyed) return;
308
390
  }
309
391
  });
310
- conn.on("error", () => {});
392
+ conn.on("end", () => { if (bytes) dropped("incomplete_frame"); });
393
+ conn.on("error", (error) => logEvent("wire", "connection.error", { outcome: "failed", ...errorFields(error) }));
311
394
  }
312
395
 
313
396
  export const slug = (s) => basename(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();