pairling 0.2.10 → 0.2.12

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.
@@ -456,10 +456,22 @@ AUTH_RESULT_CACHE_SECONDS = max(0.0, float(os.environ.get("PAIRLING_AUTH_RESULT_
456
456
  AUTH_RESULT_CACHE_MAX = max(16, int(os.environ.get("PAIRLING_AUTH_RESULT_CACHE_MAX", "512")))
457
457
  RUNTIME_MAX_ACTIVE_FAST_REQUESTS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_FAST_REQUESTS", "4")))
458
458
  RUNTIME_MAX_ACTIVE_REQUESTS = max(4, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_REQUESTS", "12")))
459
- RUNTIME_MAX_ACTIVE_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_STREAMS", "6")))
459
+ RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS", "8")))
460
+ RUNTIME_MAX_ACTIVE_STREAMS = max(2, int(os.environ.get(
461
+ "PAIRLING_RUNTIME_MAX_ACTIVE_DETAIL_STREAMS",
462
+ os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_STREAMS", "6"),
463
+ )))
464
+ RUNTIME_MAX_ACTIVE_AUX_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_AUX_STREAMS", "4")))
460
465
  RUNTIME_MAX_ACTIVE_CONNECTIONS = max(
461
- RUNTIME_MAX_ACTIVE_FAST_REQUESTS + RUNTIME_MAX_ACTIVE_REQUESTS + RUNTIME_MAX_ACTIVE_STREAMS + 2,
462
- int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_CONNECTIONS", "24")),
466
+ (
467
+ RUNTIME_MAX_ACTIVE_FAST_REQUESTS
468
+ + RUNTIME_MAX_ACTIVE_REQUESTS
469
+ + RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS
470
+ + RUNTIME_MAX_ACTIVE_STREAMS
471
+ + RUNTIME_MAX_ACTIVE_AUX_STREAMS
472
+ + 2
473
+ ),
474
+ int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_CONNECTIONS", "28")),
463
475
  )
464
476
  TERMINAL_TRUTH_OSASCRIPT_TIMEOUT_SECONDS = max(
465
477
  0.5,
@@ -469,9 +481,6 @@ TERMINAL_SURFACE_V2_NONCE_SALT = os.urandom(16).hex()
469
481
  LAST_HUMAN_ACTIVITY_AT = 0.0
470
482
  DAEMON_STARTED_AT = _time.time()
471
483
  BOUND_HOST = ""
472
- POWER_STATE_PATH = Path(os.environ.get("COMPANION_POWER_STATE_PATH", "/var/run/pairling-power-state.json"))
473
- POWER_STATE_FALLBACK_PATH = Path(os.environ.get("COMPANION_POWER_STATE_FALLBACK_PATH", "/tmp/pairling-power-state.json"))
474
- POWER_STATE_STALE_SECONDS = 90
475
484
  DAEMON_VERSION = "2026-05-07"
476
485
 
477
486
  _sessions_health_lock = threading.Lock()
@@ -553,6 +562,7 @@ def _clean_terminal_display_text(text: str) -> str:
553
562
 
554
563
  _HEALTH_PROBE_CACHE_SECONDS = 30.0
555
564
  _HEALTH_PAYLOAD_CACHE_SECONDS = 5.0
565
+ REQUEST_READ_TIMEOUT_SECONDS = 15.0
556
566
  _health_probe_cache_lock = threading.Lock()
557
567
  _health_probe_cache: dict[str, tuple[float, object]] = {}
558
568
  _health_payload_cache_lock = threading.Lock()
@@ -564,7 +574,9 @@ _auth_result_cache_lock = threading.Lock()
564
574
  _auth_result_cache: dict[tuple, tuple[float, object]] = {}
565
575
  _FAST_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_FAST_REQUESTS)
566
576
  _REQUEST_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_REQUESTS)
577
+ _DASHBOARD_STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS)
567
578
  _STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_STREAMS)
579
+ _AUX_STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_AUX_STREAMS)
568
580
  _CONNECTION_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_CONNECTIONS)
