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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairling",
3
- "version": "0.2.9",
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.9",
44
- "@pairling/runtime-darwin-x64": "0.2.9"
43
+ "@pairling/runtime-darwin-arm64": "0.2.11",
44
+ "@pairling/runtime-darwin-x64": "0.2.11"
45
45
  }
46
46
  }
@@ -1 +1 @@
1
- c97bbab
1
+ 83c69e1
@@ -1 +1 @@
1
- 0.2.9
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
- RUNTIME_MAX_ACTIVE_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_STREAMS", "6")))
459
+ RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS", "8")))
460
+ RUNTIME_MAX_ACTIVE_STREAMS = max(2, int(os.environ.get(
461
+ "PAIRLING_RUNTIME_MAX_ACTIVE_DETAIL_STREAMS",
462
+ os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_STREAMS", "6"),
463
+ )))
464
+ RUNTIME_MAX_ACTIVE_AUX_STREAMS = max(2, int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_AUX_STREAMS", "4")))
460
465
  RUNTIME_MAX_ACTIVE_CONNECTIONS = max(
461
- RUNTIME_MAX_ACTIVE_FAST_REQUESTS + RUNTIME_MAX_ACTIVE_REQUESTS + RUNTIME_MAX_ACTIVE_STREAMS + 2,
462
- int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_CONNECTIONS", "24")),
466
+ (
467
+ RUNTIME_MAX_ACTIVE_FAST_REQUESTS
468
+ + RUNTIME_MAX_ACTIVE_REQUESTS
469
+ + RUNTIME_MAX_ACTIVE_DASHBOARD_STREAMS
470
+ + RUNTIME_MAX_ACTIVE_STREAMS
471
+ + RUNTIME_MAX_ACTIVE_AUX_STREAMS
472
+ + 2
473
+ ),
474
+ int(os.environ.get("PAIRLING_RUNTIME_MAX_ACTIVE_CONNECTIONS", "28")),
463
475
  )
464
476
  TERMINAL_TRUTH_OSASCRIPT_TIMEOUT_SECONDS = max(
465
477
  0.5,
@@ -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
- coordinator = _apply_pairling_connect_posture(coordinator, power_state, connect)
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
- state, _path, age, error = _read_guardian_state()
2627
- coordinator = _coordinator_from_guardian(state, age, error)
2628
- coordinator = _apply_pairling_connect_posture(
2629
- coordinator, _normalize_guardian_state(state), _pairling_connect_health()
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 = (_read_guardian_state()[0] or {})
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 row.get("claude_uuid")
4382
- and float(row.get("last_heartbeat") or 0) > cutoff
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
- if PTY_BROKER is not None:
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
- session = PTY_BROKER.get(broker_id)
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
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
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
- return {"available": False, "source": "unavailable", "reason": "no_terminal_tty"}
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
- """Read-only Codex provider: persisted rollouts, no process control yet."""
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=False))
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): not explicitly closed, has a
9076
- # claude_uuid (proves hooks fired post-migration), AND heartbeat is
9077
- # fresh enough. The freshness gate hides DORMANT zombies claudes
9078
- # whose process is technically alive but stopped firing hooks hours
9079
- # or days ago (typical of sentinel-mode terminals whose hooks point
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
- "Requires closed_at null, claude_uuid present, and a recent heartbeat.",
9198
- "This is the canonical Claude dashboard source.",
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
- body: dict = {"source": _sessions_stream_source(), "items": rows, "ts": _time.time()}
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
- broker_found = self._broker_session_for(raw_session)
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
- tail = PTY_BROKER.raw_tail(broker_id, since=since)
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
- if last_truth is None:
9946
- # Preserve the original ordering guarantee: clients see the
9947
- # first truth event before any tail data, so reducers that
9948
- # gate terminal lines on transcript truth never drop bytes.
9949
- if now - last_keepalive >= 20.0:
9950
- if not emit("keepalive", {}):
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
- terminal_payload = self._session_live_terminal_tail(session_id, terminal_offset)
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
- source = _terminal_surface_source(raw_session)
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
- session = PTY_BROKER.get(broker_id)
10897
+ try:
10898
+ session = PTY_BROKER.get(broker_id)
10899
+ except Exception:
10900
+ session = None
10592
10901
  if session:
