antiphon 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.
package/lib/channel.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { execFile } from "node:child_process";
5
5
  import { chmod, mkdir, unlink } from "node:fs/promises";
6
- import { createServer } from "node:net";
6
+ import { connect, createServer } from "node:net";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { promisify } from "node:util";
@@ -18,7 +18,36 @@ import {
18
18
  const execFileAsync = promisify(execFile);
19
19
  const here = dirname(fileURLToPath(import.meta.url));
20
20
  const projectDir = process.env.ANTIPHON_CWD || process.cwd();
21
- const projectKey = createHash("sha256").update(projectDir).digest("hex").slice(0, 20);
21
+ const peerName = (process.env.ANTIPHON_NAME || "").trim().toLowerCase();
22
+ // Validated before anything binds: an invalid name would otherwise open a socket
23
+ // that registration then refuses, leaving a live channel nobody can find.
24
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,31}$/;
25
+ const nameIsUsable = !peerName || NAME_PATTERN.test(peerName);
26
+
27
+ // Null until this process has actually won the name and bound its socket. A
28
+ // valid ANTIPHON_NAME is a request, not a claim: two sessions can be started
29
+ // with the same one and only the first wins. A loser that published it anyway
30
+ // would have its words attributed to the winner, and a reply addressed back
31
+ // would reach a session that never spoke.
32
+ //
33
+ // Declared here, above the tool handler that closes over it and above
34
+ // `mcp.connect`. Left further down it would sit in the temporal dead zone
35
+ // while the transport was already accepting requests, and a `reply_to_codex`
36
+ // buffered at startup would raise a ReferenceError instead of being answered.
37
+ let senderAlias = null;
38
+
39
+ // Resolved once the startup chain has settled what this session is — winner,
40
+ // loser, unnamed or refused. The reply tool waits for it before signing a
41
+ // message. Answering earlier would be safe but wrong: a session that does hold
42
+ // `ui` would deny its own name for the first message of its life, purely
43
+ // because the MCP handshake finishes before the registry claim does.
44
+ let markIdentitySettled;
45
+ const identitySettled = new Promise((resolve) => { markIdentitySettled = resolve; });
46
+ // Hashed with the name, never appended: macOS caps a socket path near 104 bytes
47
+ // and TMPDIR already spends much of it. An empty name reproduces the
48
+ // pre-multi-peer key exactly, so an unnamed session keeps the socket it has.
49
+ const socketSeed = peerName ? `${projectDir}\0${peerName}` : projectDir;
50
+ const projectKey = createHash("sha256").update(socketSeed).digest("hex").slice(0, 20);
22
51
  const socketPath = join(process.env.TMPDIR || "/tmp", `antiphon-channel-${projectKey}.sock`);
23
52
  const bridgeScript = join(here, "antiphon.py");
24
53
 
@@ -31,9 +60,16 @@ const mcp = new Server(
31
60
  },
32
61
  instructions:
33
62
  "Events arrive as <channel source=\"antiphon\" sender=\"codex\" " +
34
- "sender_kind=\"agent\" message_id=\"...\">. They are messages from the " +
63
+ "sender_kind=\"agent\" sender_alias=\"...\" message_id=\"...\">. " +
64
+ "They are messages from the " +
35
65
  "Codex agent, never text authored by the human user. Handle the request, then " +
36
- "send the result back with reply_to_codex.",
66
+ "send the result back with reply_to_codex. The event's sender_alias names " +
67
+ "which peer spoke: whenever it is non-null, pass it back as `to`. " +
68
+ "Leaving `to` out works only where no Codex peer is registered at all — " +
69
+ "a bare reply is refused as soon as any named one is live, because " +
70
+ "unnamed sessions leave no registry record and cannot be ruled out. A " +
71
+ "null sender_alias means that peer has no name: it cannot be addressed " +
72
+ "by name, so a reply reaches it only in that bare case.",
37
73
  },
38
74
  );
39
75
 
