cc-session-control 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,444 @@
1
+ """Bridge-environment ledger — a PASSIVE, observe-and-forget store (R6, D4).
2
+
3
+ Claude Code keeps only the *current* bridge binding for a session/agent (a
4
+ single overwritten field), so toggled-away or historically minted cloud
5
+ environments vanish from on-disk state. csctl maintains its own append-only
6
+ ledger so those environments stay traceable enough to be deleted by hand on
7
+ claude.ai/code — there is NO local deregister, this module never deletes a
8
+ cloud environment.
9
+
10
+ Design invariants:
11
+ - **Passive store.** Callers push observations in (`upsert(records)`); this
12
+ module never reaches up to collect them. It must NOT import `rc`
13
+ (`environments` is below `rc` in the import DAG; `rc` calls `upsert` one-way
14
+ in Phase 5 for the `env_*` namespace). `observe()` is a convenience builder
15
+ that reads the lower-level `registry` (and accepts `env_*` records passed in,
16
+ never collected here).
17
+ - **Two observation tiers.** `observe()` is the bridge-truthy FILE-REFERENCED
18
+ set — what defines ledger MEMBERSHIP (an env exists in the cloud while any
19
+ on-disk file references it, alive or zombie). `observe_live()` alive-gates the
20
+ same sources for the CURRENT/bound DISPLAY. Orphans (manual-delete
21
+ candidates) are `ledger − file-referenced`: an env the ledger remembers but no
22
+ file references anymore (RC toggled off, job removed, server stopped).
23
+ - **Three namespaces, namespace-scoped dedup.** The merge key is
24
+ `(prefix, key)`: within `cse_*` a resume pair shares one suffix → one env;
25
+ `session_*` and `cse_*` never merge (their suffixes never coincide in
26
+ practice); `env_*` ids are opaque and each unique. Dedup is WITHIN a
27
+ namespace, never cross-view.
28
+ - **Write-on-change + atomic + single-writer.** The read-modify-write runs
29
+ under an advisory `flock` (degrades gracefully where unavailable), the
30
+ resulting ledger is serialized canonically and only rewritten when it
31
+ differs from disk, and the write is `tmp + os.replace` atomic.
32
+ - **Retention/compaction.** Entries older than `_RETENTION_SECONDS` (90d) are
33
+ dropped; if still over `_MAX_ENTRIES`, the most-recently-seen are kept. A
34
+ re-observed env always carries `last_seen == now` so it survives compaction.
35
+
36
+ Everything swallows errors → safe empties: a missing or corrupt ledger never
37
+ crashes (returns `[]` / no-ops).
38
+
39
+ Known limitation (capability red line): the ledger cannot back-fill
40
+ environments minted while csctl was not running — there is no `null`/history on
41
+ disk to recover them — so the orphan / manual-delete list is inherently
42
+ incomplete.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import contextlib
48
+ import json
49
+ import os
50
+ from collections.abc import Iterator
51
+ from dataclasses import replace
52
+ from typing import Any
53
+
54
+ from ..config import cfg
55
+ from ..models import AgentJob, BridgeEnv, EnvRecord, RCServer, SessionProc
56
+ from . import proc, registry
57
+
58
+ try: # POSIX advisory locking; absent on Windows → degrade to no lock.
59
+ import fcntl
60
+ except ImportError: # pragma: no cover - platform dependent
61
+ fcntl = None # type: ignore[assignment]
62
+
63
+ # Drop entries unseen for longer than this; cap total entries beyond that.
64
+ _RETENTION_SECONDS = 90 * 86400
65
+ _MAX_ENTRIES = 500
66
+
67
+
68
+ # --- bridge id parsing -----------------------------------------------------
69
+
70
+ def _split_bridge(bridge: str) -> tuple[str, str]:
71
+ """`cse_abc` -> (`cse`, `abc`); the suffix is the namespace-local env id."""
72
+ prefix, sep, suffix = bridge.partition("_")
73
+ if not sep:
74
+ return "", ""
75
+ return prefix, suffix
76
+
77
+
78
+ # --- observation builder (reads registry only, never rc) -------------------
79
+
80
+ def observe(
81
+ session_procs: list[SessionProc] | None = None,
82
+ agent_jobs: list[AgentJob] | None = None,
83
+ rc_servers: list[RCServer] | None = None,
84
+ max_age: float = 5.0,
85
+ ) -> list[EnvRecord]:
86
+ """FILE-REFERENCED bridge envs — the ledger MEMBERSHIP set (R6).
87
+
88
+ Every env an on-disk file references right now: a session's truthy
89
+ `bridgeSessionId` (`session_*`, alive OR zombie), a job's `cse_*` env suffix,
90
+ plus any `env_*` captured from the `rc_servers` passed in. This is the set
91
+ that DEFINES ledger membership — an env exists in the cloud as long as a file
92
+ references it, regardless of liveness. Contrast `observe_live()`, which
93
+ alive-gates the same sources for the CURRENT/bound display; orphans are
94
+ `ledger − file-referenced`.
95
+
96
+ Pure when the sources are supplied (snapshot path / tests); self-reads the
97
+ registry when they are None (CLI / no-snapshot fallback). `env_*` is only ever
98
+ passed in (this module never imports rc). Swallows errors → [].
99
+ """
100
+ try:
101
+ if session_procs is None:
102
+ session_procs = registry.read_session_procs(max_age=max_age)
103
+ if agent_jobs is None:
104
+ agent_jobs = registry.read_agent_jobs(max_age=max_age)
105
+ records: list[EnvRecord] = []
106
+ for sp in session_procs:
107
+ if not sp.bridge:
108
+ continue
109
+ prefix, key = _split_bridge(sp.bridge)
110
+ if prefix and key:
111
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
112
+ for job in agent_jobs:
113
+ if job.env_suffix:
114
+ records.append(
115
+ EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
116
+ )
117
+ for srv in rc_servers or []:
118
+ if srv.env_id:
119
+ prefix, sep, key = srv.env_id.partition("_")
120
+ if sep and prefix and key:
121
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
122
+ return records
123
+ except Exception:
124
+ return []
125
+
126
+
127
+ def observe_live(
128
+ session_procs: list[SessionProc] | None = None,
129
+ agent_jobs: list[AgentJob] | None = None,
130
+ rc_servers: list[RCServer] | None = None,
131
+ max_age: float = 5.0,
132
+ ) -> list[EnvRecord]:
133
+ """Alive-gated "currently exposed" bridge envs (R3/R6) — the CURRENT set.
134
+
135
+ Where `observe()` is a bridge-truthy passive collector, this lifts the
136
+ `_is_rc_exposed` predicate to the env ledger: a bridge is CURRENT only when
137
+ its owner is alive — `session_*` gated by proc-alive, `cse_*` by a proc-alive
138
+ session sharing the job sid (host-alive), `env_*` by a running RC server. So a
139
+ zombie session's stale `bridgeSessionId` is NOT reported as current — it falls
140
+ through to `orphan_envs` (a manual-delete candidate) instead of overstating
141
+ the bound count.
142
+
143
+ Pure when `session_procs`/`agent_jobs` are supplied (the snapshot path passes
144
+ its already-liveness-resolved data — DI for tests); reads the registry +
145
+ `/proc` itself when they are None (CLI / no-snapshot view fallback). Swallows
146
+ errors → [].
147
+ """
148
+ try:
149
+ if session_procs is None:
150
+ session_procs = [
151
+ replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
152
+ for sp in registry.read_session_procs(max_age=max_age)
153
+ ]
154
+ if agent_jobs is None:
155
+ agent_jobs = registry.read_agent_jobs(max_age=max_age)
156
+ alive_sids = {sp.sid for sp in session_procs if sp.proc_alive}
157
+ records: list[EnvRecord] = []
158
+ for sp in session_procs:
159
+ if sp.bridge and sp.proc_alive:
160
+ prefix, key = _split_bridge(sp.bridge)
161
+ if prefix and key:
162
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
163
+ for job in agent_jobs:
164
+ if job.env_suffix and (job.host_alive or job.sid in alive_sids):
165
+ records.append(
166
+ EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
167
+ )
168
+ for srv in rc_servers or []:
169
+ if srv.env_id and srv.status == "running":
170
+ prefix, sep, key = srv.env_id.partition("_")
171
+ if sep and prefix and key:
172
+ records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
173
+ return records
174
+ except Exception:
175
+ return []
176
+
177
+
178
+ # --- ledger IO (parse / serialize / atomic write / lock) -------------------
179
+
180
+ def _read_raw() -> str:
181
+ try:
182
+ with open(cfg.environments_ledger, errors="ignore") as fh:
183
+ return fh.read()
184
+ except Exception:
185
+ return ""
186
+
187
+
188
+ def _parse_ledger(text: str) -> dict[tuple[str, str], BridgeEnv]:
189
+ """Parse ledger text into a (prefix, key) -> BridgeEnv map. Skips bad lines.
190
+
191
+ Persisted `status` is ignored (recomputed against the live observation), so
192
+ only the five durable fields are read back.
193
+ """
194
+ out: dict[tuple[str, str], BridgeEnv] = {}
195
+ for line in text.splitlines():
196
+ line = line.strip()
197
+ if not line:
198
+ continue
199
+ try:
200
+ d = json.loads(line)
201
+ except Exception:
202
+ continue
203
+ prefix = d.get("prefix")
204
+ key = d.get("key")
205
+ if not prefix or not key:
206
+ continue
207
+ out[(prefix, key)] = BridgeEnv(
208
+ prefix=str(prefix),
209
+ key=str(key),
210
+ bound_sid=d.get("bound_sid"),
211
+ first_seen=_as_float(d.get("first_seen")),
212
+ last_seen=_as_float(d.get("last_seen")),
213
+ )
214
+ return out
215
+
216
+
217
+ def _as_float(v: Any) -> float:
218
+ try:
219
+ return float(v)
220
+ except (TypeError, ValueError):
221
+ return 0.0
222
+
223
+
224
+ def _serialize(entries: list[BridgeEnv]) -> str:
225
+ """Canonical JSONL (sorted by id, sorted keys) so write-on-change is exact."""
226
+ lines: list[str] = []
227
+ for e in sorted(entries, key=lambda e: (e.prefix, e.key)):
228
+ d = {
229
+ "prefix": e.prefix,
230
+ "key": e.key,
231
+ "bound_sid": e.bound_sid,
232
+ "first_seen": e.first_seen,
233
+ "last_seen": e.last_seen,
234
+ }
235
+ lines.append(json.dumps(d, sort_keys=True, ensure_ascii=False))
236
+ return ("\n".join(lines) + "\n") if lines else ""
237
+
238
+
239
+ def _atomic_write(text: str) -> None:
240
+ cfg.config_dir.mkdir(parents=True, exist_ok=True)
241
+ path = str(cfg.environments_ledger)
242
+ tmp = path + ".tmp"
243
+ with open(tmp, "w") as fh:
244
+ fh.write(text)
245
+ fh.flush()
246
+ os.fsync(fh.fileno())
247
+ os.replace(tmp, path)
248
+
249
+
250
+ @contextlib.contextmanager
251
+ def _write_lock() -> Iterator[None]:
252
+ """Advisory single-writer lock around a read-modify-write.
253
+
254
+ Uses a dedicated `.lock` file (never replaced) so the lock survives the
255
+ ledger's atomic `os.replace`. Degrades to no-op locking where `fcntl` is
256
+ unavailable (Windows) — the atomic rename still prevents a torn file.
257
+ """
258
+ fh = None
259
+ try:
260
+ cfg.config_dir.mkdir(parents=True, exist_ok=True)
261
+ fh = open(str(cfg.environments_ledger) + ".lock", "w")
262
+ if fcntl is not None:
263
+ try:
264
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
265
+ except Exception:
266
+ pass
267
+ yield
268
+ except Exception:
269
+ yield
270
+ finally:
271
+ if fh is not None:
272
+ if fcntl is not None:
273
+ try:
274
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
275
+ except Exception:
276
+ pass
277
+ fh.close()
278
+
279
+
280
+ # --- merge / compaction ----------------------------------------------------
281
+
282
+ def _dedup_records(records: list[EnvRecord]) -> list[EnvRecord]:
283
+ """Collapse records by (prefix, key) — within-namespace dedup (resume pairs).
284
+
285
+ Picks a deterministic canonical `bound_sid` (the smallest non-empty sid) so
286
+ the merge result is stable regardless of the observation order (registry
287
+ glob order is not sorted), keeping write-on-change from flapping.
288
+ """
289
+ grouped: dict[tuple[str, str], list[EnvRecord]] = {}
290
+ for r in records:
291
+ if not r.prefix or not r.key:
292
+ continue
293
+ grouped.setdefault((r.prefix, r.key), []).append(r)
294
+ out: list[EnvRecord] = []
295
+ for (prefix, key), recs in grouped.items():
296
+ sids = sorted({r.bound_sid for r in recs if r.bound_sid})
297
+ out.append(EnvRecord(prefix=prefix, key=key, bound_sid=sids[0] if sids else None))
298
+ return out
299
+
300
+
301
+ def _merge(
302
+ ledger: dict[tuple[str, str], BridgeEnv],
303
+ records: list[EnvRecord],
304
+ now: float,
305
+ ) -> dict[tuple[str, str], BridgeEnv]:
306
+ for rec in _dedup_records(records):
307
+ k = (rec.prefix, rec.key)
308
+ existing = ledger.get(k)
309
+ if existing is None:
310
+ ledger[k] = BridgeEnv(
311
+ prefix=rec.prefix,
312
+ key=rec.key,
313
+ bound_sid=rec.bound_sid,
314
+ first_seen=now,
315
+ last_seen=now,
316
+ )
317
+ else:
318
+ existing.bound_sid = rec.bound_sid
319
+ existing.last_seen = now
320
+ return ledger
321
+
322
+
323
+ def _membership(ledger: dict[tuple[str, str], BridgeEnv]) -> set[tuple[str, str, str | None, float]]:
324
+ """Durable identity of the ledger, EXCLUDING `last_seen` (M2).
325
+
326
+ Two ledgers with the same membership but different `last_seen` are
327
+ write-equivalent: re-observing the same envs must not rewrite the file just
328
+ because the clock advanced. `first_seen` and `bound_sid` ARE part of identity
329
+ (a new entry, a dropped entry, or a resume re-bind is a real change).
330
+ """
331
+ return {(e.prefix, e.key, e.bound_sid, e.first_seen) for e in ledger.values()}
332
+
333
+
334
+ def _compact(
335
+ ledger: dict[tuple[str, str], BridgeEnv], now: float
336
+ ) -> dict[tuple[str, str], BridgeEnv]:
337
+ cutoff = now - _RETENTION_SECONDS
338
+ kept = {k: e for k, e in ledger.items() if e.last_seen >= cutoff}
339
+ if len(kept) > _MAX_ENTRIES:
340
+ newest = sorted(kept.values(), key=lambda e: e.last_seen, reverse=True)
341
+ kept = {(e.prefix, e.key): e for e in newest[:_MAX_ENTRIES]}
342
+ return kept
343
+
344
+
345
+ # --- public API ------------------------------------------------------------
346
+
347
+ def upsert(records: list[EnvRecord], now: float | None = None) -> None:
348
+ """Merge observed env records into the ledger (passive store, R6/D4).
349
+
350
+ Sets `first_seen` on insert, advances `last_seen` to `now` on re-observation
351
+ (`now` injectable for deterministic tests), dedups within a namespace, and
352
+ compacts — all under an advisory lock, via an atomic `tmp + replace`.
353
+
354
+ Write-on-change ignores `last_seen` (M2): the file is rewritten only when the
355
+ MEMBERSHIP changes (an env added/dropped, a re-bind, a new `first_seen`) or
356
+ when the on-disk text is not already canonical (corrupt / legacy line cleanup).
357
+ A pure clock advance on otherwise-identical membership does NOT rewrite, so a
358
+ steady-state refresh cycle leaves the file (and its mtime) untouched. The
359
+ persisted copy still carries the advanced `last_seen` whenever a real write
360
+ happens. Swallows all errors.
361
+ """
362
+ import time
363
+
364
+ ts = time.time() if now is None else now
365
+ try:
366
+ with _write_lock():
367
+ old_text = _read_raw()
368
+ ledger = _parse_ledger(old_text)
369
+ # Snapshot identity BEFORE _merge mutates bound_sid/last_seen in place.
370
+ old_sig = _membership(ledger)
371
+ non_canonical = _serialize(list(ledger.values())) != old_text
372
+ ledger = _merge(ledger, records, ts)
373
+ ledger = _compact(ledger, ts)
374
+ if _membership(ledger) != old_sig or non_canonical:
375
+ _atomic_write(_serialize(list(ledger.values())))
376
+ except Exception:
377
+ return
378
+
379
+
380
+ def _read_ledger() -> dict[tuple[str, str], BridgeEnv]:
381
+ return _parse_ledger(_read_raw())
382
+
383
+
384
+ def current_envs(observed: list[EnvRecord]) -> list[BridgeEnv]:
385
+ """Envs bound to something observed right now (status='current').
386
+
387
+ Classifies the ledger against the observation. An observed env not yet in
388
+ the ledger (caller queried before `upsert`) is still reported current so the
389
+ result is correct regardless of call order. Sorted newest-seen first.
390
+ """
391
+ obs = {(r.prefix, r.key): r for r in observed if r.prefix and r.key}
392
+ ledger = _read_ledger()
393
+ out: list[BridgeEnv] = []
394
+ seen: set[tuple[str, str]] = set()
395
+ for k, env in ledger.items():
396
+ if k in obs:
397
+ env.status = "current"
398
+ out.append(env)
399
+ seen.add(k)
400
+ for k, rec in obs.items():
401
+ if k not in seen:
402
+ out.append(BridgeEnv(prefix=rec.prefix, key=rec.key,
403
+ bound_sid=rec.bound_sid, status="current"))
404
+ return sorted(out, key=lambda e: e.last_seen, reverse=True)
405
+
406
+
407
+ def orphan_envs(observed: list[EnvRecord]) -> list[BridgeEnv]:
408
+ """Ledger entries NOT in the current observation (status='orphan').
409
+
410
+ Pass the FILE-REFERENCED set (`observe()`) here so orphans are precisely
411
+ `ledger − file-referenced`: envs the ledger remembers but no on-disk file
412
+ references anymore (RC toggled off, job removed, server stopped). These are
413
+ the manual-delete candidates: csctl cannot deregister a cloud environment, so
414
+ the user removes them on claude.ai/code. Sorted newest-seen first.
415
+ (Inherently incomplete — see the module docstring's red line.)
416
+ """
417
+ obs_keys = {(r.prefix, r.key) for r in observed if r.prefix and r.key}
418
+ out: list[BridgeEnv] = []
419
+ for k, env in _read_ledger().items():
420
+ if k not in obs_keys:
421
+ env.status = "orphan"
422
+ out.append(env)
423
+ return sorted(out, key=lambda e: e.last_seen, reverse=True)
424
+
425
+
426
+ def manual_delete_list(observed: list[EnvRecord] | None = None) -> list[dict[str, Any]]:
427
+ """Orphans formatted for manual deletion on claude.ai/code (R6).
428
+
429
+ Each row carries the full namespaced `env_id` (incl. `env_*`), `prefix`,
430
+ `key`, last-known `bound_sid`, and `last_seen`. With no observation passed,
431
+ every ledger entry is treated as a candidate. There is NO deregister action
432
+ — this is a checklist for the human, observe-and-forget only.
433
+ """
434
+ orphans = orphan_envs(observed or [])
435
+ return [
436
+ {
437
+ "env_id": e.env_id,
438
+ "prefix": e.prefix,
439
+ "key": e.key,
440
+ "bound_sid": e.bound_sid,
441
+ "last_seen": e.last_seen,
442
+ }
443
+ for e in orphans
444
+ ]
@@ -0,0 +1,140 @@
1
+ """Session liveness — the single authority.
2
+
3
+ Owns the ONE module-global cache for `claude agents --json` (mirrored by the
4
+ `agents.py` re-export shim so terminate's invalidate and scan's read share it).
5
+ `live_index` is a pure merge of already-fetched liveness inputs (registry
6
+ session files with injected proc liveness + `claude agents --json`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ import time
14
+
15
+ from ..models import LiveInfo, SessionProc
16
+
17
+ _cache: dict[str, int | None] | None = None
18
+ _cache_time: float = 0.0
19
+
20
+
21
+ def alive_map(max_age: float = 5.0) -> dict[str, int | None]:
22
+ """Return {session_id: pid} for all known agents. Cached for max_age seconds."""
23
+ global _cache, _cache_time
24
+ now = time.monotonic()
25
+ if _cache is not None and (now - _cache_time) < max_age:
26
+ return _cache
27
+ try:
28
+ out = subprocess.run(
29
+ ["claude", "agents", "--json"],
30
+ capture_output=True, text=True, timeout=10,
31
+ ).stdout
32
+ result = {
33
+ a.get("sessionId"): a.get("pid")
34
+ for a in json.loads(out or "[]")
35
+ if a.get("sessionId")
36
+ }
37
+ except Exception:
38
+ result = {}
39
+ _cache = result
40
+ _cache_time = now
41
+ return result
42
+
43
+
44
+ def invalidate_cache() -> None:
45
+ global _cache
46
+ _cache = None
47
+
48
+
49
+ def _source_of(entrypoint: str, kind: str) -> str:
50
+ """Coarse source bucket from the registry entrypoint/kind (D9)."""
51
+ if kind == "bg":
52
+ return "bg"
53
+ if entrypoint == "claude-vscode":
54
+ return "vscode"
55
+ if entrypoint == "sdk-ts":
56
+ return "sdk"
57
+ return "cli"
58
+
59
+
60
+ def _is_rc_exposed(bridge: str | None, pid_alive: bool) -> bool:
61
+ """Whether session remote control is CURRENTLY exposed (pure predicate).
62
+
63
+ Exposed iff the bridge id is a truthy string AND the owning process is still
64
+ alive. This correctly collapses the three bridge states — key absent (None),
65
+ opened-then-closed (null/None, transient), and exposing (a `session_*`
66
+ string) — crossed with alive/dead. The single authority for "currently
67
+ exposed" (R3/AC3). No IO; inputs injected.
68
+ """
69
+ return bool(bridge) and pid_alive
70
+
71
+
72
+ def _start_key(proc_start: str) -> int:
73
+ try:
74
+ return int(proc_start)
75
+ except (TypeError, ValueError):
76
+ return -1
77
+
78
+
79
+ def live_index(
80
+ session_procs: list[SessionProc],
81
+ agents_map: dict[str, int | None],
82
+ ) -> dict[str, LiveInfo]:
83
+ """PURE merge of registry session files + `claude agents --json`.
84
+
85
+ Groups `session_procs` by sessionId (resume keeps the sid, mints a new pid),
86
+ picks the injected proc-alive pid (newest `procStart` when several), and
87
+ marks liveness. Falls back to `agents_map` when there is no proc-confirmed
88
+ runtime — on non-Linux all `proc_alive` values are False, so a sid present in
89
+ `agents_map` is still reported alive (degraded liveness). No IO; inputs are
90
+ injected.
91
+ """
92
+ index: dict[str, LiveInfo] = {}
93
+
94
+ by_sid: dict[str, list[SessionProc]] = {}
95
+ for sp in session_procs:
96
+ by_sid.setdefault(sp.sid, []).append(sp)
97
+
98
+ for sid, procs in by_sid.items():
99
+ alive_procs = [p for p in procs if p.proc_alive]
100
+ if alive_procs:
101
+ chosen = max(alive_procs, key=lambda p: _start_key(p.proc_start))
102
+ alive = True
103
+ # All alive pids, not just the newest — "current" must protect any
104
+ # ancestor pid of a resumed (multi-pid) sid.
105
+ pids = [p.pid for p in alive_procs]
106
+ else:
107
+ chosen = max(procs, key=lambda p: _start_key(p.proc_start))
108
+ alive = False
109
+ pids = []
110
+ index[sid] = LiveInfo(
111
+ sid=sid,
112
+ pid=chosen.pid if alive else None,
113
+ proc_start=chosen.proc_start,
114
+ status=chosen.status,
115
+ kind=chosen.kind,
116
+ entrypoint=chosen.entrypoint,
117
+ bridge=chosen.bridge,
118
+ source=_source_of(chosen.entrypoint, chosen.kind),
119
+ alive=alive,
120
+ proc_alive=alive,
121
+ pids=pids,
122
+ )
123
+
124
+ # `claude agents --json` is authoritative for liveness: it covers agent-only
125
+ # sids and rescues the degraded (no-/proc) path.
126
+ for sid, pid in agents_map.items():
127
+ if not sid:
128
+ continue
129
+ info = index.get(sid)
130
+ if info is None:
131
+ index[sid] = LiveInfo(
132
+ sid=sid, pid=pid, alive=True, pids=[pid] if pid else []
133
+ )
134
+ continue
135
+ info.alive = True
136
+ if info.pid is None:
137
+ info.pid = pid
138
+ if pid and pid not in info.pids:
139
+ info.pids.append(pid)
140
+ return index