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/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