antiphon 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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> # UserPromptSubmit hook (reads JSON from stdin)
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
- `UserPromptSubmit` hook contract (the same input fields, the same output
23
- wrapper), so a single `hook` function serves both.
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
- SUMMARY_BUDGET = 2600 # character budget for the injected summary
47
- EVENT_LIMIT = 40 # max events that go into the summary
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
- PUSH_MARKERS = {
54
- "codex": re.compile(r"^\s*@codex\b[:,]?\s*(.+)$", re.MULTILINE),
55
- "claude": re.compile(r"^\s*@claude\b[:,]?\s*(.+)$", re.MULTILINE),
56
- }
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 read_cursor(cwd):
104
- new_path = state_path(cwd)
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 (OSError, json.JSONDecodeError):
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
- def write_cursor(cwd, data):
118
- path = state_path(cwd)
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, start=0.0):
246
- """(time, type, text) type: you | claude | tool"""
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
- for path in claude_transcripts(cwd)[:3]:
249
- for line in tail_lines(path):
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
- continue
254
- if d.get("isMeta"):
255
- continue
256
- ts = iso_epoch(d.get("timestamp"))
257
- if ts <= start:
258
- continue
259
- kind = d.get("type")
260
- msg = d.get("message") or {}
261
- content = msg.get("content")
262
- if kind == "user":
263
- text = ""
264
- if isinstance(content, str):
265
- text = content
266
- elif isinstance(content, list):
267
- text = " ".join(c.get("text", "") for c in content
268
- if isinstance(c, dict) and c.get("type") == "text")
269
- text = text.strip()
270
- # tool results and system injections are not the user's own words
271
- if text and not text.startswith("<") and not _is_self_injected(text):
272
- events.append((ts, "you", text))
273
- elif kind == "assistant":
274
- for c in content if isinstance(content, list) else []:
275
- if c.get("type") == "text" and c.get("text", "").strip():
276
- events.append((ts, "claude", c["text"].strip()))
277
- elif c.get("type") == "tool_use":
278
- i = c.get("input") or {}
279
- detail = i.get("file_path") or i.get("command") or i.get("pattern") or ""
280
- events.append((ts, "tool", f"{c.get('name', '?')} {detail}".strip()))
281
- return sorted(events)
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, start=0.0):
338
- """(time, type, text) type: you | codex | tool"""
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
- for path in codex_rollout_files(cwd)[:3]:
341
- for line in tail_lines(path):
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
- continue
346
- ts = iso_epoch(d.get("timestamp"))
347
- if ts <= start:
348
- continue
349
- kind, p = d.get("type"), d.get("payload") or {}
350
- if kind == "response_item" and p.get("type") == "message":
351
- role = p.get("role")
352
- text = " ".join(
353
- c.get("text") or c.get("input_text") or ""
354
- for c in p.get("content") or [] if isinstance(c, dict)
355
- ).strip()
356
- if not text or role == "developer":
357
- continue
358
- if role == "user":
359
- if text.startswith("<") or _is_self_injected(text):
360
- continue
361
- events.append((ts, "you", text))
362
- elif role == "assistant":
363
- events.append((ts, "codex", text))
364
- elif kind == "event_msg" and p.get("type") == "exec_command_begin":
365
- cmd = p.get("command")
366
- if isinstance(cmd, list):
367
- cmd = " ".join(cmd)
368
- if cmd:
369
- events.append((ts, "tool", f"shell {cmd}"))
370
- return sorted(events)
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 build_summary(cwd, side, start=0.0):
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: (text, last_event_time, message_count). `message_count` doesn't
391
- count tool calls the notice shown in the terminal uses it."""
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(cwd, start)
1346
+ events, reached = codex_events(
1347
+ cwd, positions, since, visible_record_limit=EVENT_LIMIT + 1)
394
1348
  else:
395
- events = claude_events(cwd, start)
396
- other = OTHER_SIDE[side][1]
397
-
398
- if not events:
399
- return "", 0.0, 0
400
-
401
- events = events[-EVENT_LIMIT:]
402
- last_time = events[-1][0]
403
- count = sum(1 for _, kind, _ in events if kind != "tool")
404
-
405
- lines = []
406
- tools = []
407
- for ts, kind, text in events:
408
- if kind == "tool":
409
- tools.append(truncate(text, 70))
410
- continue
411
- if tools:
412
- lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
413
- tools = []
414
- clock = datetime.fromtimestamp(ts).strftime("%H:%M")
415
- lines.append(f"[{clock}] {LABEL.get(kind, kind)}: {truncate(text, 420)}")
416
- if tools:
417
- lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
418
-
419
- body = "\n".join(lines)
420
- truncated = False
421
- if len(body) > SUMMARY_BUDGET:
422
- # keep the newest; trim from the front
423
- while len(body) > SUMMARY_BUDGET and len(lines) > 1:
424
- lines.pop(0)
425
- body = "\n".join(lines)
426
- truncated = True
427
-
428
- head = f"## What happened on the {other} side (since your last turn)"
429
- foot = ("\nThis record belongs to the Antiphon bridge — this is what actually happened "
430
- "there. Do not assume anything that is not in it.")
431
- if truncated:
432
- foot = "\n(older lines were cut for budget)" + foot
433
- return f"{head}\n{body}{foot}", last_time, count
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
- """UserPromptSubmit hook: injects the other side's summary into the context.
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`) and the same
443
- output wrapper, so a single `hook` function serves both."""
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
- cursor = read_cursor(cwd)
454
- key = f"{side}_seen"
455
- start = float(cursor.get(key) or (time.time() - LOOKBACK))
456
- text, last, _ = build_summary(cwd, side, start)
457
- if text and last:
458
- cursor[key] = last
459
- write_cursor(cwd, cursor)
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
- if not text:
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 PUSH_MARKERS:
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
- messages = [m.strip() for m in PUSH_MARKERS[target].findall(reply_text) if m.strip()]
556
- if not messages:
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
- outgoing = "\n".join(messages)
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
- if ok:
576
- cursor[key] = outgoing
577
- write_cursor(cwd, cursor)
578
- print(f"antiphon: delivered to {target.title()} ({len(outgoing)} characters)",
579
- file=sys.stderr)
580
- else:
581
- print(f"antiphon: delivery failed — {detail}", file=sys.stderr)
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 send_to_codex(session, message):
586
- """Leaves a message with the running session via `codex queue`. Returns: (success, detail)."""
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 send_to_claude(cwd, text):
609
- """Sends a Codex message to Claude Code's MCP Channel socket."""
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": str(uuid.uuid4()),
1803
+ "message_id": message_id or delivery_id(),
1804
+ "sender_alias": sender_alias,
613
1805
  }
614
- last_error = None
615
- for path in (claude_socket_path(cwd),):
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
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
618
- sock.settimeout(5)
619
- sock.connect(path)
620
- sock.sendall(json.dumps(request, ensure_ascii=False).encode())
621
- sock.shutdown(socket.SHUT_WR)
622
- reply_bytes = b""
623
- while len(reply_bytes) < 64 * 1024:
624
- chunk = sock.recv(8192)
625
- if not chunk:
626
- break
627
- reply_bytes += chunk
628
- break
629
- except OSError as e:
630
- last_error = e
631
- else:
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"{last_error.strerror or type(last_error).__name__}")
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
- cwd = project_dir()
654
- session_id = codex_session_id(cwd)
655
- if not session_id:
656
- print("reply: no running Codex session found", file=sys.stderr)
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
- ok, detail = send_to_codex(session_id, f"{CHANNEL_LABEL} {text.strip()}")
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 your last turn. "
670
- "This normally arrives automatically via the hook, no extra effort "
671
- "required; this tool is the fallback call it by hand if you suspect "
672
- "the bridge has gone quiet."),
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
- sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result},
679
- ensure_ascii=False) + "\n")
680
- sys.stdout.flush()
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.1.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
- p = request.get("params") or {}
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
- cursor = read_cursor(cwd)
708
- start = float(cursor.get("codex_seen") or (time.time() - LOOKBACK))
709
- text, last, _ = build_summary(cwd, "codex", start)
710
- if text and last:
711
- cursor["codex_seen"] = last
712
- write_cursor(cwd, cursor)
713
- output = text or "Nothing new on the Claude Code side since your last turn."
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
- output = f"unknown tool: {name}"
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. If you suspect the bridge has gone "
733
- "quiet, you can call the `antiphon_read` tool by hand.\n\n"
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. When you want to hand "
738
- "Claude a task directly, put `@claude` at the start of a line in your reply; "
739
- "only that line is sent to the running Claude session as an MCP Channel event.\n")
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. Events "
744
- "that come directly from that agent are marked "
745
- "`<channel source=\"antiphon\" sender=\"codex\" sender_kind=\"agent\">`; they "
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.\n")
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 the UserPromptSubmit list; does nothing if it's already there.
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)} files" + (f" (newest: {os.path.basename(c[0])})" if c else " — none"))
1119
- print(f"Codex rollouts: {len(x)} files" + (f" (newest: {os.path.basename(x[0])})" if x else " — none"))
1120
- socket_path = claude_socket_path(cwd)
1121
- print(f"Claude channel: {'live' if os.path.exists(socket_path) else 'down'} ({socket_path})")
1122
- cursor = read_cursor(cwd)
1123
- for k, v in (cursor or {}).items():
1124
- if k.endswith("_seen") and isinstance(v, (int, float)):
1125
- shown = datetime.fromtimestamp(v).strftime('%H:%M:%S') if v else '—'
1126
- else:
1127
- shown = truncate(str(v), 80) if v else '—'
1128
- print(f"cursor {k}: {shown}")
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
- start = float((cursor or {}).get(f"{side}_seen") or (time.time() - LOOKBACK))
1131
- text, _, count = build_summary(cwd, side, start)
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 or "(nothing new)")
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,