569
581
  _STREAM_ENDPOINTS = {
570
582
  "/health-stream",
@@ -582,7 +594,26 @@ _STREAM_ENDPOINTS = {
582
594
  "/turn-state-stream",
583
595
  "/llm-route-stream",
584
596
  }
585
- _FAST_ENDPOINTS = {"/health", "/healthz", "/readyz", "/routez", "/power-state", "/manifest"}
597
+ _DASHBOARD_STREAM_ENDPOINTS = {
598
+ "/health-stream",
599
+ "/sessions-stream",
600
+ }
601
+ _AUX_STREAM_ENDPOINTS = {
602
+ "/activity-stream",
603
+ "/commands-stream",
604
+ "/invocations-stream",
605
+ "/llm-route-stream",
606
+ }
607
+ _FAST_ENDPOINTS = {"/health", "/healthz", "/readyz", "/routez", "/manifest"}
608
+
609
+
610
+ def _is_orchestration_stream_path(path: str) -> bool:
611
+ prefix = f"{ORCHESTRATIONS_ROUTE}/"
612
+ suffix = "/stream"
613
+ if not path.startswith(prefix) or not path.endswith(suffix):
614
+ return False
615
+ inner = path[len(prefix):-len(suffix)]
616
+ return bool(inner) and "/" not in inner
586
617
 
587
618
 
588
619
  class _RuntimeAdmission:
@@ -912,12 +943,8 @@ def _pairdrop_gateway_provenance_ok(headers, client_address=None) -> bool:
912
943
  header_ok = str(getter("X-Pairling-Connect-Gateway", "") or "") == "pairling-connectd"
913
944
  if not header_ok:
914
945
  return False
915
- # The gateway header alone is spoofable, so in the default loopback-only bind
916
- # also require the loopback hop from connectd. Under PAIRLING_BIND_MODE=all a
917
- # direct-LAN client is non-loopback, so waive the loopback requirement there
918
- # to avoid breaking that path.
919
- if os.environ.get("PAIRLING_BIND_MODE", "loopback").strip().lower() == "all":
920
- return True
946
+ # The gateway header alone is spoofable. The trusted connectd hop is loopback,
947
+ # even when connectd received the original request over a tailnet route.
921
948
  return _loopback_client_address(client_address)
922
949
 
923
950
 
@@ -957,7 +984,7 @@ def _required_scopes_for_request(path: str, method: str) -> set[str]:
957
984
  return {"files:write"}
958
985
  if _is_pairdrop_upload_path(path):
959
986
  return {"files:write"}
960
- if path in {"/health", "/healthz", "/readyz", "/routez", "/power-state", "/health-stream", "/provider-status", "/status", "/aperture-cli/status", "/aperture-cli/providers", "/aperture-cli/launch-contexts"}:
987
+ if path in {"/health", "/healthz", "/readyz", "/routez", "/health-stream", "/provider-status", "/status", "/aperture-cli/status", "/aperture-cli/providers", "/aperture-cli/launch-contexts"}:
961
988
  return {"health:read"}
962
989
  if path == "/manifest":
963
990
  return {"manifest:read"}
@@ -1043,6 +1070,10 @@ def _funnel_origin_request(headers, client_address) -> bool:
1043
1070
  return str(getter("X-Pairling-Funnel-Origin", "") or "").strip() == "1"
1044
1071
 
1045
1072
 
1073
+ def _pair_claim_requires_app_attest(headers, client_address) -> bool:
1074
+ return _funnel_origin_request(headers, client_address) or not _loopback_client_address(client_address)
1075
+
1076
+
1046
1077
  def _connectd_peer_node_id(headers) -> str:
1047
1078
  getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
1048
1079
  value = str(getter("X-Pairling-Peer-Node", "") or "").strip()
@@ -1699,6 +1730,24 @@ def _agent_registry_heartbeat_by_claude_uuid(provider: str, claude_uuid: str, *,
1699
1730
  return False
1700
1731
 
1701
1732
 
1733
+ def _agent_registry_heartbeat_by_native_id(provider: str, native_id: str, *,
1734
+ terminal_tty: str = "", pid: int = 0) -> bool:
1735
+ if not provider or not native_id:
1736
+ return False
1737
+ try:
1738
+ with _agent_registry_conn() as conn:
1739
+ cur = conn.execute(
1740
+ "UPDATE agent_sessions SET last_heartbeat = ?, state = 'running', closed_at = NULL, "
1741
+ "terminal_tty = COALESCE(NULLIF(?, ''), terminal_tty), "
1742
+ "pid = COALESCE(NULLIF(?, 0), pid) "
1743
+ "WHERE provider = ? AND native_id = ?",
1744
+ (_time.time(), terminal_tty or "", int(pid or 0), provider, native_id),
1745
+ )
1746
+ return cur.rowcount > 0
1747
+ except Exception:
1748
+ return False
1749
+
1750
+
1702
1751
  def _agent_registry_mark_closed_by_claude_uuid(provider: str, claude_uuid: str) -> bool:
1703
1752
  """Tombstone keyed on claude_uuid (SessionEnd hook path). Idempotent —
1704
1753
  only rows without an existing closed_at are touched, like the PG
@@ -1717,6 +1766,21 @@ def _agent_registry_mark_closed_by_claude_uuid(provider: str, claude_uuid: str)
1717
1766
  return False
1718
1767
 
1719
1768
 
1769
+ def _agent_registry_mark_closed_by_native_id(provider: str, native_id: str) -> bool:
1770
+ if not provider or not native_id:
1771
+ return False
1772
+ try:
1773
+ with _agent_registry_conn() as conn:
1774
+ cur = conn.execute(
1775
+ "UPDATE agent_sessions SET closed_at = ?, state = 'terminated' "
1776
+ "WHERE provider = ? AND native_id = ? AND closed_at IS NULL",
1777
+ (_time.time(), provider, native_id),
1778
+ )
1779
+ return cur.rowcount > 0
1780
+ except Exception:
1781
+ return False
1782
+
1783
+
1720
1784
  def _registry_metadata_from_row(row: dict | None) -> dict:
1721
1785
  if not row:
1722
1786
  return {}
@@ -1975,7 +2039,15 @@ def _runtime_admission_for_path(path: str) -> _RuntimeAdmission:
1975
2039
  if _FAST_ADMISSION_SEMAPHORE.acquire(blocking=False):
1976
2040
  return _RuntimeAdmission(_FAST_ADMISSION_SEMAPHORE, True)
1977
2041
  return _RuntimeAdmission(None, False, "fast_capacity_exceeded")
1978
- if path in _STREAM_ENDPOINTS:
2042
+ if path in _DASHBOARD_STREAM_ENDPOINTS:
2043
+ if _DASHBOARD_STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
2044
+ return _RuntimeAdmission(_DASHBOARD_STREAM_ADMISSION_SEMAPHORE, True)
2045
+ return _RuntimeAdmission(None, False, "dashboard_stream_capacity_exceeded")
2046
+ if path in _AUX_STREAM_ENDPOINTS:
2047
+ if _AUX_STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
2048
+ return _RuntimeAdmission(_AUX_STREAM_ADMISSION_SEMAPHORE, True)
2049
+ return _RuntimeAdmission(None, False, "aux_stream_capacity_exceeded")
2050
+ if path in _STREAM_ENDPOINTS or _is_orchestration_stream_path(path):
1979
2051
  if _STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
1980
2052
  return _RuntimeAdmission(_STREAM_ADMISSION_SEMAPHORE, True)
1981
2053
  return _RuntimeAdmission(None, False, "stream_capacity_exceeded")
@@ -2119,19 +2191,6 @@ def _codex_terminal_tty_candidates(reg: dict | None) -> list[str]:
2119
2191
  return candidates
2120
2192
 
2121
2193
 
2122
- def _guardian_tailnet_ip(power_state: dict | None) -> str | None:
2123
- if not isinstance(power_state, dict):
2124
- return None
2125
- for section_name in ("network", "facts"):
2126
- section = power_state.get(section_name)
2127
- if not isinstance(section, dict):
2128
- continue
2129
- ip = str(section.get("tailscale_ip") or "").strip()
2130
- if ip.startswith("100."):
2131
- return ip
2132
- return None
2133
-
2134
-
2135
2194
  def _probe_tailnet_ip() -> str | None:
2136
2195
  ok, out, _ = _run_text(["tailscale", "ip", "-4"], timeout=3)
2137
2196
  if not ok:
@@ -2143,29 +2202,10 @@ def _probe_tailnet_ip() -> str | None:
2143
2202
  return None
2144
2203
 
2145
2204
 
2146
- def _tailnet_ip(power_state: dict | None = None) -> str | None:
2147
- guardian_ip = _guardian_tailnet_ip(power_state)
2148
- if guardian_ip:
2149
- return guardian_ip
2205
+ def _tailnet_ip() -> str | None:
2150
2206
  return _cached_probe("tailnet_ip", _HEALTH_PROBE_CACHE_SECONDS, _probe_tailnet_ip)
2151
2207
 
2152
2208
 
2153
- def _guardian_lan_ips(power_state: dict | None) -> list[str]:
2154
- if not isinstance(power_state, dict):
2155
- return []
2156
- network = power_state.get("network")
2157
- if not isinstance(network, dict):
2158
- return []
2159
- ips: list[str] = []
2160
- for item in network.get("lan_ips") or []:
2161
- ip = str(item or "").strip()
2162
- if not ip or ip.startswith(("127.", "169.254.", "100.")):
2163
- continue
2164
- if ip not in ips:
2165
- ips.append(ip)
2166
- return ips
2167
-
2168
-
2169
2209
  def _probe_lan_ips() -> list[str]:
2170
2210
  ok, out, _ = _run_text(["/sbin/ifconfig"], timeout=3)
2171
2211
  if not ok:
@@ -2180,27 +2220,10 @@ def _probe_lan_ips() -> list[str]:
2180
2220
  return ips
2181
2221
 
2182
2222
 
2183
- def _lan_ips(power_state: dict | None = None) -> list[str]:
2184
- guardian_ips = _guardian_lan_ips(power_state)
2185
- if guardian_ips:
2186
- return guardian_ips
2223
+ def _lan_ips() -> list[str]:
2187
2224
  return _cached_probe("lan_ips", _HEALTH_PROBE_CACHE_SECONDS, _probe_lan_ips)
2188
2225
 
2189
2226
 
2190
- def _guardian_listener_entries(power_state: dict | None) -> list[str]:
2191
- if not isinstance(power_state, dict):
2192
- return []
2193
- daemon = power_state.get("daemon")
2194
- if not isinstance(daemon, dict):
2195
- return []
2196
- entries: list[str] = []
2197
- for item in daemon.get("listen") or []:
2198
- entry = str(item or "").strip()
2199
- if entry and entry not in entries:
2200
- entries.append(entry)
2201
- return entries
2202
-
2203
-
2204
2227
  def _probe_listener_entries() -> list[str]:
2205
2228
  host = BOUND_HOST or os.environ.get("PAIRLING_BOUND_HOST", "")
2206
2229
  entries: list[str] = []
@@ -2217,153 +2240,10 @@ def _probe_listener_entries() -> list[str]:
2217
2240
  return entries
2218
2241
 
2219
2242
 
2220
- def _listener_entries(power_state: dict | None = None) -> list[str]:
2221
- guardian_entries = _guardian_listener_entries(power_state)
2222
- if guardian_entries:
2223
- return guardian_entries
2243
+ def _listener_entries() -> list[str]:
2224
2244
  return _cached_probe("listener_entries", _HEALTH_PROBE_CACHE_SECONDS, _probe_listener_entries)
2225
2245
 
2226
2246
 
2227
- def _read_guardian_state() -> tuple[dict | None, str | None, float | None, str | None]:
2228
- for path in (POWER_STATE_PATH, POWER_STATE_FALLBACK_PATH):
2229
- try:
2230
- if not path.exists():
2231
- continue
2232
- state = json.loads(path.read_text())
2233
- generated = float(state.get("generated_at") or state.get("ts") or 0)
2234
- age = max(0.0, _time.time() - generated) if generated else None
2235
- return state, str(path), age, None
2236
- except Exception as exc:
2237
- return None, str(path), None, f"{type(exc).__name__}: {exc}"
2238
- return None, None, None, "guardian state missing"
2239
-
2240
-
2241
- def _coordinator_from_guardian(state: dict | None, age: float | None, error: str | None) -> dict:
2242
- if not state:
2243
- return {
2244
- "role": "primary_coordinator",
2245
- "posture": "unknown",
2246
- "severity": "unknown",
2247
- "summary": error or "Guardian state is unavailable",
2248
- "stale": True,
2249
- }
2250
- if isinstance(state.get("posture"), dict):
2251
- posture = state.get("posture") or {}
2252
- status = posture.get("status") or "unknown"
2253
- severity = posture.get("severity") or ("ok" if status == "ready" else status)
2254
- summary = posture.get("summary") or state.get("summary") or "Coordinator posture is unknown"
2255
- else:
2256
- status = state.get("posture") or "unknown"
2257
- severity = state.get("severity") or ("ok" if status == "ready" else status)
2258
- summary = state.get("summary") or "Coordinator posture is unknown"
2259
- stale = age is None or age > POWER_STATE_STALE_SECONDS
2260
- if stale:
2261
- status = "unknown"
2262
- severity = "unknown"
2263
- summary = f"Guardian sample is stale ({int(age or 0)}s old)"
2264
- return {
2265
- "role": (state.get("host") or {}).get("role") if isinstance(state.get("host"), dict) else "primary_coordinator",
2266
- "host": (state.get("host") or {}).get("name") if isinstance(state.get("host"), dict) else (state.get("host") or DEFAULT_COORDINATOR_HOST),
2267
- "posture": status,
2268
- "severity": severity,
2269
- "summary": summary,
2270
- "stale": stale,
2271
- "sample_age_seconds": age,
2272
- }
2273
-
2274
-
2275
- def _normalize_guardian_state(state: dict | None) -> dict | None:
2276
- if not state:
2277
- return None
2278
- if isinstance(state.get("posture"), dict) and any(isinstance(state.get(key), dict) for key in ("host", "network", "daemon")):
2279
- normalized = dict(state)
2280
- host = normalized.get("host")
2281
- if not isinstance(host, dict):
2282
- normalized["host"] = {
2283
- "name": str(host or DEFAULT_COORDINATOR_HOST),
2284
- "role": "primary_coordinator",
2285
- }
2286
- return normalized
2287
-
2288
- facts = state.get("facts") if isinstance(state.get("facts"), dict) else {}
2289
- checks_in = state.get("checks") if isinstance(state.get("checks"), list) else []
2290
- checks: dict[str, str] = {}
2291
- warnings: list[str] = []
2292
- for item in checks_in:
2293
- if not isinstance(item, dict):
2294
- continue
2295
- ident = str(item.get("id") or "check")
2296
- ok = bool(item.get("ok"))
2297
- message = str(item.get("message") or ("ok" if ok else "failed"))
2298
- checks[ident] = "ok" if ok else message
2299
- if not ok:
2300
- warnings.append(message)
2301
-
2302
- sleep_minutes = facts.get("sleep_minutes")
2303
- disk_sleep = facts.get("disk_sleep_minutes")
2304
- thermal_speed = facts.get("thermal_cpu_speed_limit")
2305
- thermal_scheduler = facts.get("thermal_cpu_scheduler_limit")
2306
- thermal_state = "nominal"
2307
- if isinstance(thermal_speed, (int, float)) and thermal_speed < 80:
2308
- thermal_state = "warning"
2309
- if isinstance(thermal_scheduler, (int, float)) and thermal_scheduler < 80:
2310
- thermal_state = "warning"
2311
-
2312
- host_name = state.get("host") if isinstance(state.get("host"), str) else DEFAULT_COORDINATOR_HOST
2313
- return {
2314
- "schema_version": state.get("schema_version") or 1,
2315
- "generated_at": state.get("generated_at") or state.get("ts") or _time.time(),
2316
- "host": {
2317
- "name": host_name or DEFAULT_COORDINATOR_HOST,
2318
- "role": "primary_coordinator",
2319
- },
2320
- "posture": {
2321
- "status": state.get("posture") or "unknown",
2322
- "severity": state.get("severity") or "unknown",
2323
- "summary": state.get("summary") or "Coordinator posture is unknown",
2324
- },
2325
- "power": {
2326
- "ac_power": facts.get("ac_power"),
2327
- "battery_percent": facts.get("battery_percent"),
2328
- "low_power_mode": facts.get("low_power_mode"),
2329
- "system_sleep_disabled": sleep_minutes == 0 if sleep_minutes is not None else None,
2330
- "display_sleep_minutes": facts.get("display_sleep_minutes"),
2331
- "disk_sleep_disabled": disk_sleep == 0 if disk_sleep is not None else None,
2332
- "caffeinate_pid": facts.get("caffeinate_pid"),
2333
- "prevent_system_sleep": facts.get("prevent_system_sleep"),
2334
- "prevent_idle_system_sleep": facts.get("prevent_user_idle_system_sleep"),
2335
- "prevent_display_sleep": facts.get("prevent_user_idle_display_sleep"),
2336
- },
2337
- "lid": {
2338
- "closed": facts.get("lid_closed"),
2339
- "apple_clamshell_causes_sleep": facts.get("clamshell_causes_sleep"),
2340
- "supported_posture": facts.get("lid_closed") is False,
2341
- },
2342
- "thermal": {
2343
- "state": thermal_state,
2344
- "cpu_speed_limit": thermal_speed,
2345
- "cpu_scheduler_limit": thermal_scheduler,
2346
- },
2347
- "network": {
2348
- "tailscale_installed": facts.get("tailscale_ip") is not None,
2349
- "tailscale_variant": "standalone",
2350
- "tailscale_ip": facts.get("tailscale_ip"),
2351
- "tailscale_status": "ok" if facts.get("tailscale_ip") else "missing",
2352
- "default_interface": None,
2353
- "lan_ips": [],
2354
- },
2355
- "daemon": {
2356
- "pairling_pid": os.getpid(),
2357
- "listen": (listener_entries := _listener_entries()),
2358
- "reachable_local": bool(listener_entries),
2359
- "reachable_tailnet": facts.get("daemon_reachable"),
2360
- },
2361
- "warnings": warnings,
2362
- "checks": checks,
2363
- "raw_schema": "legacy_flat_guardian",
2364
- }
2365
-
2366
-
2367
2247
  def _pairling_connect_health() -> dict:
2368
2248
  """Cached snapshot of the Pairling Connect (connectd) axis for health
2369
2249
  surfaces: {"ready": bool, "summary": dict | None, "routes": list}.
@@ -2417,38 +2297,7 @@ def _coordinator_from_pairling_connect(connect: dict) -> dict:
2417
2297
  }
2418
2298
 
2419
2299
 
2420
- # Guardian checks whose failure is fully compensated by a ready Pairling
2421
- # Connect route: both only measure the standalone-Tailscale axis.
2422
- _TAILNET_AXIS_CHECK_IDS = {"tailscale_ip", "daemon_reachable"}
2423
-
2424
-
2425
- def _apply_pairling_connect_posture(coordinator: dict, power_state: dict | None, connect: dict) -> dict:
2426
- """Downgrade tailnet-axis criticality when the Pairling Connect route is
2427
- ready. The guardian historically measured only standalone Tailscale; a
2428
- healthy embedded connectd route serves phones regardless, so its absence
2429
- alone must not mark the coordinator unsafe. Any other failing check
2430
- keeps the original posture untouched."""
2431
- if not connect.get("ready"):
2432
- return coordinator
2433
- if coordinator.get("posture") != "unsafe":
2434
- return coordinator
2435
- checks = (power_state or {}).get("checks")
2436
- if not isinstance(checks, dict) or not checks:
2437
- return coordinator
2438
- failing = {cid for cid, msg in checks.items() if msg != "ok"}
2439
- if not failing or not failing.issubset(_TAILNET_AXIS_CHECK_IDS):
2440
- return coordinator
2441
- adjusted = dict(coordinator)
2442
- adjusted["posture"] = "warning"
2443
- adjusted["severity"] = "warning"
2444
- adjusted["summary"] = (
2445
- "Pairling Connect tailnet route is ready; standalone Tailscale is offline."
2446
- )
2447
- adjusted["tailnet_axis"] = "pairling_connect"
2448
- return adjusted
2449
-
2450
-
2451
- def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[dict]:
2300
+ def _health_routes(coordinator: dict) -> list[dict]:
2452
2301
  now = _time.time()
2453
2302
  routes: list[dict] = []
2454
2303
  connect = _pairling_connect_health()
@@ -2466,7 +2315,7 @@ def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[d
2466
2315
  "source": str(route.get("source") or "pairling_connectd"),
2467
2316
  "id": route.get("id"),
2468
2317
  })