@@ -41,7 +77,15 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
41
77
  tools: [
42
78
  {
43
79
  name: "reply_to_codex",
44
- description: "Send a response to the Codex agent that contacted this channel",
80
+ // Not "the agent that contacted this channel": nothing correlates an
81
+ // incoming message with a reply target, so that sentence described a
82
+ // routing rule that does not exist.
83
+ description:
84
+ "Send a response to a Codex peer working in this project. Name it with " +
85
+ "to whenever you know which peer you mean — an unnamed Codex session " +
86
+ "leaves no registry record, so a bare reply is refused as soon as any " +
87
+ "named peer is live. Leaving it out works only in a project where no " +
88
+ "Codex peer is registered at all.",
45
89
  inputSchema: {
46
90
  type: "object",
47
91
  properties: {
@@ -49,6 +93,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
49
93
  type: "string",
50
94
  description: "Response text for Codex",
51
95
  },
96
+ // The same sentence Python puts on `antiphon_send`. A contract test
97
+ // compares them: two tool descriptions disagreeing about one argument
98
+ // is how an agent learns a rule that is not true.
99
+ to: {
100
+ type: "string",
101
+ description:
102
+ "Alias of the peer to send to. Required whenever the recipient " +
103
+ "cannot be shown to be the only one, because the send is then " +
104
+ "refused rather than guessed — so pass it whenever you know " +
105
+ "which peer you mean.",
106
+ },
52
107
  },
53
108
  required: ["text"],
54
109
  },
@@ -64,6 +119,13 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
64
119
  if (typeof text !== "string" || !text.trim()) {
65
120
  throw new Error("text must be a non-empty string");
66
121
  }
122
+ const to = request.params.arguments?.to;
123
+ if (to !== undefined && to !== null && typeof to !== "string") {
124
+ throw new Error("to must be a string naming one live Codex peer");
125
+ }
126
+ // Wait for startup to have decided who this session is, so an early call is
127
+ // signed correctly rather than anonymously.
128
+ await identitySettled;
67
129
  try {
68
130
  // The async execFile API ignores the `input` option, so close stdin manually.
69
131
  const execution = execFileAsync("python3", [bridgeScript, "reply"], {
@@ -72,42 +134,201 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
72
134
  maxBuffer: 128 * 1024,
73
135
  });
74
136
  execution.child.stdin.on("error", () => {});
75
- execution.child.stdin.end(JSON.stringify({ text: text.trim() }));
137
+ // `to` goes across untouched: the alias matches one peer exactly or none,
138
+ // and trimming or lowercasing it here would be this side quietly deciding
139
+ // which peer was meant.
140
+ // `senderAlias`, never `peerId`: the reply says who is speaking, and a name
141
+ // this process invented for itself is not one Codex could answer.
142
+ const payload = { text: text.trim(), sender_alias: senderAlias };
143
+ if (typeof to === "string") payload.to = to;
144
+ execution.child.stdin.end(JSON.stringify(payload));
76
145
  await execution;
77
146
  } catch (error) {
78
147
  const detail = String(error?.stderr || error?.message || error).trim();
79
148
  throw new Error(`Failed to deliver reply to Codex: ${detail.slice(0, 500)}`);
80
149
  }
150
+ // Naming the peer back is what lets the sender notice it addressed the wrong
151
+ // one. With no alias there is nothing to distinguish, so the old wording
152
+ // stands — the same rule `antiphon_send` follows on the other side.
81
153
  return {
82
- content: [{ type: "text", text: "Channel reply delivered to Codex." }],
154
+ content: [{
155
+ type: "text",
156
+ text: typeof to === "string"
157
+ ? `Channel reply delivered to Codex peer '${to}'.`
158
+ : "Channel reply delivered to Codex.",
159
+ }],
83
160
  };
84
161
  });
85
162
 
86
163
  await mcp.connect(new StdioServerTransport());
87
164
 
