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/BACKLOG.md +329 -0
- package/README.md +172 -22
- package/bin/antiphon.mjs +11 -0
- package/lib/antiphon.py +2042 -247
- package/lib/channel.mjs +323 -26
- package/lib/peers.py +689 -0
- package/package.json +20 -5
package/lib/antiphon.py
CHANGED
|
@@ -7,7 +7,7 @@ Usage:
|
|
|
7
7
|
antiphon setup # installs the hook on both sides
|
|
8
8
|
antiphon status # shows what's happening on both sides (for humans)
|
|
9
9
|
antiphon summary [side] # the text that side would see (claude | codex)
|
|
10
|
-
antiphon hook <side> #
|
|
10
|
+
antiphon hook <side> # prompt and session hook (reads JSON from stdin)
|
|
11
11
|
antiphon push <target> # Stop hook: pushes `@codex` / `@claude` lines
|
|
12
12
|
antiphon reply # sends a Claude Channel reply to Codex (stdin JSON)
|
|
13
13
|
antiphon channel # long-lived Node.js MCP Channel server (started by Claude Code)
|
|
@@ -18,17 +18,25 @@ reads and derives from them. That way there's no write race, no stale record,
|
|
|
18
18
|
and no second source of truth. The only persistent state is a cursor tracking
|
|
19
19
|
how far each side has read.
|
|
20
20
|
|
|
21
|
-
Both sides are symmetric: Claude Code and Codex CLI speak the same
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
Both sides are symmetric: Claude Code and Codex CLI speak the same hook
|
|
22
|
+
contract (the same input fields, the same output wrapper), so a single `hook`
|
|
23
|
+
function serves both. Only `UserPromptSubmit` injects context; Codex also runs
|
|
24
|
+
it at `SessionStart`, where the session id arrives and nothing is injected.
|
|
24
25
|
|
|
25
26
|
The pull and hook layer uses the Python standard library; the Claude Channel
|
|
26
27
|
server runs on Node.js with the official MCP SDK.
|
|
27
28
|
"""
|
|
28
29
|
|
|
29
30
|
import glob
|
|
31
|
+
import collections
|
|
32
|
+
import contextlib
|
|
33
|
+
import errno
|
|
34
|
+
import fcntl
|
|
30
35
|
import hashlib
|
|
36
|
+
import heapq
|
|
37
|
+
import itertools
|
|
31
38
|
import json
|
|
39
|
+
import math
|
|
32
40
|
import os
|
|
33
41
|
import re
|
|
34
42
|
import socket
|
|
@@ -36,6 +44,8 @@ import subprocess
|
|
|
36
44
|
import sys
|
|
37
45
|
import time
|
|
38
46
|
import uuid
|
|
47
|
+
|
|
48
|
+
import peers
|
|
39
49
|
from datetime import datetime
|
|
40
50
|
|
|
41
51
|
HOME = os.path.expanduser("~")
|
|
@@ -43,17 +53,154 @@ CLAUDE_PROJECTS = os.path.join(HOME, ".claude", "projects")
|
|
|
43
53
|
CODEX_SESSIONS = os.path.join(HOME, ".codex", "sessions")
|
|
44
54
|
|
|
45
55
|
TAIL_BYTES = 300_000 # amount to read from the tail of each transcript file
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
EVENT_LIMIT = 40 # completed source records per page
|
|
57
|
+
PAGE_BUDGET = 8_000 # UTF-8 bytes in an ordinary complete page envelope
|
|
58
|
+
RECENT_FILES = 3 # transcript files per side the summary reads at all
|
|
48
59
|
LOOKBACK = 6 * 3600 # anything older than this doesn't count as part of "this session"
|
|
49
60
|
|
|
61
|
+
# EVENT_LIMIT and PAGE_BUDGET bound a complete page. RECENT_FILES still bounds
|
|
62
|
+
# discovery; it does not authorize cutting any record selected from that set.
|
|
63
|
+
|
|
50
64
|
# A marker at the start of a line in a reply says that line should be pushed
|
|
51
65
|
# to the target. The line-start requirement is deliberate: mentioning the
|
|
52
66
|
# marker inside prose shouldn't trigger it.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
# Parsed in two stages so no line addressed at the other side can vanish. Every
|
|
68
|
+
# single-regex version tried here dropped something silently: one refused
|
|
69
|
+
# `@claude:BAD run` outright, another swallowed the comma in `@claude:api, run
|
|
70
|
+
# it` into the name, a third lost `@claude:api,run it` entirely. A marker line
|
|
71
|
+
# that disappears because its name was punctuated oddly is exactly the failure
|
|
72
|
+
# this bridge exists to remove.
|
|
73
|
+
MARKER_SIDES = ("claude", "codex")
|
|
74
|
+
PUSH_MARKERS = re.compile(r"^\s*@(?P<side>claude|codex)\b(?P<rest>.*)$", re.MULTILINE)
|
|
75
|
+
# A colon followed by whitespace is the unaddressed form and means what it has
|
|
76
|
+
# always meant; a colon followed by anything else is a name being claimed,
|
|
77
|
+
# however malformed.
|
|
78
|
+
MARKER_ALIAS = re.compile(r"^:(?P<claim>\S+)")
|
|
79
|
+
# The unaddressed form's delimiter, consumed once and only when no name was
|
|
80
|
+
# claimed. Stripping a *set* of characters here ate the message's own
|
|
81
|
+
# punctuation: `@claude:api .NET issue` arrived as "NET issue".
|
|
82
|
+
MARKER_DELIMITER = re.compile(r"^[:,]?[ \t]*")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_markers(target, text):
|
|
86
|
+
"""[(alias, message)] for every marker line addressed at `target`.
|
|
87
|
+
|
|
88
|
+
`alias` is None when no name was claimed, `""` when one was claimed and is
|
|
89
|
+
empty, and the raw string otherwise. `message` may be empty. Nothing is
|
|
90
|
+
filtered here: whether a name exists is routing's decision, and a line the
|
|
91
|
+
human wrote and the bridge swallowed without a word is the thing to avoid.
|
|
92
|
+
"""
|
|
93
|
+
found = []
|
|
94
|
+
for match in PUSH_MARKERS.finditer(text or ""):
|
|
95
|
+
if match.group("side") != target:
|
|
96
|
+
continue
|
|
97
|
+
rest, alias = match.group("rest"), None
|
|
98
|
+
claim = MARKER_ALIAS.match(rest)
|
|
99
|
+
if claim:
|
|
100
|
+
# The claim already swallowed any delimiter attached to the name, so
|
|
101
|
+
# only whitespace separates it from the message. Anything else here
|
|
102
|
+
# belongs to the message.
|
|
103
|
+
alias = claim.group("claim").rstrip(",;:.")
|
|
104
|
+
rest = rest[claim.end():].lstrip(" \t")
|
|
105
|
+
else:
|
|
106
|
+
rest = MARKER_DELIMITER.sub("", rest, count=1)
|
|
107
|
+
found.append((alias, rest.rstrip()))
|
|
108
|
+
return found
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def group_by_recipient(target, text):
|
|
112
|
+
"""{recipient or None: [messages]}, in the order they were written.
|
|
113
|
+
|
|
114
|
+
Keyed by the alias itself. `alias or ""` would fold None and "" together and
|
|
115
|
+
hand `@claude:: fix` to the unaddressed path, undoing the parser's care in
|
|
116
|
+
telling them apart.
|
|
117
|
+
"""
|
|
118
|
+
batches = {}
|
|
119
|
+
for alias, message in parse_markers(target, text):
|
|
120
|
+
batches.setdefault(alias, []).append(message)
|
|
121
|
+
return batches
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def batch_fingerprint(messages):
|
|
125
|
+
"""Canonical JSON, full digest.
|
|
126
|
+
|
|
127
|
+
A newline join collides: ["a\nb", "c"] and ["a", "b\nc"] hash identically,
|
|
128
|
+
so one batch would suppress a different one.
|
|
129
|
+
"""
|
|
130
|
+
return hashlib.sha256(json.dumps(
|
|
131
|
+
messages, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Where a pre-digest cursor value is parked when a record has to be written
|
|
135
|
+
# beside it. `\0` cannot appear in a peer name, so it can never be mistaken for
|
|
136
|
+
# a recipient slot — `""` is the unaddressed one and every other is `@alias`.
|
|
137
|
+
LEGACY_SLOT = "\0legacy"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def migrate_pushed(sent, unaddressed):
|
|
141
|
+
"""(record, already_delivered) for a cursor that may hold the old format.
|
|
142
|
+
|
|
143
|
+
The old format stored the joined text rather than a digest, so comparing it
|
|
144
|
+
against a digest is always unequal and would resend the last message once on
|
|
145
|
+
upgrade. It is compared in its own form instead — either as the whole value,
|
|
146
|
+
or parked in `LEGACY_SLOT` beside a record that had to be written next to
|
|
147
|
+
it. It is only given up when something really supersedes it: a matching bare
|
|
148
|
+
batch here, or a new unaddressed delivery via `forget_superseded`. It cannot
|
|
149
|
+
be converted into a digest, because a batch of two lines and one line that
|
|
150
|
+
joins to the same string are different things.
|
|
151
|
+
|
|
152
|
+
Any other shape — a list, a number, a hand-edited mistake — starts over as
|
|
153
|
+
an empty record rather than raising. `dict()` on a list is a TypeError, and
|
|
154
|
+
it would surface as a traceback out of the Stop hook, which is the last
|
|
155
|
+
place a malformed file should be able to reach.
|
|
156
|
+
"""
|
|
157
|
+
if isinstance(sent, str):
|
|
158
|
+
if bool(unaddressed) and sent == "\n".join(unaddressed):
|
|
159
|
+
return {}, True # the caller sets `""` in its place
|
|
160
|
+
# Not superseded: this turn may be named-only, or the bare batch may be
|
|
161
|
+
# a different message, or its delivery may fail. Parked rather than
|
|
162
|
+
# dropped, because it is the only record that the last bare message
|
|
163
|
+
# already went, and losing it sends that message a second time.
|
|
164
|
+
return {LEGACY_SLOT: sent}, False
|
|
165
|
+
if isinstance(sent, dict):
|
|
166
|
+
record = dict(sent)
|
|
167
|
+
legacy = record.get(LEGACY_SLOT)
|
|
168
|
+
already = (bool(unaddressed) and isinstance(legacy, str)
|
|
169
|
+
and legacy == "\n".join(unaddressed))
|
|
170
|
+
if already:
|
|
171
|
+
record.pop(LEGACY_SLOT) # the caller is about to set `""` instead
|
|
172
|
+
return record, already
|
|
173
|
+
return {}, False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def forget_superseded(record):
|
|
177
|
+
"""Drops the legacy value once the unaddressed slot holds a digest.
|
|
178
|
+
|
|
179
|
+
Until then it is kept. It describes the last *unaddressed* delivery, so a
|
|
180
|
+
turn that sent only named lines has superseded nothing, and clearing it
|
|
181
|
+
there would resend that message the next time somebody writes a bare line.
|
|
182
|
+
"""
|
|
183
|
+
if "" in record:
|
|
184
|
+
record.pop(LEGACY_SLOT, None)
|
|
185
|
+
return record
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def deliver_batches(batches, sent, deliver):
|
|
189
|
+
"""Calls `deliver(recipient, messages)` for each batch that has not gone yet.
|
|
190
|
+
|
|
191
|
+
A recipient's fingerprint advances only if its own delivery succeeded, so one
|
|
192
|
+
failure does not suppress its retry while another recipient's success is
|
|
193
|
+
kept. The key is `""` for unaddressed and `"@alias"` otherwise, so a peer
|
|
194
|
+
named the empty string cannot collide with the unaddressed slot.
|
|
195
|
+
"""
|
|
196
|
+
for recipient, messages in batches.items():
|
|
197
|
+
key = "" if recipient is None else f"@{recipient}"
|
|
198
|
+
fingerprint = batch_fingerprint(messages)
|
|
199
|
+
if sent.get(key) == fingerprint:
|
|
200
|
+
continue
|
|
201
|
+
if deliver(recipient, messages):
|
|
202
|
+
sent[key] = fingerprint
|
|
203
|
+
return sent
|
|
57
204
|
SESSION_ID = re.compile(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
58
205
|
r"[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$")
|
|
59
206
|
|
|
@@ -77,16 +224,266 @@ def _is_self_injected(text):
|
|
|
77
224
|
return text.lstrip().lower().startswith(_SELF_INJECTION_PREFIXES)
|
|
78
225
|
|
|
79
226
|
|
|
227
|
+
def _join_text_blocks(blocks):
|
|
228
|
+
"""Join present text blocks without treating whitespace as absence."""
|
|
229
|
+
return "\n\n".join(block for block in blocks
|
|
230
|
+
if isinstance(block, str) and block != "")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# A host writes records of its own into the same transcript a person types
|
|
234
|
+
# into: slash commands, their output, task notifications, and this bridge's
|
|
235
|
+
# own deliveries. They are recognised by what they are — the host's
|
|
236
|
+
# `promptSource`, or a complete opening tag from a closed, measured set —
|
|
237
|
+
# never by a leading `<`. A user pasting HTML, JSX or a stack trace starts
|
|
238
|
+
# with `<` too, and used to vanish.
|
|
239
|
+
#
|
|
240
|
+
# The sets are per side because the tags are: `ide_opened_file` is Claude
|
|
241
|
+
# Code's, `recommended_plugins` and `realtime_delegation` are Codex's, and
|
|
242
|
+
# `local-command-caveat` was seen only on the Claude side. Shared, one side's
|
|
243
|
+
# tag would silence the other side's user for typing that text.
|
|
244
|
+
# `image` belongs in neither: it is a person's attachment.
|
|
245
|
+
|
|
246
|
+
# Strictly what was measured on that side, and nothing else. A tag missing
|
|
247
|
+
# here costs one stray host line in a summary — visible, and fixed by adding
|
|
248
|
+
# it. A tag here that a person could type costs that person's whole message,
|
|
249
|
+
# silently. Adding a plausible sibling by symmetry is how `local-command-caveat`
|
|
250
|
+
# — measured only on the Claude side — first reached the Codex set.
|
|
251
|
+
# Measured on 2026-08-30 over 1,575 Claude and 445 Codex `role: user` records;
|
|
252
|
+
# see BACKLOG.md for when this census has to be re-run.
|
|
253
|
+
CLAUDE_HOST_WRAPPERS = (
|
|
254
|
+
"channel", "task-notification", "ide_opened_file",
|
|
255
|
+
"command-name", "command-message",
|
|
256
|
+
"local-command-caveat", "local-command-stdout",
|
|
257
|
+
"bash-input", "bash-stdout",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
CODEX_HOST_WRAPPERS = (
|
|
261
|
+
"task-notification", "recommended_plugins", "realtime_delegation",
|
|
262
|
+
"subagent_notification", "environment_context",
|
|
263
|
+
"command-name", "command-message", "local-command-stdout",
|
|
264
|
+
"bash-input", "bash-stdout",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# `promptSource` values measured carrying host records as well as people's
|
|
268
|
+
# words, so neither answer can be taken from the field alone and the shape of
|
|
269
|
+
# the record decides. An absent field is the same case. Every other value,
|
|
270
|
+
# including one this code has never seen, means a person: refusing an unknown
|
|
271
|
+
# source would let a future host version silence someone in silence.
|
|
272
|
+
MIXED_SOURCES = ("sdk",)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _wrapper_pattern(names):
|
|
276
|
+
"""`(?=[\\s>/])` makes it a whole tag name: `<channels of …>` is not `<channel>`."""
|
|
277
|
+
return re.compile(r"<(?:" + "|".join(re.escape(name) for name in names) + r")(?=[\s>/])")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
CLAUDE_WRAPPER_OPENING = _wrapper_pattern(CLAUDE_HOST_WRAPPERS)
|
|
281
|
+
CODEX_WRAPPER_OPENING = _wrapper_pattern(CODEX_HOST_WRAPPERS)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _is_host_record(text, wrappers, prompt_source=None):
|
|
285
|
+
"""True if `text` is something the host put in the transcript.
|
|
286
|
+
|
|
287
|
+
`wrappers` is the compiled tag set for the side being read.
|
|
288
|
+
`prompt_source` is Claude Code's `promptSource` field, absent on the Codex
|
|
289
|
+
side and on older Claude records. `system` settles it: the host wrote this.
|
|
290
|
+
A value measured to carry both kinds — or no field at all — leaves only the
|
|
291
|
+
shape of the record, where a known wrapper tag is the one thing refused.
|
|
292
|
+
Any other value means a person. Unknown provenance delivers; it never
|
|
293
|
+
silences.
|
|
294
|
+
"""
|
|
295
|
+
if prompt_source == "system":
|
|
296
|
+
return True
|
|
297
|
+
if prompt_source and prompt_source not in MIXED_SOURCES:
|
|
298
|
+
return False
|
|
299
|
+
return wrappers.match((text or "").lstrip()) is not None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def sender_alias(candidate):
|
|
303
|
+
"""A candidate alias if it could be one, else None.
|
|
304
|
+
|
|
305
|
+
A pure check, with no fallback to the environment. Every caller is handed
|
|
306
|
+
its candidate by whatever actually established it — the registry claim, or
|
|
307
|
+
the channel server that holds it — and reaching for `ANTIPHON_NAME` here
|
|
308
|
+
would put back the assumption the callers exist to replace.
|
|
309
|
+
"""
|
|
310
|
+
return candidate if peers.valid_name(candidate) else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def claimed_alias(cwd, kind):
|
|
314
|
+
"""This session's alias, but only if this session really holds it.
|
|
315
|
+
|
|
316
|
+
`ANTIPHON_NAME` is a request, not a claim. Two sessions can be started with
|
|
317
|
+
the same one and exactly one wins the registry. The loser publishing it
|
|
318
|
+
anyway would attribute its words to the winner, and a reply addressed back
|
|
319
|
+
would reach a session that never spoke — the misidentification the registry
|
|
320
|
+
exists to end, arriving through the label meant to prevent it.
|
|
321
|
+
|
|
322
|
+
So the alias is published only when the live record under it belongs to
|
|
323
|
+
this session, matched on the owner key. Anything that cannot be shown —
|
|
324
|
+
no key, no record, a record from another owner, a record written before
|
|
325
|
+
owner keys existed — yields None. A wrong identity is worse than none.
|
|
326
|
+
"""
|
|
327
|
+
alias = sender_alias(peers.explicit_name())
|
|
328
|
+
if not alias:
|
|
329
|
+
return None # nothing asked for; nothing to check
|
|
330
|
+
owner = peers.owner_key()
|
|
331
|
+
if not owner:
|
|
332
|
+
return None
|
|
333
|
+
for peer in peers.read_peers(cwd, kind):
|
|
334
|
+
if peer.get("name") == alias:
|
|
335
|
+
return alias if peer.get("owner") == owner else None
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def delivery_id():
|
|
340
|
+
"""An id for one delivery attempt.
|
|
341
|
+
|
|
342
|
+
It says which attempt, and nothing more. It is deliberately not a
|
|
343
|
+
correlation id: holding one logical id across a retry needs pending-delivery
|
|
344
|
+
state this release does not have, and calling it correlation would promise
|
|
345
|
+
reply routing that is not implemented.
|
|
346
|
+
"""
|
|
347
|
+
return str(uuid.uuid4())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# What an unaddressable sender renders as, and the registry key such a peer
|
|
351
|
+
# occupies: one word for "this peer has no name", wherever that has to be said.
|
|
352
|
+
# Angle brackets are chosen precisely because `valid_name` cannot produce them —
|
|
353
|
+
# `unnamed` on its own is a perfectly legal `ANTIPHON_NAME`, so a bare
|
|
354
|
+
# `from=unnamed` would mean either "this peer has no name" or "this peer is
|
|
355
|
+
# called unnamed", and the reader could not tell which.
|
|
356
|
+
NO_ALIAS = peers.UNNAMED
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def queue_label(alias, message_id):
|
|
360
|
+
"""`[from=<alias> id=<uuid>]` for the paths that carry only text.
|
|
361
|
+
|
|
362
|
+
`codex queue` takes a message and no metadata, so what the socket puts in
|
|
363
|
+
`meta` has to be visible here. It goes **after** the bridge or channel
|
|
364
|
+
prefix, never before: those prefixes anchor the self-injection filter and
|
|
365
|
+
the echo guard, and a message that no longer starts with one would be read
|
|
366
|
+
back as new traffic and delivered again.
|
|
367
|
+
|
|
368
|
+
`alias` is already validated, so it cannot close this bracket and open
|
|
369
|
+
another.
|
|
370
|
+
"""
|
|
371
|
+
return f"[from={alias or NO_ALIAS} id={message_id}]"
|
|
372
|
+
|
|
373
|
+
|
|
80
374
|
# ---------- helpers ----------
|
|
81
375
|
|
|
82
376
|
def project_dir():
|
|
83
377
|
return os.path.abspath(os.environ.get("ANTIPHON_CWD") or os.getcwd())
|
|
84
378
|
|
|
85
379
|
|
|
86
|
-
def state_path(cwd):
|
|
380
|
+
def state_path(cwd, kind):
|
|
381
|
+
"""Where this peer's cursor lives. `kind` is the side the caller runs on.
|
|
382
|
+
|
|
383
|
+
A named peer owns its own file. An unnamed one keeps the project-wide path:
|
|
384
|
+
without a name there is one peer per side by definition, so there is nothing
|
|
385
|
+
to race with, and moving the path would strand every existing install.
|
|
386
|
+
"""
|
|
387
|
+
name = peers.explicit_name()
|
|
388
|
+
if peers.valid_kind(kind) and peers.valid_name(name):
|
|
389
|
+
return os.path.join(peers.peer_dir(cwd, kind, name), "cursor.json")
|
|
87
390
|
return os.path.join(cwd, ".antiphon", "cursor.json")
|
|
88
391
|
|
|
89
392
|
|
|
393
|
+
CURSOR_LOCK_PATIENCE = 2.0 # seconds; a stuck holder must not hang a turn
|
|
394
|
+
CURSOR_LOCK_RETRY_DELAY = 0.05
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@contextlib.contextmanager
|
|
398
|
+
def cursor_lock(cwd, kind, patience=None):
|
|
399
|
+
"""Serializes one peer's whole read-select-deliver-advance transaction.
|
|
400
|
+
|
|
401
|
+
Yields True when the lock was taken and False when it was not, having
|
|
402
|
+
already said on stderr why not. A caller that could not take it must
|
|
403
|
+
deliver nothing rather than proceed unserialized, and must exit non-zero:
|
|
404
|
+
on exit 0 the host sends stderr to a debug log and shows the person
|
|
405
|
+
nothing, so a leaked descriptor would deafen the bridge with no symptom.
|
|
406
|
+
|
|
407
|
+
Locking only the selection would not do. A takes the lock, picks a page and
|
|
408
|
+
releases before writing; B then reads a cursor that has not moved and picks
|
|
409
|
+
the same page. The exclusion has to cover the write and the advance too —
|
|
410
|
+
and every other writer of this file has to take the same lock, or it can
|
|
411
|
+
write back a snapshot from before the advance.
|
|
412
|
+
|
|
413
|
+
This is a lock beside the cursor, never the project-wide registry lock.
|
|
414
|
+
That one serializes every claim, refresh, prune and release in the project,
|
|
415
|
+
and holding it across a model-facing write would make an unrelated peer's
|
|
416
|
+
start, stop or refresh queue behind this peer's context page. Named peers
|
|
417
|
+
each own a separate cursor file, so a lock beside each one gives exactly
|
|
418
|
+
the exclusion required without coupling their lifetimes — for a named
|
|
419
|
+
install. The default, unnamed install has no name to split on: `state_path`
|
|
420
|
+
returns the same file for `claude` and for `codex` alike, so this lock
|
|
421
|
+
guards both sides of the project at once. A caller that holds it for long
|
|
422
|
+
is not only making its own peer wait; it is making **the other agent**
|
|
423
|
+
wait, on a bridge with no name in play to tell them apart.
|
|
424
|
+
|
|
425
|
+
The wait is bounded because the hook runs on a person's every prompt: a
|
|
426
|
+
holder that is stuck rather than dead would otherwise hang the turn. A
|
|
427
|
+
holder that dies has its `flock` released by the kernel, so a crash frees
|
|
428
|
+
the lock rather than wedging the project.
|
|
429
|
+
|
|
430
|
+
Not reentrant. `flock` is held per open file description, so a second
|
|
431
|
+
attempt from this same process on a fresh descriptor blocks exactly as
|
|
432
|
+
another process would. Nothing called while this is held may take it again.
|
|
433
|
+
"""
|
|
434
|
+
if patience is None:
|
|
435
|
+
# Read at call time, not bound in the signature, so the constant stays
|
|
436
|
+
# the single place this is set — and a test can lower it.
|
|
437
|
+
patience = CURSOR_LOCK_PATIENCE
|
|
438
|
+
path = state_path(cwd, kind) + ".lock"
|
|
439
|
+
try:
|
|
440
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
441
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
442
|
+
except OSError as exc:
|
|
443
|
+
# The lock file could not even be opened — a directory that is not
|
|
444
|
+
# writable, or not a directory at all.
|
|
445
|
+
print(f"antiphon: no delivery lock at {path}: {exc}", file=sys.stderr)
|
|
446
|
+
yield False
|
|
447
|
+
return
|
|
448
|
+
held = False
|
|
449
|
+
deadline = time.monotonic() + patience
|
|
450
|
+
try:
|
|
451
|
+
while True:
|
|
452
|
+
try:
|
|
453
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
454
|
+
held = True
|
|
455
|
+
break
|
|
456
|
+
except BlockingIOError:
|
|
457
|
+
# The only errno that means "somebody else has it".
|
|
458
|
+
if time.monotonic() >= deadline:
|
|
459
|
+
# Neutral on purpose: this fires for callers that deliver
|
|
460
|
+
# context and for callers that only record a push, and
|
|
461
|
+
# "context not delivered" was a lie for the second kind.
|
|
462
|
+
# The caller knows what it was trying to do and says so
|
|
463
|
+
# itself, on its own line.
|
|
464
|
+
print("antiphon: another delivery for this peer is still "
|
|
465
|
+
f"running after {patience:g}s", file=sys.stderr)
|
|
466
|
+
break
|
|
467
|
+
time.sleep(CURSOR_LOCK_RETRY_DELAY)
|
|
468
|
+
except OSError as exc:
|
|
469
|
+
# ENOTSUP, EIO, ENOLCK: a filesystem whose lock manager cannot
|
|
470
|
+
# answer. Retrying that for the full patience and then giving
|
|
471
|
+
# up quietly would turn a broken mount into a bridge that
|
|
472
|
+
# stopped delivering for no stated reason.
|
|
473
|
+
print(f"antiphon: cannot lock {path}: {exc}", file=sys.stderr)
|
|
474
|
+
break
|
|
475
|
+
yield held
|
|
476
|
+
finally:
|
|
477
|
+
if held:
|
|
478
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
479
|
+
os.close(fd)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def sender_side(target):
|
|
483
|
+
"""The side a message addressed to `target` is being sent from."""
|
|
484
|
+
return "codex" if target == "claude" else "claude"
|
|
485
|
+
|
|
486
|
+
|
|
90
487
|
LEGACY_KEYS = {
|
|
91
488
|
"codex_gordu": "codex_seen",
|
|
92
489
|
"claude_gordu": "claude_seen",
|
|
@@ -100,22 +497,156 @@ def _translate_cursor_keys(data):
|
|
|
100
497
|
return {LEGACY_KEYS.get(k, k): v for k, v in data.items()}
|
|
101
498
|
|
|
102
499
|
|
|
103
|
-
def
|
|
104
|
-
|
|
500
|
+
def _read_cursor_state(cwd, kind):
|
|
501
|
+
"""Return ``(cursor, state)`` without confusing absence with corruption."""
|
|
502
|
+
new_path = state_path(cwd, kind)
|
|
105
503
|
try:
|
|
106
504
|
with open(new_path, encoding="utf-8") as f:
|
|
107
505
|
data = json.load(f)
|
|
108
|
-
except
|
|
109
|
-
return {}
|
|
506
|
+
except FileNotFoundError:
|
|
507
|
+
return {}, "missing"
|
|
508
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
509
|
+
print("antiphon: the existing cursor could not be read safely; "
|
|
510
|
+
"restarting discovered transcript history", file=sys.stderr)
|
|
511
|
+
return {}, "invalid"
|
|
512
|
+
if not isinstance(data, dict):
|
|
513
|
+
print("antiphon: the existing cursor is not a usable object; "
|
|
514
|
+
"restarting discovered transcript history", file=sys.stderr)
|
|
515
|
+
return {}, "invalid"
|
|
516
|
+
|
|
517
|
+
# Translated for the caller and not written back. This runs inside the
|
|
518
|
+
# delivery hold, and `flock` is per open file description, so taking the
|
|
519
|
+
# lock here would block this process against itself; writing without it
|
|
520
|
+
# would let a read put back a snapshot from before somebody's advance.
|
|
521
|
+
# The translation is idempotent, so the next write from a locked path
|
|
522
|
+
# persists it and nothing behaves differently until then.
|
|
523
|
+
return _translate_cursor_keys(data), "valid"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def read_cursor(cwd, kind):
|
|
527
|
+
"""Return the translated cursor object for backwards-compatible callers."""
|
|
528
|
+
return _read_cursor_state(cwd, kind)[0]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def cursor_time(cursor, key, default=None):
|
|
532
|
+
"""The timestamp `key` holds, or the normal lookback when it holds no time.
|
|
533
|
+
|
|
534
|
+
Every reader of a `_seen` value went through `float(cursor.get(key) or ...)`,
|
|
535
|
+
which raises on a string that is not a number and passes `NaN` and
|
|
536
|
+
`Infinity` straight through — and `json` parses both of those literals by
|
|
537
|
+
default, so a cursor really can hold them. A `NaN` start makes every
|
|
538
|
+
comparison against it false, so nothing is ever new again and the bridge
|
|
539
|
+
goes quiet without saying so; an infinite one survives that far and raises
|
|
540
|
+
in `datetime.fromtimestamp` instead. So does a finite `1e308`, which is a
|
|
541
|
+
number and not any time this machine can name. All of them are answered the
|
|
542
|
+
way a missing value already is: the normal lookback.
|
|
543
|
+
|
|
544
|
+
Finite numeric strings are still accepted, because `float()` accepted them
|
|
545
|
+
and a peer upgrading with one on disk must not silently replay six hours.
|
|
546
|
+
"""
|
|
547
|
+
if default is None:
|
|
548
|
+
default = time.time() - LOOKBACK
|
|
549
|
+
value = cursor.get(key) if isinstance(cursor, dict) else None
|
|
550
|
+
if isinstance(value, bool) or not value:
|
|
551
|
+
# `True` is an `int` and `float(True)` is 1.0 — a 1970 start that would
|
|
552
|
+
# replay the whole transcript. It is not a time; neither is 0 or None.
|
|
553
|
+
return default
|
|
554
|
+
if isinstance(value, str):
|
|
555
|
+
try:
|
|
556
|
+
value = float(value)
|
|
557
|
+
except ValueError:
|
|
558
|
+
return default
|
|
559
|
+
if not isinstance(value, (int, float)):
|
|
560
|
+
return default
|
|
561
|
+
try:
|
|
562
|
+
value = float(value)
|
|
563
|
+
datetime.fromtimestamp(value)
|
|
564
|
+
except (ValueError, OverflowError, OSError):
|
|
565
|
+
# Finite, and still not a time. `1e308` passes every check `NaN` and the
|
|
566
|
+
# infinities fail, and no clock can render it — used as a start it makes
|
|
567
|
+
# every transcript line look old, so the bridge goes quiet and stays
|
|
568
|
+
# quiet. `fromtimestamp` is the only authority on what this platform can
|
|
569
|
+
# hold as a local time, so asking it is the whole check.
|
|
570
|
+
return default
|
|
571
|
+
return value
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
CURSOR_VERSION = 2
|
|
575
|
+
PAGE_CURSOR_VERSION = 3
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def page_cursor_key(side):
|
|
579
|
+
return "%s_pages" % side
|
|
110
580
|
|
|
111
|
-
translated = _translate_cursor_keys(data)
|
|
112
|
-
if translated != data:
|
|
113
|
-
write_cursor(cwd, translated)
|
|
114
|
-
return translated
|
|
115
581
|
|
|
582
|
+
def _valid_position(entry):
|
|
583
|
+
"""A cursor entry is trusted only when every part of it is what it claims.
|
|
116
584
|
|
|
117
|
-
|
|
118
|
-
|
|
585
|
+
This file is hand-edited, restored from the wrong place, and written by
|
|
586
|
+
other versions — the suite has a class about exactly that. An entry that is
|
|
587
|
+
not a position must send the caller to the lookback, which repeats, rather
|
|
588
|
+
than into a seek to byte 1 or an exception out of a hook.
|
|
589
|
+
"""
|
|
590
|
+
return (isinstance(entry, dict)
|
|
591
|
+
and isinstance(entry.get("gen"), str)
|
|
592
|
+
and isinstance(entry.get("offset"), int)
|
|
593
|
+
and not isinstance(entry.get("offset"), bool)
|
|
594
|
+
and entry["offset"] >= 0)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def positions_for(cursor, side, loader_state="valid"):
|
|
598
|
+
"""Return ``(positions, since, replay_reason)`` for one paging reader.
|
|
599
|
+
|
|
600
|
+
A v2 value is deliberately never reinterpreted as a delivered page
|
|
601
|
+
frontier. Its presence requests a bounded byte-zero replay under the
|
|
602
|
+
separate v3 key, which keeps an overlapping old process from advancing a
|
|
603
|
+
new reader past content it did not deliver.
|
|
604
|
+
"""
|
|
605
|
+
if loader_state == "invalid":
|
|
606
|
+
return {}, None, "cursor_recovery"
|
|
607
|
+
cursor = cursor if isinstance(cursor, dict) else {}
|
|
608
|
+
key = page_cursor_key(side)
|
|
609
|
+
legacy_key = "%s_seen" % side
|
|
610
|
+
since = time.time() - LOOKBACK
|
|
611
|
+
if key in cursor:
|
|
612
|
+
value = cursor.get(key)
|
|
613
|
+
if (isinstance(value, dict)
|
|
614
|
+
and value.get("v") == PAGE_CURSOR_VERSION
|
|
615
|
+
and isinstance(value.get("sources"), dict)):
|
|
616
|
+
sources = {sid: entry for sid, entry in value["sources"].items()
|
|
617
|
+
if _valid_position(entry)}
|
|
618
|
+
if len(sources) == len(value["sources"]):
|
|
619
|
+
replay = value.get("replay")
|
|
620
|
+
if (replay is not None
|
|
621
|
+
and (not isinstance(replay, str)
|
|
622
|
+
or replay not in REPLAY_NOTICES)):
|
|
623
|
+
print("antiphon: cursor replay metadata was invalid and was "
|
|
624
|
+
"ignored", file=sys.stderr)
|
|
625
|
+
replay = None
|
|
626
|
+
return sources, since, replay
|
|
627
|
+
print("antiphon: paging cursor state was invalid; restarting discovered "
|
|
628
|
+
"transcript history", file=sys.stderr)
|
|
629
|
+
return {}, None, "cursor_recovery"
|
|
630
|
+
if legacy_key in cursor:
|
|
631
|
+
return {}, None, "legacy_upgrade"
|
|
632
|
+
return {}, since, None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _advance_page_cursor(cwd, kind, cursor, side, positions, advance):
|
|
636
|
+
"""Persist the delivered source prefix and replay lifecycle as one value."""
|
|
637
|
+
if advance is None:
|
|
638
|
+
return True
|
|
639
|
+
merged = dict(positions)
|
|
640
|
+
merged.update(advance.sources)
|
|
641
|
+
value = {"v": PAGE_CURSOR_VERSION, "sources": merged}
|
|
642
|
+
if advance.has_more and advance.replay_reason in REPLAY_NOTICES:
|
|
643
|
+
value["replay"] = advance.replay_reason
|
|
644
|
+
cursor[page_cursor_key(side)] = value
|
|
645
|
+
return write_cursor(cwd, cursor, kind)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def write_cursor(cwd, data, kind):
|
|
649
|
+
path = state_path(cwd, kind)
|
|
119
650
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
120
651
|
tmp = f"{path}.{os.getpid()}.tmp"
|
|
121
652
|
try:
|
|
@@ -131,6 +662,53 @@ def write_cursor(cwd, data):
|
|
|
131
662
|
return False
|
|
132
663
|
|
|
133
664
|
|
|
665
|
+
def update_cursor(cwd, kind, mutate):
|
|
666
|
+
"""Read-modify-write one peer's cursor inside its lock.
|
|
667
|
+
|
|
668
|
+
Every writer of `cursor.json` goes through here. The file holds both the
|
|
669
|
+
`_seen` timestamps and the push fingerprints, and each writer rewrites the
|
|
670
|
+
whole object, so a snapshot read outside the lock and written inside it
|
|
671
|
+
silently reverts whatever happened in between — measured: an advance from
|
|
672
|
+
1.0 to 2.0 undone by a push that had read the file first.
|
|
673
|
+
|
|
674
|
+
`mutate` is called with the freshly read cursor and returns the object to
|
|
675
|
+
write. There are three outcomes, and only the middle one means anything
|
|
676
|
+
was lost: `False` if the lock could not be taken (nothing was read,
|
|
677
|
+
changed or written), `True` with nothing written when `mutate` changed
|
|
678
|
+
nothing, and `True` after a real write otherwise.
|
|
679
|
+
|
|
680
|
+
A `mutate` that changes nothing writes nothing. It is handed a copy that
|
|
681
|
+
nothing else holds, so it may edit in place; the value read from disk is
|
|
682
|
+
kept intact to compare against. Two reads would have been cheaper and
|
|
683
|
+
wrong: a caller cannot see whether `read_cursor` returns a fresh object,
|
|
684
|
+
and under a test double that returns the same one, an in-place edit makes
|
|
685
|
+
every write look unnecessary.
|
|
686
|
+
|
|
687
|
+
`mutate` is expected to return a dict — the docstring above says "it may
|
|
688
|
+
edit in place", and a lambda built for `.update(...)`'s return value
|
|
689
|
+
returns `None` instead. Written as-is, that overwrites the cursor with
|
|
690
|
+
`null`: every `_seen` timestamp and every push fingerprint gone, silently,
|
|
691
|
+
and `updated == before` never catches it because `None != {}`. Refused
|
|
692
|
+
here instead, because this is the one funnel every writer shares.
|
|
693
|
+
"""
|
|
694
|
+
with cursor_lock(cwd, kind) as locked:
|
|
695
|
+
if not locked:
|
|
696
|
+
return False
|
|
697
|
+
before, state = _read_cursor_state(cwd, kind)
|
|
698
|
+
if state == "invalid":
|
|
699
|
+
print("antiphon: refusing to update an invalid cursor", file=sys.stderr)
|
|
700
|
+
return False
|
|
701
|
+
updated = mutate(json.loads(json.dumps(before)))
|
|
702
|
+
if not isinstance(updated, dict):
|
|
703
|
+
print(f"antiphon: mutate returned {type(updated).__name__}, not a "
|
|
704
|
+
f"dict; refusing to overwrite {state_path(cwd, kind)}",
|
|
705
|
+
file=sys.stderr)
|
|
706
|
+
return False
|
|
707
|
+
if updated == before:
|
|
708
|
+
return True
|
|
709
|
+
return write_cursor(cwd, updated, kind)
|
|
710
|
+
|
|
711
|
+
|
|
134
712
|
def truncate(s, n):
|
|
135
713
|
s = " ".join((s or "").split())
|
|
136
714
|
return s if len(s) <= n else s[:n].rstrip() + "…"
|
|
@@ -148,6 +726,71 @@ def tail_lines(path):
|
|
|
148
726
|
return []
|
|
149
727
|
|
|
150
728
|
|
|
729
|
+
def source_id(path):
|
|
730
|
+
"""What a transcript is, as opposed to where it currently sits.
|
|
731
|
+
|
|
732
|
+
Both hosts name a transcript after the session that wrote it — Claude Code
|
|
733
|
+
as `<uuid>.jsonl`, Codex as `rollout-<timestamp>-<uuid>.jsonl` — and that
|
|
734
|
+
uuid outlives a move, a rename of the project directory, or a copy. A path
|
|
735
|
+
does not, and a cursor keyed on one would start again from nothing the
|
|
736
|
+
first time anything moved. Anything without a uuid falls back to the
|
|
737
|
+
basename, which is still narrower than the path.
|
|
738
|
+
"""
|
|
739
|
+
match = SESSION_ID.search(path)
|
|
740
|
+
return match.group(1) if match else os.path.basename(path)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def source_generation(path):
|
|
744
|
+
"""An identity for *this* file at this path, or None if it cannot be read.
|
|
745
|
+
|
|
746
|
+
An offset is only meaningful inside one immutable run of a file. Rotation
|
|
747
|
+
puts a different file at the same name and an offset into the old one lands
|
|
748
|
+
anywhere in the new one, so something has to be able to say "no, this is not
|
|
749
|
+
what you were reading". Device and inode catch a replacement; the hash of
|
|
750
|
+
the first record catches the case where an inode is reused, which happens
|
|
751
|
+
more often than it sounds on a busy temporary filesystem.
|
|
752
|
+
"""
|
|
753
|
+
try:
|
|
754
|
+
st = os.stat(path)
|
|
755
|
+
with open(path, "rb") as f:
|
|
756
|
+
first = f.readline()
|
|
757
|
+
if not first.endswith(b"\n"):
|
|
758
|
+
# A file whose only line is still being written has no stable
|
|
759
|
+
# first record yet. Treat it as unidentifiable rather than
|
|
760
|
+
# fingerprinting a half-line that will change.
|
|
761
|
+
return None
|
|
762
|
+
except OSError:
|
|
763
|
+
return None
|
|
764
|
+
digest = hashlib.sha256(first).hexdigest()[:16]
|
|
765
|
+
return "%d:%d:%s" % (st.st_dev, st.st_ino, digest)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def read_records(path, offset=0):
|
|
769
|
+
"""Yield `(start, end, line)` for each complete line at or after `offset`.
|
|
770
|
+
|
|
771
|
+
`line` is decoded text without its newline; `start` and `end` are byte
|
|
772
|
+
offsets, so `end` of the last record is where the next read begins. A
|
|
773
|
+
trailing partial line is not a record: it yields nothing and leaves `end`
|
|
774
|
+
before it, so the writer can finish it and the next read picks it up whole.
|
|
775
|
+
|
|
776
|
+
This replaces reading a fixed window at the end of the file. That window
|
|
777
|
+
made a record larger than itself invisible — not truncated, never seen —
|
|
778
|
+
while an offset costs only the bytes that are actually new.
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
with open(path, "rb") as f:
|
|
782
|
+
if offset:
|
|
783
|
+
f.seek(offset)
|
|
784
|
+
position = offset
|
|
785
|
+
for raw in f:
|
|
786
|
+
if not raw.endswith(b"\n"):
|
|
787
|
+
return # incomplete: not a record yet
|
|
788
|
+
start, position = position, position + len(raw)
|
|
789
|
+
yield start, position, raw[:-1].decode("utf-8", "replace")
|
|
790
|
+
except OSError:
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
|
|
151
794
|
def head_lines(path, limit=12, num_bytes=64 * 1024):
|
|
152
795
|
"""Returns the lines at the start of the file, used for session metadata."""
|
|
153
796
|
try:
|
|
@@ -166,6 +809,97 @@ def iso_epoch(s):
|
|
|
166
809
|
return 0.0
|
|
167
810
|
|
|
168
811
|
|
|
812
|
+
Event = collections.namedtuple("Event", "time kind text source generation offset end")
|
|
813
|
+
Record = collections.namedtuple(
|
|
814
|
+
"Record", "time source generation offset end events")
|
|
815
|
+
PageAdvance = collections.namedtuple(
|
|
816
|
+
"PageAdvance", "sources has_more replay_reason")
|
|
817
|
+
REPLAY_NOTICES = {
|
|
818
|
+
"legacy_upgrade": (
|
|
819
|
+
"replay: replaying discovered history after an upgrade; duplicates "
|
|
820
|
+
"are expected until this backlog drains"),
|
|
821
|
+
"cursor_recovery": (
|
|
822
|
+
"replay: replaying discovered history because the previous cursor "
|
|
823
|
+
"could not be trusted; duplicates are expected until this backlog "
|
|
824
|
+
"drains"),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def offset_at_or_after(path, timestamp):
|
|
829
|
+
"""The offset of the first record at or after `timestamp`, or the file's end.
|
|
830
|
+
|
|
831
|
+
Run only for a source a peer has genuinely never read, to place the normal
|
|
832
|
+
lookback window. It is deliberately NOT how legacy cursors arrive here: a
|
|
833
|
+
present v2/`_seen` value, like a malformed or unreadable cursor file, takes
|
|
834
|
+
the conservative byte-zero replay instead, because an old process may still
|
|
835
|
+
be moving that value and its boundary cannot be trusted. `>=` rather than
|
|
836
|
+
`>` repeats a record sharing the boundary timestamp — a duplicate, which
|
|
837
|
+
this bridge accepts where it never accepts a gap.
|
|
838
|
+
"""
|
|
839
|
+
end = 0
|
|
840
|
+
for start, end, line in read_records(path):
|
|
841
|
+
try:
|
|
842
|
+
when = iso_epoch(json.loads(line).get("timestamp"))
|
|
843
|
+
except (json.JSONDecodeError, AttributeError):
|
|
844
|
+
continue
|
|
845
|
+
if when >= timestamp:
|
|
846
|
+
return start
|
|
847
|
+
return end
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _source_size(path):
|
|
851
|
+
"""The file's size, or None when it could not be measured at all.
|
|
852
|
+
|
|
853
|
+
`None` is not zero: a file `stat` cannot reach -- vanished, permissions
|
|
854
|
+
changed underneath the bridge -- has no size to compare a recorded offset
|
|
855
|
+
against, and treating that as "zero bytes long" would tell `_start_offset`
|
|
856
|
+
the file had shrunk, which is a different fact and points at the wrong
|
|
857
|
+
cause.
|
|
858
|
+
"""
|
|
859
|
+
try:
|
|
860
|
+
return os.path.getsize(path)
|
|
861
|
+
except OSError:
|
|
862
|
+
return None
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _start_offset(path, sid, generation, positions, since):
|
|
866
|
+
"""Where to start reading one source: its recorded offset, when the file
|
|
867
|
+
is still the one that offset was measured against and has not shrunk
|
|
868
|
+
underneath it; from byte zero, when the recorded offset cannot be
|
|
869
|
+
trusted; from the lookback, or byte zero, only when there is no
|
|
870
|
+
recorded entry to distrust in the first place.
|
|
871
|
+
|
|
872
|
+
Every reason to distrust a recorded offset resolves the same way —
|
|
873
|
+
because repeating records is the error this bridge accepts and skipping
|
|
874
|
+
them is the one it does not.
|
|
875
|
+
"""
|
|
876
|
+
recorded = (positions or {}).get(sid)
|
|
877
|
+
if recorded:
|
|
878
|
+
# Both branches below return 0, not the shared fallback at the end of
|
|
879
|
+
# this function. An offset that cannot be trusted says nothing about
|
|
880
|
+
# what this peer has already seen, so the whole source is offered
|
|
881
|
+
# again; bounding that by the lookback (the shared fallback) would
|
|
882
|
+
# skip everything older than it -- a gap, where a repeat is the error
|
|
883
|
+
# this bridge accepts everywhere else. That fallback answers a
|
|
884
|
+
# different question: a source with no recorded entry at all.
|
|
885
|
+
if recorded.get("gen") != generation:
|
|
886
|
+
print("antiphon: a transcript was replaced since it was last read; "
|
|
887
|
+
"reading it again", file=sys.stderr)
|
|
888
|
+
return 0
|
|
889
|
+
size = _source_size(path)
|
|
890
|
+
if size is None:
|
|
891
|
+
print("antiphon: a transcript could not be measured; reading it again",
|
|
892
|
+
file=sys.stderr)
|
|
893
|
+
return 0
|
|
894
|
+
if recorded["offset"] > size:
|
|
895
|
+
print("antiphon: a transcript is shorter than the %d bytes already "
|
|
896
|
+
"read from it; reading it again" % recorded["offset"],
|
|
897
|
+
file=sys.stderr)
|
|
898
|
+
return 0
|
|
899
|
+
return recorded["offset"]
|
|
900
|
+
return offset_at_or_after(path, since) if since is not None else 0
|
|
901
|
+
|
|
902
|
+
|
|
169
903
|
# ---------- Claude side ----------
|
|
170
904
|
|
|
171
905
|
def _claude_slug(cwd):
|
|
@@ -242,43 +976,79 @@ def claude_transcripts(cwd):
|
|
|
242
976
|
return files
|
|
243
977
|
|
|
244
978
|
|
|
245
|
-
def claude_events(cwd,
|
|
246
|
-
"""
|
|
979
|
+
def claude_events(cwd, positions=None, since=None, visible_record_limit=None):
|
|
980
|
+
"""Return visible events and the safe scanned position for each source.
|
|
981
|
+
|
|
982
|
+
A completed JSONL record consumes at most one visible lookahead slot even
|
|
983
|
+
when it contains several text and tool blocks. Filtered records consume no
|
|
984
|
+
slot, so the scanner can pass them to EOF or to the next visible record.
|
|
985
|
+
"""
|
|
247
986
|
events = []
|
|
248
|
-
|
|
249
|
-
|
|
987
|
+
reached = {}
|
|
988
|
+
position = itertools.count()
|
|
989
|
+
for path in claude_transcripts(cwd)[:RECENT_FILES]:
|
|
990
|
+
visible_records = 0
|
|
991
|
+
sid = source_id(path)
|
|
992
|
+
gen = source_generation(path)
|
|
993
|
+
offset = _start_offset(path, sid, gen, positions, since)
|
|
994
|
+
if gen is not None:
|
|
995
|
+
reached[sid] = {"gen": gen, "offset": offset}
|
|
996
|
+
for start, end, line in read_records(path, offset):
|
|
997
|
+
if gen is not None:
|
|
998
|
+
reached[sid] = {"gen": gen, "offset": end}
|
|
999
|
+
before = len(events)
|
|
250
1000
|
try:
|
|
251
1001
|
d = json.loads(line)
|
|
252
1002
|
except json.JSONDecodeError:
|
|
253
|
-
|
|
254
|
-
if d.get("isMeta"):
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1003
|
+
d = None
|
|
1004
|
+
if isinstance(d, dict) and not d.get("isMeta"):
|
|
1005
|
+
ts = iso_epoch(d.get("timestamp"))
|
|
1006
|
+
kind = d.get("type")
|
|
1007
|
+
msg = d.get("message") or {}
|
|
1008
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
1009
|
+
if kind == "user":
|
|
1010
|
+
text = ""
|
|
1011
|
+
if isinstance(content, str):
|
|
1012
|
+
text = content
|
|
1013
|
+
elif isinstance(content, list):
|
|
1014
|
+
text = _join_text_blocks(
|
|
1015
|
+
c.get("text", "") for c in content
|
|
1016
|
+
if isinstance(c, dict) and c.get("type") == "text")
|
|
1017
|
+
if (text != ""
|
|
1018
|
+
and not _is_host_record(text, CLAUDE_WRAPPER_OPENING,
|
|
1019
|
+
d.get("promptSource"))
|
|
1020
|
+
and not _is_self_injected(text)):
|
|
1021
|
+
events.append((ts, path, next(position),
|
|
1022
|
+
Event(ts, "you", text, sid, gen, start, end)))
|
|
1023
|
+
elif kind == "assistant":
|
|
1024
|
+
for c in content if isinstance(content, list) else []:
|
|
1025
|
+
if not isinstance(c, dict):
|
|
1026
|
+
continue
|
|
1027
|
+
if c.get("type") == "text":
|
|
1028
|
+
text = c.get("text")
|
|
1029
|
+
if isinstance(text, str) and text != "":
|
|
1030
|
+
events.append((ts, path, next(position),
|
|
1031
|
+
Event(ts, "claude", text,
|
|
1032
|
+
sid, gen, start, end)))
|
|
1033
|
+
elif c.get("type") == "tool_use":
|
|
1034
|
+
arguments = c.get("input") or {}
|
|
1035
|
+
arguments = arguments if isinstance(arguments, dict) else {}
|
|
1036
|
+
detail = (arguments.get("file_path")
|
|
1037
|
+
or arguments.get("command")
|
|
1038
|
+
or arguments.get("pattern") or "")
|
|
1039
|
+
events.append((ts, path, next(position),
|
|
1040
|
+
Event(ts, "tool",
|
|
1041
|
+
f"{c.get('name', '?')} {detail}".strip(),
|
|
1042
|
+
sid, gen, start, end)))
|
|
1043
|
+
if len(events) > before:
|
|
1044
|
+
visible_records += 1
|
|
1045
|
+
if (visible_record_limit is not None
|
|
1046
|
+
and visible_records >= visible_record_limit):
|
|
1047
|
+
break
|
|
1048
|
+
events.sort(key=lambda item: (
|
|
1049
|
+
item[0], item[3].source, item[3].generation or "",
|
|
1050
|
+
item[3].offset, item[2]))
|
|
1051
|
+
return [item[3] for item in events], reached
|
|
282
1052
|
|
|
283
1053
|
|
|
284
1054
|
# ---------- Codex side ----------
|
|
@@ -334,40 +1104,65 @@ def codex_rollout_files(cwd, days=3):
|
|
|
334
1104
|
return matched
|
|
335
1105
|
|
|
336
1106
|
|
|
337
|
-
def codex_events(cwd,
|
|
338
|
-
"""
|
|
1107
|
+
def codex_events(cwd, positions=None, since=None, visible_record_limit=None):
|
|
1108
|
+
"""Return visible events and the safe scanned position for each rollout."""
|
|
339
1109
|
events = []
|
|
340
|
-
|
|
341
|
-
|
|
1110
|
+
reached = {}
|
|
1111
|
+
position = itertools.count()
|
|
1112
|
+
for path in codex_rollout_files(cwd)[:RECENT_FILES]:
|
|
1113
|
+
visible_records = 0
|
|
1114
|
+
sid = source_id(path)
|
|
1115
|
+
gen = source_generation(path)
|
|
1116
|
+
offset = _start_offset(path, sid, gen, positions, since)
|
|
1117
|
+
if gen is not None:
|
|
1118
|
+
reached[sid] = {"gen": gen, "offset": offset}
|
|
1119
|
+
for start, end, line in read_records(path, offset):
|
|
1120
|
+
if gen is not None:
|
|
1121
|
+
reached[sid] = {"gen": gen, "offset": end}
|
|
1122
|
+
before = len(events)
|
|
342
1123
|
try:
|
|
343
1124
|
d = json.loads(line)
|
|
344
1125
|
except json.JSONDecodeError:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1126
|
+
d = None
|
|
1127
|
+
if isinstance(d, dict):
|
|
1128
|
+
ts = iso_epoch(d.get("timestamp"))
|
|
1129
|
+
kind, payload = d.get("type"), d.get("payload") or {}
|
|
1130
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
1131
|
+
if kind == "response_item" and payload.get("type") == "message":
|
|
1132
|
+
role = payload.get("role")
|
|
1133
|
+
text = _join_text_blocks(
|
|
1134
|
+
(c.get("text") or c.get("input_text") or "")
|
|
1135
|
+
for c in payload.get("content") or []
|
|
1136
|
+
if isinstance(c, dict))
|
|
1137
|
+
if text != "" and role != "developer":
|
|
1138
|
+
if role == "user":
|
|
1139
|
+
if (not _is_host_record(text, CODEX_WRAPPER_OPENING)
|
|
1140
|
+
and not _is_self_injected(text)):
|
|
1141
|
+
events.append((ts, path, next(position),
|
|
1142
|
+
Event(ts, "you", text, sid, gen,
|
|
1143
|
+
start, end)))
|
|
1144
|
+
elif role == "assistant":
|
|
1145
|
+
events.append((ts, path, next(position),
|
|
1146
|
+
Event(ts, "codex", text, sid, gen,
|
|
1147
|
+
start, end)))
|
|
1148
|
+
elif (kind == "event_msg"
|
|
1149
|
+
and payload.get("type") == "exec_command_begin"):
|
|
1150
|
+
command = payload.get("command")
|
|
1151
|
+
if isinstance(command, list):
|
|
1152
|
+
command = " ".join(command)
|
|
1153
|
+
if command:
|
|
1154
|
+
events.append((ts, path, next(position),
|
|
1155
|
+
Event(ts, "tool", f"shell {command}",
|
|
1156
|
+
sid, gen, start, end)))
|
|
1157
|
+
if len(events) > before:
|
|
1158
|
+
visible_records += 1
|
|
1159
|
+
if (visible_record_limit is not None
|
|
1160
|
+
and visible_records >= visible_record_limit):
|
|
1161
|
+
break
|
|
1162
|
+
events.sort(key=lambda item: (
|
|
1163
|
+
item[0], item[3].source, item[3].generation or "",
|
|
1164
|
+
item[3].offset, item[2]))
|
|
1165
|
+
return [item[3] for item in events], reached
|
|
371
1166
|
|
|
372
1167
|
|
|
373
1168
|
# ---------- summary ----------
|
|
@@ -382,65 +1177,190 @@ OTHER_SIDE = {
|
|
|
382
1177
|
}
|
|
383
1178
|
|
|
384
1179
|
|
|
385
|
-
def
|
|
1180
|
+
def _ordered_records(events):
|
|
1181
|
+
"""Return completed source records in a source-prefix-preserving merge."""
|
|
1182
|
+
grouped = {}
|
|
1183
|
+
for event in events:
|
|
1184
|
+
key = (event.source, event.generation, event.offset, event.end)
|
|
1185
|
+
grouped.setdefault(key, []).append(event)
|
|
1186
|
+
|
|
1187
|
+
streams = {}
|
|
1188
|
+
for (source, generation, offset, end), record_events in grouped.items():
|
|
1189
|
+
record = Record(record_events[0].time, source, generation, offset, end,
|
|
1190
|
+
tuple(record_events))
|
|
1191
|
+
streams.setdefault((source, generation), []).append(record)
|
|
1192
|
+
|
|
1193
|
+
ordered_streams = []
|
|
1194
|
+
for stream_key in sorted(streams, key=lambda key: (key[0], key[1] or "")):
|
|
1195
|
+
stream = streams[stream_key]
|
|
1196
|
+
stream.sort(key=lambda record: (record.offset, record.end))
|
|
1197
|
+
ordered_streams.append(stream)
|
|
1198
|
+
|
|
1199
|
+
heap = []
|
|
1200
|
+
for stream_number, stream in enumerate(ordered_streams):
|
|
1201
|
+
record = stream[0]
|
|
1202
|
+
heapq.heappush(heap, (record.time, record.source, record.generation or "",
|
|
1203
|
+
record.offset, stream_number, 0, record))
|
|
1204
|
+
|
|
1205
|
+
records = []
|
|
1206
|
+
while heap:
|
|
1207
|
+
_time, _source, _generation, _offset, stream_number, index, record = heapq.heappop(heap)
|
|
1208
|
+
records.append(record)
|
|
1209
|
+
next_index = index + 1
|
|
1210
|
+
stream = ordered_streams[stream_number]
|
|
1211
|
+
if next_index < len(stream):
|
|
1212
|
+
next_record = stream[next_index]
|
|
1213
|
+
heapq.heappush(
|
|
1214
|
+
heap,
|
|
1215
|
+
(next_record.time, next_record.source, next_record.generation or "",
|
|
1216
|
+
next_record.offset, stream_number, next_index, next_record))
|
|
1217
|
+
return records
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _render_record(record):
|
|
1221
|
+
"""Render one completed source record without cutting its non-tool text."""
|
|
1222
|
+
pieces = []
|
|
1223
|
+
tools = []
|
|
1224
|
+
run_kind = None
|
|
1225
|
+
run_time = None
|
|
1226
|
+
run_texts = []
|
|
1227
|
+
|
|
1228
|
+
def flush_tools():
|
|
1229
|
+
if tools:
|
|
1230
|
+
pieces.append(" · {} tool calls: {}".format(
|
|
1231
|
+
len(tools), truncate(" | ".join(tools[-3:]), 130)))
|
|
1232
|
+
del tools[:]
|
|
1233
|
+
|
|
1234
|
+
def flush_run():
|
|
1235
|
+
nonlocal run_kind, run_time, run_texts
|
|
1236
|
+
if run_kind is not None:
|
|
1237
|
+
clock = datetime.fromtimestamp(run_time).strftime("%H:%M")
|
|
1238
|
+
pieces.append("[{}] {}:\n{}".format(
|
|
1239
|
+
clock, LABEL.get(run_kind, run_kind), "\n\n".join(run_texts)))
|
|
1240
|
+
run_kind = None
|
|
1241
|
+
run_time = None
|
|
1242
|
+
run_texts = []
|
|
1243
|
+
|
|
1244
|
+
for event in record.events:
|
|
1245
|
+
if event.kind == "tool":
|
|
1246
|
+
flush_run()
|
|
1247
|
+
tools.append(truncate(event.text, 70))
|
|
1248
|
+
continue
|
|
1249
|
+
flush_tools()
|
|
1250
|
+
if run_kind != event.kind:
|
|
1251
|
+
flush_run()
|
|
1252
|
+
run_kind = event.kind
|
|
1253
|
+
run_time = event.time
|
|
1254
|
+
run_texts.append(event.text)
|
|
1255
|
+
flush_run()
|
|
1256
|
+
flush_tools()
|
|
1257
|
+
return "\n".join(pieces), int(any(event.kind != "tool" for event in record.events))
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _append_page_section(text, section):
|
|
1261
|
+
"""Separate envelope sections without changing a record's trailing bytes."""
|
|
1262
|
+
if not text:
|
|
1263
|
+
return section
|
|
1264
|
+
if text.endswith("\n"):
|
|
1265
|
+
return text + section
|
|
1266
|
+
return text + "\n" + section
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _render_page(side, records, has_more, replay_reason):
|
|
1270
|
+
"""Render the exact visible envelope whose UTF-8 size is page-bounded."""
|
|
1271
|
+
other = OTHER_SIDE[side][1]
|
|
1272
|
+
text = "## What happened on the {} side (since your last turn)".format(other)
|
|
1273
|
+
text = _append_page_section(text, "has_more: {}".format(str(has_more).lower()))
|
|
1274
|
+
text = _append_page_section(text, "has_more_scope: currently discovered sources")
|
|
1275
|
+
if replay_reason is not None:
|
|
1276
|
+
text = _append_page_section(text, REPLAY_NOTICES[replay_reason])
|
|
1277
|
+
for record in records:
|
|
1278
|
+
rendered, _count = _render_record(record)
|
|
1279
|
+
text = _append_page_section(text, rendered)
|
|
1280
|
+
if has_more:
|
|
1281
|
+
if side == "codex":
|
|
1282
|
+
text = _append_page_section(
|
|
1283
|
+
text, "More remains; call antiphon_read again or continue on a later turn.")
|
|
1284
|
+
else:
|
|
1285
|
+
text = _append_page_section(text, "More remains; it will continue on a later turn.")
|
|
1286
|
+
return _append_page_section(
|
|
1287
|
+
text, "This record belongs to the Antiphon bridge — this is what actually happened "
|
|
1288
|
+
"there. Do not assume anything that is not in it.")
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _page_frontier(records, selected, scanned):
|
|
1292
|
+
"""Return offsets that stop at each source's first undelivered record."""
|
|
1293
|
+
first_remaining = {}
|
|
1294
|
+
for record in records[selected:]:
|
|
1295
|
+
first_remaining.setdefault(record.source, record.offset)
|
|
1296
|
+
frontier = {}
|
|
1297
|
+
for source, position in scanned.items():
|
|
1298
|
+
offset = first_remaining.get(source, position["offset"])
|
|
1299
|
+
frontier[source] = dict(position, offset=offset)
|
|
1300
|
+
return frontier
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _build_page(events, scanned, side, replay_reason=None):
|
|
1304
|
+
"""Build one bounded, whole-record page and its safe source frontier."""
|
|
1305
|
+
if replay_reason not in (None, "legacy_upgrade", "cursor_recovery"):
|
|
1306
|
+
raise ValueError("unknown replay reason")
|
|
1307
|
+
records = _ordered_records(events)
|
|
1308
|
+
if not records:
|
|
1309
|
+
if not scanned:
|
|
1310
|
+
return "", None, 0
|
|
1311
|
+
if replay_reason is None:
|
|
1312
|
+
return "", PageAdvance(dict(scanned), False, None), 0
|
|
1313
|
+
text = _render_page(side, [], False, replay_reason)
|
|
1314
|
+
return text, PageAdvance(dict(scanned), False, replay_reason), 0
|
|
1315
|
+
|
|
1316
|
+
maximum = min(EVENT_LIMIT, len(records))
|
|
1317
|
+
selected = 0
|
|
1318
|
+
text = ""
|
|
1319
|
+
for length in range(1, maximum + 1):
|
|
1320
|
+
has_more = length < len(records)
|
|
1321
|
+
candidate = _render_page(side, records[:length], has_more, replay_reason)
|
|
1322
|
+
if len(candidate.encode("utf-8")) <= PAGE_BUDGET:
|
|
1323
|
+
selected = length
|
|
1324
|
+
text = candidate
|
|
1325
|
+
|
|
1326
|
+
if selected == 0:
|
|
1327
|
+
selected = 1
|
|
1328
|
+
text = _render_page(side, records[:selected], len(records) > selected,
|
|
1329
|
+
replay_reason)
|
|
1330
|
+
|
|
1331
|
+
has_more = selected < len(records)
|
|
1332
|
+
frontier = _page_frontier(records, selected, scanned)
|
|
1333
|
+
count = sum(_render_record(record)[1] for record in records[:selected])
|
|
1334
|
+
return text, PageAdvance(frontier, has_more, replay_reason), count
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def build_summary(cwd, side, positions=None, since=None, replay_reason=None):
|
|
386
1338
|
"""`side` is the side that will READ the summary ('claude' | 'codex').
|
|
387
1339
|
Turns what happened on the other side, and what the user said, into
|
|
388
1340
|
compact text.
|
|
389
1341
|
|
|
390
|
-
Returns
|
|
391
|
-
|
|
1342
|
+
Returns ``(text, page_advance, message_count)``. The page advance is the
|
|
1343
|
+
safe contiguous delivered prefix, plus filtered bytes before the first
|
|
1344
|
+
undelivered visible record; it is not the parser's scanned EOF."""
|
|
392
1345
|
if side == "claude":
|
|
393
|
-
events = codex_events(
|
|
1346
|
+
events, reached = codex_events(
|
|
1347
|
+
cwd, positions, since, visible_record_limit=EVENT_LIMIT + 1)
|
|
394
1348
|
else:
|
|
395
|
-
events = claude_events(
|
|
396
|
-
|
|
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
|
|
1349
|
+
events, reached = claude_events(
|
|
1350
|
+
cwd, positions, since, visible_record_limit=EVENT_LIMIT + 1)
|
|
1351
|
+
return _build_page(events, reached, side, replay_reason)
|
|
434
1352
|
|
|
435
1353
|
|
|
436
1354
|
# ---------- hook (both sides share the same contract) ----------
|
|
437
1355
|
|
|
438
1356
|
def hook(side="claude"):
|
|
439
|
-
"""
|
|
1357
|
+
"""Injects the other side's summary into the context, and on the Codex side
|
|
1358
|
+
records which session is behind this alias.
|
|
440
1359
|
|
|
441
1360
|
`side` is which CLI this hook is running inside ('claude' | 'codex').
|
|
442
|
-
Claude Code and Codex CLI speak the same input fields (`cwd
|
|
443
|
-
output wrapper, so a single
|
|
1361
|
+
Claude Code and Codex CLI speak the same input fields (`cwd`,
|
|
1362
|
+
`hook_event_name`, `session_id`) and the same output wrapper, so a single
|
|
1363
|
+
`hook` function serves both."""
|
|
444
1364
|
if side not in OTHER_SIDE:
|
|
445
1365
|
print(f"hook: unknown side {side!r} (claude | codex)", file=sys.stderr)
|
|
446
1366
|
return 1
|
|
@@ -448,31 +1368,81 @@ def hook(side="claude"):
|
|
|
448
1368
|
input_data = json.load(sys.stdin)
|
|
449
1369
|
except (json.JSONDecodeError, ValueError):
|
|
450
1370
|
input_data = {}
|
|
1371
|
+
if not isinstance(input_data, dict):
|
|
1372
|
+
# `[]` and `"x"` are valid JSON. `.get` on either raises, and out of a
|
|
1373
|
+
# Stop hook that is a traceback in somebody's terminal.
|
|
1374
|
+
input_data = {}
|
|
451
1375
|
cwd = os.path.abspath(input_data.get("cwd") or project_dir())
|
|
1376
|
+
event = input_data.get("hook_event_name") or "UserPromptSubmit"
|
|
1377
|
+
|
|
1378
|
+
if side == "codex":
|
|
1379
|
+
# On every event, not only `SessionStart`. A missed one then costs a
|
|
1380
|
+
# turn of routability rather than the whole session's.
|
|
1381
|
+
record_codex_session(cwd, input_data.get("session_id"),
|
|
1382
|
+
input_data.get("transcript_path"))
|
|
1383
|
+
|
|
1384
|
+
if event != "UserPromptSubmit":
|
|
1385
|
+
# Only a prompt has something for context to attach to. Anything else —
|
|
1386
|
+
# `SessionStart`, or an event this version has never heard of — records
|
|
1387
|
+
# and returns without a word, rather than emitting a wrapper naming an
|
|
1388
|
+
# event that did not happen. The cursor stays where it was too: a
|
|
1389
|
+
# summary nobody was shown has not been seen.
|
|
1390
|
+
return 0
|
|
452
1391
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1392
|
+
with cursor_lock(cwd, side) as locked:
|
|
1393
|
+
if not locked:
|
|
1394
|
+
# `cursor_lock` has already said why on stderr, but its message is
|
|
1395
|
+
# deliberately neutral about what was lost — this is the detail
|
|
1396
|
+
# only this caller knows. Non-zero so the person actually sees it:
|
|
1397
|
+
# on exit 0 that line reaches a debug log and nothing else, and a
|
|
1398
|
+
# bridge that stopped delivering would look exactly like a
|
|
1399
|
+
# counterpart with nothing to say.
|
|
1400
|
+
print("antiphon: context not delivered this turn", file=sys.stderr)
|
|
1401
|
+
return 1
|
|
1402
|
+
cursor, cursor_state = _read_cursor_state(cwd, side)
|
|
1403
|
+
positions, since, replay_reason = positions_for(
|
|
1404
|
+
cursor, side, cursor_state)
|
|
1405
|
+
text, advance, _ = build_summary(
|
|
1406
|
+
cwd, side, positions, since, replay_reason)
|
|
1407
|
+
if not text:
|
|
1408
|
+
# Nothing to deliver this turn, so the write-then-advance order
|
|
1409
|
+
# below does not protect anything -- there is no page to lose.
|
|
1410
|
+
# The parser's own high-water mark still has to move, or a
|
|
1411
|
+
# source with nothing visible in it (filtered records, or one a
|
|
1412
|
+
# v1 cursor just placed) is read again from scratch every turn.
|
|
1413
|
+
if not _advance_page_cursor(
|
|
1414
|
+
cwd, side, cursor, side, positions, advance):
|
|
1415
|
+
print("antiphon: nothing to show, but could not record cursor "
|
|
1416
|
+
"progress", file=sys.stderr)
|
|
1417
|
+
return 0
|
|
460
1418
|
|
|
461
|
-
|
|
1419
|
+
# The hook prints nothing to the terminal. The counter used to say
|
|
1420
|
+
# "message" but it was counting the other side's transcript events;
|
|
1421
|
+
# incoming channel messages already show up via their own notices.
|
|
1422
|
+
# Context is injected silently.
|
|
1423
|
+
if not _deliver(json.dumps({
|
|
1424
|
+
"hookSpecificOutput": {
|
|
1425
|
+
"hookEventName": "UserPromptSubmit",
|
|
1426
|
+
"additionalContext": text,
|
|
1427
|
+
}
|
|
1428
|
+
}, ensure_ascii=False)):
|
|
1429
|
+
# The page never left this process, so it has not been delivered
|
|
1430
|
+
# and the cursor stays where it was. The next turn offers it again.
|
|
1431
|
+
print("antiphon: could not write this turn's context", file=sys.stderr)
|
|
1432
|
+
return 1
|
|
1433
|
+
if not _advance_page_cursor(
|
|
1434
|
+
cwd, side, cursor, side, positions, advance):
|
|
1435
|
+
# The page WAS delivered, so the exit code stays 0: a non-zero
|
|
1436
|
+
# exit suppresses plain-text stdout as context, and whether it
|
|
1437
|
+
# also suppresses `additionalContext` is undocumented and
|
|
1438
|
+
# unmeasured. Risking the page we just handed over to report a
|
|
1439
|
+
# cursor failure would trade a visible repeat for a silent
|
|
1440
|
+
# loss. The symptom of this branch is the same context
|
|
1441
|
+
# arriving every turn.
|
|
1442
|
+
print("antiphon: delivered, but could not record the cursor",
|
|
1443
|
+
file=sys.stderr)
|
|
462
1444
|
return 0
|
|
463
1445
|
|
|
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
1446
|
|
|
477
1447
|
def notice_text(side, count):
|
|
478
1448
|
"""The one-line notice in `status` output (the hook no longer uses this)."""
|
|
@@ -536,13 +1506,17 @@ def push(target="codex"):
|
|
|
536
1506
|
explicit `@codex` or `@claude` marker at the start of a line triggers a
|
|
537
1507
|
push.
|
|
538
1508
|
"""
|
|
539
|
-
if target not in
|
|
1509
|
+
if target not in MARKER_SIDES:
|
|
540
1510
|
print(f"push: unknown target {target!r} (claude | codex)", file=sys.stderr)
|
|
541
1511
|
return 1
|
|
542
1512
|
try:
|
|
543
1513
|
input_data = json.load(sys.stdin)
|
|
544
1514
|
except (json.JSONDecodeError, ValueError):
|
|
545
1515
|
input_data = {}
|
|
1516
|
+
if not isinstance(input_data, dict):
|
|
1517
|
+
# `[]` and `"x"` are valid JSON. `.get` on either raises, and out of a
|
|
1518
|
+
# Stop hook that is a traceback in somebody's terminal.
|
|
1519
|
+
input_data = {}
|
|
546
1520
|
if input_data.get("stop_hook_active"):
|
|
547
1521
|
return 0 # don't re-enter a turn we triggered ourselves
|
|
548
1522
|
cwd = os.path.abspath(input_data.get("cwd") or project_dir())
|
|
@@ -552,38 +1526,125 @@ def push(target="codex"):
|
|
|
552
1526
|
|
|
553
1527
|
reply_reader = last_claude_reply if target == "codex" else last_codex_reply
|
|
554
1528
|
reply_text = reply_reader(transcript)
|
|
555
|
-
|
|
556
|
-
|
|
1529
|
+
batches = {}
|
|
1530
|
+
for recipient, messages in group_by_recipient(target, reply_text).items():
|
|
1531
|
+
# Reported per line, not per recipient: a batch holding one empty marker
|
|
1532
|
+
# and one real message is not empty, so a per-batch check would let the
|
|
1533
|
+
# empty line disappear without a word.
|
|
1534
|
+
for blank in (m for m in messages if not m.strip()):
|
|
1535
|
+
named = f":{recipient}" if recipient is not None else ""
|
|
1536
|
+
print(f"antiphon: a @{target}{named} line carried no message, "
|
|
1537
|
+
"nothing sent for it", file=sys.stderr)
|
|
1538
|
+
said = [m for m in messages if m.strip()]
|
|
1539
|
+
if said:
|
|
1540
|
+
batches[recipient] = said
|
|
1541
|
+
if not batches:
|
|
557
1542
|
return 0
|
|
558
1543
|
|
|
559
|
-
|
|
560
|
-
cursor = read_cursor(cwd)
|
|
1544
|
+
side = sender_side(target)
|
|
561
1545
|
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
1546
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1547
|
+
# Once for the turn, not once per recipient. Who is speaking cannot change
|
|
1548
|
+
# between two lines of one reply, and working it out again for each would
|
|
1549
|
+
# walk the process tree again for an answer that is already known.
|
|
1550
|
+
who = claimed_alias(cwd, side)
|
|
1551
|
+
|
|
1552
|
+
def deliver(recipient, messages):
|
|
1553
|
+
outgoing = "\n".join(messages)
|
|
1554
|
+
attempt = delivery_id() # but each attempt is its own attempt
|
|
1555
|
+
if target == "codex":
|
|
1556
|
+
ok, detail = send_to_codex(
|
|
1557
|
+
cwd, f"{PUSH_LABEL} {queue_label(who, attempt)} {outgoing}",
|
|
1558
|
+
recipient)
|
|
1559
|
+
else:
|
|
1560
|
+
ok, detail = send_to_claude(cwd, outgoing, recipient,
|
|
1561
|
+
sender_alias=who, message_id=attempt)
|
|
1562
|
+
named = f":{recipient}" if recipient else ""
|
|
1563
|
+
if ok:
|
|
1564
|
+
print(f"antiphon: delivered to {target.title()}{named} "
|
|
1565
|
+
f"({len(outgoing)} characters)", file=sys.stderr)
|
|
1566
|
+
else:
|
|
1567
|
+
# Returning False leaves this recipient's fingerprint where it was,
|
|
1568
|
+
# so the line is offered again next turn instead of being recorded
|
|
1569
|
+
# as delivered and lost.
|
|
1570
|
+
print(f"antiphon: delivery failed — {detail}", file=sys.stderr)
|
|
1571
|
+
return ok
|
|
1572
|
+
|
|
1573
|
+
# The send happens here, outside any lock — reversing an earlier ruling
|
|
1574
|
+
# that held it inside, on the grounds that read-check-send-record is one
|
|
1575
|
+
# transaction. Measurement overturned that: holding this peer's cursor
|
|
1576
|
+
# lock across a send that hangs was measured at a 5,008 ms hold, against a
|
|
1577
|
+
# concurrent reader's own patience of 2,038 ms — it gave up and delivered
|
|
1578
|
+
# no context at all. `_queue_codex` allows 15 s per recipient and
|
|
1579
|
+
# `send_to_claude` up to 1.5 s of connect patience plus a 5 s socket
|
|
1580
|
+
# timeout, both well past `CURSOR_LOCK_PATIENCE` (2.0 s): a waiter does not
|
|
1581
|
+
# wait behind a slow send, it is *guaranteed* to give up. And for the
|
|
1582
|
+
# default unnamed install this was never only this peer's lock —
|
|
1583
|
+
# `state_path` returns the one cursor file both `claude` and `codex` share
|
|
1584
|
+
# — so a slow push on one side was blocking the *other agent's* context
|
|
1585
|
+
# delivery, not merely delaying a retry of its own.
|
|
1586
|
+
#
|
|
1587
|
+
# So the dedupe decision and the send both run against a read taken
|
|
1588
|
+
# without the lock. A stale read can at worst send a message that was
|
|
1589
|
+
# already sent — a duplicate, the trade this project makes everywhere else
|
|
1590
|
+
# — never a drop. `deliver_batches` below sends whatever `raw_sent`
|
|
1591
|
+
# doesn't already recognise and mutates it in place with the fingerprints
|
|
1592
|
+
# of what actually went out.
|
|
1593
|
+
raw_sent, already = migrate_pushed(read_cursor(cwd, side).get(key),
|
|
1594
|
+
batches.get(None) or [])
|
|
1595
|
+
before_send = dict(raw_sent)
|
|
1596
|
+
if already:
|
|
1597
|
+
# The exact bare message already went out under the old string
|
|
1598
|
+
# format; nothing left to send for it, but the migration to the new
|
|
1599
|
+
# shape still needs recording.
|
|
1600
|
+
raw_sent[""] = batch_fingerprint(batches[None])
|
|
1601
|
+
updated = forget_superseded(deliver_batches(batches, raw_sent, deliver))
|
|
1602
|
+
# The delta: only the slots this call actually resolved — never the whole
|
|
1603
|
+
# map computed from the read above. Writing that map back, whole, would
|
|
1604
|
+
# reintroduce exactly the lost update the cursor lock exists to prevent:
|
|
1605
|
+
# a cursor snapshot must never be carried across the lock boundary, only
|
|
1606
|
+
# a fact about what this call just did.
|
|
1607
|
+
delivered = {slot: fingerprint for slot, fingerprint in updated.items()
|
|
1608
|
+
if before_send.get(slot) != fingerprint}
|
|
1609
|
+
if not delivered:
|
|
1610
|
+
return 0 # nothing sent, nothing to record
|
|
1611
|
+
|
|
1612
|
+
def mutate(cursor):
|
|
1613
|
+
# `cursor` is what `update_cursor` just read under the lock, moments
|
|
1614
|
+
# ago — the only cursor state this may build on. `delivered` is
|
|
1615
|
+
# applied on top of *this*, never on top of `raw_sent` above, which
|
|
1616
|
+
# may already be stale by the time this runs.
|
|
1617
|
+
fresh, _ = migrate_pushed(cursor.get(key), [])
|
|
1618
|
+
merged = dict(fresh)
|
|
1619
|
+
merged.update(delivered)
|
|
1620
|
+
cursor[key] = forget_superseded(merged)
|
|
1621
|
+
return cursor
|
|
1622
|
+
|
|
1623
|
+
if not update_cursor(cwd, side, mutate):
|
|
1624
|
+
# The message already left this process — `deliver` above said so on
|
|
1625
|
+
# its own line. What failed is only the bookkeeping, so the
|
|
1626
|
+
# consequence is a possible duplicate next turn, never a second copy
|
|
1627
|
+
# of a drop that already happened: say exactly that, and let it be
|
|
1628
|
+
# seen. `push` injects nothing into anyone's context, so returning
|
|
1629
|
+
# non-zero here costs nobody the page a hook's non-zero would.
|
|
1630
|
+
missed = ", ".join(sorted(
|
|
1631
|
+
"(unaddressed)" if slot == "" else slot[1:] for slot in delivered))
|
|
1632
|
+
print(f"antiphon: sent to {target} but could not record delivery for "
|
|
1633
|
+
f"{missed} in {state_path(cwd, side)}; a duplicate send is "
|
|
1634
|
+
"possible next turn, not a drop", file=sys.stderr)
|
|
1635
|
+
return 1
|
|
582
1636
|
return 0
|
|
583
1637
|
|
|
584
1638
|
|
|
585
|
-
def
|
|
586
|
-
"""Leaves a message with
|
|
1639
|
+
def _queue_codex(session, message):
|
|
1640
|
+
"""Leaves a message with one Codex session via `codex queue`.
|
|
1641
|
+
|
|
1642
|
+
The transport, not the decision. `send_to_codex` picks the session; this
|
|
1643
|
+
only carries. Keeping them apart is what lets a refusal be tested without
|
|
1644
|
+
ever starting a process.
|
|
1645
|
+
|
|
1646
|
+
Returns: (success, detail).
|
|
1647
|
+
"""
|
|
587
1648
|
try:
|
|
588
1649
|
result = subprocess.run(
|
|
589
1650
|
["codex", "queue", "--thread", session, "--message", message],
|
|
@@ -605,36 +1666,205 @@ def claude_socket_path(cwd):
|
|
|
605
1666
|
f"antiphon-channel-{key}.sock")
|
|
606
1667
|
|
|
607
1668
|
|
|
608
|
-
def
|
|
609
|
-
"""
|
|
1669
|
+
def _legacy_target(cwd, kind):
|
|
1670
|
+
"""Where a message went before any of this existed.
|
|
1671
|
+
|
|
1672
|
+
Reached only when the registry holds nothing at all, which is the unnamed
|
|
1673
|
+
single-pair case. An older channel server still serving the project-wide
|
|
1674
|
+
socket is a working peer, and the newest rollout matching this directory is
|
|
1675
|
+
still the one Codex session in it; upgrading must not cut either off.
|
|
1676
|
+
"""
|
|
1677
|
+
if kind == "claude":
|
|
1678
|
+
return claude_socket_path(cwd), ""
|
|
1679
|
+
session = codex_session_id(cwd)
|
|
1680
|
+
if not session:
|
|
1681
|
+
return None, "not delivered: no Codex session found in this directory"
|
|
1682
|
+
return session, ""
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def _peer_states(live):
|
|
1686
|
+
"""Every live peer, and whether anything could actually reach it.
|
|
1687
|
+
|
|
1688
|
+
A refusal that named only the peers would leave the reader wondering which
|
|
1689
|
+
one to wait for. Naming the states answers that in the same breath.
|
|
1690
|
+
"""
|
|
1691
|
+
return ", ".join(sorted(
|
|
1692
|
+
"{}: {}".format(peer.get("name") or "?",
|
|
1693
|
+
"ready" if peer.get("address") is not None
|
|
1694
|
+
else "waiting for its first turn")
|
|
1695
|
+
for peer in live))
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def resolve_target(cwd, kind, alias=None):
|
|
1699
|
+
"""Which peer a message goes to. Returns (address, detail).
|
|
1700
|
+
|
|
1701
|
+
`address` is None when nothing can be delivered safely. The bridge does not
|
|
1702
|
+
choose between peers and does not broadcast: a choice made here is invisible
|
|
1703
|
+
to everyone, which is the failure the registry exists to end, and a message
|
|
1704
|
+
sent to three agents starts three agents on it. An agent picking a name is a
|
|
1705
|
+
different thing — that choice is written in its own words and can be read
|
|
1706
|
+
back and disagreed with.
|
|
1707
|
+
|
|
1708
|
+
The count that decides a bare message is how many peers are **live**, never
|
|
1709
|
+
how many are ready. Readiness is not permission to guess: a second peer
|
|
1710
|
+
between its start and its first turn is as much a candidate as the one that
|
|
1711
|
+
happens to be routable already, and picking the ready one would be choosing
|
|
1712
|
+
by timing.
|
|
1713
|
+
|
|
1714
|
+
`read_peers` returns a Claude endpoint as it stands, and a Codex endpoint
|
|
1715
|
+
with an address only when a session record under the *same owner key*
|
|
1716
|
+
supplies one, so an address of `None` means the same thing on both sides:
|
|
1717
|
+
live, and nothing can reach it yet.
|
|
1718
|
+
"""
|
|
1719
|
+
if not peers.valid_kind(kind):
|
|
1720
|
+
return None, f"not delivered: unknown peer kind {kind!r} (claude | codex)"
|
|
1721
|
+
|
|
1722
|
+
live = peers.read_peers(cwd, kind)
|
|
1723
|
+
names = ", ".join(sorted(p.get("name") or "?" for p in live))
|
|
1724
|
+
|
|
1725
|
+
if alias is not None:
|
|
1726
|
+
if not peers.valid_name(alias):
|
|
1727
|
+
return None, (f"not delivered: {alias!r} is not a usable peer name"
|
|
1728
|
+
+ (f"; live {kind} peers: {names}" if names else ""))
|
|
1729
|
+
# Exact, or not at all. A near miss is a different peer.
|
|
1730
|
+
match = [p for p in live if p.get("name") == alias]
|
|
1731
|
+
if not match:
|
|
1732
|
+
return None, (f"not delivered: no live {kind} peer named {alias!r}"
|
|
1733
|
+
+ (f"; live peers: {names}" if names else ""))
|
|
1734
|
+
if match[0].get("address") is None:
|
|
1735
|
+
return None, (f"not delivered: {alias!r} is live but not yet routable "
|
|
1736
|
+
"— it has not run a turn yet")
|
|
1737
|
+
return match[0]["address"], ""
|
|
1738
|
+
|
|
1739
|
+
if not live:
|
|
1740
|
+
# Nothing registered at all: the unnamed single pair, exactly as it was
|
|
1741
|
+
# before any of this. Not provably unique either, but it is the shipped
|
|
1742
|
+
# behaviour of every existing install and breaking it would cost far
|
|
1743
|
+
# more than the guess it makes.
|
|
1744
|
+
return _legacy_target(cwd, kind)
|
|
1745
|
+
if len(live) > 1:
|
|
1746
|
+
return None, (f"not delivered: {len(live)} {kind} peers are live "
|
|
1747
|
+
f"({_peer_states(live)}); address one by name")
|
|
1748
|
+
if kind == "codex":
|
|
1749
|
+
# One record is not one session. A Codex session registers only when it
|
|
1750
|
+
# was given a name, so any number of unnamed ones can be running beside
|
|
1751
|
+
# this one and none of them appears here — delivering to the visible one
|
|
1752
|
+
# would be a guess wearing a certainty. The asymmetry stops here: a
|
|
1753
|
+
# Claude channel server always registers, named or not, so one live
|
|
1754
|
+
# record on that side really is one live peer.
|
|
1755
|
+
return None, (f"not delivered: {_peer_states(live)} is the only "
|
|
1756
|
+
"registered Codex peer, but unnamed Codex sessions are "
|
|
1757
|
+
"not discoverable and cannot be ruled out — address a "
|
|
1758
|
+
"peer by name")
|
|
1759
|
+
# Reached only for Claude, whose live records always carry a usable address:
|
|
1760
|
+
# the addressless shape is Codex-only and `read_peers` skips every other
|
|
1761
|
+
# unusable one.
|
|
1762
|
+
return live[0]["address"], ""
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def send_to_codex(cwd, message, alias=None):
|
|
1766
|
+
"""Sends a message to a Codex peer, chosen by `alias` or by there being one.
|
|
1767
|
+
|
|
1768
|
+
Returns (ok, detail). Nothing is started when the recipient cannot be
|
|
1769
|
+
decided: the refusal happens before the transport is touched.
|
|
1770
|
+
"""
|
|
1771
|
+
address, detail = resolve_target(cwd, "codex", alias)
|
|
1772
|
+
if address is None:
|
|
1773
|
+
return False, detail
|
|
1774
|
+
return _queue_codex(address, message)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
# The channel server refuses anything larger. Checking here too means a sender
|
|
1778
|
+
# is told before transport instead of halfway through it. A contract test keeps
|
|
1779
|
+
# the two numbers equal.
|
|
1780
|
+
MAX_CHANNEL_BYTES = 128 * 1024
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
# A channel that is not there *yet* looks exactly like one that is not there at
|
|
1784
|
+
# all. Measured: Claude's MCP handshake completes 27-41ms before the socket is
|
|
1785
|
+
# bound, and a message sent the moment the channel looked ready was refused ten
|
|
1786
|
+
# times out of ten. The first thing a session says is precisely when that
|
|
1787
|
+
# happens, so the sender waits briefly rather than reporting a channel that is
|
|
1788
|
+
# about to exist as down.
|
|
1789
|
+
NOT_LISTENING_YET = frozenset({errno.ENOENT, errno.ECONNREFUSED})
|
|
1790
|
+
CONNECT_PATIENCE = 1.5 # seconds; a real outage still fails promptly
|
|
1791
|
+
CONNECT_RETRY_DELAY = 0.05
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def send_to_claude(cwd, text, alias=None, sender_alias=None, message_id=None):
|
|
1795
|
+
"""Sends a Codex message to a Claude peer's MCP Channel socket.
|
|
1796
|
+
|
|
1797
|
+
`sender_alias` and `message_id` travel in the payload and become the
|
|
1798
|
+
notification's metadata, so the receiving agent can see who spoke and
|
|
1799
|
+
address a reply deliberately.
|
|
1800
|
+
"""
|
|
610
1801
|
request = {
|
|
611
1802
|
"content": text,
|
|
612
|
-
"message_id":
|
|
1803
|
+
"message_id": message_id or delivery_id(),
|
|
1804
|
+
"sender_alias": sender_alias,
|
|
613
1805
|
}
|
|
614
|
-
|
|
615
|
-
|
|
1806
|
+
payload = json.dumps(request, ensure_ascii=False).encode()
|
|
1807
|
+
if len(payload) > MAX_CHANNEL_BYTES:
|
|
1808
|
+
return False, (f"message is {len(payload)} bytes; the channel accepts at "
|
|
1809
|
+
f"most {MAX_CHANNEL_BYTES}")
|
|
1810
|
+
|
|
1811
|
+
deadline = time.monotonic() + CONNECT_PATIENCE
|
|
1812
|
+
while True:
|
|
1813
|
+
# Re-resolved every attempt: a named peer can register in the meantime,
|
|
1814
|
+
# which moves the address from the project-wide path to its own.
|
|
1815
|
+
address, detail = resolve_target(cwd, "claude", alias)
|
|
1816
|
+
if address is None:
|
|
1817
|
+
# `mcp.connect` finishes before channel.mjs publishes its registry
|
|
1818
|
+
# claim. With an explicit, valid alias the first lookup can
|
|
1819
|
+
# therefore miss the peer altogether, before there is even an
|
|
1820
|
+
# address to connect to. That absence is as transient and as
|
|
1821
|
+
# indistinguishable from a real outage as ENOENT below. Invalid
|
|
1822
|
+
# aliases and bare ambiguity are decisions, not readiness races,
|
|
1823
|
+
# and still fail immediately.
|
|
1824
|
+
if (alias is not None and peers.valid_name(alias)
|
|
1825
|
+
and time.monotonic() < deadline):
|
|
1826
|
+
time.sleep(CONNECT_RETRY_DELAY)
|
|
1827
|
+
continue
|
|
1828
|
+
return False, detail
|
|
1829
|
+
sock = None
|
|
616
1830
|
try:
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1831
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
1832
|
+
sock.settimeout(5)
|
|
1833
|
+
sock.connect(address)
|
|
1834
|
+
except OSError as error:
|
|
1835
|
+
if sock is not None:
|
|
1836
|
+
sock.close()
|
|
1837
|
+
if error.errno in NOT_LISTENING_YET and time.monotonic() < deadline:
|
|
1838
|
+
time.sleep(CONNECT_RETRY_DELAY)
|
|
1839
|
+
continue
|
|
1840
|
+
return False, ("Claude MCP Channel is down: "
|
|
1841
|
+
f"{error.strerror or type(error).__name__}")
|
|
1842
|
+
break
|
|
1843
|
+
|
|
1844
|
+
# Connected. Nothing past this point is retried: the bytes may already have
|
|
1845
|
+
# been accepted, and a second attempt would deliver the message twice.
|
|
1846
|
+
try:
|
|
1847
|
+
with sock:
|
|
1848
|
+
sock.sendall(payload)
|
|
1849
|
+
sock.shutdown(socket.SHUT_WR)
|
|
1850
|
+
reply_bytes = b""
|
|
1851
|
+
while len(reply_bytes) < 64 * 1024:
|
|
1852
|
+
chunk = sock.recv(8192)
|
|
1853
|
+
if not chunk:
|
|
1854
|
+
break
|
|
1855
|
+
reply_bytes += chunk
|
|
1856
|
+
except OSError as error:
|
|
632
1857
|
return False, ("Claude MCP Channel is down: "
|
|
633
|
-
f"{
|
|
1858
|
+
f"{error.strerror or type(error).__name__}")
|
|
634
1859
|
try:
|
|
635
1860
|
result = json.loads(reply_bytes.decode())
|
|
636
1861
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
637
1862
|
return False, "Claude MCP Channel returned an invalid response"
|
|
1863
|
+
if not isinstance(result, dict):
|
|
1864
|
+
# Decoded, and not an answer: `[]` and `null` are valid JSON and `.get`
|
|
1865
|
+
# on either raises out of a path whose caller is only ever told success
|
|
1866
|
+
# or a reason.
|
|
1867
|
+
return False, "Claude MCP Channel returned an invalid response"
|
|
638
1868
|
if not result.get("ok"):
|
|
639
1869
|
return False, str(result.get("error") or "channel delivery failed")[:200]
|
|
640
1870
|
return True, ""
|
|
@@ -646,43 +1876,317 @@ def reply(*_):
|
|
|
646
1876
|
input_data = json.load(sys.stdin)
|
|
647
1877
|
except (json.JSONDecodeError, ValueError):
|
|
648
1878
|
input_data = {}
|
|
1879
|
+
if not isinstance(input_data, dict):
|
|
1880
|
+
# `[]` and `"x"` are valid JSON. `.get` on either raises, and out of a
|
|
1881
|
+
# Stop hook that is a traceback in somebody's terminal.
|
|
1882
|
+
input_data = {}
|
|
649
1883
|
text = input_data.get("text")
|
|
650
1884
|
if not isinstance(text, str) or not text.strip():
|
|
651
1885
|
print("reply: empty text", file=sys.stderr)
|
|
652
1886
|
return 1
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1887
|
+
to = input_data.get("to")
|
|
1888
|
+
if to is not None and not isinstance(to, str):
|
|
1889
|
+
print("reply: to must be a string naming one live Codex peer",
|
|
1890
|
+
file=sys.stderr)
|
|
657
1891
|
return 1
|
|
658
|
-
|
|
1892
|
+
cwd = project_dir()
|
|
1893
|
+
text = text.strip()
|
|
1894
|
+
# `channel.mjs` passes the peer name it validated for itself.
|
|
1895
|
+
who = sender_alias(input_data.get("sender_alias"))
|
|
1896
|
+
label = queue_label(who, delivery_id())
|
|
1897
|
+
ok, detail = send_to_codex(cwd, f"{CHANNEL_LABEL} {label} {text}", to)
|
|
659
1898
|
if not ok:
|
|
660
1899
|
print(f"reply: {detail}", file=sys.stderr)
|
|
661
1900
|
return 1
|
|
1901
|
+
_record_delivery(cwd, "codex", text, to)
|
|
1902
|
+
return 0
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _record_delivery(cwd, target, text, alias=None):
|
|
1906
|
+
"""Remembers what was just delivered, in the shape `push` dedupes on.
|
|
1907
|
+
|
|
1908
|
+
Without it a message sent mid-turn through a channel tool arrives twice:
|
|
1909
|
+
once from the tool, once more when the same text ends the turn as an
|
|
1910
|
+
`@claude` / `@codex` line.
|
|
1911
|
+
|
|
1912
|
+
Per recipient, in the same `""` / `"@alias"` scheme `deliver_batches` uses.
|
|
1913
|
+
This used to write a bare string over the whole record, so a delivery to
|
|
1914
|
+
`api` erased what `ui` had already been sent and `ui` received it again.
|
|
1915
|
+
|
|
1916
|
+
A record from before that scheme is carried forward rather than dropped.
|
|
1917
|
+
It holds the joined text, not a digest, so it cannot be converted — a batch
|
|
1918
|
+
of two lines and one line joining to the same string are different things —
|
|
1919
|
+
and it is kept in its own form for `migrate_pushed` to compare. Dropping it
|
|
1920
|
+
would resend the last unaddressed message once.
|
|
1921
|
+
"""
|
|
1922
|
+
side = sender_side(target)
|
|
1923
|
+
key = f"last_pushed_{target}"
|
|
1924
|
+
|
|
1925
|
+
def mutate(cursor):
|
|
1926
|
+
held = cursor.get(key)
|
|
1927
|
+
sent = dict(held) if isinstance(held, dict) else {}
|
|
1928
|
+
if isinstance(held, str):
|
|
1929
|
+
sent[LEGACY_SLOT] = held
|
|
1930
|
+
sent["" if alias is None else f"@{alias}"] = batch_fingerprint([text])
|
|
1931
|
+
cursor[key] = forget_superseded(sent)
|
|
1932
|
+
return cursor
|
|
1933
|
+
|
|
1934
|
+
update_cursor(cwd, side, mutate)
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def register_peer(*_):
|
|
1938
|
+
"""Records a peer on behalf of the Node channel server.
|
|
1939
|
+
|
|
1940
|
+
Kept in Python so the registry has exactly one implementation; the channel
|
|
1941
|
+
server shells out here the way it already does for `reply`. The `pid` in the
|
|
1942
|
+
payload is the long-lived owner — this process exits immediately, and a
|
|
1943
|
+
record carrying its pid would read as dead at once.
|
|
1944
|
+
"""
|
|
1945
|
+
try:
|
|
1946
|
+
data = json.load(sys.stdin)
|
|
1947
|
+
except (json.JSONDecodeError, ValueError):
|
|
1948
|
+
data = {}
|
|
1949
|
+
if not isinstance(data, dict):
|
|
1950
|
+
data = {} # valid JSON of the wrong shape is not a payload
|
|
1951
|
+
kind, name, address = data.get("kind"), data.get("name"), data.get("address")
|
|
1952
|
+
if kind not in ("claude", "codex") or not isinstance(address, str):
|
|
1953
|
+
print("register_peer: kind and address are required", file=sys.stderr)
|
|
1954
|
+
return 1
|
|
1955
|
+
# The owner key this subprocess can see is the CLI root above the channel
|
|
1956
|
+
# server, which is what a Stop hook in the same session computes too. It is
|
|
1957
|
+
# how that hook later shows the alias is genuinely this session's.
|
|
1958
|
+
ok, detail = peers.register(project_dir(), kind, name, address,
|
|
1959
|
+
pid=data.get("pid"), owner_key=peers.owner_key())
|
|
1960
|
+
if not ok:
|
|
1961
|
+
print(f"register_peer: {detail}", file=sys.stderr)
|
|
1962
|
+
return 1
|
|
1963
|
+
return 0
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def unregister_peer(*_):
|
|
1967
|
+
"""Releases a claim the channel server made but could not honour.
|
|
1968
|
+
|
|
1969
|
+
A record whose socket never came up is a lie the registry would keep
|
|
1970
|
+
telling: senders would be handed an address nothing serves.
|
|
1971
|
+
"""
|
|
1972
|
+
try:
|
|
1973
|
+
data = json.load(sys.stdin)
|
|
1974
|
+
except (json.JSONDecodeError, ValueError):
|
|
1975
|
+
data = {}
|
|
1976
|
+
if not isinstance(data, dict):
|
|
1977
|
+
data = {}
|
|
1978
|
+
kind, name = data.get("kind"), data.get("name")
|
|
1979
|
+
if kind not in ("claude", "codex"):
|
|
1980
|
+
print("unregister_peer: kind is required", file=sys.stderr)
|
|
1981
|
+
return 1
|
|
1982
|
+
peers.unregister(project_dir(), kind, name, pid=data.get("pid"))
|
|
662
1983
|
return 0
|
|
663
1984
|
|
|
664
1985
|
|
|
665
1986
|
# ---------- Codex MCP server ----------
|
|
666
1987
|
|
|
1988
|
+
def _tool_error(message):
|
|
1989
|
+
return {"content": [{"type": "text", "text": message}], "isError": True}
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
# The same sentence on both sides of the bridge. A contract test compares them,
|
|
1993
|
+
# because two tool descriptions saying different things about one argument is
|
|
1994
|
+
# how an agent learns a rule that is not true.
|
|
1995
|
+
TO_DESCRIPTION = ("Alias of the peer to send to. Required whenever the recipient "
|
|
1996
|
+
"cannot be shown to be the only one, because the send is then "
|
|
1997
|
+
"refused rather than guessed — so pass it whenever you know "
|
|
1998
|
+
"which peer you mean.")
|
|
1999
|
+
|
|
667
2000
|
TOOLS = [{
|
|
668
2001
|
"name": "antiphon_read",
|
|
669
|
-
"description": ("Returns what happened on the Claude Code side since
|
|
670
|
-
"
|
|
671
|
-
"
|
|
672
|
-
"
|
|
2002
|
+
"description": ("Returns one page of what happened on the Claude Code side since "
|
|
2003
|
+
"your last turn, oldest first. When the page ends with "
|
|
2004
|
+
"`has_more: true`, more completed records are already waiting: call "
|
|
2005
|
+
"this tool again, or let later turns drain them. `has_more: false` "
|
|
2006
|
+
"covers only the transcripts discovery can currently see, not all "
|
|
2007
|
+
"project history. If the next record alone is larger than an "
|
|
2008
|
+
"ordinary page, this tool refuses it instead of truncating: nothing "
|
|
2009
|
+
"is read or marked seen, and the next automatic prompt hook — whose "
|
|
2010
|
+
"host can spill an oversized record to a file — delivers it whole. "
|
|
2011
|
+
"Pages normally arrive automatically via the hook; reach for this "
|
|
2012
|
+
"tool to drain a backlog or when the bridge seems quiet."),
|
|
673
2013
|
"inputSchema": {"type": "object", "properties": {}},
|
|
2014
|
+
}, {
|
|
2015
|
+
"name": "antiphon_send",
|
|
2016
|
+
"description": ("Sends a message to a Claude Code peer working in this project, "
|
|
2017
|
+
"without waiting for your turn to end. It arrives as your words, "
|
|
2018
|
+
"attributed to you, and wakes Claude immediately — so you can hand "
|
|
2019
|
+
"work over and carry on. It does not block: call `antiphon_read` "
|
|
2020
|
+
"later in the same turn to pick up whatever Claude did. Name the "
|
|
2021
|
+
"peer with `to` when more than one is live; with a single peer "
|
|
2022
|
+
"you can leave it out."),
|
|
2023
|
+
"inputSchema": {
|
|
2024
|
+
"type": "object",
|
|
2025
|
+
"properties": {
|
|
2026
|
+
"text": {"type": "string", "description": "Message for Claude"},
|
|
2027
|
+
"to": {"type": "string", "description": TO_DESCRIPTION},
|
|
2028
|
+
},
|
|
2029
|
+
"required": ["text"],
|
|
2030
|
+
},
|
|
674
2031
|
}]
|
|
675
2032
|
|
|
676
2033
|
|
|
2034
|
+
def _deliver(line):
|
|
2035
|
+
"""Write one model-facing line to stdout and get it out of this process.
|
|
2036
|
+
|
|
2037
|
+
Returns whether that succeeded. Neither host acknowledges hook output or a
|
|
2038
|
+
tool result, so nothing here can learn whether the model was actually shown
|
|
2039
|
+
the text; "delivered" means only what is locally observable — the write and
|
|
2040
|
+
the flush both returned. That is the whole reason a cursor is advanced
|
|
2041
|
+
after this and never before, and why the contract is at-least-once: a crash
|
|
2042
|
+
in the window redelivers a page, which both agents can see, where advancing
|
|
2043
|
+
first would drop it in silence.
|
|
2044
|
+
"""
|
|
2045
|
+
try:
|
|
2046
|
+
sys.stdout.write(line + "\n")
|
|
2047
|
+
sys.stdout.flush()
|
|
2048
|
+
except (OSError, ValueError):
|
|
2049
|
+
return False
|
|
2050
|
+
return True
|
|
2051
|
+
|
|
2052
|
+
|
|
677
2053
|
def _mcp_result(mid, result):
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
2054
|
+
"""Writes one JSON-RPC response; returns whether it left this process."""
|
|
2055
|
+
return _deliver(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result},
|
|
2056
|
+
ensure_ascii=False))
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def _send_tool(cwd, text, to=None, sender=None):
|
|
2060
|
+
"""Delivers `text` to a Claude peer now, and reports honestly if it can't.
|
|
2061
|
+
|
|
2062
|
+
A silent success would be the worst outcome: Codex would believe Claude had
|
|
2063
|
+
been told, and neither side would notice the message never arrived. So
|
|
2064
|
+
every way this can fail — an alias that is not a name, one nobody answers
|
|
2065
|
+
to, one that is live but not routable yet, or no alias where two peers are
|
|
2066
|
+
live — comes back as a tool error before any transport is opened.
|
|
2067
|
+
|
|
2068
|
+
`to` is passed to the resolver exactly as given: an alias matches one peer
|
|
2069
|
+
or none. `sender` is the alias this server actually won at start-up, not
|
|
2070
|
+
what the environment asked for: a server refused its name holds nothing and
|
|
2071
|
+
says nothing.
|
|
2072
|
+
"""
|
|
2073
|
+
if not isinstance(text, str) or not text.strip():
|
|
2074
|
+
return _tool_error("text must be a non-empty string")
|
|
2075
|
+
if to is not None and not isinstance(to, str):
|
|
2076
|
+
return _tool_error("to must be a string naming one live Claude peer")
|
|
2077
|
+
text = text.strip()
|
|
2078
|
+
ok, detail = send_to_claude(cwd, text, to, sender_alias=sender_alias(sender),
|
|
2079
|
+
message_id=delivery_id())
|
|
2080
|
+
if not ok:
|
|
2081
|
+
return _tool_error(f"Not delivered to Claude: {detail}")
|
|
2082
|
+
_record_delivery(cwd, "claude", text, to)
|
|
2083
|
+
# Naming the peer back is what lets the sender notice it addressed the wrong
|
|
2084
|
+
# one. With a single peer there is nothing to distinguish, so the old
|
|
2085
|
+
# wording stands.
|
|
2086
|
+
where = f"peer {to!r}" if to else "channel"
|
|
2087
|
+
return {"content": [{"type": "text",
|
|
2088
|
+
"text": f"Delivered to the Claude Code {where}."}]}
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def register_codex_peer(cwd):
|
|
2092
|
+
"""Registers this MCP server: alias, pid, owner key, and no address yet.
|
|
2093
|
+
|
|
2094
|
+
Returns the alias it actually holds, or None. `mcp()` releases exactly what
|
|
2095
|
+
this returns, so a server that was refused the alias holds nothing and
|
|
2096
|
+
cannot delete the holder's record on its way out — that would hand the name
|
|
2097
|
+
to whoever asked next and take a working peer down with it.
|
|
2098
|
+
|
|
2099
|
+
Three outcomes and only one of them is silent. A session with no alias is
|
|
2100
|
+
the unchanged single-peer case: nothing was asked for, so there is nothing
|
|
2101
|
+
to warn about, and no reason to walk a process tree either. A session that
|
|
2102
|
+
asked for an alias and cannot have one has to be told — somebody typed
|
|
2103
|
+
`ANTIPHON_NAME=build` and would otherwise believe `@codex:build` works while
|
|
2104
|
+
it silently never will.
|
|
2105
|
+
"""
|
|
2106
|
+
alias = peers.explicit_name()
|
|
2107
|
+
if not alias:
|
|
2108
|
+
return None
|
|
2109
|
+
if not peers.valid_name(alias):
|
|
2110
|
+
print(f"antiphon: ANTIPHON_NAME={alias!r} is not a usable name "
|
|
2111
|
+
"([a-z0-9][a-z0-9_-]{0,31}); named routing is off for this session "
|
|
2112
|
+
"and the single unnamed peer still works.", file=sys.stderr)
|
|
2113
|
+
return None
|
|
2114
|
+
try:
|
|
2115
|
+
owner = peers.owner_key()
|
|
2116
|
+
if not owner:
|
|
2117
|
+
print("antiphon: named routing disabled — could not identify the "
|
|
2118
|
+
f"owning Codex process, so {alias!r} cannot be addressed. The "
|
|
2119
|
+
"bare single-peer fallback still works.", file=sys.stderr)
|
|
2120
|
+
return None
|
|
2121
|
+
ok, detail = peers.register(cwd, "codex", alias, None,
|
|
2122
|
+
pid=os.getpid(), owner_key=owner)
|
|
2123
|
+
except Exception as error:
|
|
2124
|
+
# Named routing is a layer over a bridge that already works without it.
|
|
2125
|
+
# Nothing here may cost this session its tools, so every failure is
|
|
2126
|
+
# caught — and every one is named in full, so a bug shows up loudly
|
|
2127
|
+
# instead of being swallowed as a shrug.
|
|
2128
|
+
print(f"antiphon: named routing disabled — the peer registry could not "
|
|
2129
|
+
f"be written ({type(error).__name__}: {error}). The bare "
|
|
2130
|
+
"single-peer fallback still works.", file=sys.stderr)
|
|
2131
|
+
return None
|
|
2132
|
+
if not ok:
|
|
2133
|
+
print(f"antiphon: {detail}", file=sys.stderr)
|
|
2134
|
+
return None
|
|
2135
|
+
return alias
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def record_codex_session(cwd, session_id, transcript):
|
|
2139
|
+
"""Writes the hook's half: which session is behind this alias.
|
|
2140
|
+
|
|
2141
|
+
Returns whether it wrote. Silent when this session has no usable alias or
|
|
2142
|
+
cannot identify itself — the server already said so once at start-up, which
|
|
2143
|
+
is the right number of times to say it, and repeating it on every turn would
|
|
2144
|
+
be noise. A refusal is different: it means somebody else holds the alias
|
|
2145
|
+
right now, and that stays true and stays worth saying.
|
|
2146
|
+
"""
|
|
2147
|
+
alias = peers.explicit_name()
|
|
2148
|
+
if not (peers.valid_name(alias) and session_id):
|
|
2149
|
+
return False
|
|
2150
|
+
try:
|
|
2151
|
+
owner = peers.owner_key()
|
|
2152
|
+
if not owner:
|
|
2153
|
+
return False
|
|
2154
|
+
ok, detail = peers.write_session(cwd, "codex", alias, session_id,
|
|
2155
|
+
transcript, owner)
|
|
2156
|
+
except Exception as error:
|
|
2157
|
+
print(f"antiphon: {alias!r} could not be recorded "
|
|
2158
|
+
f"({type(error).__name__}: {error}); it is not addressable this "
|
|
2159
|
+
"turn. The bare single-peer fallback still works.", file=sys.stderr)
|
|
2160
|
+
return False
|
|
2161
|
+
if not ok:
|
|
2162
|
+
print(f"antiphon: {detail}", file=sys.stderr)
|
|
2163
|
+
return ok
|
|
681
2164
|
|
|
682
2165
|
|
|
683
2166
|
def mcp():
|
|
684
2167
|
"""The MCP stdio server Codex connects to."""
|
|
685
2168
|
cwd = project_dir()
|
|
2169
|
+
alias = register_codex_peer(cwd)
|
|
2170
|
+
try:
|
|
2171
|
+
# The alias this process won, carried in rather than re-derived: the
|
|
2172
|
+
# environment cannot tell whether the claim succeeded.
|
|
2173
|
+
return _mcp_serve(cwd, alias)
|
|
2174
|
+
finally:
|
|
2175
|
+
if alias:
|
|
2176
|
+
try:
|
|
2177
|
+
# Only what this process actually claimed, and `unregister` is
|
|
2178
|
+
# pid-guarded on top of that. A `SIGKILL` leaves the record
|
|
2179
|
+
# behind, which is what pid-based pruning in `read_peers` is
|
|
2180
|
+
# for: the clean path releases the name at once, the fallback
|
|
2181
|
+
# catches the rest.
|
|
2182
|
+
peers.unregister(cwd, "codex", alias, pid=os.getpid())
|
|
2183
|
+
except OSError:
|
|
2184
|
+
pass
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def _mcp_serve(cwd, alias=None):
|
|
2188
|
+
"""The request loop, split out so `mcp()` reads as what it now is: a
|
|
2189
|
+
lifetime around it."""
|
|
686
2190
|
for line in sys.stdin:
|
|
687
2191
|
line = line.strip()
|
|
688
2192
|
if not line:
|
|
@@ -691,29 +2195,95 @@ def mcp():
|
|
|
691
2195
|
request = json.loads(line)
|
|
692
2196
|
except json.JSONDecodeError:
|
|
693
2197
|
continue
|
|
2198
|
+
if not isinstance(request, dict):
|
|
2199
|
+
# Valid JSON, and not a request. Skipping it costs this line; a
|
|
2200
|
+
# traceback would cost the session and every tool with it.
|
|
2201
|
+
continue
|
|
694
2202
|
method, mid = request.get("method"), request.get("id")
|
|
695
2203
|
if method == "initialize":
|
|
696
2204
|
_mcp_result(mid, {
|
|
697
2205
|
"protocolVersion": "2024-11-05",
|
|
698
2206
|
"capabilities": {"tools": {}},
|
|
699
|
-
"serverInfo": {"name": "antiphon", "version": "0.
|
|
2207
|
+
"serverInfo": {"name": "antiphon", "version": "0.3.0"},
|
|
700
2208
|
})
|
|
701
2209
|
elif method == "tools/list":
|
|
702
2210
|
_mcp_result(mid, {"tools": TOOLS})
|
|
703
2211
|
elif method == "tools/call":
|
|
704
|
-
|
|
2212
|
+
# `params` and `arguments` come off the wire, so neither is trusted
|
|
2213
|
+
# to be an object. `.get` on a list raises, and it would end the
|
|
2214
|
+
# session rather than the request.
|
|
2215
|
+
p = request.get("params")
|
|
2216
|
+
p = p if isinstance(p, dict) else {}
|
|
705
2217
|
name = p.get("name")
|
|
706
2218
|
if name == "antiphon_read":
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
2219
|
+
with cursor_lock(cwd, "codex") as locked:
|
|
2220
|
+
if not locked:
|
|
2221
|
+
# A JSON-RPC request must always be answered: a tool
|
|
2222
|
+
# call with no response leaves the caller waiting on a
|
|
2223
|
+
# request that never completes. It is an error result,
|
|
2224
|
+
# not content — this tool's content is the other side's
|
|
2225
|
+
# words, and a plain string here would read as some.
|
|
2226
|
+
_mcp_result(mid, _tool_error(
|
|
2227
|
+
"another read is in flight; nothing was read and "
|
|
2228
|
+
"nothing was marked seen — try again in a moment"))
|
|
2229
|
+
else:
|
|
2230
|
+
cursor, cursor_state = _read_cursor_state(cwd, "codex")
|
|
2231
|
+
positions, since, replay_reason = positions_for(
|
|
2232
|
+
cursor, "codex", cursor_state)
|
|
2233
|
+
text, advance, _ = build_summary(
|
|
2234
|
+
cwd, "codex", positions, since, replay_reason)
|
|
2235
|
+
oversized = (text
|
|
2236
|
+
and len(text.encode("utf-8")) > PAGE_BUDGET)
|
|
2237
|
+
if oversized:
|
|
2238
|
+
output = _tool_error(
|
|
2239
|
+
"The next complete record is too large for a safe "
|
|
2240
|
+
"antiphon_read result. Nothing was read or marked "
|
|
2241
|
+
"seen; the next prompt hook will deliver it whole.")
|
|
2242
|
+
else:
|
|
2243
|
+
output = {"content": [{
|
|
2244
|
+
"type": "text",
|
|
2245
|
+
"text": text or (
|
|
2246
|
+
"Nothing new on the Claude Code side "
|
|
2247
|
+
"since your last turn."),
|
|
2248
|
+
}]}
|
|
2249
|
+
# Answer first, mark seen second — the same order as
|
|
2250
|
+
# the hook, for the same reason: a result that was
|
|
2251
|
+
# never written is a page the model never saw. That
|
|
2252
|
+
# ordering protects a page that was actually
|
|
2253
|
+
# selected; when there is nothing to deliver, no page
|
|
2254
|
+
# depends on it, and the parser's own high-water mark
|
|
2255
|
+
# still has to move, or a source with nothing visible
|
|
2256
|
+
# in it is read again from scratch every turn.
|
|
2257
|
+
delivered = _mcp_result(mid, output)
|
|
2258
|
+
if oversized:
|
|
2259
|
+
continue
|
|
2260
|
+
if not text and delivered:
|
|
2261
|
+
if not _advance_page_cursor(
|
|
2262
|
+
cwd, "codex", cursor, "codex", positions,
|
|
2263
|
+
advance):
|
|
2264
|
+
print("antiphon: could not record progress "
|
|
2265
|
+
"in the cursor",
|
|
2266
|
+
file=sys.stderr)
|
|
2267
|
+
elif delivered:
|
|
2268
|
+
if not _advance_page_cursor(
|
|
2269
|
+
cwd, "codex", cursor, "codex", positions,
|
|
2270
|
+
advance):
|
|
2271
|
+
# Symmetric with the hook: the page was
|
|
2272
|
+
# delivered, so the tool result already went
|
|
2273
|
+
# out and this stays a diagnostic rather than
|
|
2274
|
+
# a second, failing response — the model
|
|
2275
|
+
# would just see the same context again next
|
|
2276
|
+
# turn.
|
|
2277
|
+
print("antiphon: delivered, but could not "
|
|
2278
|
+
"record the cursor",
|
|
2279
|
+
file=sys.stderr)
|
|
2280
|
+
elif name == "antiphon_send":
|
|
2281
|
+
arguments = p.get("arguments")
|
|
2282
|
+
arguments = arguments if isinstance(arguments, dict) else {}
|
|
2283
|
+
_mcp_result(mid, _send_tool(cwd, arguments.get("text"),
|
|
2284
|
+
arguments.get("to"), alias))
|
|
714
2285
|
else:
|
|
715
|
-
|
|
716
|
-
_mcp_result(mid, {"content": [{"type": "text", "text": output}]})
|
|
2286
|
+
_mcp_result(mid, _tool_error(f"unknown tool: {name}"))
|
|
717
2287
|
elif mid is not None:
|
|
718
2288
|
_mcp_result(mid, {})
|
|
719
2289
|
return 0
|
|
@@ -729,22 +2299,64 @@ SECTION_HEADING = "## The Antiphon bridge"
|
|
|
729
2299
|
AGENTS_RULE = ("\n## The Antiphon bridge\n\n"
|
|
730
2300
|
"You are working alongside Claude Code on this project. What happens on the "
|
|
731
2301
|
"other side is injected into your context automatically at the start of each "
|
|
732
|
-
"turn — you don't need to do anything else.
|
|
733
|
-
"
|
|
2302
|
+
"turn — you don't need to do anything else. It arrives as one page of "
|
|
2303
|
+
"completed records, oldest first; a `has_more: true` line means more is "
|
|
2304
|
+
"already waiting, so call the `antiphon_read` tool again (or let later turns "
|
|
2305
|
+
"drain it) until it reports `has_more: false` — which covers only the "
|
|
2306
|
+
"currently discovered transcripts, not all project history. A page carrying "
|
|
2307
|
+
"a replay notice is re-delivering history after an upgrade or cursor "
|
|
2308
|
+
"recovery and can contain duplicates; it is complete when the notice "
|
|
2309
|
+
"disappears. If the single next record is larger than an ordinary page, "
|
|
2310
|
+
"`antiphon_read` refuses it instead of truncating it — nothing is read or marked seen — and the next "
|
|
2311
|
+
"automatic prompt hook delivers it whole. That injected context is "
|
|
2312
|
+
"project-wide awareness rather than mail addressed to you: it may merge "
|
|
2313
|
+
"activity from several project transcripts under one Claude label, so read "
|
|
2314
|
+
"it as what is happening nearby.\n\n"
|
|
734
2315
|
"When Claude wants to tell you something directly, you'll see it as a user "
|
|
735
2316
|
"message starting with `[Antiphon bridge] Claude:` (pushed from Claude's Stop "
|
|
736
2317
|
"hook) or `[Antiphon channel] Claude:` (a direct reply through the channel) — "
|
|
737
|
-
"either way, these are Claude's words, not the user's.
|
|
738
|
-
"
|
|
739
|
-
"
|
|
2318
|
+
"either way, these are Claude's words, not the user's. After that prefix comes "
|
|
2319
|
+
"`[from=<alias> id=<uuid>]`, naming which Claude peer spoke: reply to that one "
|
|
2320
|
+
"with `antiphon_send(to=<alias>)` or `@claude:<alias>`. A literal "
|
|
2321
|
+
"`from=<unnamed>` means that peer has no name and cannot be addressed back — "
|
|
2322
|
+
"with only one Claude peer live you can leave the recipient out entirely. The "
|
|
2323
|
+
"id names one delivery attempt; nothing routes replies by it.\n\n"
|
|
2324
|
+
"When you want to hand Claude a task directly, put `@claude` at the start of a "
|
|
2325
|
+
"line in your reply; only that line is sent to the Claude session as an MCP "
|
|
2326
|
+
"Channel event. To reach Claude without ending your turn, call the "
|
|
2327
|
+
"`antiphon_send` tool instead: it delivers immediately, so Claude can start "
|
|
2328
|
+
"working while you carry on, and `antiphon_read` picks up the answer later in "
|
|
2329
|
+
"the same turn.\n\n"
|
|
2330
|
+
"A direct send reaches one peer and is never broadcast. Write `@claude:name`, "
|
|
2331
|
+
"or `antiphon_send(to=name)`, whenever more than one Claude peer is live: an "
|
|
2332
|
+
"unaddressed send is refused rather than delivered to a guess. For the same "
|
|
2333
|
+
"reason every terminal in a project with more than one session per side has to "
|
|
2334
|
+
"be started with `ANTIPHON_NAME` set — a session without a name is live but "
|
|
2335
|
+
"unaddressable, and nothing can be sent back to it.\n")
|
|
740
2336
|
|
|
741
2337
|
CLAUDE_RULE = ("\n## The Antiphon bridge\n\n"
|
|
742
2338
|
"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.
|
|
744
|
-
"
|
|
745
|
-
"
|
|
2339
|
+
"other side is injected into your context at the start of each turn. That "
|
|
2340
|
+
"injected context is project-wide awareness rather than mail addressed to you: "
|
|
2341
|
+
"it may merge activity from several project transcripts under one Codex label, "
|
|
2342
|
+
"so read it as what is happening nearby.\n\n"
|
|
2343
|
+
"Events that come directly from that agent are marked "
|
|
2344
|
+
"`<channel source=\"antiphon\" sender=\"codex\" sender_kind=\"agent\" "
|
|
2345
|
+
"sender_alias=\"...\">`; they "
|
|
746
2346
|
"are the words of the Codex agent, not of the human user. Use the "
|
|
747
|
-
"`reply_to_codex` tool to answer them
|
|
2347
|
+
"`reply_to_codex` tool to answer them, passing `sender_alias` back "
|
|
2348
|
+
"as `to` whenever it is non-null: a bare reply is refused as soon "
|
|
2349
|
+
"as any named Codex peer is live, because unnamed sessions leave "
|
|
2350
|
+
"no registry record and cannot be ruled out. A null `sender_alias` "
|
|
2351
|
+
"means that peer has no name: it cannot be answered by name, and "
|
|
2352
|
+
"a bare reply reaches it only where nothing is registered.\n\n"
|
|
2353
|
+
"A reply reaches one peer and is never broadcast, and the same holds when you "
|
|
2354
|
+
"open the exchange: `@codex:name` at the start of a line addresses one peer, "
|
|
2355
|
+
"and an unaddressed line is refused rather than delivered to a guess. For the "
|
|
2356
|
+
"same reason every terminal in a project with more than one session per side "
|
|
2357
|
+
"has to be started with `ANTIPHON_NAME` set — Codex terminals above all, "
|
|
2358
|
+
"because an unnamed Codex session leaves no record at all, and one that exists "
|
|
2359
|
+
"unseen is why a bare message to Codex is refused.\n")
|
|
748
2360
|
|
|
749
2361
|
|
|
750
2362
|
class ConfigFileError(Exception):
|
|
@@ -849,7 +2461,10 @@ def _dedupe_hooks(hooks, command):
|
|
|
849
2461
|
|
|
850
2462
|
|
|
851
2463
|
def _add_hook(hooks, command, legacy_commands=None, label=None):
|
|
852
|
-
"""Adds the command to
|
|
2464
|
+
"""Adds the command to one event's list; does nothing if it's already there.
|
|
2465
|
+
|
|
2466
|
+
`hooks` is the list for a single event, so the same command installed under
|
|
2467
|
+
two events is two calls and stays exactly one entry under each.
|
|
853
2468
|
|
|
854
2469
|
If `legacy_commands` is given, upgrade those first — otherwise, once the
|
|
855
2470
|
side argument gets added, the old entry would stick around and the hook
|
|
@@ -915,12 +2530,22 @@ def _codex_config_block(cwd):
|
|
|
915
2530
|
|
|
916
2531
|
Note `args = ["mcp"]`, not `["channel"]`: the channel server is Claude's side
|
|
917
2532
|
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.
|
|
2533
|
+
messages labelled as Claude's — the one thing this bridge exists to prevent.
|
|
2534
|
+
|
|
2535
|
+
`env_vars` names a variable to forward from the parent rather than a value to
|
|
2536
|
+
set. Codex does not pass the parent environment through: measured on live
|
|
2537
|
+
processes, the Claude MCP child carried 46 variables and the Codex child 10 —
|
|
2538
|
+
a curated set plus whatever `env` declares. Without this line `ANTIPHON_NAME`
|
|
2539
|
+
never reaches `antiphon mcp` however the terminal was started, so the server
|
|
2540
|
+
and the hook could not agree on which peer they belong to."""
|
|
919
2541
|
return (f'[{CODEX_MCP_TABLE}]\n'
|
|
920
2542
|
'command = "antiphon"\n'
|
|
921
2543
|
'args = ["mcp"]\n'
|
|
922
2544
|
'# read-only local bridge; no need to ask on every turn\n'
|
|
923
2545
|
'default_tools_approval_mode = "approve"\n'
|
|
2546
|
+
'# forwarded, not set: the peer name comes from the terminal that\n'
|
|
2547
|
+
"# started this session, and Codex does not pass it down otherwise\n"
|
|
2548
|
+
'env_vars = ["ANTIPHON_NAME"]\n'
|
|
924
2549
|
f'\n[{CODEX_MCP_TABLE}.env]\n'
|
|
925
2550
|
f'ANTIPHON_CWD = "{cwd}"\n')
|
|
926
2551
|
|
|
@@ -1019,6 +2644,20 @@ def setup():
|
|
|
1019
2644
|
install(codex_target, codex_mutate,
|
|
1020
2645
|
"Codex hook installed", "Codex hook already installed")
|
|
1021
2646
|
|
|
2647
|
+
# The Codex session id arrives at SessionStart, so the same command is
|
|
2648
|
+
# installed there too. Under both events is also the fallback: if
|
|
2649
|
+
# SessionStart is missed — an older CLI, a config predating this — the first
|
|
2650
|
+
# prompt records the session instead, and a peer becomes routable one turn
|
|
2651
|
+
# later rather than never. SessionEnd is deliberately not installed: it can
|
|
2652
|
+
# be delayed or missed, so nothing may depend on it.
|
|
2653
|
+
def codex_session_mutate(data):
|
|
2654
|
+
hooks = data.setdefault("hooks", {}).setdefault("SessionStart", [])
|
|
2655
|
+
return _add_hook(hooks, codex_command, label="Antiphon bridge")
|
|
2656
|
+
|
|
2657
|
+
install(codex_target, codex_session_mutate,
|
|
2658
|
+
"Codex session hook installed (SessionStart)",
|
|
2659
|
+
"Codex session hook already installed")
|
|
2660
|
+
|
|
1022
2661
|
# --- Codex side: push to Claude (Stop hook) ---
|
|
1023
2662
|
reverse_push_command = PUSH_COMMAND.format(target="claude")
|
|
1024
2663
|
legacy_reverse_push_commands = _legacy_commands(script, "it", "claude")
|
|
@@ -1099,6 +2738,13 @@ def setup():
|
|
|
1099
2738
|
print("\n— Start Claude with the channel enabled:")
|
|
1100
2739
|
print(" claude --dangerously-load-development-channels server:antiphon")
|
|
1101
2740
|
print(" In the research preview, the first launch needs both a development channel and an MCP approval.")
|
|
2741
|
+
print("\n— More than one terminal on either side? Name every one of them:")
|
|
2742
|
+
print(" ANTIPHON_NAME=ui claude --dangerously-load-development-channels server:antiphon")
|
|
2743
|
+
print(" ANTIPHON_NAME=build codex")
|
|
2744
|
+
print(" An unnamed session still runs, but it cannot be addressed by name. Name the")
|
|
2745
|
+
print(" Codex terminals above all: an unnamed Codex session leaves no record at all,")
|
|
2746
|
+
print(" so once any Codex peer is named, an unaddressed message to Codex is refused")
|
|
2747
|
+
print(" rather than sent to a guess.")
|
|
1102
2748
|
if failures:
|
|
1103
2749
|
listed = "\n ".join(failures)
|
|
1104
2750
|
print(f"\n✗ setup did not finish. {len(failures)} file(s) were left untouched "
|
|
@@ -1110,42 +2756,191 @@ def setup():
|
|
|
1110
2756
|
|
|
1111
2757
|
# ---------- status, for humans ----------
|
|
1112
2758
|
|
|
2759
|
+
def _file_count(number):
|
|
2760
|
+
"""`none`, `1 file`, `2 files` — the way a person would write it."""
|
|
2761
|
+
if not number:
|
|
2762
|
+
return "none"
|
|
2763
|
+
return f"{number} file" if number == 1 else f"{number} files"
|
|
2764
|
+
|
|
2765
|
+
|
|
2766
|
+
def _live_by_kind(cwd):
|
|
2767
|
+
"""One reading of the registry, grouped by side and sorted by name.
|
|
2768
|
+
|
|
2769
|
+
One reading, because everything on screen has to describe the same moment.
|
|
2770
|
+
Read separately, a session that starts or stops between two reads makes the
|
|
2771
|
+
halves contradict each other — a live channel above an empty peer list, or
|
|
2772
|
+
a peer listed under a channel reported down. It also scans and prunes once
|
|
2773
|
+
instead of three times for one screen.
|
|
2774
|
+
|
|
2775
|
+
Sorted by name rather than left in `read_peers` order. That order is by
|
|
2776
|
+
start time, which is right for resolution and wrong for a list a person
|
|
2777
|
+
reads: one that reshuffles whenever a session restarts cannot be read
|
|
2778
|
+
twice.
|
|
2779
|
+
"""
|
|
2780
|
+
grouped = {"claude": [], "codex": []}
|
|
2781
|
+
for peer in peers.read_peers(cwd):
|
|
2782
|
+
grouped.setdefault(peer.get("kind"), []).append(peer)
|
|
2783
|
+
return {kind: sorted(found, key=lambda peer: peer.get("name") or "")
|
|
2784
|
+
for kind, found in grouped.items()}
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def _peer_report(live):
|
|
2788
|
+
"""The `Peers:` block and the addressing hints under it, as lines.
|
|
2789
|
+
|
|
2790
|
+
Empty when nothing is registered, which is the unnamed single pair: there
|
|
2791
|
+
is nobody to choose between, so there is nothing to say.
|
|
2792
|
+
"""
|
|
2793
|
+
if not (live["claude"] or live["codex"]):
|
|
2794
|
+
return []
|
|
2795
|
+
|
|
2796
|
+
lines = ["", "Peers:"]
|
|
2797
|
+
for kind in ("claude", "codex"):
|
|
2798
|
+
for peer in live[kind]:
|
|
2799
|
+
# In words, and never the address itself. Whoever is reading this is
|
|
2800
|
+
# deciding who to address; a socket path or a rollout id answers
|
|
2801
|
+
# none of that, and puts both on screen and into whatever they
|
|
2802
|
+
# paste next.
|
|
2803
|
+
state = ("ready" if peer.get("address") is not None
|
|
2804
|
+
else "waiting for first turn")
|
|
2805
|
+
lines.append(f" {kind.title()} {peer.get('name')} — {state}")
|
|
2806
|
+
|
|
2807
|
+
def addressable(kind):
|
|
2808
|
+
return [p.get("name") for p in live[kind]
|
|
2809
|
+
if peers.valid_name(p.get("name"))]
|
|
2810
|
+
|
|
2811
|
+
# Readiness never narrows either list. A peer between its start and its
|
|
2812
|
+
# first turn is as much a candidate as one that happens to be routable
|
|
2813
|
+
# already, and letting readiness decide would hand routing to whichever
|
|
2814
|
+
# started first.
|
|
2815
|
+
if len(live["claude"]) > 1:
|
|
2816
|
+
named = ", ".join(f"@claude:{name}" for name in addressable("claude"))
|
|
2817
|
+
lines.append(f" → a bare @claude line is refused; address one: {named}")
|
|
2818
|
+
if any(p.get("name") == peers.UNNAMED for p in live["claude"]):
|
|
2819
|
+
lines.append(" → one Claude peer has no name and cannot be "
|
|
2820
|
+
"addressed; restart it with ANTIPHON_NAME set to "
|
|
2821
|
+
"reach it while others are live")
|
|
2822
|
+
if live["codex"]:
|
|
2823
|
+
# Even one. A Codex session registers only when it was given a name, so
|
|
2824
|
+
# a single record cannot rule out the unnamed ones that leave none.
|
|
2825
|
+
named = ", ".join(f"@codex:{name}" for name in addressable("codex"))
|
|
2826
|
+
lines.append(f" → a bare @codex line is refused, because unnamed Codex "
|
|
2827
|
+
f"sessions leave no record; address one: {named}")
|
|
2828
|
+
return lines
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
_STATUS_SEEN_KEYS = frozenset(("claude_seen", "codex_seen"))
|
|
2832
|
+
_STATUS_PAGE_KEYS = frozenset(("claude_pages", "codex_pages"))
|
|
2833
|
+
_STATUS_CURSOR_KEYS = (_STATUS_SEEN_KEYS | _STATUS_PAGE_KEYS
|
|
2834
|
+
| {"last_pushed_claude", "last_pushed_codex"})
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
def _cursor_entry(key, value):
|
|
2838
|
+
"""How one cursor entry reads in `status`.
|
|
2839
|
+
|
|
2840
|
+
Known cursor formats expose only the progress a person can act on. Unknown
|
|
2841
|
+
sibling entries are preserved on disk for rolling compatibility, but their
|
|
2842
|
+
values stay opaque here: a newer format may contain transcript paths,
|
|
2843
|
+
session ids or generation fingerprints that status must never print.
|
|
2844
|
+
"""
|
|
2845
|
+
if key in _STATUS_SEEN_KEYS and isinstance(value, (int, float)) \
|
|
2846
|
+
and not isinstance(value, bool) and math.isfinite(value):
|
|
2847
|
+
if not value:
|
|
2848
|
+
return "—"
|
|
2849
|
+
try:
|
|
2850
|
+
return datetime.fromtimestamp(value).strftime("%H:%M:%S")
|
|
2851
|
+
except (ValueError, OverflowError, OSError):
|
|
2852
|
+
pass
|
|
2853
|
+
if key in _STATUS_SEEN_KEYS or key in _STATUS_PAGE_KEYS:
|
|
2854
|
+
expected = (PAGE_CURSOR_VERSION if key in _STATUS_PAGE_KEYS
|
|
2855
|
+
else CURSOR_VERSION)
|
|
2856
|
+
if (isinstance(value, dict) and value.get("v") == expected
|
|
2857
|
+
and isinstance(value.get("sources"), dict)
|
|
2858
|
+
and all(_valid_position(entry)
|
|
2859
|
+
for entry in value["sources"].values())):
|
|
2860
|
+
sources = value["sources"]
|
|
2861
|
+
offsets = sorted((entry["offset"] for entry in sources.values()),
|
|
2862
|
+
reverse=True)
|
|
2863
|
+
noun = "source" if len(sources) == 1 else "sources"
|
|
2864
|
+
progress = ", ".join(str(offset) for offset in offsets) or "—"
|
|
2865
|
+
return truncate("%d %s, at %s"
|
|
2866
|
+
% (len(sources), noun, progress), 80)
|
|
2867
|
+
return "invalid cursor state"
|
|
2868
|
+
return "opaque cursor state"
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
def _status_preview(text):
|
|
2872
|
+
"""Clip only a display preview, preserving UTF-8 and delivery semantics."""
|
|
2873
|
+
encoded = text.encode("utf-8")
|
|
2874
|
+
if len(encoded) <= PAGE_BUDGET:
|
|
2875
|
+
return text
|
|
2876
|
+
marker = "\n(status preview ends here; the oversized next record remains unread)"
|
|
2877
|
+
marker_bytes = marker.encode("utf-8")
|
|
2878
|
+
prefix = encoded[:PAGE_BUDGET - len(marker_bytes)].decode(
|
|
2879
|
+
"utf-8", errors="ignore")
|
|
2880
|
+
return prefix + marker
|
|
2881
|
+
|
|
2882
|
+
|
|
1113
2883
|
def status():
|
|
1114
2884
|
cwd = project_dir()
|
|
1115
2885
|
print(f"project: {cwd}\n")
|
|
1116
2886
|
c = claude_transcripts(cwd)
|
|
1117
2887
|
x = codex_rollout_files(cwd)
|
|
1118
|
-
print(f"Claude transcripts: {len(c)
|
|
1119
|
-
print(f"Codex rollouts: {len(x)
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2888
|
+
print(f"Claude transcripts: {_file_count(len(c))}")
|
|
2889
|
+
print(f"Codex rollouts: {_file_count(len(x))}")
|
|
2890
|
+
# One snapshot for the channel line and the peer list both. Derived from the
|
|
2891
|
+
# registry when anything is registered: a named session serves its own
|
|
2892
|
+
# socket, so probing the project-wide path would report a working channel as
|
|
2893
|
+
# down. The path itself is never printed either way.
|
|
2894
|
+
live = _live_by_kind(cwd)
|
|
2895
|
+
channel = ("live" if live["claude"]
|
|
2896
|
+
else "live" if os.path.exists(claude_socket_path(cwd)) else "down")
|
|
2897
|
+
print(f"Claude channel: {channel}")
|
|
2898
|
+
for line in _peer_report(live):
|
|
2899
|
+
print(line)
|
|
2900
|
+
snapshots = {}
|
|
2901
|
+
by_path = {}
|
|
2902
|
+
for side in ("claude", "codex"):
|
|
2903
|
+
path = state_path(cwd, side)
|
|
2904
|
+
if path not in by_path:
|
|
2905
|
+
by_path[path] = _read_cursor_state(cwd, side)
|
|
2906
|
+
snapshots[side] = by_path[path]
|
|
2907
|
+
printed = set()
|
|
2908
|
+
distinct = len(by_path) > 1
|
|
2909
|
+
for side in ("claude", "codex"):
|
|
2910
|
+
path = state_path(cwd, side)
|
|
2911
|
+
if path in printed:
|
|
2912
|
+
continue
|
|
2913
|
+
printed.add(path)
|
|
2914
|
+
cursor, _state = snapshots[side]
|
|
2915
|
+
label = side + " " if distinct else ""
|
|
2916
|
+
for key, value in (cursor or {}).items():
|
|
2917
|
+
shown_key = (key if key in _STATUS_CURSOR_KEYS
|
|
2918
|
+
else "unknown cursor entry")
|
|
2919
|
+
print(f"cursor {label}{shown_key}: {_cursor_entry(key, value)}")
|
|
1129
2920
|
for side in ("claude", "codex"):
|
|
1130
|
-
|
|
1131
|
-
|
|
2921
|
+
cursor, cursor_state = snapshots[side]
|
|
2922
|
+
positions, since, replay_reason = positions_for(
|
|
2923
|
+
cursor, side, cursor_state)
|
|
2924
|
+
text, _, count = build_summary(
|
|
2925
|
+
cwd, side, positions, since, replay_reason)
|
|
1132
2926
|
print(f"\n=== what {side} would see ===")
|
|
1133
2927
|
if count:
|
|
1134
2928
|
print(notice_text(side, count))
|
|
1135
|
-
print(text
|
|
2929
|
+
print(_status_preview(text) if text else "(nothing new)")
|
|
1136
2930
|
return 0
|
|
1137
2931
|
|
|
1138
2932
|
|
|
1139
2933
|
def print_summary(side="claude"):
|
|
1140
2934
|
cwd = project_dir()
|
|
1141
|
-
text, _, _ = build_summary(cwd, side, time.time() - LOOKBACK)
|
|
2935
|
+
text, _, _ = build_summary(cwd, side, since=time.time() - LOOKBACK)
|
|
1142
2936
|
print(text or "(nothing new)")
|
|
1143
2937
|
return 0
|
|
1144
2938
|
|
|
1145
2939
|
|
|
1146
2940
|
COMMANDS = {
|
|
1147
2941
|
"setup": setup, "status": status, "hook": hook, "summary": print_summary,
|
|
1148
|
-
"push": push, "reply": reply, "mcp": mcp,
|
|
2942
|
+
"push": push, "reply": reply, "mcp": mcp, "register_peer": register_peer,
|
|
2943
|
+
"unregister_peer": unregister_peer,
|
|
1149
2944
|
# Legacy aliases for old local installs, kept during the transition period.
|
|
1150
2945
|
"kur": setup, "durum": status, "kanca": hook, "ozet": print_summary,
|
|
1151
2946
|
"it": push, "yanit": reply,
|