antiphon 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +329 -0
- package/README.md +172 -22
- package/bin/antiphon.mjs +11 -0
- package/lib/antiphon.py +2042 -247
- package/lib/channel.mjs +323 -26
- package/lib/peers.py +689 -0
- package/package.json +20 -5
package/lib/peers.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Peer identity: naming, the socket key, and the registry both sides read.
|
|
2
|
+
|
|
3
|
+
A peer is one agent session working in one project directory. Antiphon assumed
|
|
4
|
+
exactly one per side and never said so; this module is the part that lets
|
|
5
|
+
several coexist without taking each other's sockets and cursors.
|
|
6
|
+
|
|
7
|
+
An explicit name is what buys isolation, and it is the only thing that does. A
|
|
8
|
+
session started without one occupies the reserved `UNNAMED` key below: it is
|
|
9
|
+
counted, it is served, and it cannot be addressed by name — which is exactly
|
|
10
|
+
what having no name means. There is one such peer per side per project, and a
|
|
11
|
+
second session that wants one finds the key taken. Nothing is ever invented on
|
|
12
|
+
a session's behalf; a name it did not choose is a name the other side could
|
|
13
|
+
address without the session having agreed to answer to it.
|
|
14
|
+
|
|
15
|
+
A Codex peer is written by two processes that never meet. The MCP server owns
|
|
16
|
+
`endpoint.json` and knows the pid; the hook owns `session.json` and knows the
|
|
17
|
+
session id, which is the address. Each writes its own file, so neither can lose
|
|
18
|
+
the other's fields, and `read_peers` joins the two on the owner key when it is
|
|
19
|
+
read. Anything that cannot be joined is listed as live and unroutable rather
|
|
20
|
+
than guessed at.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import fcntl
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
|
|
33
|
+
# Both sides are matched with `fullmatch`, never `match`: `$` also matches just
|
|
34
|
+
# before a trailing newline, so `re.match` accepted "ui\n" as a peer name and it
|
|
35
|
+
# would have gone straight into a file name and a socket seed.
|
|
36
|
+
KIND_PATTERN = re.compile(r"claude|codex")
|
|
37
|
+
# A pid and the start time that tells it from a recycled one, as `owner_key`
|
|
38
|
+
# below produces it. A bare pid is refused deliberately: it is the recycled
|
|
39
|
+
# number the start time exists to rule out.
|
|
40
|
+
OWNER_PATTERN = re.compile(r"[1-9][0-9]*:\S(?:.*\S)?")
|
|
41
|
+
# The canonical UUID a Codex session is named by, lowercase as the CLI writes it
|
|
42
|
+
# and as `antiphon.SESSION_ID` reads it back off a rollout file name. A contract
|
|
43
|
+
# test keeps the two spellings from drifting apart.
|
|
44
|
+
SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
45
|
+
r"[0-9a-f]{4}-[0-9a-f]{12}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def explicit_name():
|
|
49
|
+
"""The name set for this session, or "" when none was set."""
|
|
50
|
+
return (os.environ.get("ANTIPHON_NAME") or "").strip().lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# The registry key a peer with no name occupies. It is not a name: the angle
|
|
54
|
+
# brackets are outside the alias grammar, so nothing anyone can type or write in
|
|
55
|
+
# an `@claude:` marker can ever be it, and the two can never collide. It is the
|
|
56
|
+
# same spelling the visible label uses, because it is the same idea — one word
|
|
57
|
+
# for "this peer has no name", wherever that has to be said.
|
|
58
|
+
#
|
|
59
|
+
# The check against it is exact, never a prefix or a shape: `claude-abc` is a
|
|
60
|
+
# name somebody may deliberately choose, and inferring from the look of a name
|
|
61
|
+
# would take their alias away over a resemblance. An earlier version generated
|
|
62
|
+
# `claude-<3hex>` for an unnamed session, which was a real name in the registry
|
|
63
|
+
# for a peer that told the other side it had none — and a message addressed to
|
|
64
|
+
# that key resolved.
|
|
65
|
+
UNNAMED = "<unnamed>"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def valid_name(name):
|
|
69
|
+
"""Whether `name` is a public alias: what a person may type, what an
|
|
70
|
+
`@claude:` marker may carry, what a reply may be addressed to.
|
|
71
|
+
|
|
72
|
+
Both this and `valid_kind` are handed values that came out of JSON — a
|
|
73
|
+
tool argument, a marker, a record read off disk — so a non-string is
|
|
74
|
+
refused rather than passed to `fullmatch`, which raises on one."""
|
|
75
|
+
return isinstance(name, str) and bool(NAME_PATTERN.fullmatch(name))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def valid_key(kind, name):
|
|
79
|
+
"""Whether `name` may be this kind of peer's place in the registry.
|
|
80
|
+
|
|
81
|
+
Every public alias may, on either side. The reserved key may only on the
|
|
82
|
+
Claude side, because that is the only thing it represents: an unnamed
|
|
83
|
+
channel server, which registers because it serves a socket somebody has to
|
|
84
|
+
be able to find. An unnamed Codex session deliberately has no record at all
|
|
85
|
+
— that is exactly why one visible Codex peer cannot be shown to be the only
|
|
86
|
+
one running — so a record under this key there would be a live peer nobody
|
|
87
|
+
could ever name, and it would make every bare message ambiguous while being
|
|
88
|
+
unreachable itself.
|
|
89
|
+
|
|
90
|
+
Directory names and record fields are checked with this; addressing is
|
|
91
|
+
checked with `valid_name`, which is narrower still.
|
|
92
|
+
"""
|
|
93
|
+
return valid_name(name) or (kind == "claude" and name == UNNAMED)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def valid_kind(kind):
|
|
97
|
+
"""`kind` is concatenated into a directory name; unvalidated, `../..` walks
|
|
98
|
+
out of the project."""
|
|
99
|
+
return isinstance(kind, str) and bool(KIND_PATTERN.fullmatch(kind))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def valid_owner_key(key):
|
|
103
|
+
"""A key the registry is willing to record as an identity.
|
|
104
|
+
|
|
105
|
+
Anything else is refused rather than stored and ignored: a malformed key
|
|
106
|
+
would register cleanly and then join nothing, which looks like a peer that
|
|
107
|
+
simply never came back.
|
|
108
|
+
"""
|
|
109
|
+
return isinstance(key, str) and bool(OWNER_PATTERN.fullmatch(key))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def valid_session_id(value):
|
|
113
|
+
"""A canonical UUID and nothing else.
|
|
114
|
+
|
|
115
|
+
This becomes an address. An id that is not one routes a message at nothing
|
|
116
|
+
and does it silently, which is the whole failure this registry exists to
|
|
117
|
+
end. `fullmatch`, like every other pattern here: `$` also matches before a
|
|
118
|
+
trailing newline.
|
|
119
|
+
"""
|
|
120
|
+
return isinstance(value, str) and bool(SESSION_ID_PATTERN.fullmatch(value))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def socket_key(cwd, name=""):
|
|
124
|
+
"""Hashed, never appended: the path must not grow past the platform's limit.
|
|
125
|
+
|
|
126
|
+
An empty name reproduces the pre-multi-peer key byte for byte, so an unnamed
|
|
127
|
+
session keeps the socket it already has.
|
|
128
|
+
"""
|
|
129
|
+
base = os.path.abspath(cwd)
|
|
130
|
+
seed = base if not name else f"{base}\0{name}"
|
|
131
|
+
return hashlib.sha256(seed.encode()).hexdigest()[:20]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def peers_dir(cwd):
|
|
135
|
+
return os.path.join(cwd, ".antiphon", "peers")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def peer_dir(cwd, kind, name):
|
|
139
|
+
"""One directory per peer: its records and its cursor live together."""
|
|
140
|
+
return os.path.join(peers_dir(cwd), f"{kind}-{name}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _peer_file(cwd, kind, name):
|
|
144
|
+
"""The record written only by the process that owns the peer.
|
|
145
|
+
|
|
146
|
+
One file per writer: the hook writes `session.json` beside it, so the two
|
|
147
|
+
never read-modify-write the same document and cannot lose each other's
|
|
148
|
+
fields.
|
|
149
|
+
"""
|
|
150
|
+
return os.path.join(peer_dir(cwd, kind, name), "endpoint.json")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _session_file(cwd, kind, name):
|
|
154
|
+
"""The hook's record, beside the server's.
|
|
155
|
+
|
|
156
|
+
The server knows the pid and never the session id; the hook knows the
|
|
157
|
+
session id and must never claim a pid, having usually exited by the time
|
|
158
|
+
anyone reads it. Two files, one writer each.
|
|
159
|
+
"""
|
|
160
|
+
return os.path.join(peer_dir(cwd, kind, name), "session.json")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextlib.contextmanager
|
|
164
|
+
def _registry_lock(cwd):
|
|
165
|
+
"""Serializes every claim, refresh, prune and release in this project.
|
|
166
|
+
|
|
167
|
+
One lock for the whole registry rather than one per name: a claim has to
|
|
168
|
+
check the name *and* the address, and two claims holding different per-name
|
|
169
|
+
locks would not be serialized against each other at all. Contention is a
|
|
170
|
+
handful of sessions, so a single lock costs nothing and removes the ordering
|
|
171
|
+
problem entirely. It is not reentrant, so nothing called while it is held may
|
|
172
|
+
take it again.
|
|
173
|
+
"""
|
|
174
|
+
directory = peers_dir(cwd)
|
|
175
|
+
os.makedirs(directory, exist_ok=True)
|
|
176
|
+
fd = os.open(os.path.join(directory, ".lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
177
|
+
try:
|
|
178
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
179
|
+
yield
|
|
180
|
+
finally:
|
|
181
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
182
|
+
os.close(fd)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _read_record(path):
|
|
186
|
+
"""The record as a dict, or None. Valid JSON of the wrong shape is not a
|
|
187
|
+
record: a bare array used to raise out of `read_peers` on `.get`."""
|
|
188
|
+
try:
|
|
189
|
+
with open(path, encoding="utf-8") as f:
|
|
190
|
+
record = json.load(f)
|
|
191
|
+
except (OSError, ValueError):
|
|
192
|
+
return None
|
|
193
|
+
return record if isinstance(record, dict) else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _address_of(record):
|
|
197
|
+
"""A usable address, or None. An empty address is not a peer: stored, it made
|
|
198
|
+
the single-peer resolver fall back to the legacy socket without saying so."""
|
|
199
|
+
address = record.get("address") if hasattr(record, "get") else None
|
|
200
|
+
if not isinstance(address, str) or not address.strip():
|
|
201
|
+
return None
|
|
202
|
+
return address
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _owner_of(record):
|
|
206
|
+
"""The record's owner key, or None. Never its pid: they are two different
|
|
207
|
+
identities, and a reader that takes one for the other joins nothing."""
|
|
208
|
+
owner = record.get("owner") if hasattr(record, "get") else None
|
|
209
|
+
return owner if valid_owner_key(owner) else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _addressless(record):
|
|
213
|
+
"""True for the one shape that is live without being reachable.
|
|
214
|
+
|
|
215
|
+
A Codex server is handed a project directory and nothing else; the rollout
|
|
216
|
+
id it answers to arrives with the first message. Between those two moments
|
|
217
|
+
the peer exists and can be named, which is what an ambiguity refusal needs,
|
|
218
|
+
and it is stored with its address explicitly `None`.
|
|
219
|
+
|
|
220
|
+
Every other unusable address stays skipped — empty, blank, wrong type, or
|
|
221
|
+
absent altogether. Those say nothing about being on their way, and reading
|
|
222
|
+
silence as a claim is the guess this registry exists to refuse.
|
|
223
|
+
"""
|
|
224
|
+
return (record.get("kind") == "codex"
|
|
225
|
+
and record.get("address", "") is None
|
|
226
|
+
and _owner_of(record) is not None)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _session_address(cwd, peer):
|
|
230
|
+
"""The session id an addressless Codex endpoint answers to, or None.
|
|
231
|
+
|
|
232
|
+
The two halves are joined on the owner key and nothing else. A missing
|
|
233
|
+
session record, one with no owner, one from a different owner, and one whose
|
|
234
|
+
id is not a canonical UUID all read the same way: live, not routable. There
|
|
235
|
+
is no rule that reaches for the likeliest session, because reaching for the
|
|
236
|
+
likeliest is what the silent misrouting was.
|
|
237
|
+
|
|
238
|
+
`.get`, never `[...]`: a half-written record must not raise out of every
|
|
239
|
+
read of the registry. `name` is validated before it becomes a path — it
|
|
240
|
+
comes off disk, and `../..` would read a record from outside the project.
|
|
241
|
+
"""
|
|
242
|
+
kind, name = peer.get("kind"), peer.get("name")
|
|
243
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
244
|
+
return None
|
|
245
|
+
owner = _owner_of(peer)
|
|
246
|
+
session = _read_record(_session_file(cwd, kind, name))
|
|
247
|
+
if not (owner and session and session.get("owner") == owner):
|
|
248
|
+
return None
|
|
249
|
+
claimed = session.get("session_id")
|
|
250
|
+
return claimed if valid_session_id(claimed) else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _started_at(record):
|
|
254
|
+
"""The record's timestamp as a float, or 0. Sorting a float against a string
|
|
255
|
+
raises, and it would raise inside every read of the registry."""
|
|
256
|
+
try:
|
|
257
|
+
return float(record.get("started_at"))
|
|
258
|
+
except (AttributeError, TypeError, ValueError):
|
|
259
|
+
return 0.0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _pid_of(record):
|
|
263
|
+
"""A usable owner pid, or None when the record identifies nobody.
|
|
264
|
+
|
|
265
|
+
Anything that is not a positive integer names no process, so it cannot be
|
|
266
|
+
checked for liveness and must not hold a name hostage either.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
pid = int(record.get("pid"))
|
|
270
|
+
except (AttributeError, TypeError, ValueError):
|
|
271
|
+
return None
|
|
272
|
+
return pid if pid > 0 else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _birth_of(record):
|
|
276
|
+
"""The start time of the process the record was written for, or None.
|
|
277
|
+
|
|
278
|
+
None is two different histories with one correct reading. The record may
|
|
279
|
+
predate this field, or `ps` may have had nothing to say when the claim was
|
|
280
|
+
made. Neither is evidence that the pid has been recycled, so both fall back
|
|
281
|
+
to the liveness the registry has always used.
|
|
282
|
+
"""
|
|
283
|
+
birth = record.get("birth") if hasattr(record, "get") else None
|
|
284
|
+
if not isinstance(birth, str) or not birth.strip():
|
|
285
|
+
return None
|
|
286
|
+
return birth
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def alive(pid):
|
|
290
|
+
"""True if the process still exists. Signal 0 checks without delivering.
|
|
291
|
+
|
|
292
|
+
A process that has exited but not yet been reaped is a zombie and still
|
|
293
|
+
answers this, so a peer can read as live for the window before its parent
|
|
294
|
+
reaps it. The cost is a delivery attempt that fails loudly against a socket
|
|
295
|
+
nobody serves, never a silent misroute.
|
|
296
|
+
|
|
297
|
+
This answers "somebody holds that number", which is weaker than what any
|
|
298
|
+
caller here wants to know. `_record_alive` is what they ask; this is one
|
|
299
|
+
half of its answer.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
os.kill(int(pid), 0)
|
|
303
|
+
except (OSError, TypeError, ValueError):
|
|
304
|
+
return False
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _record_alive(record):
|
|
309
|
+
"""Whether the process this record was written for is the one still running.
|
|
310
|
+
|
|
311
|
+
Every liveness decision in the registry goes through here, because they are
|
|
312
|
+
all the same decision and one of them getting it wrong is enough. A pid is a
|
|
313
|
+
number the kernel hands out again; `owner_key` has always said so by pairing
|
|
314
|
+
a pid with a start time, and liveness used to contradict it by asking only
|
|
315
|
+
whether the number was in use. An endpoint that crashed without releasing
|
|
316
|
+
its claim therefore came back to life the moment its number was reassigned
|
|
317
|
+
to an unrelated process — holding an alias, holding an address, and standing
|
|
318
|
+
between a Codex session and the address it was entitled to.
|
|
319
|
+
|
|
320
|
+
Three readings, and only the last one is a corpse:
|
|
321
|
+
|
|
322
|
+
- no fingerprint in the record: it predates the field or its owner could not
|
|
323
|
+
be fingerprinted at registration. The pid alone, exactly as before.
|
|
324
|
+
- a fingerprint, and none readable now: `ps` failed, which is evidence of
|
|
325
|
+
nothing. Releasing a peer that may well be live over a lookup that could
|
|
326
|
+
not be made would trade a rare bug for a common one.
|
|
327
|
+
- a fingerprint, and a different one: the process this record names is gone
|
|
328
|
+
and its number belongs to somebody else. Dead, and prunable.
|
|
329
|
+
"""
|
|
330
|
+
pid = _pid_of(record)
|
|
331
|
+
if pid is None or not alive(pid):
|
|
332
|
+
return False
|
|
333
|
+
recorded = _birth_of(record)
|
|
334
|
+
if recorded is None:
|
|
335
|
+
return True
|
|
336
|
+
observed = _process_birth(pid)
|
|
337
|
+
return observed is None or observed == recorded
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _prune(cwd, kind, name, dead_pid):
|
|
341
|
+
"""Removes a dead peer's record, but only if it is still that peer's.
|
|
342
|
+
|
|
343
|
+
Re-read under the lock: between the unlocked read that spotted the corpse
|
|
344
|
+
and this call, a new owner may have claimed the name, and deleting its fresh
|
|
345
|
+
record would leave a live peer invisible. The directory stays, so a peer
|
|
346
|
+
returning under the same name finds its cursor where it left it.
|
|
347
|
+
"""
|
|
348
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
349
|
+
return
|
|
350
|
+
with _registry_lock(cwd):
|
|
351
|
+
held = _read_record(_peer_file(cwd, kind, name))
|
|
352
|
+
held_pid = _pid_of(held) if held else None
|
|
353
|
+
if held_pid is None or held_pid != dead_pid:
|
|
354
|
+
return
|
|
355
|
+
if _record_alive(held):
|
|
356
|
+
return
|
|
357
|
+
path = _peer_file(cwd, kind, name)
|
|
358
|
+
try:
|
|
359
|
+
os.unlink(path)
|
|
360
|
+
except OSError:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _scan(cwd):
|
|
365
|
+
"""Every readable record that agrees with the directory holding it.
|
|
366
|
+
|
|
367
|
+
Unlocked and unpruned; safe to call under the lock.
|
|
368
|
+
|
|
369
|
+
The directory is a peer's real identity — it is where every writer for that
|
|
370
|
+
peer puts its files. The `kind` and `name` inside a record are what the rest
|
|
371
|
+
of this module builds paths and decisions from, so a record claiming a name
|
|
372
|
+
other than its own directory's would send the session join looking inside
|
|
373
|
+
another peer, and report an address for an endpoint that does not exist. A
|
|
374
|
+
record that disagrees with where it lives is not read at all.
|
|
375
|
+
|
|
376
|
+
Split at the first hyphen only: neither kind contains one, so everything
|
|
377
|
+
after it belongs to the alias and `codex-my-build` keeps its name.
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
entries = sorted(os.listdir(peers_dir(cwd)))
|
|
381
|
+
except OSError:
|
|
382
|
+
return []
|
|
383
|
+
records = []
|
|
384
|
+
for entry in entries:
|
|
385
|
+
kind, _, name = entry.partition("-")
|
|
386
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
387
|
+
continue
|
|
388
|
+
record = _read_record(os.path.join(peers_dir(cwd), entry, "endpoint.json"))
|
|
389
|
+
if record is None:
|
|
390
|
+
continue
|
|
391
|
+
if record.get("kind") != kind or record.get("name") != name:
|
|
392
|
+
continue
|
|
393
|
+
records.append(record)
|
|
394
|
+
return records
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def read_peers(cwd, kind=None):
|
|
398
|
+
"""Live peers, newest first. Records left by dead processes are removed.
|
|
399
|
+
|
|
400
|
+
Live is `_record_alive`, not a signal to a pid: a record whose process is
|
|
401
|
+
gone is still removed when its number has been handed to somebody else.
|
|
402
|
+
|
|
403
|
+
Live is not the same as reachable. A Codex peer that has not been given its
|
|
404
|
+
address yet is listed with `address` set to `None`, because a peer nobody
|
|
405
|
+
can name is a peer an ambiguity refusal cannot mention. This is the single
|
|
406
|
+
public reading of the registry, so every caller that intends to *deliver*
|
|
407
|
+
something has to check the address it got rather than assume one.
|
|
408
|
+
|
|
409
|
+
It is also where the two halves of a Codex peer are joined: an addressless
|
|
410
|
+
endpoint takes the session id its own hook recorded, and only its own. The
|
|
411
|
+
join happens on the way out rather than at either write, so neither writer
|
|
412
|
+
ever has to read the other's file.
|
|
413
|
+
|
|
414
|
+
A record that cannot be parsed is skipped rather than raised: a half-written
|
|
415
|
+
entry must never take the bridge down with it.
|
|
416
|
+
"""
|
|
417
|
+
found = []
|
|
418
|
+
for peer in _scan(cwd):
|
|
419
|
+
peer_pid = _pid_of(peer)
|
|
420
|
+
if peer_pid is None:
|
|
421
|
+
continue # identifies nobody; not a peer, not prunable
|
|
422
|
+
if _address_of(peer) is None and not _addressless(peer):
|
|
423
|
+
continue # reaches nobody, and is not on its way
|
|
424
|
+
if not _record_alive(peer):
|
|
425
|
+
_prune(cwd, peer.get("kind"), peer.get("name"), peer_pid)
|
|
426
|
+
continue
|
|
427
|
+
if kind is None or peer.get("kind") == kind:
|
|
428
|
+
if _addressless(peer):
|
|
429
|
+
peer["address"] = _session_address(cwd, peer)
|
|
430
|
+
found.append(peer)
|
|
431
|
+
found.sort(key=_started_at, reverse=True)
|
|
432
|
+
return found
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def register(cwd, kind, name, address, pid=None, owner_key=None):
|
|
436
|
+
"""Claims `name` for `pid`. Returns (ok, detail).
|
|
437
|
+
|
|
438
|
+
`pid` is the process whose life the peer's life follows, and it is often not
|
|
439
|
+
the caller. `channel.mjs` registers by shelling out to a short-lived Python
|
|
440
|
+
subprocess; recording that subprocess's pid would mark the peer dead the
|
|
441
|
+
instant the call returned.
|
|
442
|
+
|
|
443
|
+
The whole read-check-write runs under an exclusive `flock`. An `O_EXCL`
|
|
444
|
+
create is not enough on its own: it makes an *empty* file visible before the
|
|
445
|
+
record is written, so a second claimant reads nothing, concludes the record
|
|
446
|
+
is unowned, and takes it. Measured — two racing claimants both won every
|
|
447
|
+
time with `O_EXCL` and exactly one wins under the lock. The bridge is
|
|
448
|
+
Unix-only already, so a lock file costs nothing in portability.
|
|
449
|
+
|
|
450
|
+
`owner_key` is the session two writers share, and it is kept strictly apart
|
|
451
|
+
from `pid`, which is the process whose life the record follows. The
|
|
452
|
+
parameter is not called `owner` because that was already the local holding
|
|
453
|
+
the resolved pid; the two would have shadowed each other and written a
|
|
454
|
+
number where the join expects a key. The local is `owner_pid` now, and the
|
|
455
|
+
field the record stores the key under is `owner`.
|
|
456
|
+
|
|
457
|
+
A `None` address is accepted from a Codex peer that has a valid owner key,
|
|
458
|
+
and from nothing else. It is stored as `None` rather than as a sentinel: a
|
|
459
|
+
`"pending"` string would be an address as far as the collision check is
|
|
460
|
+
concerned, and the second Codex server would be refused for one the first
|
|
461
|
+
does not really serve.
|
|
462
|
+
|
|
463
|
+
There is deliberately no `transcript` parameter. That field belongs to
|
|
464
|
+
`session.json`, whose only writer is the hook; accepting it here would invite
|
|
465
|
+
exactly the cross-writer overwrite the split exists to prevent.
|
|
466
|
+
"""
|
|
467
|
+
if not valid_kind(kind):
|
|
468
|
+
return False, f"invalid peer kind {kind!r}: expected 'claude' or 'codex'"
|
|
469
|
+
if not valid_key(kind, name):
|
|
470
|
+
return False, (f"invalid peer name {name!r} for a {kind} peer: "
|
|
471
|
+
"expected [a-z0-9][a-z0-9_-]{0,31}")
|
|
472
|
+
if address is None:
|
|
473
|
+
if kind != "codex":
|
|
474
|
+
return False, (f"invalid peer address {address!r}: only a Codex peer "
|
|
475
|
+
"may register before it has one")
|
|
476
|
+
if not valid_owner_key(owner_key):
|
|
477
|
+
return False, (f"invalid peer address {address!r}: omitting it takes a "
|
|
478
|
+
f"valid owner key, got {owner_key!r}")
|
|
479
|
+
elif _address_of({"address": address}) is None:
|
|
480
|
+
return False, f"invalid peer address {address!r}: expected a non-empty string"
|
|
481
|
+
if owner_key is not None and not valid_owner_key(owner_key):
|
|
482
|
+
return False, (f"invalid owner key {owner_key!r}: expected a pid and the "
|
|
483
|
+
"start time that tells it from a recycled one")
|
|
484
|
+
owner_pid = _pid_of({"pid": pid}) if pid is not None else os.getpid()
|
|
485
|
+
if owner_pid is None:
|
|
486
|
+
return False, f"invalid owner pid {pid!r}: expected a positive integer"
|
|
487
|
+
# Observed here and taken from nowhere else. There is no parameter for it
|
|
488
|
+
# and no field of the payload reaches it: a fingerprint a caller could hand
|
|
489
|
+
# in is a stale record vouching for itself, which is the one claim the
|
|
490
|
+
# comparison exists to disbelieve. Read before the lock — `ps` is a
|
|
491
|
+
# subprocess, and nothing about it needs the registry held still.
|
|
492
|
+
birth = _process_birth(owner_pid)
|
|
493
|
+
with _registry_lock(cwd):
|
|
494
|
+
for other in _scan(cwd):
|
|
495
|
+
other_pid = _pid_of(other)
|
|
496
|
+
if other_pid is None or not _record_alive(other):
|
|
497
|
+
continue
|
|
498
|
+
if other.get("kind") != kind:
|
|
499
|
+
continue # a rollout id and a socket path never collide
|
|
500
|
+
if other.get("name") == name:
|
|
501
|
+
if other_pid == owner_pid:
|
|
502
|
+
continue # this process refreshing its own record
|
|
503
|
+
if kind == "codex" and owner_key and _owner_of(other) == owner_key:
|
|
504
|
+
# Codex can bring up a second MCP server for one CLI session
|
|
505
|
+
# before the first has exited. Judged by pid alone the
|
|
506
|
+
# newcomer looks like an intruder, and the session locks
|
|
507
|
+
# itself out of its own name until its predecessor is
|
|
508
|
+
# reaped. A shared key excuses a differing pid; a dead owner
|
|
509
|
+
# is already gone above, so it never excuses a missing
|
|
510
|
+
# process.
|
|
511
|
+
#
|
|
512
|
+
# Codex only, on purpose. A Claude endpoint is a socket
|
|
513
|
+
# this process is serving, and two channel servers under one
|
|
514
|
+
# CLI root would otherwise let the second overwrite the
|
|
515
|
+
# first's record while the first's socket is still the one
|
|
516
|
+
# answering — the registry would then describe a server
|
|
517
|
+
# nobody reaches. A rollout id is not owned that way.
|
|
518
|
+
continue
|
|
519
|
+
return False, f"peer name {name!r} is already held by pid {other_pid}"
|
|
520
|
+
if address is not None and _address_of(other) == address:
|
|
521
|
+
# The contended resource is the address, not the name, and the
|
|
522
|
+
# two races are different. Two sessions under one alias are
|
|
523
|
+
# caught above; this catches two *different* aliases carrying
|
|
524
|
+
# one address — a Codex session registering a second name
|
|
525
|
+
# against its own rollout, or a hand-written record — where the
|
|
526
|
+
# registry would show two peers while a message addressed to
|
|
527
|
+
# either reached whichever actually held it.
|
|
528
|
+
return False, (f"address {address!r} is already served by peer "
|
|
529
|
+
f"{other.get('name')!r} (pid {other_pid})")
|
|
530
|
+
path = _peer_file(cwd, kind, name)
|
|
531
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
532
|
+
record = {"kind": kind, "name": name, "pid": owner_pid,
|
|
533
|
+
"address": address, "started_at": time.time()}
|
|
534
|
+
if owner_key:
|
|
535
|
+
record["owner"] = owner_key
|
|
536
|
+
if birth:
|
|
537
|
+
# Kept apart from `started_at`, which is when the claim was made and
|
|
538
|
+
# is what the listing sorts on. This is when the process was born,
|
|
539
|
+
# and it is the half of its identity the number does not carry.
|
|
540
|
+
record["birth"] = birth
|
|
541
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
542
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
543
|
+
json.dump(record, f, ensure_ascii=False)
|
|
544
|
+
os.replace(tmp, path)
|
|
545
|
+
return True, ""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def unregister(cwd, kind, name, pid=None):
|
|
549
|
+
"""Releases a name, but only if this owner still holds it."""
|
|
550
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
551
|
+
return
|
|
552
|
+
owner = _pid_of({"pid": pid}) if pid is not None else os.getpid()
|
|
553
|
+
if owner is None:
|
|
554
|
+
return
|
|
555
|
+
with _registry_lock(cwd):
|
|
556
|
+
path = _peer_file(cwd, kind, name)
|
|
557
|
+
held = _read_record(path)
|
|
558
|
+
held_pid = _pid_of(held) if held else None
|
|
559
|
+
if held_pid is not None and held_pid != owner:
|
|
560
|
+
return
|
|
561
|
+
try:
|
|
562
|
+
os.unlink(path)
|
|
563
|
+
except OSError:
|
|
564
|
+
pass
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def read_session(cwd, kind, name):
|
|
568
|
+
"""The hook's record for an alias as a dict, or None."""
|
|
569
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
570
|
+
return None
|
|
571
|
+
return _read_record(_session_file(cwd, kind, name))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def write_session(cwd, kind, name, session_id, transcript, owner):
|
|
575
|
+
"""Records which session is behind an alias. Returns (ok, detail).
|
|
576
|
+
|
|
577
|
+
Refuses when a live endpoint holds the alias for a different owner, and
|
|
578
|
+
touches nothing at all in that case: the session that got there first keeps
|
|
579
|
+
working and this one is told. The guard is on the **endpoint**, not on any
|
|
580
|
+
session record already present. A guard that compared session owners could
|
|
581
|
+
only refuse a second owner once the first one's hook had run, and a server
|
|
582
|
+
that has registered without an id yet is precisely the peer that is live and
|
|
583
|
+
about to become routable.
|
|
584
|
+
|
|
585
|
+
An alias no endpoint holds is writable: the hook can fire before the server
|
|
586
|
+
registers, and the record simply waits. It is not a peer until an endpoint
|
|
587
|
+
appears — the registry is listed from `endpoint.json`, so a session record
|
|
588
|
+
on its own describes nobody.
|
|
589
|
+
|
|
590
|
+
No pid is written. The hook has usually exited by the time anyone reads
|
|
591
|
+
this, and a pid it left behind would mark the peer dead on the next read.
|
|
592
|
+
"""
|
|
593
|
+
if not (valid_kind(kind) and valid_key(kind, name)):
|
|
594
|
+
return False, f"invalid peer {kind!r}/{name!r}"
|
|
595
|
+
if not valid_session_id(session_id):
|
|
596
|
+
return False, (f"invalid session id {session_id!r}: expected a canonical "
|
|
597
|
+
"UUID")
|
|
598
|
+
if not valid_owner_key(owner):
|
|
599
|
+
return False, (f"invalid owner key {owner!r}: expected a pid and the "
|
|
600
|
+
"start time that tells it from a recycled one")
|
|
601
|
+
with _registry_lock(cwd):
|
|
602
|
+
endpoint = _read_record(_peer_file(cwd, kind, name))
|
|
603
|
+
if endpoint and _owner_of(endpoint) != owner and _record_alive(endpoint):
|
|
604
|
+
return False, (f"alias {name!r} is held by another live {kind} session "
|
|
605
|
+
f"(pid {_pid_of(endpoint)}); its record was not touched")
|
|
606
|
+
record = {"kind": kind, "name": name, "owner": owner,
|
|
607
|
+
"session_id": session_id}
|
|
608
|
+
if isinstance(transcript, str) and transcript.strip():
|
|
609
|
+
# Nothing is ever delivered to a transcript path. Refusing the whole
|
|
610
|
+
# record over a missing one would cost the session its address for a
|
|
611
|
+
# field no message travels through.
|
|
612
|
+
record["transcript"] = transcript
|
|
613
|
+
path = _session_file(cwd, kind, name)
|
|
614
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
615
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
616
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
617
|
+
json.dump(record, f, ensure_ascii=False)
|
|
618
|
+
os.replace(tmp, path)
|
|
619
|
+
return True, ""
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------- owner key: pairing two writers on one session ----------
|
|
623
|
+
|
|
624
|
+
CLI_ROOTS = ("claude", "codex")
|
|
625
|
+
MAX_ANCESTRY = 8
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _process_info(pid):
|
|
629
|
+
"""(ppid, start time, command) for a live pid, or None.
|
|
630
|
+
|
|
631
|
+
Separated from the walk so tests can drive it without building real process
|
|
632
|
+
trees. The start time is `ps`'s `lstart`, a fixed 24 characters wide.
|
|
633
|
+
"""
|
|
634
|
+
try:
|
|
635
|
+
out = subprocess.run(["ps", "-o", "ppid=,lstart=,command=", "-p", str(pid)],
|
|
636
|
+
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
637
|
+
except (OSError, subprocess.SubprocessError):
|
|
638
|
+
return None
|
|
639
|
+
if not out:
|
|
640
|
+
return None
|
|
641
|
+
try:
|
|
642
|
+
ppid, rest = out.split(None, 1)
|
|
643
|
+
except ValueError:
|
|
644
|
+
return None
|
|
645
|
+
return ppid, rest[:24].strip(), rest[24:].strip()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _process_birth(pid):
|
|
649
|
+
"""The start time `ps` reports for `pid` itself, or None when it has none.
|
|
650
|
+
|
|
651
|
+
The same reading `owner_key` builds its key from, asked about one process
|
|
652
|
+
instead of a walk: two processes that hold one number in turn were born at
|
|
653
|
+
different moments, and that difference is all the registry needs to tell
|
|
654
|
+
them apart. Seconds of resolution is the resolution `owner_key` already
|
|
655
|
+
trusts for the same purpose.
|
|
656
|
+
"""
|
|
657
|
+
info = _process_info(pid)
|
|
658
|
+
return (info[1] or None) if info else None
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def owner_key(pid=None):
|
|
662
|
+
"""`"<root pid>:<start time>"` for the CLI session above `pid`, or None.
|
|
663
|
+
|
|
664
|
+
On the Codex side no single process knows which session it belongs to: the
|
|
665
|
+
hook is handed a session id and exits, and the long-lived server is handed
|
|
666
|
+
only a project directory. They pair up by walking to the same CLI process.
|
|
667
|
+
|
|
668
|
+
The start time is part of the key because a pid alone is recycled, and a
|
|
669
|
+
recycled pid matching the wrong session is exactly the silent
|
|
670
|
+
misidentification this refuses to make. For the same reason there is no
|
|
671
|
+
environment override: a key anyone could set would let one session claim
|
|
672
|
+
another's identity.
|
|
673
|
+
|
|
674
|
+
None means no key, which means fall back to what the bridge does today. It
|
|
675
|
+
never returns a best guess.
|
|
676
|
+
"""
|
|
677
|
+
current = str(pid or os.getpid())
|
|
678
|
+
for _ in range(MAX_ANCESTRY):
|
|
679
|
+
info = _process_info(current)
|
|
680
|
+
if not info:
|
|
681
|
+
return None
|
|
682
|
+
parent, start, command = info
|
|
683
|
+
head = os.path.basename((command.split() or [""])[0])
|
|
684
|
+
if head in CLI_ROOTS:
|
|
685
|
+
return f"{current}:{start}"
|
|
686
|
+
if parent in ("0", "1", current):
|
|
687
|
+
return None
|
|
688
|
+
current = parent
|
|
689
|
+
return None
|