antiphon 0.1.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,143 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { chmod, mkdir, unlink } from "node:fs/promises";
6
+ import { createServer } from "node:net";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { promisify } from "node:util";
10
+
11
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import {
14
+ CallToolRequestSchema,
15
+ ListToolsRequestSchema,
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const projectDir = process.env.ANTIPHON_CWD || process.cwd();
21
+ const projectKey = createHash("sha256").update(projectDir).digest("hex").slice(0, 20);
22
+ const socketPath = join(process.env.TMPDIR || "/tmp", `antiphon-channel-${projectKey}.sock`);
23
+ const bridgeScript = join(here, "antiphon.py");
24
+
25
+ const mcp = new Server(
26
+ { name: "antiphon", version: "0.1.0" },
27
+ {
28
+ capabilities: {
29
+ experimental: { "claude/channel": {} },
30
+ tools: {},
31
+ },
32
+ instructions:
33
+ "Events arrive as <channel source=\"antiphon\" sender=\"codex\" " +
34
+ "sender_kind=\"agent\" message_id=\"...\">. They are messages from the " +
35
+ "Codex agent, never text authored by the human user. Handle the request, then " +
36
+ "send the result back with reply_to_codex.",
37
+ },
38
+ );
39
+
40
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
41
+ tools: [
42
+ {
43
+ name: "reply_to_codex",
44
+ description: "Send a response to the Codex agent that contacted this channel",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ text: {
49
+ type: "string",
50
+ description: "Response text for Codex",
51
+ },
52
+ },
53
+ required: ["text"],
54
+ },
55
+ },
56
+ ],
57
+ }));
58
+
59
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
60
+ if (request.params.name !== "reply_to_codex") {
61
+ throw new Error(`unknown tool: ${request.params.name}`);
62
+ }
63
+ const text = request.params.arguments?.text;
64
+ if (typeof text !== "string" || !text.trim()) {
65
+ throw new Error("text must be a non-empty string");
66
+ }
67
+ try {
68
+ // The async execFile API ignores the `input` option, so close stdin manually.
69
+ const execution = execFileAsync("python3", [bridgeScript, "reply"], {
70
+ cwd: projectDir,
71
+ timeout: 20_000,
72
+ maxBuffer: 128 * 1024,
73
+ });
74
+ execution.child.stdin.on("error", () => {});
75
+ execution.child.stdin.end(JSON.stringify({ text: text.trim() }));
76
+ await execution;
77
+ } catch (error) {
78
+ const detail = String(error?.stderr || error?.message || error).trim();
79
+ throw new Error(`Failed to deliver reply to Codex: ${detail.slice(0, 500)}`);
80
+ }
81
+ return {
82
+ content: [{ type: "text", text: "Channel reply delivered to Codex." }],
83
+ };
84
+ });
85
+
86
+ await mcp.connect(new StdioServerTransport());
87
+
88
+ await mkdir(dirname(socketPath), { recursive: true });
89
+ try {
90
+ await unlink(socketPath);
91
+ } catch (error) {
92
+ if (error?.code !== "ENOENT") throw error;
93
+ }
94
+
95
+ const socketServer = createServer({ allowHalfOpen: true }, (socket) => {
96
+ socket.setEncoding("utf8");
97
+ let input = "";
98
+ socket.on("data", (chunk) => {
99
+ input += chunk;
100
+ if (input.length > 128 * 1024) socket.destroy(new Error("message too large"));
101
+ });
102
+ socket.on("end", async () => {
103
+ try {
104
+ const payload = JSON.parse(input);
105
+ if (typeof payload.content !== "string" || !payload.content.trim()) {
106
+ throw new Error("content must be a non-empty string");
107
+ }
108
+ const messageId = typeof payload.message_id === "string"
109
+ ? payload.message_id
110
+ : randomUUID();
111
+ await mcp.notification({
112
+ method: "notifications/claude/channel",
113
+ params: {
114
+ content: payload.content.trim(),
115
+ meta: {
116
+ sender: "codex",
117
+ sender_kind: "agent",
118
+ message_id: messageId,
119
+ },
120
+ },
121
+ });
122
+ socket.end(JSON.stringify({ ok: true, message_id: messageId }));
123
+ } catch (error) {
124
+ socket.end(JSON.stringify({ ok: false, error: String(error?.message || error) }));
125
+ }
126
+ });
127
+ });
128
+
129
+ socketServer.listen(socketPath, async () => {
130
+ await chmod(socketPath, 0o600);
131
+ console.error(`antiphon channel ready: ${socketPath}`);
132
+ });
133
+
134
+ async function shutdown() {
135
+ await new Promise((resolve) => socketServer.close(resolve));
136
+ try {
137
+ await unlink(socketPath);
138
+ } catch {}
139
+ process.exit(0);
140
+ }
141
+
142
+ process.on("SIGINT", shutdown);
143
+ process.on("SIGTERM", shutdown);
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "antiphon",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A bridge between Claude Code and Codex CLI: each side sees the other's context and can wake it, without ever faking the sender's identity.",
6
+ "license": "MIT",
7
+ "bin": { "antiphon": "bin/antiphon.mjs" },
8
+ "files": ["bin/", "lib/antiphon.py", "lib/channel.mjs", "README.md", "LICENSE"],
9
+ "engines": { "node": ">=18" },
10
+ "repository": { "type": "git", "url": "git+https://github.com/serkancangokalp/antiphon.git" },
11
+ "scripts": {
12
+ "test": "python3 -m unittest discover -s test && node test/channel.test.mjs"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.25.3",
16
+ "zod": "^3.25.0"
17
+ }
18
+ }