2469
- tailnet = _tailnet_ip(power_state)
2318
+ tailnet = _tailnet_ip()
2470
2319
  if tailnet:
2471
2320
  ok = coordinator.get("posture") in ("ready", "warning")
2472
2321
  routes.append({
@@ -2476,7 +2325,7 @@ def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[d
2476
2325
  "score": 100 if ok else 40,
2477
2326
  "last_ok_at": now if ok else None,
2478
2327
  })
2479
- for ip in _lan_ips(power_state)[:2]:
2328
+ for ip in _lan_ips()[:2]:
2480
2329
  routes.append({
2481
2330
  "kind": "lan",
2482
2331
  "base_url": f"http://{ip}:{PORT}",
@@ -2497,8 +2346,8 @@ def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[d
2497
2346
  return routes
2498
2347
 
2499
2348
 
2500
- def _daemon_snapshot(power_state: dict | None = None) -> dict:
2501
- entries = _listener_entries(power_state)
2349
+ def _daemon_snapshot() -> dict:
2350
+ entries = _listener_entries()
2502
2351
  return {
2503
2352
  "name": "pairlingd",
2504
2353
  "pid": os.getpid(),
@@ -2571,14 +2420,13 @@ def _routez_payload(auth_result=None) -> dict:
2571
2420
  }
2572
2421
 
2573
2422
 
2574
- def _health_payload(full_power: bool = False, authenticated: bool = False, auth_result=None) -> dict:
2423
+ def _health_payload(authenticated: bool = False, auth_result=None) -> dict:
2575
2424
  connect = _pairling_connect_health()
2576
- power_state = None
2577
2425
  coordinator = _coordinator_from_pairling_connect(connect)
2578
2426
  if connect.get("summary") is not None:
2579
2427
  coordinator = dict(coordinator)
2580
2428
  coordinator["pairling_connect"] = connect["summary"]
2581
- routes = _health_routes(coordinator, power_state)
2429
+ routes = _health_routes(coordinator)
2582
2430
  ok = coordinator.get("posture") in ("ready", "warning")
2583
2431
  runtime_info = _runtime_info_snapshot()
2584
2432
  public_runtime = _public_runtime_info(runtime_info) if _public_runtime_info else runtime_info
@@ -2593,7 +2441,7 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
2593
2441
  "required": True,
2594
2442
  "legacy_global_token": False,
2595
2443
  },
2596
- "daemon": _daemon_snapshot(power_state),
2444
+ "daemon": _daemon_snapshot(),
2597
2445
  "coordinator": coordinator,
2598
2446
  }
2599
2447
  if authenticated:
@@ -2617,16 +2465,16 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
2617
2465
  return payload
2618
2466
 
2619
2467
 
2620
- def _cached_health_payload(full_power: bool = False, authenticated: bool = False, auth_result=None) -> dict:
2468
+ def _cached_health_payload(authenticated: bool = False, auth_result=None) -> dict:
2621
2469
  device_id = str(getattr(auth_result, "device_id", "") or "")
2622
2470
  install_id = str(getattr(auth_result, "install_id", "") or "")
2623
- key = (bool(full_power), bool(authenticated), device_id, install_id)
2471
+ key = (bool(authenticated), device_id, install_id)
2624
2472
  now = _time.time()
2625
2473
  with _health_payload_cache_lock:
2626
2474
  cached = _health_payload_cache.get(key)
2627
2475
  if cached is not None and now - cached[0] < _HEALTH_PAYLOAD_CACHE_SECONDS:
2628
2476
  return _copy_cache_value(cached[1])
2629
- payload = _health_payload(full_power=full_power, authenticated=authenticated, auth_result=auth_result)
2477
+ payload = _health_payload(authenticated=authenticated, auth_result=auth_result)
2630
2478
  with _health_payload_cache_lock:
2631
2479
  _health_payload_cache[key] = (now, _copy_cache_value(payload))
2632
2480
  return _copy_cache_value(payload)
@@ -2662,38 +2510,24 @@ def _health_diff_digest(payload: dict) -> str:
2662
2510
  mirror = stable.get("mirror")
2663
2511
  if isinstance(mirror, dict):
2664
2512
  mirror.pop("updated_at", None)
2665
- stable.pop("guardian_sample_age_seconds", None)
2666
2513
  return hashlib.sha256(json.dumps(stable, sort_keys=True).encode()).hexdigest()
2667
2514
 
2668
2515
 
2669
2516
  def _orchestration_preflight_from_health(health: dict) -> tuple[dict, dict]:
2670
- power_state = health.get("power_state") if isinstance(health.get("power_state"), dict) else None
2671
- if power_state is None:
2672
- power_state = {}
2673
2517
  coordinator = health.get("coordinator") or {}
2674
2518
  route = (health.get("routes") or [{}])[0]
2675
2519
  runtime_info = health.get("runtime") if isinstance(health.get("runtime"), dict) else {}
2676
- power = power_state.get("power") if isinstance(power_state.get("power"), dict) else {}
2677
- lid = power_state.get("lid") if isinstance(power_state.get("lid"), dict) else {}
2678
- network = power_state.get("network") if isinstance(power_state.get("network"), dict) else {}
2679
- thermal = power_state.get("thermal") if isinstance(power_state.get("thermal"), dict) else {}
2680
- facts = power_state.get("facts") if isinstance(power_state.get("facts"), dict) else {}
2681
2520
  preflight = {
2682
2521
  "posture": coordinator.get("posture") or "unknown",
2683
- "warnings": power_state.get("warnings") or [],
2522
+ "warnings": [],
2684
2523
  "route": route.get("kind") or "unknown",
2685
2524
  "route_base": route.get("base_url"),
2686
2525
  "checked_at": health.get("ts"),
2687
- "tailscale_ip": network.get("tailscale_ip") or facts.get("tailscale_ip"),
2688
- "lid_closed": lid.get("closed") if "closed" in lid else facts.get("lid_closed"),
2689
- "ac_power": power.get("ac_power") if "ac_power" in power else facts.get("ac_power"),
2690
- "low_power_mode": power.get("low_power_mode") if "low_power_mode" in power else facts.get("low_power_mode"),
2691
- "thermal": thermal.get("state") or (
2692
- "throttled" if (
2693
- (facts.get("thermal_cpu_speed_limit") is not None and facts.get("thermal_cpu_speed_limit") < 80) or
2694
- (facts.get("thermal_cpu_scheduler_limit") is not None and facts.get("thermal_cpu_scheduler_limit") < 80)
2695
- ) else "normal"
2696
- ),
2526
+ "tailscale_ip": None,
2527
+ "lid_closed": None,
2528
+ "ac_power": None,
2529
+ "low_power_mode": None,
2530
+ "thermal": "unknown",
2697
2531
  "runtime_version": runtime_info.get("runtime_version"),
2698
2532
  "runtime_source_revision": runtime_info.get("source_revision"),
2699
2533
  "runtime_contract_version": runtime_info.get("contract_version") or RUNTIME_CONTRACT_VERSION,
@@ -2800,6 +2634,7 @@ def _pid_for_tty_command(tty: str, command_name: str) -> int:
2800
2634
 
2801
2635
 
2802
2636
  _codex_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
2637
+ _claude_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
2803
2638
  _codex_task_boundary_cache: dict[str, dict[str, object]] = {}
2804
2639
 
2805
2640
 
@@ -2831,6 +2666,11 @@ def _is_codex_cli_command(command: str) -> bool:
2831
2666
  )
2832
2667
 
2833
2668
 
2669
+ def _is_claude_cli_command(command: str) -> bool:
2670
+ lower = (command or "").lower()
2671
+ return re.search(r"(^|/)claude(\s|$)", lower) is not None
2672
+
2673
+
2834
2674
  def _codex_live_terminal_rows() -> list[dict]:
2835
2675
  now = _time.time()
2836
2676
  cached_ts = float(_codex_terminal_scan_cache.get("ts") or 0)
@@ -2883,6 +2723,56 @@ def _codex_live_terminal_rows() -> list[dict]:
2883
2723
  return rows
2884
2724
 
2885
2725
 
2726
+ def _claude_live_terminal_rows() -> list[dict]:
2727
+ now = _time.time()
2728
+ cached_ts = float(_claude_terminal_scan_cache.get("ts") or 0)
2729
+ if now - cached_ts < 2:
2730
+ return list(_claude_terminal_scan_cache.get("rows") or [])
2731
+ try:
2732
+ proc = subprocess.run(
2733
+ ["ps", "-axo", "pid=,tty=,lstart=,command="],
2734
+ capture_output=True,
2735
+ text=True,
2736
+ timeout=2,
2737
+ )
2738
+ except Exception:
2739
+ return []
2740
+ if proc.returncode != 0:
2741
+ return []
2742
+
2743
+ by_tty: dict[str, dict] = {}
2744
+ for line in proc.stdout.splitlines():
2745
+ parts = line.strip().split(None, 7)
2746
+ if len(parts) < 8 or not parts[0].isdigit():
2747
+ continue
2748
+ tty_name = parts[1]
2749
+ command = parts[7]
2750
+ if tty_name == "??" or not _is_claude_cli_command(command):
2751
+ continue
2752
+ try:
2753
+ started = datetime.strptime(" ".join(parts[2:7]), "%a %b %d %H:%M:%S %Y").timestamp()
2754
+ except Exception:
2755
+ started = 0.0
2756
+ pid = int(parts[0])
2757
+ tty = f"/dev/{tty_name}"
2758
+ cwd = _process_cwd(pid)
2759
+ if not cwd:
2760
+ continue
2761
+ current = by_tty.get(tty)
2762
+ if current is None or pid < int(current.get("pid") or 0):
2763
+ by_tty[tty] = {
2764
+ "pid": pid,
2765
+ "tty": tty,
2766
+ "project": cwd,
2767
+ "started_at": started,
2768
+ "command": command,
2769
+ }
2770
+ rows = list(by_tty.values())
2771
+ _claude_terminal_scan_cache["ts"] = now
2772
+ _claude_terminal_scan_cache["rows"] = rows
2773
+ return rows
2774
+
2775
+
2886
2776
  def _codex_discover_terminal_control(project: str, observed_started_at: float) -> dict | None:
2887
2777
  if not project or observed_started_at <= 0:
2888
2778
  return None
@@ -2898,6 +2788,109 @@ def _codex_discover_terminal_control(project: str, observed_started_at: float) -
2898
2788
  return best if delta <= 600 else None
2899
2789
 
2900
2790
 
2791
+ def _codex_terminal_native_id(row: dict) -> str:
2792
+ tty = str(row.get("tty") or "")
2793
+ project = str(row.get("project") or "")
2794
+ started = int(float(row.get("started_at") or 0))
2795
+ seed = f"{tty}|{project}|{started}"
2796
+ return "terminal-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
2797
+
2798
+
2799
+ def _claude_terminal_native_id(row: dict) -> str:
2800
+ tty = str(row.get("tty") or "")
2801
+ project = str(row.get("project") or "")
2802
+ started = int(float(row.get("started_at") or 0))
2803
+ seed = f"{tty}|{project}|{started}"
2804
+ return "terminal-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
2805
+
2806
+
2807
+ def _codex_register_terminal_only_rows(seen: set[str]) -> None:
2808
+ for terminal in _codex_live_terminal_rows():
2809
+ tty = str(terminal.get("tty") or "")
2810
+ project = str(terminal.get("project") or "")
2811
+ pid = int(terminal.get("pid") or 0)
2812
+ if not project or not pid or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
2813
+ continue
2814
+ existing = _agent_registry_get_by_tty("codex", tty)
2815
+ if existing and not existing.get("closed_at"):
2816
+ native_id = existing.get("native_id") or ""
2817
+ if native_id in seen:
2818
+ continue
2819
+ _agent_registry_update_control(
2820
+ "codex",
2821
+ native_id,
2822
+ pid=pid,
2823
+ terminal_tty=tty,
2824
+ state="running",
2825
+ reopen=True,
2826
+ )
2827
+ continue
2828
+ native_id = _codex_terminal_native_id(terminal)
2829
+ if native_id in seen:
2830
+ continue
2831
+ _agent_registry_upsert(
2832
+ "codex",
2833
+ native_id,
2834
+ project,
2835
+ pid=pid,
2836
+ terminal_tty=tty,
2837
+ state="running",
2838
+ metadata={
2839
+ "discovered_by": "terminal_scan",
2840
+ "terminal_only": True,
2841
+ "command": str(terminal.get("command") or "")[:500],
2842
+ },
2843
+ working_on="Live Codex terminal",
2844
+ )
2845
+
2846
+
2847
+ def _registry_row_has_live_terminal(row: dict, command_name: str) -> bool:
2848
+ tty = str(row.get("terminal_tty") or "")
2849
+ if not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
2850
+ return False
2851
+ return bool(_pid_for_tty_command(tty, command_name))
2852
+
2853
+
2854
+ def _claude_register_terminal_only_rows(seen: set[str]) -> None:
2855
+ for terminal in _claude_live_terminal_rows():
2856
+ tty = str(terminal.get("tty") or "")
2857
+ project = str(terminal.get("project") or "")
2858
+ pid = int(terminal.get("pid") or 0)
2859
+ if not project or not pid or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
2860
+ continue
2861
+ existing = _agent_registry_get_by_tty("claude", tty)
2862
+ if existing and not existing.get("closed_at"):
2863
+ native_id = existing.get("native_id") or ""
2864
+ if native_id in seen:
2865
+ continue
2866
+ _agent_registry_update_control(
2867
+ "claude",
2868
+ native_id,
2869
+ pid=pid,
2870
+ terminal_tty=tty,
2871
+ state="running",
2872
+ reopen=True,
2873
+ )
2874
+ continue
2875
+ native_id = _claude_terminal_native_id(terminal)
2876
+ if native_id in seen:
2877
+ continue
2878
+ _agent_registry_upsert(
2879
+ "claude",
2880
+ native_id,
2881
+ project,
2882
+ pid=pid,
2883
+ terminal_tty=tty,
2884
+ state="running",
2885
+ metadata={
2886
+ "discovered_by": "terminal_scan",
2887
+ "terminal_only": True,
2888
+ "command": str(terminal.get("command") or "")[:500],
2889
+ },
2890
+ working_on="Live Claude terminal",
2891
+ )
2892
+
2893
+
2901
2894
  # Phase 4 B.3: warm claude --continue pool. Maintains up to one long-running
2902
2895
  # `claude` session per model. After 5 min idle, the worker exits.
2903
2896
  # This turns 18-25s cold start into ~2s for repeat /llm-route calls.
@@ -4388,8 +4381,10 @@ class ClaudeSessionsSqliteBackend:
4388
4381
  if live_only:
4389
4382
  source = [
4390
4383
  row for row in _agent_registry_live("claude")
4391
- if row.get("claude_uuid")
4392
- and float(row.get("last_heartbeat") or 0) > cutoff
4384
+ if (
4385
+ row.get("claude_uuid")
4386
+ and float(row.get("last_heartbeat") or 0) > cutoff
4387
+ ) or _registry_row_has_live_terminal(row, "claude")
4393
4388
  ]
4394
4389
  else:
4395
4390
  source = _agent_registry_recent("claude", since_min=within_min, limit=1000)
@@ -4415,7 +4410,7 @@ class ClaudeSessionsSqliteBackend:
4415
4410
  def collect_rows(self, since_min: int, live_only: bool, limit: int) -> list[dict]:
4416
4411
  rows: list[dict] = []
4417
4412
  for row in _agent_registry_recent("claude", since_min=since_min, limit=1000):
4418
- if not row.get("claude_uuid"):
4413
+ if not row.get("claude_uuid") and not _registry_row_has_live_terminal(row, "claude"):
4419
4414
  continue
4420
4415
  if live_only and row.get("closed_at") is not None:
4421
4416
  continue
@@ -4908,17 +4903,7 @@ def _terminal_surface_source(raw_session: str) -> dict:
4908
4903
  if provider not in AGENT_PROVIDERS:
4909
4904
  return {"available": False, "source": "unavailable", "reason": "unsupported_provider"}
4910
4905
 
4911
- if PTY_BROKER is not None:
4912
- session = PTY_BROKER.get(qualified)
4913
- if session is not None:
4914
- return {
4915
- "available": True,
4916
- "source": "broker_vt",
4917
- "reason": "broker_vt",
4918
- "broker_id": _broker_session_id(session),
4919
- "tty": _broker_slave_tty(session),
4920
- "can_control": True,
4921
- }
4906
+ broker_error = ""
4922
4907
 
4923
4908
  if provider == "codex":
4924
4909
  reg = _agent_registry_get("codex", native_id) or {}
@@ -4931,9 +4916,16 @@ def _terminal_surface_source(raw_session: str) -> dict:
4931
4916
  metadata = {}
4932
4917
  broker_id = str(metadata.get("broker_id") or "").strip()
4933
4918
  if broker_id and PTY_BROKER is not None:
4934
- session = PTY_BROKER.get(broker_id)
4919
+ try:
4920
+ session = PTY_BROKER.get(broker_id)
4921
+ except Exception as e:
4922
+ session = None
4923
+ broker_error = str(e)[:160]
4935
4924
  if session is not None:
4936
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
4925
+ try:
4926
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
4927
+ except Exception:
4928
+ pass
4937
4929
  return {
4938
4930
  "available": True,
4939
4931
  "source": "broker_vt",
@@ -4972,7 +4964,59 @@ def _terminal_surface_source(raw_session: str) -> dict:
4972
4964
  "can_control": True,
4973
4965
  }
4974
4966
 
4975
- return {"available": False, "source": "unavailable", "reason": "no_terminal_tty"}
4967
+ if provider == "claude":
4968
+ reg = _agent_registry_get("claude", native_id) or {}
4969
+ metadata = {}
4970
+ try:
4971
+ metadata = json.loads(reg.get("metadata_json") or "{}")
4972
+ if not isinstance(metadata, dict):
4973
+ metadata = {}
4974
+ except Exception:
4975
+ metadata = {}
4976
+ broker_id = str(metadata.get("broker_id") or "").strip()
4977
+ if broker_id and PTY_BROKER is not None:
4978
+ try:
4979
+ session = PTY_BROKER.get(broker_id)
4980
+ except Exception as e:
4981
+ session = None
4982
+ broker_error = str(e)[:160]
4983
+ if session is not None:
4984
+ try:
4985
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
4986
+ except Exception:
4987
+ pass
4988
+ return {
4989
+ "available": True,
4990
+ "source": "broker_vt",
4991
+ "reason": "broker_vt",
4992
+ "broker_id": _broker_session_id(session),
4993
+ "tty": _broker_slave_tty(session),
4994
+ "can_control": True,
4995
+ }
4996
+ tty = str(reg.get("terminal_tty") or "")
4997
+ if not tty:
4998
+ return {"available": False, "source": "unavailable", "reason": broker_error or "no_terminal_tty"}
4999
+ if not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
5000
+ return {"available": False, "source": "unavailable", "reason": "invalid_tty", "tty": tty}
5001
+ capture_path = _terminal_capture_for_tty(tty, reg.get("project"))
5002
+ if capture_path and capture_path.exists():
5003
+ return {
5004
+ "available": True,
5005
+ "source": "script_capture",
5006
+ "reason": "script_capture",
5007
+ "terminal_log": str(capture_path),
5008
+ "tty": tty,
5009
+ "can_control": True,
5010
+ }
5011
+ return {
5012
+ "available": True,
5013
+ "source": "terminal_app_contents",
5014
+ "reason": "terminal_app_contents",
5015
+ "tty": tty,
5016
+ "can_control": True,
5017
+ }
5018
+
5019
+ return {"available": False, "source": "unavailable", "reason": broker_error or "no_terminal_tty"}
4976
5020
 
4977
5021
 
4978
5022
  def _terminal_surface_capabilities(raw_session: str) -> dict:
@@ -5324,8 +5368,8 @@ def _session_runtime_truth_from_parts(
5324
5368
  control_state = "blocked"
5325
5369
  blocked_reason = contradictions[0]["code"] if contradictions else "terminal_surface_degraded"
5326
5370
  elif selected_surface == "v1_fallback":
5327
- control_state = "read_only"
5328
- blocked_reason = "v1_fallback"
5371
+ control_state = "eligible" if selected.get("screen_hash") and selected.get("nonce") else "read_only"
5372
+ blocked_reason = None if control_state == "eligible" else "v1_fallback"
5329
5373
  else:
5330
5374
  control_state = "unavailable"
5331
5375
  blocked_reason = "terminal_surface_unavailable"
@@ -6156,6 +6200,8 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
6156
6200
  row["provider"] = "claude"
6157
6201
  row["native_id"] = native_id
6158
6202
  row["id"] = _qualified_session_id("claude", native_id)
6203
+ row["terminal_tty"] = terminal_tty
6204
+ row["pid"] = claude_pid
6159
6205
  capabilities = list(CLAUDE_SESSION_CAPABILITIES)
6160
6206
  if terminal_tty and _terminal_capture_for_tty(terminal_tty, row.get("project")):
6161
6207
  capabilities.append("terminal_output")
@@ -6179,6 +6225,35 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
6179
6225
  return row
6180
6226
 
6181
6227
 
6228
+ def _collapse_live_session_rows_by_terminal(rows: list[dict]) -> list[dict]:
6229
+ by_tty: dict[tuple[str, str], dict] = {}
6230
+ passthrough: list[dict] = []
6231
+ for row in rows:
6232
+ provider = str(row.get("provider") or "")
6233
+ tty = str(row.get("terminal_tty") or "")
6234
+ if row.get("closed_at") is not None or not provider or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
6235
+ passthrough.append(row)
6236
+ continue
6237
+ key = (provider, tty)
6238
+ current = by_tty.get(key)
6239
+ if current is None or _session_row_terminal_score(row) > _session_row_terminal_score(current):
6240
+ by_tty[key] = row
6241
+ return passthrough + list(by_tty.values())
6242
+
6243
+
6244
+ def _session_row_terminal_score(row: dict) -> tuple[int, int, int, int, int]:
6245
+ caps = set(row.get("capabilities") or [])
6246
+ control = row.get("controllability") if isinstance(row.get("controllability"), dict) else {}
6247
+ native_id = str(row.get("native_id") or row.get("id") or "")
6248
+ return (
6249
+ 1 if bool(control.get("can_send_text") or control.get("can_terminate")) else 0,
6250
+ 0 if native_id.startswith("terminal-") else 1,
6251
+ 1 if row.get("claude_uuid") else 0,
6252
+ 1 if "transcript" in caps else 0,
6253
+ int(row.get("last_heartbeat") or 0),
6254
+ )
6255
+
6256
+
6182
6257
  def _refresh_claude_observed_activity(row: dict, project: str | None, claude_uuid: str | None) -> None:
6183
6258
  """Use transcript/turn-state evidence to correct stale PG heartbeats."""
6184
6259
  observed = int(row.get("last_heartbeat") or 0)
@@ -6539,6 +6614,10 @@ def _codex_control_overlay(row: dict, observed_mtime: float | None = None, *, ve
6539
6614
  state_payload = _codex_turn_state_payload(row.get("native_id") or "", apply_boundary=False)
6540
6615
  can_send = bool(tty)
6541
6616
  can_signal = bool(pid)
6617
+ if tty:
6618
+ row["terminal_tty"] = tty
6619
+ if pid:
6620
+ row["pid"] = pid
6542
6621
  caps = set(row.get("capabilities") or [])
6543
6622
  if state_payload or can_send or can_signal:
6544
6623
  caps.add("live_state")
@@ -6608,6 +6687,8 @@ def _codex_pending_registry_rows(seen: set[str], live_only: bool, active_within_
6608
6687
  "last_heartbeat": int(heartbeat or _time.time()),
6609
6688
  "stale_seconds": stale_seconds,
6610
6689
  "source_freshness": "registry_stale_process_alive" if heartbeat < cutoff and process_alive else "registry_live",
6690
+ "terminal_tty": tty,
6691
+ "pid": pid,
6611
6692
  "first_prompt": None,
6612
6693
  "state": "running",
6613
6694
  "tool": None,
@@ -6700,7 +6781,7 @@ def _list_codex_sessions(live_only: bool, active_within_min: int) -> list[dict]:
6700
6781
 
6701
6782
 
6702
6783
  def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> list[dict]:
6703
- """Read-only Codex provider: persisted rollouts, no process control yet."""
6784
+ """Codex provider backed by transcripts plus live terminal discovery."""
6704
6785
  index = _codex_index_map()
6705
6786
  history = _codex_history_map()
6706
6787
  cutoff = _time.time() - max(1, active_within_min) * 60
@@ -6760,12 +6841,14 @@ def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> li
6760
6841
  row["tool"] = state_payload.get("tool")
6761
6842
  row["turn_started_at"] = state_payload.get("started_at")
6762
6843
  row["effort"] = state_payload.get("effort")
6763
- rows.append(_codex_control_overlay(row, st.st_mtime, verify_process=False))
6844
+ rows.append(_codex_control_overlay(row, st.st_mtime, verify_process=True))
6764
6845
  if len(rows) >= 50:
6765
6846
  break
6847
+ _codex_register_terminal_only_rows(seen)
6766
6848
  rows.extend(_codex_pending_registry_rows(seen, live_only, active_within_min))
6767
6849
  if not live_only:
6768
6850
  rows.extend(_codex_recent_closed_registry_rows(seen, active_within_min))
6851
+ rows = _collapse_live_session_rows_by_terminal(rows)
6769
6852
  rows.sort(
6770
6853
  key=lambda r: (
6771
6854
  1 if (r.get("controllability") or {}).get("can_terminate") else 0,
@@ -7261,28 +7344,37 @@ class ClientDisconnected(Exception):
7261
7344
 
7262
7345
 
7263
7346
  class Handler(BaseHTTPRequestHandler):
7347
+ timeout = REQUEST_READ_TIMEOUT_SECONDS
7264
7348
 
7265
7349
  def do_GET(self):
7266
7350
  try:
7267
7351
  return self._dispatch()
7352
+ except socket.timeout:
7353
+ self._send_json({"ok": False, "error": {"code": "request_timeout"}}, status=408)
7268
7354
  except ClientDisconnected:
7269
7355
  return
7270
7356
 
7271
7357
  def do_POST(self):
7272
7358
  try:
7273
7359
  return self._dispatch()
7360
+ except socket.timeout:
7361
+ self._send_json({"ok": False, "error": {"code": "request_timeout"}}, status=408)
7274
7362
  except ClientDisconnected:
7275
7363
  return
7276
7364
 
7277
7365
  def do_PUT(self):
7278
7366
  try:
7279
7367
  return self._dispatch()
7368
+ except socket.timeout:
7369
+ self._send_json({"ok": False, "error": {"code": "request_timeout"}}, status=408)
7280
7370
  except ClientDisconnected:
7281
7371
  return
7282
7372
 
7283
7373
  def do_DELETE(self):
7284
7374
  try:
7285
7375
  return self._dispatch()
7376
+ except socket.timeout:
7377
+ self._send_json({"ok": False, "error": {"code": "request_timeout"}}, status=408)
7286
7378
  except ClientDisconnected:
7287
7379
  return
7288
7380
 
@@ -7536,7 +7628,9 @@ class Handler(BaseHTTPRequestHandler):
7536
7628
  elif u.path == "/manifest":
7537
7629
  self._handle_manifest(q)
7538
7630
  elif u.path == "/pair/start":
7539
- if _funnel_origin_request(self.headers, self.client_address):
7631
+ if not _loopback_client_address(self.client_address):
7632
+ self._send_json({"ok": False, "error": {"code": "pair_start_loopback_required", "message": "pair start is only available from loopback"}}, status=403)
7633
+ elif _funnel_origin_request(self.headers, self.client_address):
7540
7634
  # Belt-and-suspenders: /pair/start returns the 192-bit secret
7541
7635
  # in plaintext and must never be served to a funnel-origin
7542
7636
  # request, even if connectd's allowlist ever drifted.
@@ -7559,8 +7653,6 @@ class Handler(BaseHTTPRequestHandler):
7559
7653
  self._handle_pair_bind_node(q)
7560
7654
  elif u.path == "/healthz":
7561
7655
  self._handle_healthz(q)
7562
- elif u.path == "/power-state":
7563
- self._handle_power_state(q)
7564
7656
  elif u.path == "/health-stream":
7565
7657
  self._handle_health_stream(q)
7566
7658
  elif u.path == "/open":
@@ -7794,17 +7886,24 @@ class Handler(BaseHTTPRequestHandler):
7794
7886
  uuid = str(payload.get("claude_uuid") or "").strip()
7795
7887
  return uuid if self._CLAUDE_UUID_RE.match(uuid) else ""
7796
7888
 
7889
+ def _internal_provider(self, payload: dict) -> str:
7890
+ provider = str(payload.get("provider") or "claude").strip().lower()
7891
+ return provider if provider in {"claude", "codex"} else "claude"
7892
+
7797
7893
  def _internal_terminal_tty(self, payload: dict) -> str:
7798
7894
  tty = str(payload.get("terminal_tty") or "").strip()
7799
7895
  return tty if self._INTERNAL_TTY_RE.match(tty) else ""
7800
7896
 
7801
- def _internal_claude_pid(self, payload: dict) -> int:
7897
+ def _internal_pid(self, payload: dict) -> int:
7802
7898
  try:
7803
- pid = int(payload.get("claude_pid") or 0)
7899
+ pid = int(payload.get("pid") or payload.get("claude_pid") or payload.get("codex_pid") or 0)
7804
7900
  except (TypeError, ValueError):
7805
7901
  return 0
7806
7902
  return pid if 0 < pid < 10 ** 8 else 0
7807
7903
 
7904
+ def _internal_claude_pid(self, payload: dict) -> int:
7905
+ return self._internal_pid(payload)
7906
+
7808
7907
  def _read_internal_json(self) -> dict | None:
7809
7908
  if self.command != "POST":
7810
7909
  self.send_error(405, "POST required")
@@ -7823,6 +7922,7 @@ class Handler(BaseHTTPRequestHandler):
7823
7922
  payload = self._read_internal_json()
7824
7923
  if payload is None:
7825
7924
  return
7925
+ provider = self._internal_provider(payload)
7826
7926
  session_id = str(payload.get("id") or "").strip()
7827
7927
  project = str(payload.get("project") or "").strip()
7828
7928
  if not _safe_session_id(session_id) or not project:
@@ -7833,17 +7933,18 @@ class Handler(BaseHTTPRequestHandler):
7833
7933
  return
7834
7934
  claude_uuid = self._internal_claude_uuid(payload)
7835
7935
  ok = _agent_registry_upsert(
7836
- "claude",
7936
+ provider,
7837
7937
  session_id,
7838
7938
  project,
7839
- pid=self._internal_claude_pid(payload),
7939
+ pid=self._internal_pid(payload),
7840
7940
  terminal_tty=self._internal_terminal_tty(payload),
7841
- claude_uuid=claude_uuid,
7941
+ claude_uuid=claude_uuid if provider == "claude" else "",
7842
7942
  working_on=str(payload.get("working_on") or "")[:500],
7943
+ metadata={"registered_by": "internal_session_register"},
7843
7944
  )
7844
7945
  # Mirrors the PG pg_notify('session_ready'): only fire once the row
7845
7946
  # carries a claude_uuid — that is what /turn-state-stream waits on.
7846
- if ok and claude_uuid:
7947
+ if provider == "claude" and ok and claude_uuid:
7847
7948
  _signal_session_ready(session_id)
7848
7949
  self._send_json({"ok": bool(ok)})
7849
7950
 
@@ -7851,6 +7952,23 @@ class Handler(BaseHTTPRequestHandler):
7851
7952
  payload = self._read_internal_json()
7852
7953
  if payload is None:
7853
7954
  return
7955
+ provider = self._internal_provider(payload)
7956
+ if provider == "codex":
7957
+ session_id = str(payload.get("id") or "").strip()
7958
+ if not _safe_session_id(session_id):
7959
+ self._send_json({
7960
+ "ok": False,
7961
+ "error": {"code": "bad_request", "message": "id required"},
7962
+ }, status=400)
7963
+ return
7964
+ ok = _agent_registry_heartbeat_by_native_id(
7965
+ "codex",
7966
+ session_id,
7967
+ terminal_tty=self._internal_terminal_tty(payload),
7968
+ pid=self._internal_pid(payload),
7969
+ )
7970
+ self._send_json({"ok": bool(ok)})
7971
+ return
7854
7972
  claude_uuid = self._internal_claude_uuid(payload)
7855
7973
  if not claude_uuid:
7856
7974
  self._send_json({
@@ -7862,7 +7980,7 @@ class Handler(BaseHTTPRequestHandler):
7862
7980
  "claude",
7863
7981
  claude_uuid,
7864
7982
  terminal_tty=self._internal_terminal_tty(payload),
7865
- pid=self._internal_claude_pid(payload),
7983
+ pid=self._internal_pid(payload),
7866
7984
  )
7867
7985
  # ok=false simply means no row matched — same as the PG UPDATE no-op.
7868
7986
  self._send_json({"ok": bool(ok)})
@@ -7871,6 +7989,18 @@ class Handler(BaseHTTPRequestHandler):
7871
7989
  payload = self._read_internal_json()
7872
7990
  if payload is None:
7873
7991
  return
7992
+ provider = self._internal_provider(payload)
7993
+ if provider == "codex":
7994
+ session_id = str(payload.get("id") or "").strip()
7995
+ if not _safe_session_id(session_id):
7996
+ self._send_json({
7997
+ "ok": False,
7998
+ "error": {"code": "bad_request", "message": "id required"},
7999
+ }, status=400)
8000
+ return
8001
+ closed = _agent_registry_mark_closed_by_native_id("codex", session_id)
8002
+ self._send_json({"ok": True, "closed": bool(closed)})
8003
+ return
7874
8004
  claude_uuid = self._internal_claude_uuid(payload)
7875
8005
  if not claude_uuid:
7876
8006
  self._send_json({
@@ -7883,20 +8013,24 @@ class Handler(BaseHTTPRequestHandler):
7883
8013
 
7884
8014
  def _handle_internal_active_sessions(self, q):
7885
8015
  project = q.get("project", [""])[0].strip()
8016
+ provider_filter = str(q.get("provider", ["claude"])[0] or "claude").strip().lower()
8017
+ providers = ["claude", "codex"] if provider_filter == "all" else [provider_filter if provider_filter in {"claude", "codex"} else "claude"]
7886
8018
  cutoff = _time.time() - 300
7887
8019
  items = []
7888
- for row in _agent_registry_live("claude"):
7889
- if float(row.get("last_heartbeat") or 0) < cutoff:
7890
- continue
7891
- if project and row.get("project") != project:
7892
- continue
7893
- items.append({
7894
- "id": row.get("native_id"),
7895
- "project": row.get("project"),
7896
- "working_on": row.get("working_on") or None,
7897
- "started_at": float(row.get("started_at") or 0),
7898
- "last_heartbeat": float(row.get("last_heartbeat") or 0),
7899
- })
8020
+ for provider in providers:
8021
+ for row in _agent_registry_live(provider):
8022
+ if float(row.get("last_heartbeat") or 0) < cutoff:
8023
+ continue
8024
+ if project and row.get("project") != project:
8025
+ continue
8026
+ items.append({
8027
+ "id": row.get("native_id"),
8028
+ "provider": provider,
8029
+ "project": row.get("project"),
8030
+ "working_on": row.get("working_on") or None,
8031
+ "started_at": float(row.get("started_at") or 0),
8032
+ "last_heartbeat": float(row.get("last_heartbeat") or 0),
8033
+ })
7900
8034
  items.sort(key=lambda r: r["started_at"], reverse=True)
7901
8035
  self._send_json({"ok": True, "count": len(items), "sessions": items})
7902
8036
 
@@ -7952,17 +8086,15 @@ class Handler(BaseHTTPRequestHandler):
7952
8086
  subprocess.run(["open", "-a", SUBLIME_APP, path], check=False)
7953
8087
  self._send_text(200, b"ok\n")
7954
8088
 
7955
- # ----- /healthz + /power-state + /health-stream: coordinator health -----
8089
+ # ----- /healthz + /health-stream: coordinator health -----
7956
8090
  def _handle_health(self, q):
7957
8091
  self._send_json(_cached_health_payload(
7958
- full_power=False,
7959
8092
  authenticated=self.pairling_auth is not None,
7960
8093
  auth_result=self.pairling_auth,
7961
8094
  ))
7962
8095
 
7963
8096
  def _handle_healthz(self, q):
7964
8097
  self._send_json(_cached_health_payload(
7965
- full_power=False,
7966
8098
  authenticated=self.pairling_auth is not None,
7967
8099
  auth_result=self.pairling_auth,
7968
8100
  ))
@@ -8282,6 +8414,7 @@ class Handler(BaseHTTPRequestHandler):
8282
8414
  relay_device_id=payload.get("relay_device_id"),
8283
8415
  relay_required=bool(relay_claims_required and relay_claims_required()),
8284
8416
  relay_claim_verifier=RELAY_CLAIM_VERIFIER,
8417
+ require_direct_attest=_pair_claim_requires_app_attest(self.headers, self.client_address),
8285
8418
  )
8286
8419
  if PAIRING_ADVERTISER is not None:
8287
8420
  PAIRING_ADVERTISER.stop()
@@ -8339,6 +8472,7 @@ class Handler(BaseHTTPRequestHandler):
8339
8472
  payload = self._read_json_object()
8340
8473
  host_chain = self._pairing_host_chain()
8341
8474
  funnel_origin = _funnel_origin_request(self.headers, self.client_address)
8475
+ require_direct_attest = _pair_claim_requires_app_attest(self.headers, self.client_address)
8342
8476
  claim, k_token, aad, mac_confirm = PAIRING_STORE.psk_claim_pair(
8343
8477
  pair_id=str(payload.get("pair_id") or ""),
8344
8478
  b_pub_b64=str(payload.get("b_pub") or ""),
@@ -8354,6 +8488,7 @@ class Handler(BaseHTTPRequestHandler):
8354
8488
  relay_required=bool(relay_claims_required and relay_claims_required()),
8355
8489
  relay_claim_verifier=RELAY_CLAIM_VERIFIER,
8356
8490
  funnel_origin=funnel_origin,
8491
+ require_direct_attest=require_direct_attest,
8357
8492
  )
8358
8493
  nonce, enc_token = PAIRING_STORE.seal_psk_token(k_token, claim.device.token, aad)
8359
8494
  # Seal proof_secret (the request-proof HMAC key) under K_token for
@@ -8519,13 +8654,6 @@ class Handler(BaseHTTPRequestHandler):
8519
8654
  )
8520
8655
  self._send_json({"ok": True, "tailnet_node_id_bound": bound})
8521
8656
 
8522
- def _handle_power_state(self, q):
8523
- self._send_json(_cached_health_payload(
8524
- full_power=True,
8525
- authenticated=self.pairling_auth is not None,
8526
- auth_result=self.pairling_auth,
8527
- ))
8528
-
8529
8657
  def _handle_mirror_status(self, q):
8530
8658
  project = q.get("project", [None])[0]
8531
8659
  args = ["status"]
@@ -8641,7 +8769,6 @@ class Handler(BaseHTTPRequestHandler):
8641
8769
  deadline = _time.time() + 600
8642
8770
  while _time.time() < deadline:
8643
8771
  payload = _cached_health_payload(
8644
- full_power=False,
8645
8772
  authenticated=self.pairling_auth is not None,
8646
8773
  auth_result=self.pairling_auth,
8647
8774
  )
@@ -8898,6 +9025,7 @@ class Handler(BaseHTTPRequestHandler):
8898
9025
  for row in _list_codex_sessions(live_only=False, active_within_min=active_within_min):
8899
9026
  rows.append(self._decorate_session_lifecycle_row(row))
8900
9027
 
9028
+ rows = _collapse_live_session_rows_by_terminal(rows)
8901
9029
  rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
8902
9030
  rows = rows[:max(1, min(int(limit or 200), 500))]
8903
9031
  for row in rows:
@@ -9073,6 +9201,7 @@ class Handler(BaseHTTPRequestHandler):
9073
9201
  self._decorate_session_lifecycle_row(row)
9074
9202
  for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
9075
9203
  ]
9204
+ rows = _collapse_live_session_rows_by_terminal(rows)
9076
9205
  _record_sessions_scan(rows)
9077
9206
  body = json.dumps({"count": len(rows), "items": rows}).encode()
9078
9207
  self.send_response(200)
@@ -9082,14 +9211,11 @@ class Handler(BaseHTTPRequestHandler):
9082
9211
  self.wfile.write(body)
9083
9212
  return
9084
9213
 
9085
- # Live filter semantics (live_only): not explicitly closed, has a
9086
- # claude_uuid (proves hooks fired post-migration), AND heartbeat is
9087
- # fresh enough. The freshness gate hides DORMANT zombies claudes
9088
- # whose process is technically alive but stopped firing hooks hours
9089
- # or days ago (typical of sentinel-mode terminals whose hooks point
9090
- # at the Sentinel daemon on :9100 instead of us). The kill -0 GC
9091
- # below catches process-DEAD rows; this catches
9092
- # process-alive-but-mute rows. Defaults to within_min=60.
9214
+ # Live filter semantics (live_only): keep sessions with either a fresh
9215
+ # claude_uuid-backed hook row or a live Claude process on the recorded
9216
+ # terminal. The freshness gate hides mute zombie rows, while terminal
9217
+ # discovery keeps already-open Claude windows controllable.
9218
+ _claude_register_terminal_only_rows(set())
9093
9219
  backend = _claude_sessions_backend()
9094
9220
  try:
9095
9221
  backend_rows = backend.sessions_rows(live_only, within_min)
@@ -9163,8 +9289,11 @@ class Handler(BaseHTTPRequestHandler):
9163
9289
  self._decorate_session_lifecycle_row(row)
9164
9290
  for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
9165
9291
  )
9292
+ rows = _collapse_live_session_rows_by_terminal(rows)
9166
9293
  rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
9167
9294
  rows = rows[:50]
9295
+ else:
9296
+ rows = _collapse_live_session_rows_by_terminal(rows)
9168
9297
 
9169
9298
  _record_sessions_scan(rows)
9170
9299
  body = json.dumps({"count": len(rows), "items": rows}).encode()
@@ -9204,8 +9333,8 @@ class Handler(BaseHTTPRequestHandler):
9204
9333
  "fresh": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) < 120),
9205
9334
  "stale": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) >= 120),
9206
9335
  "notes": [
9207
- "Requires closed_at null, claude_uuid present, and a recent heartbeat.",
9208
- "This is the canonical Claude dashboard source.",
9336
+ "Uses claude_uuid-backed hook rows plus live terminal discovery.",
9337
+ "Terminal-only rows are controllable even before a transcript id is known.",
9209
9338
  ],
9210
9339
  },
9211
9340
  {
@@ -9304,7 +9433,8 @@ class Handler(BaseHTTPRequestHandler):
9304
9433
 
9305
9434
  def snapshot_payload(rows: list[dict]) -> tuple[bytes, str]:
9306
9435
  degraded = self._sessions_backend_degradation()
9307
- body: dict = {"source": _sessions_stream_source(), "items": rows, "ts": _time.time()}
9436
+ source = _sessions_stream_source()
9437
+ body: dict = {"source": source, "items": rows, "ts": _time.time()}
9308
9438
  if degraded is not None:
9309
9439
  body["degraded"] = degraded
9310
9440
  digest = hashlib.sha256(
@@ -9315,6 +9445,7 @@ class Handler(BaseHTTPRequestHandler):
9315
9445
  try:
9316
9446
  # Emit initial snapshot immediately so the iPhone never paints
9317
9447
  # an empty Dashboard while waiting for the first poll.
9448
+ # Initial snapshot body contract: {"items": initial}.
9318
9449
  initial = collect_live()
9319
9450
  payload, last_hash = snapshot_payload(initial)
9320
9451
  self.wfile.write(b"event: snapshot\ndata: " + payload + b"\n\n")
@@ -9756,10 +9887,18 @@ class Handler(BaseHTTPRequestHandler):
9756
9887
  def _session_live_terminal_tail(self, raw_session: str, since: int) -> dict | None:
9757
9888
  provider, native_id = _parse_agent_session_ref(raw_session)
9758
9889
  since = max(0, int(since or 0))
9759
- broker_found = self._broker_session_for(raw_session)
9890
+ try:
9891
+ broker_found = self._broker_session_for(raw_session)
9892
+ except Exception as e:
9893
+ broker_found = None
9894
+ self.log_message("session-live terminal broker lookup failed for %s: %s", raw_session, str(e)[:200])
9760
9895
  if broker_found and PTY_BROKER:
9761
9896
  broker_id, _ = broker_found
9762
- tail = PTY_BROKER.raw_tail(broker_id, since=since)
9897
+ try:
9898
+ tail = PTY_BROKER.raw_tail(broker_id, since=since)
9899
+ except Exception as e:
9900
+ self.log_message("session-live broker tail failed for %s: %s", raw_session, str(e)[:200])
9901
+ tail = None
9763
9902
  if tail is None:
9764
9903
  return None
9765
9904
  data, next_offset, total_bytes, reset = tail
@@ -9952,22 +10091,21 @@ class Handler(BaseHTTPRequestHandler):
9952
10091
  if not emit("turn_state", turn, source="turn-state"):
9953
10092
  return
9954
10093
 
9955
- if last_truth is None:
9956
- # Preserve the original ordering guarantee: clients see the
9957
- # first truth event before any tail data, so reducers that
9958
- # gate terminal lines on transcript truth never drop bytes.
9959
- if now - last_keepalive >= 20.0:
9960
- if not emit("keepalive", {}):
9961
- return
9962
- _time.sleep(0.05)
9963
- continue
9964
-
9965
- for receipt_event in _session_live_control_receipts_since(session_id, receipt_seq):
10094
+ try:
10095
+ receipt_events = _session_live_control_receipts_since(session_id, receipt_seq)
10096
+ except Exception as e:
10097
+ receipt_events = []
10098
+ self.log_message("session-live control receipts failed for %s: %s", session_id, str(e)[:200])
10099
+ for receipt_event in receipt_events:
9966
10100
  receipt_seq = max(receipt_seq, int(receipt_event.get("receipt_seq") or 0))
9967
10101
  if not emit("control_receipt", receipt_event, source="control-receipts"):
9968
10102
  return
9969
10103
 
9970
- terminal_payload = self._session_live_terminal_tail(session_id, terminal_offset)
10104
+ try:
10105
+ terminal_payload = self._session_live_terminal_tail(session_id, terminal_offset)
10106
+ except Exception as e:
10107
+ terminal_payload = None
10108
+ self.log_message("session-live terminal tail failed for %s: %s", session_id, str(e)[:200])
9971
10109
  if terminal_payload is not None:
9972
10110
  if terminal_payload.get("reset"):
9973
10111
  terminal_offset = 0
@@ -10118,7 +10256,14 @@ class Handler(BaseHTTPRequestHandler):
10118
10256
 
10119
10257
  def _terminal_stream_truth_for_session(self, raw_session: str) -> dict:
10120
10258
  provider, native_id = _parse_agent_session_ref(raw_session)
10121
- source = _terminal_surface_source(raw_session)
10259
+ try:
10260
+ source = _terminal_surface_source(raw_session)
10261
+ except Exception as e:
10262
+ source = {
10263
+ "available": False,
10264
+ "source": "unavailable",
10265
+ "reason": str(e)[:160] or "terminal_surface_source_failed",
10266
+ }
10122
10267
  backend = source.get("source")
10123
10268
  byte_stream_available = bool(source.get("available")) and backend in {"broker_vt", "script_capture"}
10124
10269
  try:
@@ -10587,9 +10732,6 @@ class Handler(BaseHTTPRequestHandler):
10587
10732
  if not native_id:
10588
10733
  return None
10589
10734
  qualified = _qualified_session_id(provider, native_id)
10590
- session = PTY_BROKER.get(qualified)
10591
- if session:
10592
- return qualified, session
10593
10735
  if provider == "codex":
10594
10736
  reg = _agent_registry_get("codex", native_id) or {}
10595
10737
  try:
@@ -10598,26 +10740,61 @@ class Handler(BaseHTTPRequestHandler):
10598
10740
  metadata = {}
10599
10741
  broker_id = str(metadata.get("broker_id") or "").strip()
10600
10742
  if broker_id:
10601
- session = PTY_BROKER.get(broker_id)
10743
+ try:
10744
+ session = PTY_BROKER.get(broker_id)
10745
+ except Exception:
10746
+ session = None
10602
10747
  if session:
10603
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10748
+ try:
10749
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10750
+ except Exception:
10751
+ pass
10604
10752
  return qualified, session
10605
10753
  tty = reg.get("terminal_tty") or ""
10606
10754
  if tty:
10607
- session = PTY_BROKER.get_by_tty(tty)
10755
+ try:
10756
+ session = PTY_BROKER.get_by_tty(tty)
10757
+ except Exception:
10758
+ session = None
10608
10759
  if session:
10609
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10760
+ try:
10761
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10762
+ except Exception:
10763
+ pass
10610
10764
  return qualified, session
10611
10765
  if provider == "claude":
10612
10766
  session_id = _claude_native_session_id(raw_session)
10613
10767
  if not session_id:
10614
10768
  return None
10769
+ reg = _agent_registry_get("claude", session_id) or {}
10770
+ try:
10771
+ metadata = json.loads(reg.get("metadata_json") or "{}")
10772
+ except Exception:
10773
+ metadata = {}
10774
+ broker_id = str(metadata.get("broker_id") or "").strip()
10775
+ if broker_id:
10776
+ try:
10777
+ session = PTY_BROKER.get(broker_id)
10778
+ except Exception:
10779
+ session = None
10780
+ if session:
10781
+ try:
10782
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10783
+ except Exception:
10784
+ pass
10785
+ return qualified, session
10615
10786
  tty = self._lookup_terminal_tty(session_id)
10616
10787
  if tty:
10617
- session = PTY_BROKER.get_by_tty(tty)
10788
+ try:
10789
+ session = PTY_BROKER.get_by_tty(tty)
10790
+ except Exception:
10791
+ session = None
10618
10792
  if session:
10619
10793
  qualified = _qualified_session_id("claude", session_id)
10620
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10794
+ try:
10795
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
10796
+ except Exception:
10797
+ pass
10621
10798
  return qualified, session
10622
10799
  return None
10623
10800
 
@@ -11425,15 +11602,22 @@ class Handler(BaseHTTPRequestHandler):
11425
11602
  project_basename = os.path.basename(project.rstrip("/")) or project
11426
11603
  result = self._applescript_inject(project_basename, text)
11427
11604
 
11605
+ status = 200
11428
11606
  if not result.get("ok"):
11429
11607
  # Fall back to the queue-file path so nothing is lost
11430
11608
  queue_file = QUEUE_DIR / f"{session_id}.txt"
11431
11609
  with open(queue_file, "a") as f:
11432
11610
  f.write(text + "\n")
11611
+ status = 202
11433
11612
  body = json.dumps({
11434
- "ok": True, "injected": False, "queued": True,
11613
+ "ok": False, "injected": False, "queued": True,
11614
+ "state": "queued_not_injected",
11435
11615
  "fallback_reason": result.get("reason", "unknown"),
11436
11616
  "window_match": project_basename,
11617
+ "error": {
11618
+ "code": "terminal_injection_queued",
11619
+ "message": "Text was queued but was not typed into Terminal.",
11620
+ },
11437
11621
  }).encode()
11438
11622
  else:
11439
11623
  body = json.dumps({
@@ -11441,7 +11625,7 @@ class Handler(BaseHTTPRequestHandler):
11441
11625
  "window_match": project_basename,
11442
11626
  }).encode()
11443
11627
 
11444
- self.send_response(200)
11628
+ self.send_response(status)
11445
11629
  self.send_header("Content-Type", "application/json")
11446
11630
  self.send_header("Content-Length", str(len(body)))
11447
11631
  self.end_headers()
@@ -11665,6 +11849,7 @@ class Handler(BaseHTTPRequestHandler):
11665
11849
  cached = _runtime_snapshot_cache.get(cache_key)
11666
11850
  if cached is not None and now - cached[0] < RUNTIME_SNAPSHOT_CACHE_SECONDS:
11667
11851
  return _copy_cache_value(cached[1])
11852
+ _claude_register_terminal_only_rows(set())
11668
11853
  backend_rows = _claude_sessions_backend().collect_rows(since_min, live_only, limit)
11669
11854
 
11670
11855
  rows: list[dict] = []
@@ -13820,7 +14005,7 @@ Worker instructions:
13820
14005
  self.send_error(409, "project has uncommitted changes; set allow_dirty_project to continue")
13821
14006
  return
13822
14007
 
13823
- health = _health_payload(full_power=True)
14008
+ health = _health_payload()
13824
14009
  coordinator_meta, preflight_meta = _orchestration_preflight_from_health(health)
13825
14010
  if isinstance(payload.get("coordinator"), dict):
13826
14011
  coordinator_meta.update(payload["coordinator"])
@@ -14633,10 +14818,9 @@ Worker instructions:
14633
14818
  self._send_json({"ok": True, "handoff_id": handoff_id, "composeDraft": compose_draft})
14634
14819
 
14635
14820
  def _handle_spawn_session(self, q):
14636
- """Spawn a new Claude/Codex session. Pairling-owned PTYs are the
14637
- default path; Terminal.app can attach as a client via `pairling attach`.
14638
- The legacy Terminal.app-owner path remains behind
14639
- PAIRLING_SPAWN_BACKEND=terminal_app for rollback/debugging.
14821
+ """Spawn a new Claude/Codex session. User-facing direct spawns open a
14822
+ visible Terminal.app tab. The broker remains for aperture_cli launches
14823
+ and PAIRLING_SPAWN_BACKEND=broker rollback/debugging.
14640
14824
 
14641
14825
  Security:
14642
14826
  - Project path must be absolute and exist on disk.
@@ -14742,7 +14926,7 @@ Worker instructions:
14742
14926
  }, status=400)
14743
14927
  return
14744
14928
 
14745
- if os.environ.get("PAIRLING_SPAWN_BACKEND", "broker").lower() != "terminal_app":
14929
+ if launch_strategy == "aperture_cli" or os.environ.get("PAIRLING_SPAWN_BACKEND", "terminal_app").lower() == "broker":
14746
14930
  self._handle_spawn_session_broker(
14747
14931
  project,
14748
14932
  provider,
@@ -14751,13 +14935,6 @@ Worker instructions:
14751
14935
  )
14752
14936
  return
14753
14937
 
14754
- if launch_strategy == "aperture_cli":
14755
- self._send_json({
14756
- "ok": False,
14757
- "error": "Aperture CLI launch strategy requires the Pairling PTY broker backend",
14758
- }, status=400)
14759
- return
14760
-
14761
14938
  capture_id = ""
14762
14939
  capture_log_path: Path | None = None
14763
14940
  if provider == "codex":
@@ -14772,7 +14949,11 @@ Worker instructions:
14772
14949
  else:
14773
14950
  capture_id = secrets.token_hex(12)
14774
14951
  capture_log_path = TERMINAL_CAPTURE_DIR / f"claude-{capture_id}.log"
14775
- inner = f"cd {shlex.quote(project)} && claude"
14952
+ inner = (
14953
+ f"cd {shlex.quote(project)} && "
14954
+ f"PAIRLING_PHONE_SESSION=1 "
14955
+ f"exec claude --settings {shlex.quote(str(SPAWN_SETTINGS_PATH))}"
14956
+ )
14776
14957
  shell_cmd = _terminal_script_command(capture_log_path, inner)
14777
14958
  as_escaped_cmd = _as_escape(shell_cmd)
14778
14959
 
@@ -14801,41 +14982,37 @@ Worker instructions:
14801
14982
  if len(parts) >= 2:
14802
14983
  tty = parts[1].strip()
14803
14984
  pid = 0
14804
- native_id = None
14805
- if provider == "codex" and result.get("ok"):
14806
- native_id = "pending-" + secrets.token_hex(8)
14985
+ native_id = "pending-" + secrets.token_hex(8)
14986
+ if result.get("ok"):
14807
14987
  _time.sleep(0.75)
14808
- pid = _pid_for_tty_command(tty, "codex") if tty else 0
14988
+ pid = _pid_for_tty_command(tty, provider) if tty else 0
14809
14989
  if tty and capture_log_path is not None:
14810
14990
  _write_terminal_capture_mapping(
14811
14991
  tty,
14812
14992
  capture_log_path,
14813
- provider="codex",
14993
+ provider=provider,
14814
14994
  project=project,
14815
14995
  capture_id=capture_id,
14816
14996
  )
14817
14997
  _agent_registry_upsert(
14818
- "codex",
14998
+ provider,
14819
14999
  native_id,
14820
15000
  project,
14821
15001
  pid=pid,
14822
15002
  terminal_tty=tty,
14823
15003
  metadata={
14824
- "spawned_by": "phone-companion",
15004
+ "spawned_by": "pairling",
15005
+ "launch_strategy": "direct_pairling",
15006
+ "launch_visibility": "visible_terminal",
14825
15007
  "terminal_log": str(capture_log_path) if capture_log_path else None,
14826
15008
  "capture_backend": "script" if capture_log_path else None,
15009
+ "terminal_source": "terminal_app_contents",
14827
15010
  "capture_id": capture_id or None,
14828
15011
  },
15012
+ working_on=f"New {provider.title()} session",
14829
15013
  )
14830
- _write_agent_turn_state("codex", native_id, "idle", event="spawn")
14831
- elif provider == "claude" and result.get("ok") and tty and capture_log_path is not None:
14832
- _write_terminal_capture_mapping(
14833
- tty,
14834
- capture_log_path,
14835
- provider="claude",
14836
- project=project,
14837
- capture_id=capture_id,
14838
- )
15014
+ if provider == "codex":
15015
+ _write_agent_turn_state("codex", native_id, "idle", event="spawn")
14839
15016
 
14840
15017
  # Audit log — append-only, JSONL, includes failures.
14841
15018
  try:
@@ -14867,20 +15044,21 @@ Worker instructions:
14867
15044
  self.wfile.write(body)
14868
15045
  return
14869
15046
 
14870
- body = json.dumps({
15047
+ self._send_json({
14871
15048
  "ok": True,
14872
15049
  "project": project,
14873
15050
  "provider": provider,
14874
15051
  "native_id": native_id,
15052
+ "session_id": _qualified_session_id(provider, native_id),
14875
15053
  "tty": tty,
14876
15054
  "pid": pid,
14877
15055
  "terminal_log": str(capture_log_path) if capture_log_path else None,
14878
- }).encode()
14879
- self.send_response(200)
14880
- self.send_header("Content-Type", "application/json")
14881
- self.send_header("Content-Length", str(len(body)))
14882
- self.end_headers()
14883
- self.wfile.write(body)
15056
+ "capture_backend": "script" if capture_log_path else None,
15057
+ "terminal_source": "terminal_app_contents",
15058
+ "launch_strategy": "direct_pairling",
15059
+ "launch_visibility": "visible_terminal",
15060
+ "attach_command": None,
15061
+ })
14884
15062
 
14885
15063
  def _session_context_for_workflow(self, raw_session: str) -> dict | None:
14886
15064
  provider, native_id = _parse_agent_session_ref(raw_session)
@@ -15244,7 +15422,7 @@ Worker instructions:
15244
15422
  self.wfile.write(body)
15245
15423
  return
15246
15424
 
15247
- if os.environ.get("PAIRLING_RESUME_BACKEND", "broker").lower() != "terminal_app":
15425
+ if os.environ.get("PAIRLING_RESUME_BACKEND", "terminal_app").lower() == "broker":
15248
15426
  self._handle_resume_session_broker(provider=provider, project=project, native_id=native_id, prompt=prompt)
15249
15427
  return
15250
15428
 
@@ -15299,6 +15477,8 @@ Worker instructions:
15299
15477
  "resume_target": native_id,
15300
15478
  "terminal_log": str(capture_log_path),
15301
15479
  "capture_backend": "script",
15480
+ "terminal_source": "terminal_app_contents",
15481
+ "launch_visibility": "visible_terminal",
15302
15482
  "capture_id": capture_id,
15303
15483
  },
