pairling 0.2.9 → 0.2.11
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/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +451 -118
- package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
- package/payload/mac/install/install-runtime.sh +5 -7
- package/payload-manifest.json +9 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pairling",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"url": "https://github.com/mergimg0/pairling-helper"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"@pairling/runtime-darwin-arm64": "0.2.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.11",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.11"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
83c69e1
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.11
|
|
@@ -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,
|
|
@@ -564,7 +576,9 @@ _auth_result_cache_lock = threading.Lock()
|
|
|
564
576
|
_auth_result_cache: dict[tuple, tuple[float, object]] = {}
|
|
565
577
|
_FAST_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_FAST_REQUESTS)
|
|
566
578
|
_REQUEST_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_REQUESTS)
|
|
579
|
+
_DASHBOARD_STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS)
|
|
567
580
|
_STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_STREAMS)
|
|
581
|
+
_AUX_STREAM_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_AUX_STREAMS)
|
|
568
582
|
_CONNECTION_ADMISSION_SEMAPHORE = threading.BoundedSemaphore(RUNTIME_MAX_ACTIVE_CONNECTIONS)
|
|
569
583
|
_STREAM_ENDPOINTS = {
|
|
570
584
|
"/health-stream",
|
|
@@ -582,6 +596,16 @@ _STREAM_ENDPOINTS = {
|
|
|
582
596
|
"/turn-state-stream",
|
|
583
597
|
"/llm-route-stream",
|
|
584
598
|
}
|
|
599
|
+
_DASHBOARD_STREAM_ENDPOINTS = {
|
|
600
|
+
"/health-stream",
|
|
601
|
+
"/sessions-stream",
|
|
602
|
+
}
|
|
603
|
+
_AUX_STREAM_ENDPOINTS = {
|
|
604
|
+
"/activity-stream",
|
|
605
|
+
"/commands-stream",
|
|
606
|
+
"/invocations-stream",
|
|
607
|
+
"/llm-route-stream",
|
|
608
|
+
}
|
|
585
609
|
_FAST_ENDPOINTS = {"/health", "/healthz", "/readyz", "/routez", "/power-state", "/manifest"}
|
|
586
610
|
|
|
587
611
|
|
|
@@ -1975,6 +1999,14 @@ def _runtime_admission_for_path(path: str) -> _RuntimeAdmission:
|
|
|
1975
1999
|
if _FAST_ADMISSION_SEMAPHORE.acquire(blocking=False):
|
|
1976
2000
|
return _RuntimeAdmission(_FAST_ADMISSION_SEMAPHORE, True)
|
|
1977
2001
|
return _RuntimeAdmission(None, False, "fast_capacity_exceeded")
|
|
2002
|
+
if path in _DASHBOARD_STREAM_ENDPOINTS:
|
|
2003
|
+
if _DASHBOARD_STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
|
|
2004
|
+
return _RuntimeAdmission(_DASHBOARD_STREAM_ADMISSION_SEMAPHORE, True)
|
|
2005
|
+
return _RuntimeAdmission(None, False, "dashboard_stream_capacity_exceeded")
|
|
2006
|
+
if path in _AUX_STREAM_ENDPOINTS:
|
|
2007
|
+
if _AUX_STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
|
|
2008
|
+
return _RuntimeAdmission(_AUX_STREAM_ADMISSION_SEMAPHORE, True)
|
|
2009
|
+
return _RuntimeAdmission(None, False, "aux_stream_capacity_exceeded")
|
|
1978
2010
|
if path in _STREAM_ENDPOINTS:
|
|
1979
2011
|
if _STREAM_ADMISSION_SEMAPHORE.acquire(blocking=False):
|
|
1980
2012
|
return _RuntimeAdmission(_STREAM_ADMISSION_SEMAPHORE, True)
|
|
@@ -2400,6 +2432,23 @@ def _pairling_connect_health() -> dict:
|
|
|
2400
2432
|
return _cached_probe("pairling_connect_health", _HEALTH_PROBE_CACHE_SECONDS, probe)
|
|
2401
2433
|
|
|
2402
2434
|
|
|
2435
|
+
def _coordinator_from_pairling_connect(connect: dict) -> dict:
|
|
2436
|
+
ready = bool(connect.get("ready"))
|
|
2437
|
+
return {
|
|
2438
|
+
"role": "primary_coordinator",
|
|
2439
|
+
"host": DEFAULT_COORDINATOR_HOST,
|
|
2440
|
+
"posture": "ready" if ready else "unknown",
|
|
2441
|
+
"severity": "ok" if ready else "unknown",
|
|
2442
|
+
"summary": (
|
|
2443
|
+
"Pairling Connect route is ready."
|
|
2444
|
+
if ready
|
|
2445
|
+
else "Pairling Connect route is not ready."
|
|
2446
|
+
),
|
|
2447
|
+
"stale": False,
|
|
2448
|
+
"tailnet_axis": "pairling_connect",
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
|
|
2403
2452
|
# Guardian checks whose failure is fully compensated by a ready Pairling
|
|
2404
2453
|
# Connect route: both only measure the standalone-Tailscale axis.
|
|
2405
2454
|
_TAILNET_AXIS_CHECK_IDS = {"tailscale_ip", "daemon_reachable"}
|
|
@@ -2555,11 +2604,9 @@ def _routez_payload(auth_result=None) -> dict:
|
|
|
2555
2604
|
|
|
2556
2605
|
|
|
2557
2606
|
def _health_payload(full_power: bool = False, authenticated: bool = False, auth_result=None) -> dict:
|
|
2558
|
-
state, path, age, error = _read_guardian_state()
|
|
2559
|
-
power_state = _normalize_guardian_state(state)
|
|
2560
|
-
coordinator = _coordinator_from_guardian(power_state, age, error)
|
|
2561
2607
|
connect = _pairling_connect_health()
|
|
2562
|
-
|
|
2608
|
+
power_state = None
|
|
2609
|
+
coordinator = _coordinator_from_pairling_connect(connect)
|
|
2563
2610
|
if connect.get("summary") is not None:
|
|
2564
2611
|
coordinator = dict(coordinator)
|
|
2565
2612
|
coordinator["pairling_connect"] = connect["summary"]
|
|
@@ -2599,11 +2646,6 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
|
|
|
2599
2646
|
"high_risk_count": 0,
|
|
2600
2647
|
"updated_at": _time.time(),
|
|
2601
2648
|
}
|
|
2602
|
-
if full_power:
|
|
2603
|
-
payload["guardian_path"] = path
|
|
2604
|
-
payload["guardian_error"] = error
|
|
2605
|
-
payload["guardian_sample_age_seconds"] = age
|
|
2606
|
-
payload["power_state"] = power_state
|
|
2607
2649
|
return payload
|
|
2608
2650
|
|
|
2609
2651
|
|
|
@@ -2623,11 +2665,11 @@ def _cached_health_payload(full_power: bool = False, authenticated: bool = False
|
|
|
2623
2665
|
|
|
2624
2666
|
|
|
2625
2667
|
def _mac_health_alert_snapshot() -> dict:
|
|
2626
|
-
|
|
2627
|
-
coordinator =
|
|
2628
|
-
|
|
2629
|
-
coordinator
|
|
2630
|
-
|
|
2668
|
+
connect = _pairling_connect_health()
|
|
2669
|
+
coordinator = _coordinator_from_pairling_connect(connect)
|
|
2670
|
+
if connect.get("summary") is not None:
|
|
2671
|
+
coordinator = dict(coordinator)
|
|
2672
|
+
coordinator["pairling_connect"] = connect["summary"]
|
|
2631
2673
|
return {
|
|
2632
2674
|
"ok": coordinator.get("posture") in ("ready", "warning"),
|
|
2633
2675
|
"schema_version": 1,
|
|
@@ -2659,7 +2701,7 @@ def _health_diff_digest(payload: dict) -> str:
|
|
|
2659
2701
|
def _orchestration_preflight_from_health(health: dict) -> tuple[dict, dict]:
|
|
2660
2702
|
power_state = health.get("power_state") if isinstance(health.get("power_state"), dict) else None
|
|
2661
2703
|
if power_state is None:
|
|
2662
|
-
power_state =
|
|
2704
|
+
power_state = {}
|
|
2663
2705
|
coordinator = health.get("coordinator") or {}
|
|
2664
2706
|
route = (health.get("routes") or [{}])[0]
|
|
2665
2707
|
runtime_info = health.get("runtime") if isinstance(health.get("runtime"), dict) else {}
|
|
@@ -2790,6 +2832,7 @@ def _pid_for_tty_command(tty: str, command_name: str) -> int:
|
|
|
2790
2832
|
|
|
2791
2833
|
|
|
2792
2834
|
_codex_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
|
|
2835
|
+
_claude_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
|
|
2793
2836
|
_codex_task_boundary_cache: dict[str, dict[str, object]] = {}
|
|
2794
2837
|
|
|
2795
2838
|
|
|
@@ -2821,6 +2864,11 @@ def _is_codex_cli_command(command: str) -> bool:
|
|
|
2821
2864
|
)
|
|
2822
2865
|
|
|
2823
2866
|
|
|
2867
|
+
def _is_claude_cli_command(command: str) -> bool:
|
|
2868
|
+
lower = (command or "").lower()
|
|
2869
|
+
return re.search(r"(^|/)claude(\s|$)", lower) is not None
|
|
2870
|
+
|
|
2871
|
+
|
|
2824
2872
|
def _codex_live_terminal_rows() -> list[dict]:
|
|
2825
2873
|
now = _time.time()
|
|
2826
2874
|
cached_ts = float(_codex_terminal_scan_cache.get("ts") or 0)
|
|
@@ -2873,6 +2921,56 @@ def _codex_live_terminal_rows() -> list[dict]:
|
|
|
2873
2921
|
return rows
|
|
2874
2922
|
|
|
2875
2923
|
|
|
2924
|
+
def _claude_live_terminal_rows() -> list[dict]:
|
|
2925
|
+
now = _time.time()
|
|
2926
|
+
cached_ts = float(_claude_terminal_scan_cache.get("ts") or 0)
|
|
2927
|
+
if now - cached_ts < 2:
|
|
2928
|
+
return list(_claude_terminal_scan_cache.get("rows") or [])
|
|
2929
|
+
try:
|
|
2930
|
+
proc = subprocess.run(
|
|
2931
|
+
["ps", "-axo", "pid=,tty=,lstart=,command="],
|
|
2932
|
+
capture_output=True,
|
|
2933
|
+
text=True,
|
|
2934
|
+
timeout=2,
|
|
2935
|
+
)
|
|
2936
|
+
except Exception:
|
|
2937
|
+
return []
|
|
2938
|
+
if proc.returncode != 0:
|
|
2939
|
+
return []
|
|
2940
|
+
|
|
2941
|
+
by_tty: dict[str, dict] = {}
|
|
2942
|
+
for line in proc.stdout.splitlines():
|
|
2943
|
+
parts = line.strip().split(None, 7)
|
|
2944
|
+
if len(parts) < 8 or not parts[0].isdigit():
|
|
2945
|
+
continue
|
|
2946
|
+
tty_name = parts[1]
|
|
2947
|
+
command = parts[7]
|
|
2948
|
+
if tty_name == "??" or not _is_claude_cli_command(command):
|
|
2949
|
+
continue
|
|
2950
|
+
try:
|
|
2951
|
+
started = datetime.strptime(" ".join(parts[2:7]), "%a %b %d %H:%M:%S %Y").timestamp()
|
|
2952
|
+
except Exception:
|
|
2953
|
+
started = 0.0
|
|
2954
|
+
pid = int(parts[0])
|
|
2955
|
+
tty = f"/dev/{tty_name}"
|
|
2956
|
+
cwd = _process_cwd(pid)
|
|
2957
|
+
if not cwd:
|
|
2958
|
+
continue
|
|
2959
|
+
current = by_tty.get(tty)
|
|
2960
|
+
if current is None or pid < int(current.get("pid") or 0):
|
|
2961
|
+
by_tty[tty] = {
|
|
2962
|
+
"pid": pid,
|
|
2963
|
+
"tty": tty,
|
|
2964
|
+
"project": cwd,
|
|
2965
|
+
"started_at": started,
|
|
2966
|
+
"command": command,
|
|
2967
|
+
}
|
|
2968
|
+
rows = list(by_tty.values())
|
|
2969
|
+
_claude_terminal_scan_cache["ts"] = now
|
|
2970
|
+
_claude_terminal_scan_cache["rows"] = rows
|
|
2971
|
+
return rows
|
|
2972
|
+
|
|
2973
|
+
|
|
2876
2974
|
def _codex_discover_terminal_control(project: str, observed_started_at: float) -> dict | None:
|
|
2877
2975
|
if not project or observed_started_at <= 0:
|
|
2878
2976
|
return None
|
|
@@ -2888,6 +2986,109 @@ def _codex_discover_terminal_control(project: str, observed_started_at: float) -
|
|
|
2888
2986
|
return best if delta <= 600 else None
|
|
2889
2987
|
|
|
2890
2988
|
|
|
2989
|
+
def _codex_terminal_native_id(row: dict) -> str:
|
|
2990
|
+
tty = str(row.get("tty") or "")
|
|
2991
|
+
project = str(row.get("project") or "")
|
|
2992
|
+
started = int(float(row.get("started_at") or 0))
|
|
2993
|
+
seed = f"{tty}|{project}|{started}"
|
|
2994
|
+
return "terminal-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
2995
|
+
|
|
2996
|
+
|
|
2997
|
+
def _claude_terminal_native_id(row: dict) -> str:
|
|
2998
|
+
tty = str(row.get("tty") or "")
|
|
2999
|
+
project = str(row.get("project") or "")
|
|
3000
|
+
started = int(float(row.get("started_at") or 0))
|
|
3001
|
+
seed = f"{tty}|{project}|{started}"
|
|
3002
|
+
return "terminal-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
|
+
def _codex_register_terminal_only_rows(seen: set[str]) -> None:
|
|
3006
|
+
for terminal in _codex_live_terminal_rows():
|
|
3007
|
+
tty = str(terminal.get("tty") or "")
|
|
3008
|
+
project = str(terminal.get("project") or "")
|
|
3009
|
+
pid = int(terminal.get("pid") or 0)
|
|
3010
|
+
if not project or not pid or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
|
|
3011
|
+
continue
|
|
3012
|
+
existing = _agent_registry_get_by_tty("codex", tty)
|
|
3013
|
+
if existing and not existing.get("closed_at"):
|
|
3014
|
+
native_id = existing.get("native_id") or ""
|
|
3015
|
+
if native_id in seen:
|
|
3016
|
+
continue
|
|
3017
|
+
_agent_registry_update_control(
|
|
3018
|
+
"codex",
|
|
3019
|
+
native_id,
|
|
3020
|
+
pid=pid,
|
|
3021
|
+
terminal_tty=tty,
|
|
3022
|
+
state="running",
|
|
3023
|
+
reopen=True,
|
|
3024
|
+
)
|
|
3025
|
+
continue
|
|
3026
|
+
native_id = _codex_terminal_native_id(terminal)
|
|
3027
|
+
if native_id in seen:
|
|
3028
|
+
continue
|
|
3029
|
+
_agent_registry_upsert(
|
|
3030
|
+
"codex",
|
|
3031
|
+
native_id,
|
|
3032
|
+
project,
|
|
3033
|
+
pid=pid,
|
|
3034
|
+
terminal_tty=tty,
|
|
3035
|
+
state="running",
|
|
3036
|
+
metadata={
|
|
3037
|
+
"discovered_by": "terminal_scan",
|
|
3038
|
+
"terminal_only": True,
|
|
3039
|
+
"command": str(terminal.get("command") or "")[:500],
|
|
3040
|
+
},
|
|
3041
|
+
working_on="Live Codex terminal",
|
|
3042
|
+
)
|
|
3043
|
+
|
|
3044
|
+
|
|
3045
|
+
def _registry_row_has_live_terminal(row: dict, command_name: str) -> bool:
|
|
3046
|
+
tty = str(row.get("terminal_tty") or "")
|
|
3047
|
+
if not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
|
|
3048
|
+
return False
|
|
3049
|
+
return bool(_pid_for_tty_command(tty, command_name))
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def _claude_register_terminal_only_rows(seen: set[str]) -> None:
|
|
3053
|
+
for terminal in _claude_live_terminal_rows():
|
|
3054
|
+
tty = str(terminal.get("tty") or "")
|
|
3055
|
+
project = str(terminal.get("project") or "")
|
|
3056
|
+
pid = int(terminal.get("pid") or 0)
|
|
3057
|
+
if not project or not pid or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
|
|
3058
|
+
continue
|
|
3059
|
+
existing = _agent_registry_get_by_tty("claude", tty)
|
|
3060
|
+
if existing and not existing.get("closed_at"):
|
|
3061
|
+
native_id = existing.get("native_id") or ""
|
|
3062
|
+
if native_id in seen:
|
|
3063
|
+
continue
|
|
3064
|
+
_agent_registry_update_control(
|
|
3065
|
+
"claude",
|
|
3066
|
+
native_id,
|
|
3067
|
+
pid=pid,
|
|
3068
|
+
terminal_tty=tty,
|
|
3069
|
+
state="running",
|
|
3070
|
+
reopen=True,
|
|
3071
|
+
)
|
|
3072
|
+
continue
|
|
3073
|
+
native_id = _claude_terminal_native_id(terminal)
|
|
3074
|
+
if native_id in seen:
|
|
3075
|
+
continue
|
|
3076
|
+
_agent_registry_upsert(
|
|
3077
|
+
"claude",
|
|
3078
|
+
native_id,
|
|
3079
|
+
project,
|
|
3080
|
+
pid=pid,
|
|
3081
|
+
terminal_tty=tty,
|
|
3082
|
+
state="running",
|
|
3083
|
+
metadata={
|
|
3084
|
+
"discovered_by": "terminal_scan",
|
|
3085
|
+
"terminal_only": True,
|
|
3086
|
+
"command": str(terminal.get("command") or "")[:500],
|
|
3087
|
+
},
|
|
3088
|
+
working_on="Live Claude terminal",
|
|
3089
|
+
)
|
|
3090
|
+
|
|
3091
|
+
|
|
2891
3092
|
# Phase 4 B.3: warm claude --continue pool. Maintains up to one long-running
|
|
2892
3093
|
# `claude` session per model. After 5 min idle, the worker exits.
|
|
2893
3094
|
# This turns 18-25s cold start into ~2s for repeat /llm-route calls.
|
|
@@ -4378,8 +4579,10 @@ class ClaudeSessionsSqliteBackend:
|
|
|
4378
4579
|
if live_only:
|
|
4379
4580
|
source = [
|
|
4380
4581
|
row for row in _agent_registry_live("claude")
|
|
4381
|
-
if
|
|
4382
|
-
|
|
4582
|
+
if (
|
|
4583
|
+
row.get("claude_uuid")
|
|
4584
|
+
and float(row.get("last_heartbeat") or 0) > cutoff
|
|
4585
|
+
) or _registry_row_has_live_terminal(row, "claude")
|
|
4383
4586
|
]
|
|
4384
4587
|
else:
|
|
4385
4588
|
source = _agent_registry_recent("claude", since_min=within_min, limit=1000)
|
|
@@ -4405,7 +4608,7 @@ class ClaudeSessionsSqliteBackend:
|
|
|
4405
4608
|
def collect_rows(self, since_min: int, live_only: bool, limit: int) -> list[dict]:
|
|
4406
4609
|
rows: list[dict] = []
|
|
4407
4610
|
for row in _agent_registry_recent("claude", since_min=since_min, limit=1000):
|
|
4408
|
-
if not row.get("claude_uuid"):
|
|
4611
|
+
if not row.get("claude_uuid") and not _registry_row_has_live_terminal(row, "claude"):
|
|
4409
4612
|
continue
|
|
4410
4613
|
if live_only and row.get("closed_at") is not None:
|
|
4411
4614
|
continue
|
|
@@ -4898,17 +5101,7 @@ def _terminal_surface_source(raw_session: str) -> dict:
|
|
|
4898
5101
|
if provider not in AGENT_PROVIDERS:
|
|
4899
5102
|
return {"available": False, "source": "unavailable", "reason": "unsupported_provider"}
|
|
4900
5103
|
|
|
4901
|
-
|
|
4902
|
-
session = PTY_BROKER.get(qualified)
|
|
4903
|
-
if session is not None:
|
|
4904
|
-
return {
|
|
4905
|
-
"available": True,
|
|
4906
|
-
"source": "broker_vt",
|
|
4907
|
-
"reason": "broker_vt",
|
|
4908
|
-
"broker_id": _broker_session_id(session),
|
|
4909
|
-
"tty": _broker_slave_tty(session),
|
|
4910
|
-
"can_control": True,
|
|
4911
|
-
}
|
|
5104
|
+
broker_error = ""
|
|
4912
5105
|
|
|
4913
5106
|
if provider == "codex":
|
|
4914
5107
|
reg = _agent_registry_get("codex", native_id) or {}
|
|
@@ -4921,9 +5114,16 @@ def _terminal_surface_source(raw_session: str) -> dict:
|
|
|
4921
5114
|
metadata = {}
|
|
4922
5115
|
broker_id = str(metadata.get("broker_id") or "").strip()
|
|
4923
5116
|
if broker_id and PTY_BROKER is not None:
|
|
4924
|
-
|
|
5117
|
+
try:
|
|
5118
|
+
session = PTY_BROKER.get(broker_id)
|
|
5119
|
+
except Exception as e:
|
|
5120
|
+
session = None
|
|
5121
|
+
broker_error = str(e)[:160]
|
|
4925
5122
|
if session is not None:
|
|
4926
|
-
|
|
5123
|
+
try:
|
|
5124
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
5125
|
+
except Exception:
|
|
5126
|
+
pass
|
|
4927
5127
|
return {
|
|
4928
5128
|
"available": True,
|
|
4929
5129
|
"source": "broker_vt",
|
|
@@ -4962,7 +5162,59 @@ def _terminal_surface_source(raw_session: str) -> dict:
|
|
|
4962
5162
|
"can_control": True,
|
|
4963
5163
|
}
|
|
4964
5164
|
|
|
4965
|
-
|
|
5165
|
+
if provider == "claude":
|
|
5166
|
+
reg = _agent_registry_get("claude", native_id) or {}
|
|
5167
|
+
metadata = {}
|
|
5168
|
+
try:
|
|
5169
|
+
metadata = json.loads(reg.get("metadata_json") or "{}")
|
|
5170
|
+
if not isinstance(metadata, dict):
|
|
5171
|
+
metadata = {}
|
|
5172
|
+
except Exception:
|
|
5173
|
+
metadata = {}
|
|
5174
|
+
broker_id = str(metadata.get("broker_id") or "").strip()
|
|
5175
|
+
if broker_id and PTY_BROKER is not None:
|
|
5176
|
+
try:
|
|
5177
|
+
session = PTY_BROKER.get(broker_id)
|
|
5178
|
+
except Exception as e:
|
|
5179
|
+
session = None
|
|
5180
|
+
broker_error = str(e)[:160]
|
|
5181
|
+
if session is not None:
|
|
5182
|
+
try:
|
|
5183
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
5184
|
+
except Exception:
|
|
5185
|
+
pass
|
|
5186
|
+
return {
|
|
5187
|
+
"available": True,
|
|
5188
|
+
"source": "broker_vt",
|
|
5189
|
+
"reason": "broker_vt",
|
|
5190
|
+
"broker_id": _broker_session_id(session),
|
|
5191
|
+
"tty": _broker_slave_tty(session),
|
|
5192
|
+
"can_control": True,
|
|
5193
|
+
}
|
|
5194
|
+
tty = str(reg.get("terminal_tty") or "")
|
|
5195
|
+
if not tty:
|
|
5196
|
+
return {"available": False, "source": "unavailable", "reason": broker_error or "no_terminal_tty"}
|
|
5197
|
+
if not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
|
|
5198
|
+
return {"available": False, "source": "unavailable", "reason": "invalid_tty", "tty": tty}
|
|
5199
|
+
capture_path = _terminal_capture_for_tty(tty, reg.get("project"))
|
|
5200
|
+
if capture_path and capture_path.exists():
|
|
5201
|
+
return {
|
|
5202
|
+
"available": True,
|
|
5203
|
+
"source": "script_capture",
|
|
5204
|
+
"reason": "script_capture",
|
|
5205
|
+
"terminal_log": str(capture_path),
|
|
5206
|
+
"tty": tty,
|
|
5207
|
+
"can_control": True,
|
|
5208
|
+
}
|
|
5209
|
+
return {
|
|
5210
|
+
"available": True,
|
|
5211
|
+
"source": "terminal_app_contents",
|
|
5212
|
+
"reason": "terminal_app_contents",
|
|
5213
|
+
"tty": tty,
|
|
5214
|
+
"can_control": True,
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
return {"available": False, "source": "unavailable", "reason": broker_error or "no_terminal_tty"}
|
|
4966
5218
|
|
|
4967
5219
|
|
|
4968
5220
|
def _terminal_surface_capabilities(raw_session: str) -> dict:
|
|
@@ -5314,8 +5566,8 @@ def _session_runtime_truth_from_parts(
|
|
|
5314
5566
|
control_state = "blocked"
|
|
5315
5567
|
blocked_reason = contradictions[0]["code"] if contradictions else "terminal_surface_degraded"
|
|
5316
5568
|
elif selected_surface == "v1_fallback":
|
|
5317
|
-
control_state = "read_only"
|
|
5318
|
-
blocked_reason = "v1_fallback"
|
|
5569
|
+
control_state = "eligible" if selected.get("screen_hash") and selected.get("nonce") else "read_only"
|
|
5570
|
+
blocked_reason = None if control_state == "eligible" else "v1_fallback"
|
|
5319
5571
|
else:
|
|
5320
5572
|
control_state = "unavailable"
|
|
5321
5573
|
blocked_reason = "terminal_surface_unavailable"
|
|
@@ -6146,6 +6398,8 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
|
|
|
6146
6398
|
row["provider"] = "claude"
|
|
6147
6399
|
row["native_id"] = native_id
|
|
6148
6400
|
row["id"] = _qualified_session_id("claude", native_id)
|
|
6401
|
+
row["terminal_tty"] = terminal_tty
|
|
6402
|
+
row["pid"] = claude_pid
|
|
6149
6403
|
capabilities = list(CLAUDE_SESSION_CAPABILITIES)
|
|
6150
6404
|
if terminal_tty and _terminal_capture_for_tty(terminal_tty, row.get("project")):
|
|
6151
6405
|
capabilities.append("terminal_output")
|
|
@@ -6169,6 +6423,35 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
|
|
|
6169
6423
|
return row
|
|
6170
6424
|
|
|
6171
6425
|
|
|
6426
|
+
def _collapse_live_session_rows_by_terminal(rows: list[dict]) -> list[dict]:
|
|
6427
|
+
by_tty: dict[tuple[str, str], dict] = {}
|
|
6428
|
+
passthrough: list[dict] = []
|
|
6429
|
+
for row in rows:
|
|
6430
|
+
provider = str(row.get("provider") or "")
|
|
6431
|
+
tty = str(row.get("terminal_tty") or "")
|
|
6432
|
+
if row.get("closed_at") is not None or not provider or not re.match(r"^/dev/ttys[0-9]{3,}$", tty):
|
|
6433
|
+
passthrough.append(row)
|
|
6434
|
+
continue
|
|
6435
|
+
key = (provider, tty)
|
|
6436
|
+
current = by_tty.get(key)
|
|
6437
|
+
if current is None or _session_row_terminal_score(row) > _session_row_terminal_score(current):
|
|
6438
|
+
by_tty[key] = row
|
|
6439
|
+
return passthrough + list(by_tty.values())
|
|
6440
|
+
|
|
6441
|
+
|
|
6442
|
+
def _session_row_terminal_score(row: dict) -> tuple[int, int, int, int, int]:
|
|
6443
|
+
caps = set(row.get("capabilities") or [])
|
|
6444
|
+
control = row.get("controllability") if isinstance(row.get("controllability"), dict) else {}
|
|
6445
|
+
native_id = str(row.get("native_id") or row.get("id") or "")
|
|
6446
|
+
return (
|
|
6447
|
+
1 if bool(control.get("can_send_text") or control.get("can_terminate")) else 0,
|
|
6448
|
+
0 if native_id.startswith("terminal-") else 1,
|
|
6449
|
+
1 if row.get("claude_uuid") else 0,
|
|
6450
|
+
1 if "transcript" in caps else 0,
|
|
6451
|
+
int(row.get("last_heartbeat") or 0),
|
|
6452
|
+
)
|
|
6453
|
+
|
|
6454
|
+
|
|
6172
6455
|
def _refresh_claude_observed_activity(row: dict, project: str | None, claude_uuid: str | None) -> None:
|
|
6173
6456
|
"""Use transcript/turn-state evidence to correct stale PG heartbeats."""
|
|
6174
6457
|
observed = int(row.get("last_heartbeat") or 0)
|
|
@@ -6529,6 +6812,10 @@ def _codex_control_overlay(row: dict, observed_mtime: float | None = None, *, ve
|
|
|
6529
6812
|
state_payload = _codex_turn_state_payload(row.get("native_id") or "", apply_boundary=False)
|
|
6530
6813
|
can_send = bool(tty)
|
|
6531
6814
|
can_signal = bool(pid)
|
|
6815
|
+
if tty:
|
|
6816
|
+
row["terminal_tty"] = tty
|
|
6817
|
+
if pid:
|
|
6818
|
+
row["pid"] = pid
|
|
6532
6819
|
caps = set(row.get("capabilities") or [])
|
|
6533
6820
|
if state_payload or can_send or can_signal:
|
|
6534
6821
|
caps.add("live_state")
|
|
@@ -6598,6 +6885,8 @@ def _codex_pending_registry_rows(seen: set[str], live_only: bool, active_within_
|
|
|
6598
6885
|
"last_heartbeat": int(heartbeat or _time.time()),
|
|
6599
6886
|
"stale_seconds": stale_seconds,
|
|
6600
6887
|
"source_freshness": "registry_stale_process_alive" if heartbeat < cutoff and process_alive else "registry_live",
|
|
6888
|
+
"terminal_tty": tty,
|
|
6889
|
+
"pid": pid,
|
|
6601
6890
|
"first_prompt": None,
|
|
6602
6891
|
"state": "running",
|
|
6603
6892
|
"tool": None,
|
|
@@ -6690,7 +6979,7 @@ def _list_codex_sessions(live_only: bool, active_within_min: int) -> list[dict]:
|
|
|
6690
6979
|
|
|
6691
6980
|
|
|
6692
6981
|
def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> list[dict]:
|
|
6693
|
-
"""
|
|
6982
|
+
"""Codex provider backed by transcripts plus live terminal discovery."""
|
|
6694
6983
|
index = _codex_index_map()
|
|
6695
6984
|
history = _codex_history_map()
|
|
6696
6985
|
cutoff = _time.time() - max(1, active_within_min) * 60
|
|
@@ -6750,12 +7039,14 @@ def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> li
|
|
|
6750
7039
|
row["tool"] = state_payload.get("tool")
|
|
6751
7040
|
row["turn_started_at"] = state_payload.get("started_at")
|
|
6752
7041
|
row["effort"] = state_payload.get("effort")
|
|
6753
|
-
rows.append(_codex_control_overlay(row, st.st_mtime, verify_process=
|
|
7042
|
+
rows.append(_codex_control_overlay(row, st.st_mtime, verify_process=True))
|
|
6754
7043
|
if len(rows) >= 50:
|
|
6755
7044
|
break
|
|
7045
|
+
_codex_register_terminal_only_rows(seen)
|
|
6756
7046
|
rows.extend(_codex_pending_registry_rows(seen, live_only, active_within_min))
|
|
6757
7047
|
if not live_only:
|
|
6758
7048
|
rows.extend(_codex_recent_closed_registry_rows(seen, active_within_min))
|
|
7049
|
+
rows = _collapse_live_session_rows_by_terminal(rows)
|
|
6759
7050
|
rows.sort(
|
|
6760
7051
|
key=lambda r: (
|
|
6761
7052
|
1 if (r.get("controllability") or {}).get("can_terminate") else 0,
|
|
@@ -8888,6 +9179,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8888
9179
|
for row in _list_codex_sessions(live_only=False, active_within_min=active_within_min):
|
|
8889
9180
|
rows.append(self._decorate_session_lifecycle_row(row))
|
|
8890
9181
|
|
|
9182
|
+
rows = _collapse_live_session_rows_by_terminal(rows)
|
|
8891
9183
|
rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
|
|
8892
9184
|
rows = rows[:max(1, min(int(limit or 200), 500))]
|
|
8893
9185
|
for row in rows:
|
|
@@ -9063,6 +9355,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9063
9355
|
self._decorate_session_lifecycle_row(row)
|
|
9064
9356
|
for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
|
|
9065
9357
|
]
|
|
9358
|
+
rows = _collapse_live_session_rows_by_terminal(rows)
|
|
9066
9359
|
_record_sessions_scan(rows)
|
|
9067
9360
|
body = json.dumps({"count": len(rows), "items": rows}).encode()
|
|
9068
9361
|
self.send_response(200)
|
|
@@ -9072,14 +9365,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9072
9365
|
self.wfile.write(body)
|
|
9073
9366
|
return
|
|
9074
9367
|
|
|
9075
|
-
# Live filter semantics (live_only):
|
|
9076
|
-
# claude_uuid
|
|
9077
|
-
#
|
|
9078
|
-
#
|
|
9079
|
-
|
|
9080
|
-
# at the Sentinel daemon on :9100 instead of us). The kill -0 GC
|
|
9081
|
-
# below catches process-DEAD rows; this catches
|
|
9082
|
-
# process-alive-but-mute rows. Defaults to within_min=60.
|
|
9368
|
+
# Live filter semantics (live_only): keep sessions with either a fresh
|
|
9369
|
+
# claude_uuid-backed hook row or a live Claude process on the recorded
|
|
9370
|
+
# terminal. The freshness gate hides mute zombie rows, while terminal
|
|
9371
|
+
# discovery keeps already-open Claude windows controllable.
|
|
9372
|
+
_claude_register_terminal_only_rows(set())
|
|
9083
9373
|
backend = _claude_sessions_backend()
|
|
9084
9374
|
try:
|
|
9085
9375
|
backend_rows = backend.sessions_rows(live_only, within_min)
|
|
@@ -9153,8 +9443,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9153
9443
|
self._decorate_session_lifecycle_row(row)
|
|
9154
9444
|
for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
|
|
9155
9445
|
)
|
|
9446
|
+
rows = _collapse_live_session_rows_by_terminal(rows)
|
|
9156
9447
|
rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
|
|
9157
9448
|
rows = rows[:50]
|
|
9449
|
+
else:
|
|
9450
|
+
rows = _collapse_live_session_rows_by_terminal(rows)
|
|
9158
9451
|
|
|
9159
9452
|
_record_sessions_scan(rows)
|
|
9160
9453
|
body = json.dumps({"count": len(rows), "items": rows}).encode()
|
|
@@ -9194,8 +9487,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9194
9487
|
"fresh": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) < 120),
|
|
9195
9488
|
"stale": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) >= 120),
|
|
9196
9489
|
"notes": [
|
|
9197
|
-
"
|
|
9198
|
-
"
|
|
9490
|
+
"Uses claude_uuid-backed hook rows plus live terminal discovery.",
|
|
9491
|
+
"Terminal-only rows are controllable even before a transcript id is known.",
|
|
9199
9492
|
],
|
|
9200
9493
|
},
|
|
9201
9494
|
{
|
|
@@ -9294,7 +9587,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9294
9587
|
|
|
9295
9588
|
def snapshot_payload(rows: list[dict]) -> tuple[bytes, str]:
|
|
9296
9589
|
degraded = self._sessions_backend_degradation()
|
|
9297
|
-
|
|
9590
|
+
source = _sessions_stream_source()
|
|
9591
|
+
body: dict = {"source": source, "items": rows, "ts": _time.time()}
|
|
9298
9592
|
if degraded is not None:
|
|
9299
9593
|
body["degraded"] = degraded
|
|
9300
9594
|
digest = hashlib.sha256(
|
|
@@ -9305,6 +9599,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9305
9599
|
try:
|
|
9306
9600
|
# Emit initial snapshot immediately so the iPhone never paints
|
|
9307
9601
|
# an empty Dashboard while waiting for the first poll.
|
|
9602
|
+
# Initial snapshot body contract: {"items": initial}.
|
|
9308
9603
|
initial = collect_live()
|
|
9309
9604
|
payload, last_hash = snapshot_payload(initial)
|
|
9310
9605
|
self.wfile.write(b"event: snapshot\ndata: " + payload + b"\n\n")
|
|
@@ -9746,10 +10041,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9746
10041
|
def _session_live_terminal_tail(self, raw_session: str, since: int) -> dict | None:
|
|
9747
10042
|
provider, native_id = _parse_agent_session_ref(raw_session)
|
|
9748
10043
|
since = max(0, int(since or 0))
|
|
9749
|
-
|
|
10044
|
+
try:
|
|
10045
|
+
broker_found = self._broker_session_for(raw_session)
|
|
10046
|
+
except Exception as e:
|
|
10047
|
+
broker_found = None
|
|
10048
|
+
self.log_message("session-live terminal broker lookup failed for %s: %s", raw_session, str(e)[:200])
|
|
9750
10049
|
if broker_found and PTY_BROKER:
|
|
9751
10050
|
broker_id, _ = broker_found
|
|
9752
|
-
|
|
10051
|
+
try:
|
|
10052
|
+
tail = PTY_BROKER.raw_tail(broker_id, since=since)
|
|
10053
|
+
except Exception as e:
|
|
10054
|
+
self.log_message("session-live broker tail failed for %s: %s", raw_session, str(e)[:200])
|
|
10055
|
+
tail = None
|
|
9753
10056
|
if tail is None:
|
|
9754
10057
|
return None
|
|
9755
10058
|
data, next_offset, total_bytes, reset = tail
|
|
@@ -9942,22 +10245,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9942
10245
|
if not emit("turn_state", turn, source="turn-state"):
|
|
9943
10246
|
return
|
|
9944
10247
|
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
|
|
9949
|
-
|
|
9950
|
-
|
|
9951
|
-
return
|
|
9952
|
-
_time.sleep(0.05)
|
|
9953
|
-
continue
|
|
9954
|
-
|
|
9955
|
-
for receipt_event in _session_live_control_receipts_since(session_id, receipt_seq):
|
|
10248
|
+
try:
|
|
10249
|
+
receipt_events = _session_live_control_receipts_since(session_id, receipt_seq)
|
|
10250
|
+
except Exception as e:
|
|
10251
|
+
receipt_events = []
|
|
10252
|
+
self.log_message("session-live control receipts failed for %s: %s", session_id, str(e)[:200])
|
|
10253
|
+
for receipt_event in receipt_events:
|
|
9956
10254
|
receipt_seq = max(receipt_seq, int(receipt_event.get("receipt_seq") or 0))
|
|
9957
10255
|
if not emit("control_receipt", receipt_event, source="control-receipts"):
|
|
9958
10256
|
return
|
|
9959
10257
|
|
|
9960
|
-
|
|
10258
|
+
try:
|
|
10259
|
+
terminal_payload = self._session_live_terminal_tail(session_id, terminal_offset)
|
|
10260
|
+
except Exception as e:
|
|
10261
|
+
terminal_payload = None
|
|
10262
|
+
self.log_message("session-live terminal tail failed for %s: %s", session_id, str(e)[:200])
|
|
9961
10263
|
if terminal_payload is not None:
|
|
9962
10264
|
if terminal_payload.get("reset"):
|
|
9963
10265
|
terminal_offset = 0
|
|
@@ -10108,7 +10410,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
10108
10410
|
|
|
10109
10411
|
def _terminal_stream_truth_for_session(self, raw_session: str) -> dict:
|
|
10110
10412
|
provider, native_id = _parse_agent_session_ref(raw_session)
|
|
10111
|
-
|
|
10413
|
+
try:
|
|
10414
|
+
source = _terminal_surface_source(raw_session)
|
|
10415
|
+
except Exception as e:
|
|
10416
|
+
source = {
|
|
10417
|
+
"available": False,
|
|
10418
|
+
"source": "unavailable",
|
|
10419
|
+
"reason": str(e)[:160] or "terminal_surface_source_failed",
|
|
10420
|
+
}
|
|
10112
10421
|
backend = source.get("source")
|
|
10113
10422
|
byte_stream_available = bool(source.get("available")) and backend in {"broker_vt", "script_capture"}
|
|
10114
10423
|
try:
|
|
@@ -10577,9 +10886,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
10577
10886
|
if not native_id:
|
|
10578
10887
|
return None
|
|
10579
10888
|
qualified = _qualified_session_id(provider, native_id)
|
|
10580
|
-
session = PTY_BROKER.get(qualified)
|
|
10581
|
-
if session:
|
|
10582
|
-
return qualified, session
|
|
10583
10889
|
if provider == "codex":
|
|
10584
10890
|
reg = _agent_registry_get("codex", native_id) or {}
|
|
10585
10891
|
try:
|
|
@@ -10588,26 +10894,61 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
10588
10894
|
metadata = {}
|
|
10589
10895
|
broker_id = str(metadata.get("broker_id") or "").strip()
|
|
10590
10896
|
if broker_id:
|
|
10591
|
-
|
|
10897
|
+
try:
|
|
10898
|
+
session = PTY_BROKER.get(broker_id)
|
|
10899
|
+
except Exception:
|
|
10900
|
+
session = None
|
|
10592
10901
|
if session:
|
|
10593
|
-
|
|
10902
|
+
try:
|
|
10903
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
10904
|
+
except Exception:
|
|
10905
|
+
pass
|
|
10594
10906
|
return qualified, session
|
|
10595
10907
|
tty = reg.get("terminal_tty") or ""
|
|
10596
10908
|
if tty:
|
|
10597
|
-
|
|
10909
|
+
try:
|
|
10910
|
+
session = PTY_BROKER.get_by_tty(tty)
|
|
10911
|
+
except Exception:
|
|
10912
|
+
session = None
|
|
10598
10913
|
if session:
|
|
10599
|
-
|
|
10914
|
+
try:
|
|
10915
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
10916
|
+
except Exception:
|
|
10917
|
+
pass
|
|
10600
10918
|
return qualified, session
|
|
10601
10919
|
if provider == "claude":
|
|
10602
10920
|
session_id = _claude_native_session_id(raw_session)
|
|
10603
10921
|
if not session_id:
|
|
10604
10922
|
return None
|
|
10923
|
+
reg = _agent_registry_get("claude", session_id) or {}
|
|
10924
|
+
try:
|
|
10925
|
+
metadata = json.loads(reg.get("metadata_json") or "{}")
|
|
10926
|
+
except Exception:
|
|
10927
|
+
metadata = {}
|
|
10928
|
+
broker_id = str(metadata.get("broker_id") or "").strip()
|
|
10929
|
+
if broker_id:
|
|
10930
|
+
try:
|
|
10931
|
+
session = PTY_BROKER.get(broker_id)
|
|
10932
|
+
except Exception:
|
|
10933
|
+
session = None
|
|
10934
|
+
if session:
|
|
10935
|
+
try:
|
|
10936
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
10937
|
+
except Exception:
|
|
10938
|
+
pass
|
|
10939
|
+
return qualified, session
|
|
10605
10940
|
tty = self._lookup_terminal_tty(session_id)
|
|
10606
10941
|
if tty:
|
|
10607
|
-
|
|
10942
|
+
try:
|
|
10943
|
+
session = PTY_BROKER.get_by_tty(tty)
|
|
10944
|
+
except Exception:
|
|
10945
|
+
session = None
|
|
10608
10946
|
if session:
|
|
10609
10947
|
qualified = _qualified_session_id("claude", session_id)
|
|
10610
|
-
|
|
10948
|
+
try:
|
|
10949
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
10950
|
+
except Exception:
|
|
10951
|
+
pass
|
|
10611
10952
|
return qualified, session
|
|
10612
10953
|
return None
|
|
10613
10954
|
|
|
@@ -11655,6 +11996,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
11655
11996
|
cached = _runtime_snapshot_cache.get(cache_key)
|
|
11656
11997
|
if cached is not None and now - cached[0] < RUNTIME_SNAPSHOT_CACHE_SECONDS:
|
|
11657
11998
|
return _copy_cache_value(cached[1])
|
|
11999
|
+
_claude_register_terminal_only_rows(set())
|
|
11658
12000
|
backend_rows = _claude_sessions_backend().collect_rows(since_min, live_only, limit)
|
|
11659
12001
|
|
|
11660
12002
|
rows: list[dict] = []
|
|
@@ -14623,10 +14965,9 @@ Worker instructions:
|
|
|
14623
14965
|
self._send_json({"ok": True, "handoff_id": handoff_id, "composeDraft": compose_draft})
|
|
14624
14966
|
|
|
14625
14967
|
def _handle_spawn_session(self, q):
|
|
14626
|
-
"""Spawn a new Claude/Codex session.
|
|
14627
|
-
|
|
14628
|
-
|
|
14629
|
-
PAIRLING_SPAWN_BACKEND=terminal_app for rollback/debugging.
|
|
14968
|
+
"""Spawn a new Claude/Codex session. User-facing direct spawns open a
|
|
14969
|
+
visible Terminal.app tab. The broker remains for aperture_cli launches
|
|
14970
|
+
and PAIRLING_SPAWN_BACKEND=broker rollback/debugging.
|
|
14630
14971
|
|
|
14631
14972
|
Security:
|
|
14632
14973
|
- Project path must be absolute and exist on disk.
|
|
@@ -14732,7 +15073,7 @@ Worker instructions:
|
|
|
14732
15073
|
}, status=400)
|
|
14733
15074
|
return
|
|
14734
15075
|
|
|
14735
|
-
if os.environ.get("PAIRLING_SPAWN_BACKEND", "
|
|
15076
|
+
if launch_strategy == "aperture_cli" or os.environ.get("PAIRLING_SPAWN_BACKEND", "terminal_app").lower() == "broker":
|
|
14736
15077
|
self._handle_spawn_session_broker(
|
|
14737
15078
|
project,
|
|
14738
15079
|
provider,
|
|
@@ -14741,13 +15082,6 @@ Worker instructions:
|
|
|
14741
15082
|
)
|
|
14742
15083
|
return
|
|
14743
15084
|
|
|
14744
|
-
if launch_strategy == "aperture_cli":
|
|
14745
|
-
self._send_json({
|
|
14746
|
-
"ok": False,
|
|
14747
|
-
"error": "Aperture CLI launch strategy requires the Pairling PTY broker backend",
|
|
14748
|
-
}, status=400)
|
|
14749
|
-
return
|
|
14750
|
-
|
|
14751
15085
|
capture_id = ""
|
|
14752
15086
|
capture_log_path: Path | None = None
|
|
14753
15087
|
if provider == "codex":
|
|
@@ -14762,7 +15096,11 @@ Worker instructions:
|
|
|
14762
15096
|
else:
|
|
14763
15097
|
capture_id = secrets.token_hex(12)
|
|
14764
15098
|
capture_log_path = TERMINAL_CAPTURE_DIR / f"claude-{capture_id}.log"
|
|
14765
|
-
inner =
|
|
15099
|
+
inner = (
|
|
15100
|
+
f"cd {shlex.quote(project)} && "
|
|
15101
|
+
f"PAIRLING_PHONE_SESSION=1 "
|
|
15102
|
+
f"exec claude --settings {shlex.quote(str(SPAWN_SETTINGS_PATH))}"
|
|
15103
|
+
)
|
|
14766
15104
|
shell_cmd = _terminal_script_command(capture_log_path, inner)
|
|
14767
15105
|
as_escaped_cmd = _as_escape(shell_cmd)
|
|
14768
15106
|
|
|
@@ -14791,41 +15129,37 @@ Worker instructions:
|
|
|
14791
15129
|
if len(parts) >= 2:
|
|
14792
15130
|
tty = parts[1].strip()
|
|
14793
15131
|
pid = 0
|
|
14794
|
-
native_id =
|
|
14795
|
-
if
|
|
14796
|
-
native_id = "pending-" + secrets.token_hex(8)
|
|
15132
|
+
native_id = "pending-" + secrets.token_hex(8)
|
|
15133
|
+
if result.get("ok"):
|
|
14797
15134
|
_time.sleep(0.75)
|
|
14798
|
-
pid = _pid_for_tty_command(tty,
|
|
15135
|
+
pid = _pid_for_tty_command(tty, provider) if tty else 0
|
|
14799
15136
|
if tty and capture_log_path is not None:
|
|
14800
15137
|
_write_terminal_capture_mapping(
|
|
14801
15138
|
tty,
|
|
14802
15139
|
capture_log_path,
|
|
14803
|
-
provider=
|
|
15140
|
+
provider=provider,
|
|
14804
15141
|
project=project,
|
|
14805
15142
|
capture_id=capture_id,
|
|
14806
15143
|
)
|
|
14807
15144
|
_agent_registry_upsert(
|
|
14808
|
-
|
|
15145
|
+
provider,
|
|
14809
15146
|
native_id,
|
|
14810
15147
|
project,
|
|
14811
15148
|
pid=pid,
|
|
14812
15149
|
terminal_tty=tty,
|
|
14813
15150
|
metadata={
|
|
14814
|
-
"spawned_by": "
|
|
15151
|
+
"spawned_by": "pairling",
|
|
15152
|
+
"launch_strategy": "direct_pairling",
|
|
15153
|
+
"launch_visibility": "visible_terminal",
|
|
14815
15154
|
"terminal_log": str(capture_log_path) if capture_log_path else None,
|
|
14816
15155
|
"capture_backend": "script" if capture_log_path else None,
|
|
15156
|
+
"terminal_source": "terminal_app_contents",
|
|
14817
15157
|
"capture_id": capture_id or None,
|
|
14818
15158
|
},
|
|
15159
|
+
working_on=f"New {provider.title()} session",
|
|
14819
15160
|
)
|
|
14820
|
-
|
|
14821
|
-
|
|
14822
|
-
_write_terminal_capture_mapping(
|
|
14823
|
-
tty,
|
|
14824
|
-
capture_log_path,
|
|
14825
|
-
provider="claude",
|
|
14826
|
-
project=project,
|
|
14827
|
-
capture_id=capture_id,
|
|
14828
|
-
)
|
|
15161
|
+
if provider == "codex":
|
|
15162
|
+
_write_agent_turn_state("codex", native_id, "idle", event="spawn")
|
|
14829
15163
|
|
|
14830
15164
|
# Audit log — append-only, JSONL, includes failures.
|
|
14831
15165
|
try:
|
|
@@ -14857,20 +15191,21 @@ Worker instructions:
|
|
|
14857
15191
|
self.wfile.write(body)
|
|
14858
15192
|
return
|
|
14859
15193
|
|
|
14860
|
-
|
|
15194
|
+
self._send_json({
|
|
14861
15195
|
"ok": True,
|
|
14862
15196
|
"project": project,
|
|
14863
15197
|
"provider": provider,
|
|
14864
15198
|
"native_id": native_id,
|
|
15199
|
+
"session_id": _qualified_session_id(provider, native_id),
|
|
14865
15200
|
"tty": tty,
|
|
14866
15201
|
"pid": pid,
|
|
14867
15202
|
"terminal_log": str(capture_log_path) if capture_log_path else None,
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
14873
|
-
|
|
15203
|
+
"capture_backend": "script" if capture_log_path else None,
|
|
15204
|
+
"terminal_source": "terminal_app_contents",
|
|
15205
|
+
"launch_strategy": "direct_pairling",
|
|
15206
|
+
"launch_visibility": "visible_terminal",
|
|
15207
|
+
"attach_command": None,
|
|
15208
|
+
})
|
|
14874
15209
|
|
|
14875
15210
|
def _session_context_for_workflow(self, raw_session: str) -> dict | None:
|
|
14876
15211
|
provider, native_id = _parse_agent_session_ref(raw_session)
|
|
@@ -15234,7 +15569,7 @@ Worker instructions:
|
|
|
15234
15569
|
self.wfile.write(body)
|
|
15235
15570
|
return
|
|
15236
15571
|
|
|
15237
|
-
if os.environ.get("PAIRLING_RESUME_BACKEND", "
|
|
15572
|
+
if os.environ.get("PAIRLING_RESUME_BACKEND", "terminal_app").lower() == "broker":
|
|
15238
15573
|
self._handle_resume_session_broker(provider=provider, project=project, native_id=native_id, prompt=prompt)
|
|
15239
15574
|
return
|
|
15240
15575
|
|
|
@@ -15289,6 +15624,8 @@ Worker instructions:
|
|
|
15289
15624
|
"resume_target": native_id,
|
|
15290
15625
|
"terminal_log": str(capture_log_path),
|
|
15291
15626
|
"capture_backend": "script",
|
|
15627
|
+
"terminal_source": "terminal_app_contents",
|
|
15628
|
+
"launch_visibility": "visible_terminal",
|
|
15292
15629
|
"capture_id": capture_id,
|
|
15293
15630
|
},
|
|
15294
15631
|
)
|
|
@@ -15320,7 +15657,7 @@ Worker instructions:
|
|
|
15320
15657
|
self.end_headers()
|
|
15321
15658
|
self.wfile.write(body)
|
|
15322
15659
|
return
|
|
15323
|
-
|
|
15660
|
+
self._send_json({
|
|
15324
15661
|
"ok": True,
|
|
15325
15662
|
"provider": "codex",
|
|
15326
15663
|
"native_id": native_id,
|
|
@@ -15331,13 +15668,9 @@ Worker instructions:
|
|
|
15331
15668
|
"terminal_log": str(capture_log_path),
|
|
15332
15669
|
"capture_backend": "script",
|
|
15333
15670
|
"terminal_source": "terminal_app_contents",
|
|
15671
|
+
"launch_visibility": "visible_terminal",
|
|
15334
15672
|
"attach_command": None,
|
|
15335
|
-
})
|
|
15336
|
-
self.send_response(200)
|
|
15337
|
-
self.send_header("Content-Type", "application/json")
|
|
15338
|
-
self.send_header("Content-Length", str(len(body)))
|
|
15339
|
-
self.end_headers()
|
|
15340
|
-
self.wfile.write(body)
|
|
15673
|
+
})
|
|
15341
15674
|
|
|
15342
15675
|
def _send_text_to_codex_registry(self, native_id: str, text: str, receipt_context: dict | None = None) -> None:
|
|
15343
15676
|
# Precondition: callers pass text through _sanitize_terminal_text_input.
|
|
@@ -1366,10 +1366,11 @@ def default_pair_route(port_number: int) -> dict:
|
|
|
1366
1366
|
value = os.environ.get(key)
|
|
1367
1367
|
if value:
|
|
1368
1368
|
return {"base_url": value, "source": "explicit_override", "status": "override"}
|
|
1369
|
-
#
|
|
1370
|
-
#
|
|
1371
|
-
|
|
1372
|
-
|
|
1369
|
+
# First-pair bootstrap: prefer LAN when it exists. iOS blocks plain HTTP to
|
|
1370
|
+
# a tailnet IP before the embedded Pairling Connect route is ready.
|
|
1371
|
+
lan_ip = detected_lan_ip()
|
|
1372
|
+
if lan_ip:
|
|
1373
|
+
return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
|
|
1373
1374
|
route = ready_connectd_route()
|
|
1374
1375
|
if route:
|
|
1375
1376
|
return {
|
|
@@ -1378,9 +1379,6 @@ def default_pair_route(port_number: int) -> dict:
|
|
|
1378
1379
|
"status": route["status"],
|
|
1379
1380
|
"kind": route["kind"],
|
|
1380
1381
|
}
|
|
1381
|
-
lan_ip = detected_lan_ip()
|
|
1382
|
-
if lan_ip:
|
|
1383
|
-
return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
|
|
1384
1382
|
if os.environ.get("PAIRLING_DISABLE_BONJOUR") != "1" and os.environ.get("PAIRLING_TEST_DISABLE_BONJOUR") != "1":
|
|
1385
1383
|
return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback", "kind": "bonjour"}
|
|
1386
1384
|
tailnet_ip = detected_tailnet_ip()
|
package/payload-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"connectd": {
|
|
3
3
|
"darwin-arm64": {
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "2bd3903963b0395cc2af25c0ca88450873b8f878fa8b0fcf5b70283fde21602f",
|
|
5
5
|
"team_id": "965AVD34A3"
|
|
6
6
|
},
|
|
7
7
|
"darwin-x64": {
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "301fe95148dbc5a5144165802319d817a4a5c8f1492d0b5a11ef101f4fffbc9e",
|
|
9
9
|
"team_id": "965AVD34A3"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "payload/mac/SOURCE_REVISION",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "48e3c96b6c22c6c3424b1fbcebbbee0e23f0e61535ac6b487dec510a496cb6ef"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "payload/mac/VERSION",
|
|
27
|
-
"sha256": "
|
|
27
|
+
"sha256": "bbab81dca58f2b892db293acbba7fe5731667b67876f6812e3e7b9e2253a5c51"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"path": "payload/mac/companiond/app_attest_lan.py",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"path": "payload/mac/companiond/pairlingd.py",
|
|
99
|
-
"sha256": "
|
|
99
|
+
"sha256": "ad3d9929966d41778de40be0ae908ea22ec49f811f9a6aceabd606aa41b6a2db"
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
"path": "payload/mac/companiond/providers/__init__.py",
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
},
|
|
233
233
|
{
|
|
234
234
|
"path": "payload/mac/connectd/internal/gateway/proxy.go",
|
|
235
|
-
"sha256": "
|
|
235
|
+
"sha256": "9d57225667f5ab025286c6714864762cbd31a8bdaf176f2b5679a26ca7144f74"
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
"path": "payload/mac/connectd/internal/gateway/proxy_test.go",
|
|
@@ -272,7 +272,7 @@
|
|
|
272
272
|
},
|
|
273
273
|
{
|
|
274
274
|
"path": "payload/mac/install/install-runtime.sh",
|
|
275
|
-
"sha256": "
|
|
275
|
+
"sha256": "2575f5ae2aeb03d6bce5ae1194498ac34d14d09c993c4a1a4474e187cae10618"
|
|
276
276
|
},
|
|
277
277
|
{
|
|
278
278
|
"path": "payload/mac/install/psk_dependency_check.py",
|
|
@@ -296,8 +296,8 @@
|
|
|
296
296
|
}
|
|
297
297
|
],
|
|
298
298
|
"package": "pairling",
|
|
299
|
-
"package_version": "0.2.
|
|
299
|
+
"package_version": "0.2.11",
|
|
300
300
|
"schema_version": 1,
|
|
301
301
|
"source_dirty": false,
|
|
302
|
-
"source_revision": "
|
|
302
|
+
"source_revision": "83c69e1"
|
|
303
303
|
}
|