10593
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
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
- session = PTY_BROKER.get_by_tty(tty)
10909
+ try:
10910
+ session = PTY_BROKER.get_by_tty(tty)
10911
+ except Exception:
10912
+ session = None
10598
10913
  if session:
10599
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
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
- session = PTY_BROKER.get_by_tty(tty)
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
- PTY_BROKER.register_alias(qualified, _broker_session_id(session))
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. Pairling-owned PTYs are the
14627
- default path; Terminal.app can attach as a client via `pairling attach`.
14628
- The legacy Terminal.app-owner path remains behind
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", "broker").lower() != "terminal_app":
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 = f"cd {shlex.quote(project)} && claude"
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 = None
14795
- if provider == "codex" and result.get("ok"):
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, "codex") if tty else 0
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="codex",
15140
+ provider=provider,
14804
15141
  project=project,
14805
15142
  capture_id=capture_id,
14806
15143
  )
14807
15144
  _agent_registry_upsert(
14808
- "codex",
15145
+ provider,
14809
15146
  native_id,
14810
15147
  project,
14811
15148
  pid=pid,
14812
15149
  terminal_tty=tty,
14813
15150
  metadata={
14814
- "spawned_by": "phone-companion",
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
- _write_agent_turn_state("codex", native_id, "idle", event="spawn")
14821
- elif provider == "claude" and result.get("ok") and tty and capture_log_path is not None:
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
- body = json.dumps({
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
- }).encode()
14869
- self.send_response(200)
14870
- self.send_header("Content-Type", "application/json")
14871
- self.send_header("Content-Length", str(len(body)))
14872
- self.end_headers()
14873
- self.wfile.write(body)
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", "broker").lower() != "terminal_app":
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
- body = json.dumps({
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
- }).encode()
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.
@@ -723,6 +723,7 @@ var postPaths = map[string]bool{
723
723
  "/open": true,
724
724
  "/orchestrations": true,
725
725
  "/pair/claim": true,
726
+ "/pair/bind-node": true,
726
727
  "/pair/psk-claim": true,
727
728
  "/pair/revoke": true,
728
729
  "/pair/rotate-token": true,
@@ -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
- # Remote-first pairing: if connectd reports a ready Pairling Connect route,
1370
- # the QR advertises that route and the iOS app claims it through the
1371
- # embedded pre-pair transport. LAN/Bonjour are explicit degraded fallbacks
1372
- # when Pairling Connect is not ready.
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()
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "connectd": {
3
3
  "darwin-arm64": {
4
- "sha256": "3ee070f12619390609a8c76e0ae4803ab1bc48c9f77361b8f27a4125846f38e3",
4
+ "sha256": "2bd3903963b0395cc2af25c0ca88450873b8f878fa8b0fcf5b70283fde21602f",
5
5
  "team_id": "965AVD34A3"
6
6
  },
7
7
  "darwin-x64": {
8
- "sha256": "c1ee211c3da71200d7855c9246ea04cd25fda702f7fb33a366b4d51bdaf9e230",
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": "29c2c816c529155cc86f26d44a8e8d79fc170fc26ade74fac3780554b5c864ff"
23
+ "sha256": "48e3c96b6c22c6c3424b1fbcebbbee0e23f0e61535ac6b487dec510a496cb6ef"
24
24
  },
25
25
  {
26
26
  "path": "payload/mac/VERSION",
27
- "sha256": "2f2b333be8858cb384114251b668bb1c9e45a0bb8393600b7ad4093d0e8e72f6"
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": "ca1c5551dd235990d4ff8d8b9c3c3f25e912ca9c22c7003f50c9fe1ccb6bf02e"
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": "b852408a35b71a0b62554cc93f413c3352034432f14529ab3ddbe85fa5ee49c8"
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": "40ffa67a3833ce4342c241ca4f1e7dec471a86b0c80359746cd75a8df2e33f60"
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.9",
299
+ "package_version": "0.2.11",
300
300
  "schema_version": 1,
301
301
  "source_dirty": false,
302
- "source_revision": "c97bbab"
302
+ "source_revision": "83c69e1"
303
303
  }