15304
15484
  )
@@ -15330,7 +15510,7 @@ Worker instructions:
15330
15510
  self.end_headers()
15331
15511
  self.wfile.write(body)
15332
15512
  return
15333
- body = json.dumps({
15513
+ self._send_json({
15334
15514
  "ok": True,
15335
15515
  "provider": "codex",
15336
15516
  "native_id": native_id,
@@ -15341,13 +15521,9 @@ Worker instructions:
15341
15521
  "terminal_log": str(capture_log_path),
15342
15522
  "capture_backend": "script",
15343
15523
  "terminal_source": "terminal_app_contents",
15524
+ "launch_visibility": "visible_terminal",
15344
15525
  "attach_command": None,
15345
- }).encode()
15346
- self.send_response(200)
15347
- self.send_header("Content-Type", "application/json")
15348
- self.send_header("Content-Length", str(len(body)))
15349
- self.end_headers()
15350
- self.wfile.write(body)
15526
+ })
15351
15527
 
15352
15528
  def _send_text_to_codex_registry(self, native_id: str, text: str, receipt_context: dict | None = None) -> None:
15353
15529
  # Precondition: callers pass text through _sanitize_terminal_text_input.
@@ -18331,7 +18507,7 @@ Worker instructions:
18331
18507
  def _pairdrop_store(self):
18332
18508
  if PairDropStore is None:
18333
18509
  raise RuntimeError("PairDrop store unavailable")
18334
- return PairDropStore(HOME / "Pairling" / "PairDrop" / "v1")
18510
+ return PairDropStore(HOME / "PairDrop")
18335
18511
 
18336
18512
  def _pairdrop_source(self) -> tuple[str, str]:
18337
18513
  auth = getattr(self, "pairling_auth", None)