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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/antiphon.mjs +19 -0
- package/lib/antiphon.py +1178 -0
- package/lib/channel.mjs +143 -0
- package/package.json +18 -0
package/lib/antiphon.py
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Antiphon — an open-identity bridge between Claude Code and the Codex CLI.
|
|
3
|
+
|
|
4
|
+
Two terminals, two separate agents: what you tell one, the other finds out.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
antiphon setup # installs the hook on both sides
|
|
8
|
+
antiphon status # shows what's happening on both sides (for humans)
|
|
9
|
+
antiphon summary [side] # the text that side would see (claude | codex)
|
|
10
|
+
antiphon hook <side> # UserPromptSubmit hook (reads JSON from stdin)
|
|
11
|
+
antiphon push <target> # Stop hook: pushes `@codex` / `@claude` lines
|
|
12
|
+
antiphon reply # sends a Claude Channel reply to Codex (stdin JSON)
|
|
13
|
+
antiphon channel # long-lived Node.js MCP Channel server (started by Claude Code)
|
|
14
|
+
antiphon mcp # MCP stdio server for Codex (fallback path)
|
|
15
|
+
|
|
16
|
+
Design: NO SHARED LOG IS KEPT. Both CLIs already write transcripts; Antiphon
|
|
17
|
+
reads and derives from them. That way there's no write race, no stale record,
|
|
18
|
+
and no second source of truth. The only persistent state is a cursor tracking
|
|
19
|
+
how far each side has read.
|
|
20
|
+
|
|
21
|
+
Both sides are symmetric: Claude Code and Codex CLI speak the same
|
|
22
|
+
`UserPromptSubmit` hook contract (the same input fields, the same output
|
|
23
|
+
wrapper), so a single `hook` function serves both.
|
|
24
|
+
|
|
25
|
+
The pull and hook layer uses the Python standard library; the Claude Channel
|
|
26
|
+
server runs on Node.js with the official MCP SDK.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import glob
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import socket
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
|
|
41
|
+
HOME = os.path.expanduser("~")
|
|
42
|
+
CLAUDE_PROJECTS = os.path.join(HOME, ".claude", "projects")
|
|
43
|
+
CODEX_SESSIONS = os.path.join(HOME, ".codex", "sessions")
|
|
44
|
+
|
|
45
|
+
TAIL_BYTES = 300_000 # amount to read from the tail of each transcript file
|
|
46
|
+
SUMMARY_BUDGET = 2600 # character budget for the injected summary
|
|
47
|
+
EVENT_LIMIT = 40 # max events that go into the summary
|
|
48
|
+
LOOKBACK = 6 * 3600 # anything older than this doesn't count as part of "this session"
|
|
49
|
+
|
|
50
|
+
# A marker at the start of a line in a reply says that line should be pushed
|
|
51
|
+
# to the target. The line-start requirement is deliberate: mentioning the
|
|
52
|
+
# marker inside prose shouldn't trigger it.
|
|
53
|
+
PUSH_MARKERS = {
|
|
54
|
+
"codex": re.compile(r"^\s*@codex\b[:,]?\s*(.+)$", re.MULTILINE),
|
|
55
|
+
"claude": re.compile(r"^\s*@claude\b[:,]?\s*(.+)$", re.MULTILINE),
|
|
56
|
+
}
|
|
57
|
+
SESSION_ID = re.compile(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
58
|
+
r"[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$")
|
|
59
|
+
|
|
60
|
+
# The labels every Claude→Codex message carries. `push()` and `reply()` send
|
|
61
|
+
# these, and the self-injection guard below recognizes them — one definition,
|
|
62
|
+
# so the two can't drift apart.
|
|
63
|
+
PUSH_LABEL = "[Antiphon bridge] Claude:"
|
|
64
|
+
CHANNEL_LABEL = "[Antiphon channel] Claude:"
|
|
65
|
+
|
|
66
|
+
# A message this bridge pushed arrives on the other side as an ordinary
|
|
67
|
+
# `role: user` event. Skipping those keeps the summary from echoing the
|
|
68
|
+
# bridge's own traffic back at it. Matched on the label, anchored at the start:
|
|
69
|
+
# a substring test would also swallow a user typing "antiphon is dropping
|
|
70
|
+
# messages", and that message would then never reach the other agent.
|
|
71
|
+
_SELF_INJECTION_PREFIXES = tuple(
|
|
72
|
+
label.lower() for label in (PUSH_LABEL, CHANNEL_LABEL))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_self_injected(text):
|
|
76
|
+
"""True if `text` is a message this bridge itself delivered."""
|
|
77
|
+
return text.lstrip().lower().startswith(_SELF_INJECTION_PREFIXES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------- helpers ----------
|
|
81
|
+
|
|
82
|
+
def project_dir():
|
|
83
|
+
return os.path.abspath(os.environ.get("ANTIPHON_CWD") or os.getcwd())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def state_path(cwd):
|
|
87
|
+
return os.path.join(cwd, ".antiphon", "cursor.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
LEGACY_KEYS = {
|
|
91
|
+
"codex_gordu": "codex_seen",
|
|
92
|
+
"claude_gordu": "claude_seen",
|
|
93
|
+
"son_itilen_codex": "last_pushed_codex",
|
|
94
|
+
"son_itilen_claude": "last_pushed_claude",
|
|
95
|
+
"son_itilen": "last_pushed_codex",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _translate_cursor_keys(data):
|
|
100
|
+
return {LEGACY_KEYS.get(k, k): v for k, v in data.items()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_cursor(cwd):
|
|
104
|
+
new_path = state_path(cwd)
|
|
105
|
+
try:
|
|
106
|
+
with open(new_path, encoding="utf-8") as f:
|
|
107
|
+
data = json.load(f)
|
|
108
|
+
except (OSError, json.JSONDecodeError):
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
translated = _translate_cursor_keys(data)
|
|
112
|
+
if translated != data:
|
|
113
|
+
write_cursor(cwd, translated)
|
|
114
|
+
return translated
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_cursor(cwd, data):
|
|
118
|
+
path = state_path(cwd)
|
|
119
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
120
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
121
|
+
try:
|
|
122
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
123
|
+
json.dump(data, f, ensure_ascii=False, indent=1)
|
|
124
|
+
os.replace(tmp, path)
|
|
125
|
+
return True
|
|
126
|
+
except OSError:
|
|
127
|
+
try:
|
|
128
|
+
os.unlink(tmp)
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def truncate(s, n):
|
|
135
|
+
s = " ".join((s or "").split())
|
|
136
|
+
return s if len(s) <= n else s[:n].rstrip() + "…"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def tail_lines(path):
|
|
140
|
+
try:
|
|
141
|
+
size = os.path.getsize(path)
|
|
142
|
+
with open(path, "rb") as f:
|
|
143
|
+
if size > TAIL_BYTES:
|
|
144
|
+
f.seek(size - TAIL_BYTES)
|
|
145
|
+
f.readline()
|
|
146
|
+
return f.read().decode("utf-8", "replace").splitlines()
|
|
147
|
+
except OSError:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def head_lines(path, limit=12, num_bytes=64 * 1024):
|
|
152
|
+
"""Returns the lines at the start of the file, used for session metadata."""
|
|
153
|
+
try:
|
|
154
|
+
with open(path, "rb") as f:
|
|
155
|
+
return f.read(num_bytes).decode("utf-8", "replace").splitlines()[:limit]
|
|
156
|
+
except OSError:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def iso_epoch(s):
|
|
161
|
+
if not s:
|
|
162
|
+
return 0.0
|
|
163
|
+
try:
|
|
164
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------- Claude side ----------
|
|
170
|
+
|
|
171
|
+
def _claude_slug(cwd):
|
|
172
|
+
"""The ~/.claude/projects directory name Claude Code derives from a path.
|
|
173
|
+
|
|
174
|
+
Every character that isn't alphanumeric becomes `-`, not just `/`: an
|
|
175
|
+
underscore, a dot and an existing `-` all end up as `-`. Getting this
|
|
176
|
+
wrong is silent — the slug simply names no directory, and the whole
|
|
177
|
+
Claude→Codex direction goes empty forever."""
|
|
178
|
+
return re.sub(r"[^A-Za-z0-9]", "-", cwd)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _transcript_cwd(path):
|
|
182
|
+
"""The project directory a Claude transcript records, or None."""
|
|
183
|
+
for line in head_lines(path, limit=40):
|
|
184
|
+
if '"cwd"' not in line: # cheap pre-filter, skips parsing big lines
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
d = json.loads(line)
|
|
188
|
+
except (json.JSONDecodeError, ValueError):
|
|
189
|
+
continue
|
|
190
|
+
if isinstance(d, dict) and isinstance(d.get("cwd"), str):
|
|
191
|
+
return d["cwd"]
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _find_claude_project_dir(cwd):
|
|
196
|
+
"""Finds the project directory by the cwd its transcripts carry.
|
|
197
|
+
|
|
198
|
+
The fallback for when the slug rule names no existing directory — an older
|
|
199
|
+
naming rule, or a new one. Only reached when the slug misses, so the
|
|
200
|
+
common path never pays for the scan."""
|
|
201
|
+
try:
|
|
202
|
+
entries = [os.path.join(CLAUDE_PROJECTS, name)
|
|
203
|
+
for name in os.listdir(CLAUDE_PROJECTS)]
|
|
204
|
+
except OSError:
|
|
205
|
+
return None
|
|
206
|
+
def mtime(path):
|
|
207
|
+
try:
|
|
208
|
+
return os.path.getmtime(path)
|
|
209
|
+
except OSError: # vanished mid-scan; sort it last
|
|
210
|
+
return 0.0
|
|
211
|
+
|
|
212
|
+
candidates = [p for p in entries if os.path.isdir(p)]
|
|
213
|
+
candidates.sort(key=mtime, reverse=True) # most recently used first
|
|
214
|
+
for directory in candidates:
|
|
215
|
+
transcripts = sorted(glob.glob(os.path.join(directory, "*.jsonl")),
|
|
216
|
+
key=mtime, reverse=True)
|
|
217
|
+
for path in transcripts[:3]:
|
|
218
|
+
recorded = _transcript_cwd(path)
|
|
219
|
+
if recorded is not None:
|
|
220
|
+
if recorded == cwd:
|
|
221
|
+
return directory
|
|
222
|
+
break # this directory belongs to another project
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def claude_project_dir(cwd):
|
|
227
|
+
"""The ~/.claude/projects directory holding this project's transcripts."""
|
|
228
|
+
directory = os.path.join(CLAUDE_PROJECTS, _claude_slug(cwd))
|
|
229
|
+
if os.path.isdir(directory):
|
|
230
|
+
return directory
|
|
231
|
+
return _find_claude_project_dir(cwd)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def claude_transcripts(cwd):
|
|
235
|
+
"""Claude Code transcript files belonging to this project directory (newest first)."""
|
|
236
|
+
directory = claude_project_dir(cwd)
|
|
237
|
+
if not directory:
|
|
238
|
+
return []
|
|
239
|
+
files = [p for p in glob.glob(os.path.join(directory, "*.jsonl"))
|
|
240
|
+
if os.path.getsize(p) > 0]
|
|
241
|
+
files.sort(key=os.path.getmtime, reverse=True)
|
|
242
|
+
return files
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def claude_events(cwd, start=0.0):
|
|
246
|
+
"""(time, type, text) — type: you | claude | tool"""
|
|
247
|
+
events = []
|
|
248
|
+
for path in claude_transcripts(cwd)[:3]:
|
|
249
|
+
for line in tail_lines(path):
|
|
250
|
+
try:
|
|
251
|
+
d = json.loads(line)
|
|
252
|
+
except json.JSONDecodeError:
|
|
253
|
+
continue
|
|
254
|
+
if d.get("isMeta"):
|
|
255
|
+
continue
|
|
256
|
+
ts = iso_epoch(d.get("timestamp"))
|
|
257
|
+
if ts <= start:
|
|
258
|
+
continue
|
|
259
|
+
kind = d.get("type")
|
|
260
|
+
msg = d.get("message") or {}
|
|
261
|
+
content = msg.get("content")
|
|
262
|
+
if kind == "user":
|
|
263
|
+
text = ""
|
|
264
|
+
if isinstance(content, str):
|
|
265
|
+
text = content
|
|
266
|
+
elif isinstance(content, list):
|
|
267
|
+
text = " ".join(c.get("text", "") for c in content
|
|
268
|
+
if isinstance(c, dict) and c.get("type") == "text")
|
|
269
|
+
text = text.strip()
|
|
270
|
+
# tool results and system injections are not the user's own words
|
|
271
|
+
if text and not text.startswith("<") and not _is_self_injected(text):
|
|
272
|
+
events.append((ts, "you", text))
|
|
273
|
+
elif kind == "assistant":
|
|
274
|
+
for c in content if isinstance(content, list) else []:
|
|
275
|
+
if c.get("type") == "text" and c.get("text", "").strip():
|
|
276
|
+
events.append((ts, "claude", c["text"].strip()))
|
|
277
|
+
elif c.get("type") == "tool_use":
|
|
278
|
+
i = c.get("input") or {}
|
|
279
|
+
detail = i.get("file_path") or i.get("command") or i.get("pattern") or ""
|
|
280
|
+
events.append((ts, "tool", f"{c.get('name', '?')} {detail}".strip()))
|
|
281
|
+
return sorted(events)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------- Codex side ----------
|
|
285
|
+
|
|
286
|
+
def _rollout_cwd(lines):
|
|
287
|
+
"""The cwd a Codex rollout records in its session metadata (None if absent)."""
|
|
288
|
+
for line in lines:
|
|
289
|
+
if '"cwd"' not in line: # cheap pre-filter, skips parsing big lines
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
d = json.loads(line)
|
|
293
|
+
except (json.JSONDecodeError, ValueError):
|
|
294
|
+
continue
|
|
295
|
+
if not isinstance(d, dict):
|
|
296
|
+
continue
|
|
297
|
+
for holder in (d, d.get("payload")):
|
|
298
|
+
if isinstance(holder, dict) and isinstance(holder.get("cwd"), str):
|
|
299
|
+
return holder["cwd"]
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def codex_rollout_files(cwd, days=3):
|
|
304
|
+
"""Codex rollout files whose cwd matches this project (newest first)."""
|
|
305
|
+
pattern = os.path.join(CODEX_SESSIONS, "**", "rollout-*.jsonl")
|
|
306
|
+
candidates = []
|
|
307
|
+
now = time.time()
|
|
308
|
+
for path in glob.glob(pattern, recursive=True):
|
|
309
|
+
try:
|
|
310
|
+
if now - os.path.getmtime(path) > days * 86400:
|
|
311
|
+
continue
|
|
312
|
+
except OSError:
|
|
313
|
+
continue
|
|
314
|
+
candidates.append(path)
|
|
315
|
+
candidates.sort(key=os.path.getmtime, reverse=True)
|
|
316
|
+
matched = []
|
|
317
|
+
for path in candidates[:60]:
|
|
318
|
+
# cwd lives in the session metadata. Reading the tail of a growing,
|
|
319
|
+
# active rollout could miss it and match the wrong, already-closed
|
|
320
|
+
# subsession instead.
|
|
321
|
+
lines = head_lines(path)
|
|
322
|
+
recorded = _rollout_cwd(lines)
|
|
323
|
+
if recorded is not None:
|
|
324
|
+
# An equality test, never a substring one: `/x/api` used to match
|
|
325
|
+
# a rollout recorded for `/x/api-v2`, and a push then landed in
|
|
326
|
+
# the sibling project's Codex.
|
|
327
|
+
if recorded == cwd:
|
|
328
|
+
matched.append(path)
|
|
329
|
+
continue
|
|
330
|
+
# No head line carries a cwd field: fall back to the old substring
|
|
331
|
+
# test rather than dropping a file we can't read properly.
|
|
332
|
+
if any(cwd in line for line in lines):
|
|
333
|
+
matched.append(path)
|
|
334
|
+
return matched
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def codex_events(cwd, start=0.0):
|
|
338
|
+
"""(time, type, text) — type: you | codex | tool"""
|
|
339
|
+
events = []
|
|
340
|
+
for path in codex_rollout_files(cwd)[:3]:
|
|
341
|
+
for line in tail_lines(path):
|
|
342
|
+
try:
|
|
343
|
+
d = json.loads(line)
|
|
344
|
+
except json.JSONDecodeError:
|
|
345
|
+
continue
|
|
346
|
+
ts = iso_epoch(d.get("timestamp"))
|
|
347
|
+
if ts <= start:
|
|
348
|
+
continue
|
|
349
|
+
kind, p = d.get("type"), d.get("payload") or {}
|
|
350
|
+
if kind == "response_item" and p.get("type") == "message":
|
|
351
|
+
role = p.get("role")
|
|
352
|
+
text = " ".join(
|
|
353
|
+
c.get("text") or c.get("input_text") or ""
|
|
354
|
+
for c in p.get("content") or [] if isinstance(c, dict)
|
|
355
|
+
).strip()
|
|
356
|
+
if not text or role == "developer":
|
|
357
|
+
continue
|
|
358
|
+
if role == "user":
|
|
359
|
+
if text.startswith("<") or _is_self_injected(text):
|
|
360
|
+
continue
|
|
361
|
+
events.append((ts, "you", text))
|
|
362
|
+
elif role == "assistant":
|
|
363
|
+
events.append((ts, "codex", text))
|
|
364
|
+
elif kind == "event_msg" and p.get("type") == "exec_command_begin":
|
|
365
|
+
cmd = p.get("command")
|
|
366
|
+
if isinstance(cmd, list):
|
|
367
|
+
cmd = " ".join(cmd)
|
|
368
|
+
if cmd:
|
|
369
|
+
events.append((ts, "tool", f"shell {cmd}"))
|
|
370
|
+
return sorted(events)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------- summary ----------
|
|
374
|
+
|
|
375
|
+
LABEL = {"you": "YOU", "claude": "Claude", "codex": "Codex", "tool": "·"}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# side -> (the other side's key, its display name for headings, its phrasing in notices)
|
|
379
|
+
OTHER_SIDE = {
|
|
380
|
+
"claude": ("codex", "Codex", "from Codex"),
|
|
381
|
+
"codex": ("claude", "Claude Code", "from Claude Code"),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def build_summary(cwd, side, start=0.0):
|
|
386
|
+
"""`side` is the side that will READ the summary ('claude' | 'codex').
|
|
387
|
+
Turns what happened on the other side, and what the user said, into
|
|
388
|
+
compact text.
|
|
389
|
+
|
|
390
|
+
Returns: (text, last_event_time, message_count). `message_count` doesn't
|
|
391
|
+
count tool calls — the notice shown in the terminal uses it."""
|
|
392
|
+
if side == "claude":
|
|
393
|
+
events = codex_events(cwd, start)
|
|
394
|
+
else:
|
|
395
|
+
events = claude_events(cwd, start)
|
|
396
|
+
other = OTHER_SIDE[side][1]
|
|
397
|
+
|
|
398
|
+
if not events:
|
|
399
|
+
return "", 0.0, 0
|
|
400
|
+
|
|
401
|
+
events = events[-EVENT_LIMIT:]
|
|
402
|
+
last_time = events[-1][0]
|
|
403
|
+
count = sum(1 for _, kind, _ in events if kind != "tool")
|
|
404
|
+
|
|
405
|
+
lines = []
|
|
406
|
+
tools = []
|
|
407
|
+
for ts, kind, text in events:
|
|
408
|
+
if kind == "tool":
|
|
409
|
+
tools.append(truncate(text, 70))
|
|
410
|
+
continue
|
|
411
|
+
if tools:
|
|
412
|
+
lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
|
|
413
|
+
tools = []
|
|
414
|
+
clock = datetime.fromtimestamp(ts).strftime("%H:%M")
|
|
415
|
+
lines.append(f"[{clock}] {LABEL.get(kind, kind)}: {truncate(text, 420)}")
|
|
416
|
+
if tools:
|
|
417
|
+
lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
|
|
418
|
+
|
|
419
|
+
body = "\n".join(lines)
|
|
420
|
+
truncated = False
|
|
421
|
+
if len(body) > SUMMARY_BUDGET:
|
|
422
|
+
# keep the newest; trim from the front
|
|
423
|
+
while len(body) > SUMMARY_BUDGET and len(lines) > 1:
|
|
424
|
+
lines.pop(0)
|
|
425
|
+
body = "\n".join(lines)
|
|
426
|
+
truncated = True
|
|
427
|
+
|
|
428
|
+
head = f"## What happened on the {other} side (since your last turn)"
|
|
429
|
+
foot = ("\nThis record belongs to the Antiphon bridge — this is what actually happened "
|
|
430
|
+
"there. Do not assume anything that is not in it.")
|
|
431
|
+
if truncated:
|
|
432
|
+
foot = "\n(older lines were cut for budget)" + foot
|
|
433
|
+
return f"{head}\n{body}{foot}", last_time, count
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------- hook (both sides share the same contract) ----------
|
|
437
|
+
|
|
438
|
+
def hook(side="claude"):
|
|
439
|
+
"""UserPromptSubmit hook: injects the other side's summary into the context.
|
|
440
|
+
|
|
441
|
+
`side` is which CLI this hook is running inside ('claude' | 'codex').
|
|
442
|
+
Claude Code and Codex CLI speak the same input fields (`cwd`) and the same
|
|
443
|
+
output wrapper, so a single `hook` function serves both."""
|
|
444
|
+
if side not in OTHER_SIDE:
|
|
445
|
+
print(f"hook: unknown side {side!r} (claude | codex)", file=sys.stderr)
|
|
446
|
+
return 1
|
|
447
|
+
try:
|
|
448
|
+
input_data = json.load(sys.stdin)
|
|
449
|
+
except (json.JSONDecodeError, ValueError):
|
|
450
|
+
input_data = {}
|
|
451
|
+
cwd = os.path.abspath(input_data.get("cwd") or project_dir())
|
|
452
|
+
|
|
453
|
+
cursor = read_cursor(cwd)
|
|
454
|
+
key = f"{side}_seen"
|
|
455
|
+
start = float(cursor.get(key) or (time.time() - LOOKBACK))
|
|
456
|
+
text, last, _ = build_summary(cwd, side, start)
|
|
457
|
+
if text and last:
|
|
458
|
+
cursor[key] = last
|
|
459
|
+
write_cursor(cwd, cursor)
|
|
460
|
+
|
|
461
|
+
if not text:
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
# The hook prints nothing to the terminal. The counter used to say
|
|
465
|
+
# "message" but it was counting the other side's transcript events;
|
|
466
|
+
# incoming channel messages already show up via their own notices.
|
|
467
|
+
# Context is injected silently.
|
|
468
|
+
print(json.dumps({
|
|
469
|
+
"hookSpecificOutput": {
|
|
470
|
+
"hookEventName": "UserPromptSubmit",
|
|
471
|
+
"additionalContext": text,
|
|
472
|
+
}
|
|
473
|
+
}, ensure_ascii=False))
|
|
474
|
+
return 0
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def notice_text(side, count):
|
|
478
|
+
"""The one-line notice in `status` output (the hook no longer uses this)."""
|
|
479
|
+
noun = "message" if count == 1 else "messages"
|
|
480
|
+
return f"💬 {count} new {noun} {OTHER_SIDE[side][2]}"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ---------- push (both directions) ----------
|
|
484
|
+
|
|
485
|
+
def last_claude_reply(transcript_path):
|
|
486
|
+
"""Returns the most recent assistant text in the Claude transcript."""
|
|
487
|
+
chunks = []
|
|
488
|
+
for line in tail_lines(transcript_path):
|
|
489
|
+
try:
|
|
490
|
+
d = json.loads(line)
|
|
491
|
+
except json.JSONDecodeError:
|
|
492
|
+
continue
|
|
493
|
+
if d.get("type") != "assistant" or d.get("isMeta"):
|
|
494
|
+
continue
|
|
495
|
+
content = (d.get("message") or {}).get("content")
|
|
496
|
+
texts = [c.get("text", "") for c in content or []
|
|
497
|
+
if isinstance(c, dict) and c.get("type") == "text"]
|
|
498
|
+
if texts:
|
|
499
|
+
chunks = texts # each new assistant message supersedes the last
|
|
500
|
+
return "\n".join(chunks).strip()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def last_codex_reply(transcript_path):
|
|
504
|
+
"""Returns the most recent assistant text in the Codex rollout."""
|
|
505
|
+
chunks = []
|
|
506
|
+
for line in tail_lines(transcript_path):
|
|
507
|
+
try:
|
|
508
|
+
d = json.loads(line)
|
|
509
|
+
except json.JSONDecodeError:
|
|
510
|
+
continue
|
|
511
|
+
p = d.get("payload") or {}
|
|
512
|
+
if (d.get("type") != "response_item" or p.get("type") != "message"
|
|
513
|
+
or p.get("role") != "assistant"):
|
|
514
|
+
continue
|
|
515
|
+
texts = [c.get("text") or c.get("output_text") or c.get("input_text") or ""
|
|
516
|
+
for c in p.get("content") or [] if isinstance(c, dict)]
|
|
517
|
+
if any(texts):
|
|
518
|
+
chunks = texts
|
|
519
|
+
return "\n".join(chunks).strip()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def codex_session_id(cwd):
|
|
523
|
+
"""The UUID of the newest Codex session matching cwd (None if there isn't one)."""
|
|
524
|
+
for path in codex_rollout_files(cwd)[:1]:
|
|
525
|
+
m = SESSION_ID.search(os.path.basename(path))
|
|
526
|
+
if m:
|
|
527
|
+
return m.group(1)
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def push(target="codex"):
|
|
532
|
+
"""Stop hook: pushes explicit target lines in the latest reply to the other side.
|
|
533
|
+
|
|
534
|
+
`target=codex` is called from Claude's Stop hook, `target=claude` from
|
|
535
|
+
Codex's Stop hook. Addressing is never inferred from free text — only an
|
|
536
|
+
explicit `@codex` or `@claude` marker at the start of a line triggers a
|
|
537
|
+
push.
|
|
538
|
+
"""
|
|
539
|
+
if target not in PUSH_MARKERS:
|
|
540
|
+
print(f"push: unknown target {target!r} (claude | codex)", file=sys.stderr)
|
|
541
|
+
return 1
|
|
542
|
+
try:
|
|
543
|
+
input_data = json.load(sys.stdin)
|
|
544
|
+
except (json.JSONDecodeError, ValueError):
|
|
545
|
+
input_data = {}
|
|
546
|
+
if input_data.get("stop_hook_active"):
|
|
547
|
+
return 0 # don't re-enter a turn we triggered ourselves
|
|
548
|
+
cwd = os.path.abspath(input_data.get("cwd") or project_dir())
|
|
549
|
+
transcript = input_data.get("transcript_path")
|
|
550
|
+
if not transcript or not os.path.exists(transcript):
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
reply_reader = last_claude_reply if target == "codex" else last_codex_reply
|
|
554
|
+
reply_text = reply_reader(transcript)
|
|
555
|
+
messages = [m.strip() for m in PUSH_MARKERS[target].findall(reply_text) if m.strip()]
|
|
556
|
+
if not messages:
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
outgoing = "\n".join(messages)
|
|
560
|
+
cursor = read_cursor(cwd)
|
|
561
|
+
key = f"last_pushed_{target}"
|
|
562
|
+
previous = cursor.get(key)
|
|
563
|
+
if previous == outgoing:
|
|
564
|
+
return 0 # don't push the same message twice
|
|
565
|
+
|
|
566
|
+
if target == "codex":
|
|
567
|
+
session_id = codex_session_id(cwd)
|
|
568
|
+
if not session_id:
|
|
569
|
+
print("antiphon: no Codex session found in this directory, not pushed", file=sys.stderr)
|
|
570
|
+
return 0
|
|
571
|
+
ok, detail = send_to_codex(session_id, f"{PUSH_LABEL} {outgoing}")
|
|
572
|
+
else:
|
|
573
|
+
ok, detail = send_to_claude(cwd, outgoing)
|
|
574
|
+
|
|
575
|
+
if ok:
|
|
576
|
+
cursor[key] = outgoing
|
|
577
|
+
write_cursor(cwd, cursor)
|
|
578
|
+
print(f"antiphon: delivered to {target.title()} ({len(outgoing)} characters)",
|
|
579
|
+
file=sys.stderr)
|
|
580
|
+
else:
|
|
581
|
+
print(f"antiphon: delivery failed — {detail}", file=sys.stderr)
|
|
582
|
+
return 0
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def send_to_codex(session, message):
|
|
586
|
+
"""Leaves a message with the running session via `codex queue`. Returns: (success, detail)."""
|
|
587
|
+
try:
|
|
588
|
+
result = subprocess.run(
|
|
589
|
+
["codex", "queue", "--thread", session, "--message", message],
|
|
590
|
+
capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL,
|
|
591
|
+
)
|
|
592
|
+
except FileNotFoundError:
|
|
593
|
+
return False, "codex command not found"
|
|
594
|
+
except subprocess.SubprocessError as e:
|
|
595
|
+
return False, f"{type(e).__name__}"
|
|
596
|
+
if result.returncode != 0:
|
|
597
|
+
return False, (result.stderr or result.stdout or "unknown error").strip()[:200]
|
|
598
|
+
return True, ""
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def claude_socket_path(cwd):
|
|
602
|
+
"""Deterministic path to this project's MCP Channel Unix socket."""
|
|
603
|
+
key = hashlib.sha256(os.path.abspath(cwd).encode()).hexdigest()[:20]
|
|
604
|
+
return os.path.join(os.environ.get("TMPDIR") or "/tmp",
|
|
605
|
+
f"antiphon-channel-{key}.sock")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def send_to_claude(cwd, text):
|
|
609
|
+
"""Sends a Codex message to Claude Code's MCP Channel socket."""
|
|
610
|
+
request = {
|
|
611
|
+
"content": text,
|
|
612
|
+
"message_id": str(uuid.uuid4()),
|
|
613
|
+
}
|
|
614
|
+
last_error = None
|
|
615
|
+
for path in (claude_socket_path(cwd),):
|
|
616
|
+
try:
|
|
617
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
618
|
+
sock.settimeout(5)
|
|
619
|
+
sock.connect(path)
|
|
620
|
+
sock.sendall(json.dumps(request, ensure_ascii=False).encode())
|
|
621
|
+
sock.shutdown(socket.SHUT_WR)
|
|
622
|
+
reply_bytes = b""
|
|
623
|
+
while len(reply_bytes) < 64 * 1024:
|
|
624
|
+
chunk = sock.recv(8192)
|
|
625
|
+
if not chunk:
|
|
626
|
+
break
|
|
627
|
+
reply_bytes += chunk
|
|
628
|
+
break
|
|
629
|
+
except OSError as e:
|
|
630
|
+
last_error = e
|
|
631
|
+
else:
|
|
632
|
+
return False, ("Claude MCP Channel is down: "
|
|
633
|
+
f"{last_error.strerror or type(last_error).__name__}")
|
|
634
|
+
try:
|
|
635
|
+
result = json.loads(reply_bytes.decode())
|
|
636
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
637
|
+
return False, "Claude MCP Channel returned an invalid response"
|
|
638
|
+
if not result.get("ok"):
|
|
639
|
+
return False, str(result.get("error") or "channel delivery failed")[:200]
|
|
640
|
+
return True, ""
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def reply(*_):
|
|
644
|
+
"""Sends the reply from the Claude channel reply tool to the running Codex session."""
|
|
645
|
+
try:
|
|
646
|
+
input_data = json.load(sys.stdin)
|
|
647
|
+
except (json.JSONDecodeError, ValueError):
|
|
648
|
+
input_data = {}
|
|
649
|
+
text = input_data.get("text")
|
|
650
|
+
if not isinstance(text, str) or not text.strip():
|
|
651
|
+
print("reply: empty text", file=sys.stderr)
|
|
652
|
+
return 1
|
|
653
|
+
cwd = project_dir()
|
|
654
|
+
session_id = codex_session_id(cwd)
|
|
655
|
+
if not session_id:
|
|
656
|
+
print("reply: no running Codex session found", file=sys.stderr)
|
|
657
|
+
return 1
|
|
658
|
+
ok, detail = send_to_codex(session_id, f"{CHANNEL_LABEL} {text.strip()}")
|
|
659
|
+
if not ok:
|
|
660
|
+
print(f"reply: {detail}", file=sys.stderr)
|
|
661
|
+
return 1
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ---------- Codex MCP server ----------
|
|
666
|
+
|
|
667
|
+
TOOLS = [{
|
|
668
|
+
"name": "antiphon_read",
|
|
669
|
+
"description": ("Returns what happened on the Claude Code side since your last turn. "
|
|
670
|
+
"This normally arrives automatically via the hook, no extra effort "
|
|
671
|
+
"required; this tool is the fallback — call it by hand if you suspect "
|
|
672
|
+
"the bridge has gone quiet."),
|
|
673
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
674
|
+
}]
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _mcp_result(mid, result):
|
|
678
|
+
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result},
|
|
679
|
+
ensure_ascii=False) + "\n")
|
|
680
|
+
sys.stdout.flush()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def mcp():
|
|
684
|
+
"""The MCP stdio server Codex connects to."""
|
|
685
|
+
cwd = project_dir()
|
|
686
|
+
for line in sys.stdin:
|
|
687
|
+
line = line.strip()
|
|
688
|
+
if not line:
|
|
689
|
+
continue
|
|
690
|
+
try:
|
|
691
|
+
request = json.loads(line)
|
|
692
|
+
except json.JSONDecodeError:
|
|
693
|
+
continue
|
|
694
|
+
method, mid = request.get("method"), request.get("id")
|
|
695
|
+
if method == "initialize":
|
|
696
|
+
_mcp_result(mid, {
|
|
697
|
+
"protocolVersion": "2024-11-05",
|
|
698
|
+
"capabilities": {"tools": {}},
|
|
699
|
+
"serverInfo": {"name": "antiphon", "version": "0.1.0"},
|
|
700
|
+
})
|
|
701
|
+
elif method == "tools/list":
|
|
702
|
+
_mcp_result(mid, {"tools": TOOLS})
|
|
703
|
+
elif method == "tools/call":
|
|
704
|
+
p = request.get("params") or {}
|
|
705
|
+
name = p.get("name")
|
|
706
|
+
if name == "antiphon_read":
|
|
707
|
+
cursor = read_cursor(cwd)
|
|
708
|
+
start = float(cursor.get("codex_seen") or (time.time() - LOOKBACK))
|
|
709
|
+
text, last, _ = build_summary(cwd, "codex", start)
|
|
710
|
+
if text and last:
|
|
711
|
+
cursor["codex_seen"] = last
|
|
712
|
+
write_cursor(cwd, cursor)
|
|
713
|
+
output = text or "Nothing new on the Claude Code side since your last turn."
|
|
714
|
+
else:
|
|
715
|
+
output = f"unknown tool: {name}"
|
|
716
|
+
_mcp_result(mid, {"content": [{"type": "text", "text": output}]})
|
|
717
|
+
elif mid is not None:
|
|
718
|
+
_mcp_result(mid, {})
|
|
719
|
+
return 0
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# ---------- setup ----------
|
|
723
|
+
|
|
724
|
+
HOOK_COMMAND = "antiphon hook {side}"
|
|
725
|
+
PUSH_COMMAND = "antiphon push {target}"
|
|
726
|
+
|
|
727
|
+
SECTION_HEADING = "## The Antiphon bridge"
|
|
728
|
+
|
|
729
|
+
AGENTS_RULE = ("\n## The Antiphon bridge\n\n"
|
|
730
|
+
"You are working alongside Claude Code on this project. What happens on the "
|
|
731
|
+
"other side is injected into your context automatically at the start of each "
|
|
732
|
+
"turn — you don't need to do anything else. If you suspect the bridge has gone "
|
|
733
|
+
"quiet, you can call the `antiphon_read` tool by hand.\n\n"
|
|
734
|
+
"When Claude wants to tell you something directly, you'll see it as a user "
|
|
735
|
+
"message starting with `[Antiphon bridge] Claude:` (pushed from Claude's Stop "
|
|
736
|
+
"hook) or `[Antiphon channel] Claude:` (a direct reply through the channel) — "
|
|
737
|
+
"either way, these are Claude's words, not the user's. When you want to hand "
|
|
738
|
+
"Claude a task directly, put `@claude` at the start of a line in your reply; "
|
|
739
|
+
"only that line is sent to the running Claude session as an MCP Channel event.\n")
|
|
740
|
+
|
|
741
|
+
CLAUDE_RULE = ("\n## The Antiphon bridge\n\n"
|
|
742
|
+
"You are working alongside another agent on this project. What happens on the "
|
|
743
|
+
"other side is injected into your context at the start of each turn. Events "
|
|
744
|
+
"that come directly from that agent are marked "
|
|
745
|
+
"`<channel source=\"antiphon\" sender=\"codex\" sender_kind=\"agent\">`; they "
|
|
746
|
+
"are the words of the Codex agent, not of the human user. Use the "
|
|
747
|
+
"`reply_to_codex` tool to answer them.\n")
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class ConfigFileError(Exception):
|
|
751
|
+
"""A config file exists but can't be read, so it must not be rewritten.
|
|
752
|
+
|
|
753
|
+
A trailing comma, a `//` comment or a UTF-8 BOM is enough to make a
|
|
754
|
+
hand-edited settings file unparseable. Overwriting it would silently throw
|
|
755
|
+
away the user's permissions, env, statusLine and every other tool's hooks,
|
|
756
|
+
so `setup` reports the file and leaves it exactly as it found it."""
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _read_json_object(path):
|
|
760
|
+
"""Reads a JSON object from `path`. A missing (or empty) file reads as {}.
|
|
761
|
+
|
|
762
|
+
Raises ConfigFileError for anything that exists but can't be understood —
|
|
763
|
+
the caller must not overwrite such a file."""
|
|
764
|
+
try:
|
|
765
|
+
with open(path, encoding="utf-8") as f:
|
|
766
|
+
text = f.read()
|
|
767
|
+
except FileNotFoundError:
|
|
768
|
+
return {} # the normal first install
|
|
769
|
+
except OSError as e:
|
|
770
|
+
raise ConfigFileError(
|
|
771
|
+
f"{path} could not be read ({e.strerror or type(e).__name__}); "
|
|
772
|
+
"refusing to overwrite it. Fix the file's permissions or move it "
|
|
773
|
+
"aside, then run `antiphon setup` again.") from e
|
|
774
|
+
except UnicodeDecodeError as e:
|
|
775
|
+
raise ConfigFileError(
|
|
776
|
+
f"{path} is not valid UTF-8; refusing to overwrite it. Fix the "
|
|
777
|
+
"file or move it aside, then run `antiphon setup` again.") from e
|
|
778
|
+
if not text.strip():
|
|
779
|
+
return {} # an empty file has nothing to lose
|
|
780
|
+
try:
|
|
781
|
+
data = json.loads(text)
|
|
782
|
+
except json.JSONDecodeError as e:
|
|
783
|
+
raise ConfigFileError(
|
|
784
|
+
f"{path} is not valid JSON ({e.msg}, line {e.lineno} column "
|
|
785
|
+
f"{e.colno}); refusing to overwrite it — that would throw away "
|
|
786
|
+
"everything else in the file. Fix the file (a trailing comma, a "
|
|
787
|
+
"`//` comment and a byte-order mark are the usual culprits) or "
|
|
788
|
+
"move it aside, then run `antiphon setup` again.") from e
|
|
789
|
+
if not isinstance(data, dict):
|
|
790
|
+
raise ConfigFileError(
|
|
791
|
+
f"{path} holds a JSON {type(data).__name__}, not an object; "
|
|
792
|
+
"refusing to overwrite it. Fix the file or move it aside, then "
|
|
793
|
+
"run `antiphon setup` again.")
|
|
794
|
+
return data
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _update_json(path, mutate):
|
|
798
|
+
"""Update existing JSON in place without clobbering it. Returns True if the file changed."""
|
|
799
|
+
data = _read_json_object(path)
|
|
800
|
+
if not mutate(data):
|
|
801
|
+
return False
|
|
802
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
803
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
804
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
805
|
+
return True
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _legacy_commands(script, verb, arg):
|
|
809
|
+
"""Values/patterns for finding legacy Python hooks pinned to an absolute path."""
|
|
810
|
+
exact = [f"python3 {script} {verb} {arg}", f"python3 {script} {verb}"]
|
|
811
|
+
# A global npm install has no way to know where the old clone lived; also
|
|
812
|
+
# recognize any legacy absolute path safely, by filename and subcommand.
|
|
813
|
+
pattern = re.compile(
|
|
814
|
+
rf"^python3\s+\S*antiphon\.py\s+{re.escape(verb)}"
|
|
815
|
+
rf"(?:\s+{re.escape(arg)})?$"
|
|
816
|
+
)
|
|
817
|
+
return [*exact, pattern]
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _dedupe_hooks(hooks, command):
|
|
821
|
+
"""Leaves a single entry for `command`. Returns True if anything was dropped.
|
|
822
|
+
|
|
823
|
+
Upgrading a legacy entry can land on a command that is already installed —
|
|
824
|
+
or two legacy entries can upgrade to the same one. Without this collapse
|
|
825
|
+
the hook is listed twice and fires twice per turn."""
|
|
826
|
+
seen = False
|
|
827
|
+
dropped = False
|
|
828
|
+
emptied = []
|
|
829
|
+
for group in hooks:
|
|
830
|
+
entries = group.get("hooks")
|
|
831
|
+
if not isinstance(entries, list):
|
|
832
|
+
continue
|
|
833
|
+
kept = []
|
|
834
|
+
for entry in entries:
|
|
835
|
+
if isinstance(entry, dict) and entry.get("command") == command:
|
|
836
|
+
if seen:
|
|
837
|
+
dropped = True
|
|
838
|
+
continue # a duplicate of one we already keep
|
|
839
|
+
seen = True
|
|
840
|
+
kept.append(entry)
|
|
841
|
+
if len(kept) == len(entries):
|
|
842
|
+
continue
|
|
843
|
+
group["hooks"] = kept
|
|
844
|
+
if not kept:
|
|
845
|
+
emptied.append(id(group))
|
|
846
|
+
if emptied:
|
|
847
|
+
hooks[:] = [group for group in hooks if id(group) not in emptied]
|
|
848
|
+
return dropped
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _add_hook(hooks, command, legacy_commands=None, label=None):
|
|
852
|
+
"""Adds the command to the UserPromptSubmit list; does nothing if it's already there.
|
|
853
|
+
|
|
854
|
+
If `legacy_commands` is given, upgrade those first — otherwise, once the
|
|
855
|
+
side argument gets added, the old entry would stick around and the hook
|
|
856
|
+
would fire twice."""
|
|
857
|
+
changed = False
|
|
858
|
+
if legacy_commands:
|
|
859
|
+
if isinstance(legacy_commands, (str, re.Pattern)):
|
|
860
|
+
legacy_commands = [legacy_commands]
|
|
861
|
+
for group in hooks:
|
|
862
|
+
for entry in group.get("hooks") or []:
|
|
863
|
+
current = entry.get("command", "")
|
|
864
|
+
matched = any(
|
|
865
|
+
(candidate.fullmatch(current) if isinstance(candidate, re.Pattern)
|
|
866
|
+
else current == candidate)
|
|
867
|
+
for candidate in legacy_commands
|
|
868
|
+
)
|
|
869
|
+
if matched:
|
|
870
|
+
entry["command"] = command
|
|
871
|
+
if label:
|
|
872
|
+
entry["statusMessage"] = label
|
|
873
|
+
changed = True
|
|
874
|
+
if _dedupe_hooks(hooks, command):
|
|
875
|
+
changed = True
|
|
876
|
+
for group in hooks:
|
|
877
|
+
for entry in group.get("hooks") or []:
|
|
878
|
+
if entry.get("command") == command:
|
|
879
|
+
if label and entry.get("statusMessage") != label:
|
|
880
|
+
entry["statusMessage"] = label
|
|
881
|
+
changed = True
|
|
882
|
+
return changed
|
|
883
|
+
new_entry = {"type": "command", "command": command}
|
|
884
|
+
if label:
|
|
885
|
+
new_entry["statusMessage"] = label
|
|
886
|
+
hooks.append({"hooks": [new_entry]})
|
|
887
|
+
return True
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _update_instructions(current, rule):
|
|
891
|
+
"""Adds the Antiphon section, or edits it in place if it's stale.
|
|
892
|
+
|
|
893
|
+
Just checking the heading and skipping wasn't enough: when the rule text
|
|
894
|
+
changed, the old text stayed put and kept telling the agent something no
|
|
895
|
+
longer true."""
|
|
896
|
+
heading = SECTION_HEADING
|
|
897
|
+
start = current.find(heading)
|
|
898
|
+
if start == -1:
|
|
899
|
+
return current + rule, "added"
|
|
900
|
+
end = current.find("\n## ", start + len(heading))
|
|
901
|
+
old_section = current[start:] if end == -1 else current[start:end]
|
|
902
|
+
if old_section.strip() == rule.strip():
|
|
903
|
+
return current, "already up to date"
|
|
904
|
+
tail = "" if end == -1 else current[end:]
|
|
905
|
+
return current[:start].rstrip("\n") + rule + tail, "updated"
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
CODEX_MCP_TABLE = "mcp_servers.antiphon"
|
|
909
|
+
# Matches `[table]` and `[[table]]` headers, capturing the name between them.
|
|
910
|
+
TOML_HEADER = re.compile(r"^\s*\[\[?\s*([^\[\]]+?)\s*\]\]?\s*$")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _codex_config_block(cwd):
|
|
914
|
+
"""The `.codex/config.toml` entry that gives Codex the `antiphon_read` tool.
|
|
915
|
+
|
|
916
|
+
Note `args = ["mcp"]`, not `["channel"]`: the channel server is Claude's side
|
|
917
|
+
and hands out `reply_to_codex`. Pointing Codex at it would let Codex publish
|
|
918
|
+
messages labelled as Claude's — the one thing this bridge exists to prevent."""
|
|
919
|
+
return (f'[{CODEX_MCP_TABLE}]\n'
|
|
920
|
+
'command = "antiphon"\n'
|
|
921
|
+
'args = ["mcp"]\n'
|
|
922
|
+
'# read-only local bridge; no need to ask on every turn\n'
|
|
923
|
+
'default_tools_approval_mode = "approve"\n'
|
|
924
|
+
f'\n[{CODEX_MCP_TABLE}.env]\n'
|
|
925
|
+
f'ANTIPHON_CWD = "{cwd}"\n')
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _strip_toml_table(text, table):
|
|
929
|
+
"""Drops `[table]` and its sub-tables, leaving every other section intact."""
|
|
930
|
+
kept, skipping = [], False
|
|
931
|
+
for line in text.splitlines(keepends=True):
|
|
932
|
+
header = TOML_HEADER.match(line)
|
|
933
|
+
if header:
|
|
934
|
+
name = header.group(1)
|
|
935
|
+
skipping = name == table or name.startswith(table + ".")
|
|
936
|
+
if not skipping:
|
|
937
|
+
kept.append(line)
|
|
938
|
+
return "".join(kept)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _update_codex_config(path, cwd):
|
|
942
|
+
"""Rewrites our own table in place; anything else in the file survives."""
|
|
943
|
+
current = ""
|
|
944
|
+
if os.path.exists(path):
|
|
945
|
+
with open(path, encoding="utf-8") as f:
|
|
946
|
+
current = f.read()
|
|
947
|
+
kept = _strip_toml_table(current, CODEX_MCP_TABLE).rstrip()
|
|
948
|
+
block = _codex_config_block(cwd)
|
|
949
|
+
new_text = f"{kept}\n\n{block}" if kept else block
|
|
950
|
+
if new_text == current:
|
|
951
|
+
return False
|
|
952
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
953
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
954
|
+
f.write(new_text)
|
|
955
|
+
return True
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def setup():
|
|
959
|
+
cwd = project_dir()
|
|
960
|
+
script = os.path.abspath(__file__)
|
|
961
|
+
failures = []
|
|
962
|
+
|
|
963
|
+
def install(target, mutate, done, already):
|
|
964
|
+
"""Applies one config change and reports it.
|
|
965
|
+
|
|
966
|
+
A file that can't be parsed is left alone and recorded as a failure;
|
|
967
|
+
the rest of the installation still runs, so one broken file doesn't
|
|
968
|
+
cost the user everything else. `setup` fails at the end instead."""
|
|
969
|
+
if target in failures:
|
|
970
|
+
return # already reported; two hooks share a file
|
|
971
|
+
try:
|
|
972
|
+
changed = _update_json(target, mutate)
|
|
973
|
+
except ConfigFileError as error:
|
|
974
|
+
failures.append(target)
|
|
975
|
+
print(f"✗ {error}", file=sys.stderr)
|
|
976
|
+
return
|
|
977
|
+
print(f"{'✓' if changed else '·'} {done if changed else already}: {target}")
|
|
978
|
+
|
|
979
|
+
# --- Claude Code side: .claude/settings.json ---
|
|
980
|
+
claude_target = os.path.join(cwd, ".claude", "settings.json")
|
|
981
|
+
claude_command = HOOK_COMMAND.format(side="claude")
|
|
982
|
+
legacy_commands = _legacy_commands(script, "kanca", "claude")
|
|
983
|
+
|
|
984
|
+
def claude_mutate(data):
|
|
985
|
+
hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
|
|
986
|
+
changed = _add_hook(hooks, claude_command, legacy_commands)
|
|
987
|
+
allowed = data.setdefault("permissions", {}).setdefault("allow", [])
|
|
988
|
+
reply_tool = "mcp__antiphon__reply_to_codex"
|
|
989
|
+
if reply_tool not in allowed:
|
|
990
|
+
allowed.append(reply_tool)
|
|
991
|
+
changed = True
|
|
992
|
+
return changed
|
|
993
|
+
|
|
994
|
+
install(claude_target, claude_mutate,
|
|
995
|
+
"Claude hook installed", "Claude hook already installed")
|
|
996
|
+
|
|
997
|
+
# --- Claude side: push to Codex (Stop hook) ---
|
|
998
|
+
push_command = PUSH_COMMAND.format(target="codex")
|
|
999
|
+
legacy_push_commands = _legacy_commands(script, "it", "codex")
|
|
1000
|
+
|
|
1001
|
+
def push_mutate(data):
|
|
1002
|
+
hooks = data.setdefault("hooks", {}).setdefault("Stop", [])
|
|
1003
|
+
return _add_hook(hooks, push_command, legacy_push_commands)
|
|
1004
|
+
|
|
1005
|
+
install(claude_target, push_mutate,
|
|
1006
|
+
"Push-to-Codex hook installed (Stop)",
|
|
1007
|
+
"Push-to-Codex hook already installed")
|
|
1008
|
+
|
|
1009
|
+
# --- Codex side: .codex/hooks.json (same contract, same body) ---
|
|
1010
|
+
codex_target = os.path.join(cwd, ".codex", "hooks.json")
|
|
1011
|
+
codex_command = HOOK_COMMAND.format(side="codex")
|
|
1012
|
+
legacy_codex_commands = _legacy_commands(script, "kanca", "codex")
|
|
1013
|
+
|
|
1014
|
+
def codex_mutate(data):
|
|
1015
|
+
hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
|
|
1016
|
+
return _add_hook(hooks, codex_command, legacy_codex_commands,
|
|
1017
|
+
label="Antiphon bridge")
|
|
1018
|
+
|
|
1019
|
+
install(codex_target, codex_mutate,
|
|
1020
|
+
"Codex hook installed", "Codex hook already installed")
|
|
1021
|
+
|
|
1022
|
+
# --- Codex side: push to Claude (Stop hook) ---
|
|
1023
|
+
reverse_push_command = PUSH_COMMAND.format(target="claude")
|
|
1024
|
+
legacy_reverse_push_commands = _legacy_commands(script, "it", "claude")
|
|
1025
|
+
|
|
1026
|
+
def reverse_push_mutate(data):
|
|
1027
|
+
hooks = data.setdefault("hooks", {}).setdefault("Stop", [])
|
|
1028
|
+
return _add_hook(hooks, reverse_push_command, legacy_reverse_push_commands)
|
|
1029
|
+
|
|
1030
|
+
install(codex_target, reverse_push_mutate,
|
|
1031
|
+
"Push-to-Claude hook installed (Stop)",
|
|
1032
|
+
"Push-to-Claude hook already installed")
|
|
1033
|
+
|
|
1034
|
+
# --- Codex side: the antiphon_read MCP tool (.codex/config.toml) ---
|
|
1035
|
+
codex_config = os.path.join(cwd, ".codex", "config.toml")
|
|
1036
|
+
written = _update_codex_config(codex_config, cwd)
|
|
1037
|
+
print(f"{'✓' if written else '·'} Codex MCP tool "
|
|
1038
|
+
f"{'registered' if written else 'already registered'}: {codex_config}")
|
|
1039
|
+
|
|
1040
|
+
# --- Claude Code MCP Channel ---
|
|
1041
|
+
mcp_target = os.path.join(cwd, ".mcp.json")
|
|
1042
|
+
channel_config = {
|
|
1043
|
+
"command": "antiphon",
|
|
1044
|
+
"args": ["channel"],
|
|
1045
|
+
"env": {"ANTIPHON_CWD": cwd},
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
def mcp_mutate(data):
|
|
1049
|
+
servers = data.setdefault("mcpServers", {})
|
|
1050
|
+
if servers.get("antiphon") == channel_config:
|
|
1051
|
+
return False
|
|
1052
|
+
servers["antiphon"] = channel_config
|
|
1053
|
+
return True
|
|
1054
|
+
|
|
1055
|
+
install(mcp_target, mcp_mutate,
|
|
1056
|
+
"Claude MCP Channel registered", "Claude MCP Channel already registered")
|
|
1057
|
+
|
|
1058
|
+
# Claude Code may also keep .mcp.json servers in a local allowlist.
|
|
1059
|
+
local_target = os.path.join(cwd, ".claude", "settings.local.json")
|
|
1060
|
+
|
|
1061
|
+
def local_mutate(data):
|
|
1062
|
+
enabled = data.setdefault("enabledMcpjsonServers", [])
|
|
1063
|
+
if "antiphon" in enabled:
|
|
1064
|
+
return False
|
|
1065
|
+
enabled.append("antiphon")
|
|
1066
|
+
return True
|
|
1067
|
+
|
|
1068
|
+
install(local_target, local_mutate,
|
|
1069
|
+
"Claude MCP local permission updated",
|
|
1070
|
+
"Claude MCP local permission already up to date")
|
|
1071
|
+
|
|
1072
|
+
# --- AGENTS.md rule ---
|
|
1073
|
+
agents = os.path.join(cwd, "AGENTS.md")
|
|
1074
|
+
current = ""
|
|
1075
|
+
if os.path.exists(agents):
|
|
1076
|
+
with open(agents, encoding="utf-8") as f:
|
|
1077
|
+
current = f.read()
|
|
1078
|
+
new_text, status_word = _update_instructions(current, AGENTS_RULE)
|
|
1079
|
+
if new_text != current:
|
|
1080
|
+
with open(agents, "w", encoding="utf-8") as f:
|
|
1081
|
+
f.write(new_text)
|
|
1082
|
+
print(f"{'✓' if new_text != current else '·'} AGENTS.md rule {status_word}: {agents}")
|
|
1083
|
+
|
|
1084
|
+
# --- CLAUDE.md rule ---
|
|
1085
|
+
claude_md = os.path.join(cwd, "CLAUDE.md")
|
|
1086
|
+
current = ""
|
|
1087
|
+
if os.path.exists(claude_md):
|
|
1088
|
+
with open(claude_md, encoding="utf-8") as f:
|
|
1089
|
+
current = f.read()
|
|
1090
|
+
new_text, status_word = _update_instructions(current, CLAUDE_RULE)
|
|
1091
|
+
if new_text != current:
|
|
1092
|
+
with open(claude_md, "w", encoding="utf-8") as f:
|
|
1093
|
+
f.write(new_text)
|
|
1094
|
+
print(f"{'✓' if new_text != current else '·'} CLAUDE.md rule {status_word}: {claude_md}")
|
|
1095
|
+
|
|
1096
|
+
print("\n— One last step: Codex hooks need a one-time security approval.")
|
|
1097
|
+
print(" Open `codex` in this directory; approve the hook at the 'New hook - review required' prompt.")
|
|
1098
|
+
print(" Approval is granted once and then persists (it asks again only if the file changes).")
|
|
1099
|
+
print("\n— Start Claude with the channel enabled:")
|
|
1100
|
+
print(" claude --dangerously-load-development-channels server:antiphon")
|
|
1101
|
+
print(" In the research preview, the first launch needs both a development channel and an MCP approval.")
|
|
1102
|
+
if failures:
|
|
1103
|
+
listed = "\n ".join(failures)
|
|
1104
|
+
print(f"\n✗ setup did not finish. {len(failures)} file(s) were left untouched "
|
|
1105
|
+
f"because they could not be read:\n {listed}\n"
|
|
1106
|
+
" Fix or move them, then run `antiphon setup` again.", file=sys.stderr)
|
|
1107
|
+
return 1
|
|
1108
|
+
return 0
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
# ---------- status, for humans ----------
|
|
1112
|
+
|
|
1113
|
+
def status():
|
|
1114
|
+
cwd = project_dir()
|
|
1115
|
+
print(f"project: {cwd}\n")
|
|
1116
|
+
c = claude_transcripts(cwd)
|
|
1117
|
+
x = codex_rollout_files(cwd)
|
|
1118
|
+
print(f"Claude transcripts: {len(c)} files" + (f" (newest: {os.path.basename(c[0])})" if c else " — none"))
|
|
1119
|
+
print(f"Codex rollouts: {len(x)} files" + (f" (newest: {os.path.basename(x[0])})" if x else " — none"))
|
|
1120
|
+
socket_path = claude_socket_path(cwd)
|
|
1121
|
+
print(f"Claude channel: {'live' if os.path.exists(socket_path) else 'down'} ({socket_path})")
|
|
1122
|
+
cursor = read_cursor(cwd)
|
|
1123
|
+
for k, v in (cursor or {}).items():
|
|
1124
|
+
if k.endswith("_seen") and isinstance(v, (int, float)):
|
|
1125
|
+
shown = datetime.fromtimestamp(v).strftime('%H:%M:%S') if v else '—'
|
|
1126
|
+
else:
|
|
1127
|
+
shown = truncate(str(v), 80) if v else '—'
|
|
1128
|
+
print(f"cursor {k}: {shown}")
|
|
1129
|
+
for side in ("claude", "codex"):
|
|
1130
|
+
start = float((cursor or {}).get(f"{side}_seen") or (time.time() - LOOKBACK))
|
|
1131
|
+
text, _, count = build_summary(cwd, side, start)
|
|
1132
|
+
print(f"\n=== what {side} would see ===")
|
|
1133
|
+
if count:
|
|
1134
|
+
print(notice_text(side, count))
|
|
1135
|
+
print(text or "(nothing new)")
|
|
1136
|
+
return 0
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def print_summary(side="claude"):
|
|
1140
|
+
cwd = project_dir()
|
|
1141
|
+
text, _, _ = build_summary(cwd, side, time.time() - LOOKBACK)
|
|
1142
|
+
print(text or "(nothing new)")
|
|
1143
|
+
return 0
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
COMMANDS = {
|
|
1147
|
+
"setup": setup, "status": status, "hook": hook, "summary": print_summary,
|
|
1148
|
+
"push": push, "reply": reply, "mcp": mcp,
|
|
1149
|
+
# Legacy aliases for old local installs, kept during the transition period.
|
|
1150
|
+
"kur": setup, "durum": status, "kanca": hook, "ozet": print_summary,
|
|
1151
|
+
"it": push, "yanit": reply,
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
_CO_VARARGS = 0x04 # CO_VARARGS, without importing inspect
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _max_args(func):
|
|
1158
|
+
"""How many positional arguments a command takes (None means any)."""
|
|
1159
|
+
if func.__code__.co_flags & _CO_VARARGS:
|
|
1160
|
+
return None
|
|
1161
|
+
return func.__code__.co_argcount
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
if __name__ == "__main__":
|
|
1165
|
+
command = sys.argv[1] if len(sys.argv) > 1 else "status"
|
|
1166
|
+
func = COMMANDS.get(command)
|
|
1167
|
+
if not func:
|
|
1168
|
+
print(__doc__)
|
|
1169
|
+
sys.exit(1)
|
|
1170
|
+
args = sys.argv[2:]
|
|
1171
|
+
limit = _max_args(func)
|
|
1172
|
+
if limit is not None and len(args) > limit:
|
|
1173
|
+
wanted = "no arguments" if limit == 0 else f"at most {limit} argument"
|
|
1174
|
+
print(f"antiphon: `{command}` takes {wanted}, got {len(args)}: "
|
|
1175
|
+
f"{' '.join(args)}", file=sys.stderr)
|
|
1176
|
+
print("Run `antiphon` with no arguments to see the usage.", file=sys.stderr)
|
|
1177
|
+
sys.exit(2)
|
|
1178
|
+
sys.exit(func(*args) or 0)
|