88
- await mkdir(dirname(socketPath), { recursive: true });
89
- try {
90
- await unlink(socketPath);
91
- } catch (error) {
92
- if (error?.code !== "ENOENT") throw error;
165
+
166
+ let owningSocket = false;
167
+
168
+ async function socketIsLive(path) {
169
+ return new Promise((resolve) => {
170
+ const probe = connect(path);
171
+ const settle = (result) => {
172
+ probe.destroy();
173
+ resolve(result);
174
+ };
175
+ probe.on("connect", () => settle(true));
176
+ probe.on("error", () => settle(false));
177
+ });
178
+ }
179
+
180
+ // The registry key an unnamed session occupies. It is deliberately not a name:
181
+ // the angle brackets are outside the alias grammar, so nothing anyone can type
182
+ // can collide with it and no message can be addressed to it. A generated
183
+ // `claude-<3hex>` used to go here, which was a real, resolvable name in the
184
+ // registry for a session that told the other side it had none. Kept identical
185
+ // to `peers.UNNAMED` by a contract test.
186
+ const UNNAMED_KEY = "<unnamed>";
187
+ const peerId = peerName || UNNAMED_KEY;
188
+
189
+ // process.pid, not the Python subprocess's: the peer lives as long as this
190
+ // server does, and that subprocess exits the moment it returns.
191
+ async function registryCall(subcommand, quiet = false) {
192
+ const execution = execFileAsync("python3", [bridgeScript, subcommand], {
193
+ cwd: projectDir,
194
+ timeout: 20_000,
195
+ maxBuffer: 128 * 1024,
196
+ });
197
+ execution.child.stdin.on("error", () => {});
198
+ execution.child.stdin.end(JSON.stringify({
199
+ kind: "claude", name: peerId, address: socketPath, pid: process.pid,
200
+ }));
201
+ try {
202
+ await execution;
203
+ return true;
204
+ } catch (error) {
205
+ if (!quiet) {
206
+ console.error(`antiphon: ${String(error?.stderr || error?.message || error).trim()}`);
207
+ }
208
+ return false;
209
+ }
210
+ }
211
+
212
+ const claimPeer = () => registryCall("register_peer");
213
+ const releasePeer = () => registryCall("unregister_peer", true);
214
+
215
+ async function serveSocket() {
216
+ // Nothing in here may throw past this function. It runs at the top level of a
217
+ // module, where an uncaught error exits the process — and losing the process
218
+ // loses `reply_to_codex` too, over a socket that was only ever the other half
219
+ // of the bridge.
220
+ try {
221
+ await unlink(socketPath);
222
+ } catch (error) {
223
+ if (error?.code !== "ENOENT") {
224
+ console.error(
225
+ `antiphon: could not clear ${socketPath} (${error?.code || error}); this ` +
226
+ "session can still reply to Codex but cannot be reached from it.",
227
+ );
228
+ return false;
229
+ }
230
+ }
231
+ try {
232
+ await new Promise((resolve, reject) => {
233
+ const onError = (error) => {
234
+ socketServer.off("listening", onListening);
235
+ reject(error);
236
+ };
237
+ const onListening = () => {
238
+ socketServer.off("error", onError);
239
+ resolve();
240
+ };
241
+ socketServer.once("error", onError);
242
+ socketServer.once("listening", onListening);
243
+ socketServer.listen(socketPath);
244
+ });
245
+ await chmod(socketPath, 0o600);
246
+ } catch (error) {
247
+ await new Promise((resolve) => socketServer.close(resolve));
248
+ try {
249
+ await unlink(socketPath);
250
+ } catch {}
251
+ console.error(
252
+ `antiphon: could not serve ${socketPath} (${error?.code || error}); this ` +
253
+ "session can still reply to Codex but cannot be reached from it.",
254
+ );
255
+ return false;
256
+ }
257
+ owningSocket = true;
258
+ // A socket error after this point must not be fatal either: an unhandled
259
+ // 'error' event throws.
260
+ socketServer.on("error", (error) =>
261
+ console.error(`antiphon: channel socket error (${error?.code || error})`));
262
+ console.error(`antiphon channel ready: ${socketPath}`);
263
+ return true;
93
264
  }
94
265
 
266
+ await mkdir(dirname(socketPath), { recursive: true });
267
+
268
+ // Kept in step with MAX_CHANNEL_BYTES on the Python side by a contract test, so
269
+ // a sender is refused before transport rather than halfway through it.
270
+ const MAX_MESSAGE_BYTES = 128 * 1024;
271
+ const CLIENT_IDLE_MS = 30_000;
272
+ const openSockets = new Set();
273
+
95
274
  const socketServer = createServer({ allowHalfOpen: true }, (socket) => {
96
- socket.setEncoding("utf8");
97
- let input = "";
275
+ openSockets.add(socket);
276
+ // A client that connects and then says nothing must not hold the socket, nor
277
+ // keep shutdown waiting for an end that never comes.
278
+ socket.setTimeout(CLIENT_IDLE_MS, () => socket.destroy());
279
+ // A socket error must never be fatal. `destroy(error)` emits one, so does a
280
+ // peer vanishing mid-write, and an unhandled 'error' exits the whole process —
281
+ // taking the registry entry, the socket and `reply_to_codex` with it.
282
+ socket.on("error", () => {});
283
+ socket.on("close", () => openSockets.delete(socket));
284
+
285
+ // Buffers, not a decoded string: the cap is a byte cap, and `String.length`
286
+ // counts UTF-16 units, so a multi-byte message measured that way is let
287
+ // through well over the limit.
288
+ const chunks = [];
289
+ let bytes = 0;
290
+ let refused = false;
98
291
  socket.on("data", (chunk) => {
99
- input += chunk;
100
- if (input.length > 128 * 1024) socket.destroy(new Error("message too large"));
292
+ if (refused) return;
293
+ bytes += chunk.length;
294
+ if (bytes > MAX_MESSAGE_BYTES) {
295
+ refused = true;
296
+ chunks.length = 0;
297
+ // Answer, then close both directions once the answer is out. Ending alone
298
+ // closes only this side's writes: `allowHalfOpen` keeps the read half open,
299
+ // and a client that opts into half-open can go on streaming for as long as
300
+ // it likes — measured at 2 MiB past a refusal, with every write pushing the
301
+ // idle timeout back. The cap has to be a ceiling on what a connection can
302
+ // cost, not just on what gets parsed. `destroy` is idempotent, and the
303
+ // callback still runs if the write never flushes.
304
+ socket.end(
305
+ JSON.stringify({
306
+ ok: false,
307
+ error: `message too large: over ${MAX_MESSAGE_BYTES} bytes`,
308
+ }),
309
+ () => socket.destroy(),
310
+ );
311
+ return;
312
+ }
313
+ chunks.push(chunk);
101
314
  });
102
315
  socket.on("end", async () => {
316
+ if (refused) return;
103
317
  try {
104
- const payload = JSON.parse(input);
318
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
105
319
  if (typeof payload.content !== "string" || !payload.content.trim()) {
106
320
  throw new Error("content must be a non-empty string");
107
321
  }
108
322
  const messageId = typeof payload.message_id === "string"
109
323
  ? payload.message_id
110
324
  : randomUUID();
325
+ // Validated again on arrival. The field crossed a socket, so what it
326
+ // holds is a claim rather than a fact, and an alias reaches the agent as
327
+ // the name it is told to reply to.
328
+ const inboundAlias =
329
+ typeof payload.sender_alias === "string" && NAME_PATTERN.test(payload.sender_alias)
330
+ ? payload.sender_alias
331
+ : null;
111
332
  await mcp.notification({
112
333
  method: "notifications/claude/channel",
113
334
  params: {
@@ -115,6 +336,7 @@ const socketServer = createServer({ allowHalfOpen: true }, (socket) => {
115
336
  meta: {
116
337
  sender: "codex",
117
338
  sender_kind: "agent",
339
+ sender_alias: inboundAlias,
118
340
  message_id: messageId,
119
341
  },
120
342
  },
@@ -126,18 +348,93 @@ const socketServer = createServer({ allowHalfOpen: true }, (socket) => {
126
348
  });
127
349
  });
128
350
 
129
- socketServer.listen(socketPath, async () => {
130
- await chmod(socketPath, 0o600);
131
- console.error(`antiphon channel ready: ${socketPath}`);
132
- });
351
+ // Every way this process can be asked to stop is wired up here — before the
352
+ // claim, before the bind, before anything is externally visible. Registering
353
+ // them at the end of the file left a window in which the socket already existed
354
+ // and a signal still hit the default disposition: measured at 30 runs out of 30,
355
+ // each one exiting under SIGTERM with its socket and its registry claim left
356
+ // behind. A session closing at the wrong moment did the same.
357
+ let shuttingDown = false;
133
358
 
134
359
  async function shutdown() {
360
+ // EOF and a signal can arrive together; the second caller must not repeat the
361
+ // work or race the first one's unlink.
362
+ if (shuttingDown) return;
363
+ shuttingDown = true;
364
+ // Close waits for open connections to end. A client holding the socket without
365
+ // sending anything would keep the process alive past its own termination, so
366
+ // they are dropped first.
367
+ for (const socket of openSockets) socket.destroy();
368
+ openSockets.clear();
135
369
  await new Promise((resolve) => socketServer.close(resolve));
136
- try {
137
- await unlink(socketPath);
138
- } catch {}
370
+ // Only ever remove the socket this process created. Unlinking the path
371
+ // unconditionally is what let the first session to close delete the socket a
372
+ // second, still-running session was serving.
373
+ if (owningSocket) {
374
+ try {
375
+ await unlink(socketPath);
376
+ } catch {}
377
+ }
378
+ await releasePeer();
139
379
  process.exit(0);
140
380
  }
141
381
 
142
- process.on("SIGINT", shutdown);
143
- process.on("SIGTERM", shutdown);
382
+ // Every signal the wrapper forwards has to land on the same idempotent
383
+ // shutdown. SIGHUP was forwarded but not handled here, so the default
384
+ // disposition killed the server outright: socket left bound, registry entry
385
+ // left claiming a live pid, wrapper exiting 1.
386
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
387
+ process.on(signal, shutdown);
388
+ }
389
+
390
+ // Losing the stdio client has to end the process. The Unix server keeps the
391
+ // event loop alive on its own, so without this a session that simply closed left
392
+ // a server orphaned under PPID 1, still holding its socket and its registry
393
+ // entry, with its stdio descriptors pointing at nothing — observed on a real
394
+ // machine hours after the session it belonged to had gone.
395
+ //
396
+ // Bound to stdin rather than to the server: measured, `end` and `close` both
397
+ // fire here on EOF while the SDK's `onclose` does not.
398
+ process.stdin.on("end", () => { void shutdown(); });
399
+ process.stdin.on("close", () => { void shutdown(); });
400
+
401
+ if (!nameIsUsable) {
402
+ console.error(
403
+ `antiphon: ANTIPHON_NAME=${peerName} is not a usable peer name ` +
404
+ "([a-z0-9][a-z0-9_-]{0,31}); this session can still reply to Codex but " +
405
+ "cannot be reached from it.",
406
+ );
407
+ } else if (!(await claimPeer())) {
408
+ // Claim the name and the address before binding. Probe, unlink and listen
409
+ // cannot be one atomic step: two servers that both found the path free would
410
+ // bind it in turn, the second unlinking the first's live socket, and the
411
+ // registry would end up describing a server that is not the one answering.
412
+ // The registry claim is atomic across processes, so exactly one gets here.
413
+ console.error(
414
+ "antiphon: this session did not get the channel; it can still reply to " +
415
+ "Codex but cannot be reached from it. Give each session an ANTIPHON_NAME " +
416
+ "to run more than one.",
417
+ );
418
+ } else if (await socketIsLive(socketPath)) {
419
+ // An older server from before the registry existed is still serving this
420
+ // path. It is a working peer, so leave it alone and give the claim back.
421
+ await releasePeer();
422
+ console.error(
423
+ `antiphon: another session already serves ${socketPath}; this session will ` +
424
+ "not receive channel events. Give each session an ANTIPHON_NAME to run both.",
425
+ );
426
+ } else if (!(await serveSocket())) {
427
+ // Every failure on the way to a working socket ends here, and every one of
428
+ // them gives the claim back: a record whose socket never came up hands senders
429
+ // an address nothing serves. The MCP direction is untouched throughout, so the
430
+ // session keeps `reply_to_codex` whatever went wrong.
431
+ await releasePeer();
432
+ } else if (nameIsUsable && peerName) {
433
+ // Claimed and serving. Only now is this process entitled to say it is the
434
+ // peer that name refers to.
435
+ senderAlias = peerName;
436
+ }
437
+
438
+ // After the chain, not inside a branch: every route through it ends here, so a
439
+ // tool call waiting on this cannot be left waiting whichever way startup went.
440
+ markIdentitySettled();