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.
- package/README.md +6 -7
- package/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairling_pairing.py +13 -7
- package/payload/mac/companiond/pairlingd.py +586 -410
- package/payload/mac/companiond/runtime_contract.py +0 -3
- package/payload/mac/companiond/runtime_manifest.py +2 -1
- package/payload/mac/companiond/runtime_paths.py +2 -6
- package/payload/mac/companiond/safety_monitor.py +56 -1
- package/payload/mac/connectd/internal/gateway/proxy.go +14 -1
- package/payload/mac/connectd/internal/gateway/proxy_test.go +24 -0
- package/payload/mac/install/bootstrap-first-run.sh +32 -1
- package/payload/mac/install/doctor.sh +43 -14
- package/payload/mac/install/install-runtime.sh +817 -57
- package/payload/mac/install/render-launchd.py +1 -28
- package/payload/mac/install/uninstall-runtime.sh +0 -3
- package/payload/mac/install/verify-payload-manifest.py +71 -0
- package/payload-manifest.json +23 -27
- package/payload/mac/guardian/companion-power-guardian.py +0 -613
- package/payload/mac/guardian/guardian_contract.py +0 -67
|
@@ -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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
916
|
-
#
|
|
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", "/
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
2501
|
-
entries = _listener_entries(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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":
|
|
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":
|
|
2688
|
-
"lid_closed":
|
|
2689
|
-
"ac_power":
|
|
2690
|
-
"low_power_mode":
|
|
2691
|
-
"thermal":
|
|
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
|
|
4392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
7936
|
+
provider,
|
|
7837
7937
|
session_id,
|
|
7838
7938
|
project,
|
|
7839
|
-
pid=self.
|
|
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.
|
|
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
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
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 + /
|
|
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):
|
|
9086
|
-
# claude_uuid
|
|
9087
|
-
#
|
|
9088
|
-
#
|
|
9089
|
-
|
|
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
|
-
"
|
|
9208
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10743
|
+
try:
|
|
10744
|
+
session = PTY_BROKER.get(broker_id)
|
|
10745
|
+
except Exception:
|
|
10746
|
+
session = None
|
|
10602
10747
|
if session:
|
|
10603
|
-
|
|
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
|
-
|
|
10755
|
+
try:
|
|
10756
|
+
session = PTY_BROKER.get_by_tty(tty)
|
|
10757
|
+
except Exception:
|
|
10758
|
+
session = None
|
|
10608
10759
|
if session:
|
|
10609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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(
|
|
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.
|
|
14637
|
-
|
|
14638
|
-
|
|
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", "
|
|
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 =
|
|
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 =
|
|
14805
|
-
if
|
|
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,
|
|
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=
|
|
14993
|
+
provider=provider,
|
|
14814
14994
|
project=project,
|
|
14815
14995
|
capture_id=capture_id,
|
|
14816
14996
|
)
|
|
14817
14997
|
_agent_registry_upsert(
|
|
14818
|
-
|
|
14998
|
+
provider,
|
|
14819
14999
|
native_id,
|
|
14820
15000
|
project,
|
|
14821
15001
|
pid=pid,
|
|
14822
15002
|
terminal_tty=tty,
|
|
14823
15003
|
metadata={
|
|
14824
|
-
"spawned_by": "
|
|
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
|
-
|
|
14831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
14881
|
-
|
|
14882
|
-
|
|
14883
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
-
})
|
|
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 / "
|
|
18510
|
+
return PairDropStore(HOME / "PairDrop")
|
|
18335
18511
|
|
|
18336
18512
|
def _pairdrop_source(self) -> tuple[str, str]:
|
|
18337
18513
|
auth = getattr(self, "pairling_auth", None)
|