pairling 0.2.10 → 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.10",
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.10",
44
- "@pairling/runtime-darwin-x64": "0.2.10"
43
+ "@pairling/runtime-darwin-arm64": "0.2.11",
44
+ "@pairling/runtime-darwin-x64": "0.2.11"
45
45
  }
46
46
  }
@@ -1 +1 @@
1
- 0d3be68
1
+ 83c69e1
@@ -1 +1 @@
1
- 0.2.10
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)
@@ -2800,6 +2832,7 @@ def _pid_for_tty_command(tty: str, command_name: str) -> int:
2800
2832
 
2801
2833
 
2802
2834
  _codex_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
2835
+ _claude_terminal_scan_cache: dict[str, object] = {"ts": 0.0, "rows": []}
2803
2836
  _codex_task_boundary_cache: dict[str, dict[str, object]] = {}
2804
2837
 
2805
2838
 
@@ -2831,6 +2864,11 @@ def _is_codex_cli_command(command: str) -> bool:
2831
2864
  )
2832
2865
 
2833
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
+
2834
2872
  def _codex_live_terminal_rows() -> list[dict]:
2835
2873
  now = _time.time()
2836
2874
  cached_ts = float(_codex_terminal_scan_cache.get("ts") or 0)
@@ -2883,6 +2921,56 @@ def _codex_live_terminal_rows() -> list[dict]:
2883
2921
  return rows
2884
2922
 
2885
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
+
2886
2974
  def _codex_discover_terminal_control(project: str, observed_started_at: float) -> dict | None:
2887
2975
  if not project or observed_started_at <= 0:
2888
2976
  return None
@@ -2898,6 +2986,109 @@ def _codex_discover_terminal_control(project: str, observed_started_at: float) -
2898
2986
  return best if delta <= 600 else None
2899
2987
 
2900
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
+
2901
3092
  # Phase 4 B.3: warm claude --continue pool. Maintains up to one long-running
2902
3093
  # `claude` session per model. After 5 min idle, the worker exits.
2903
3094
  # This turns 18-25s cold start into ~2s for repeat /llm-route calls.
@@ -4388,8 +4579,10 @@ class ClaudeSessionsSqliteBackend:
4388
4579
  if live_only:
4389
4580
  source = [
4390
4581
  row for row in _agent_registry_live("claude")
4391
- if row.get("claude_uuid")
4392
- 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")
4393
4586
  ]
4394
4587
  else:
4395
4588
  source = _agent_registry_recent("claude", since_min=within_min, limit=1000)
@@ -4415,7 +4608,7 @@ class ClaudeSessionsSqliteBackend:
4415
4608
  def collect_rows(self, since_min: int, live_only: bool, limit: int) -> list[dict]:
4416
4609
  rows: list[dict] = []
4417
4610
  for row in _agent_registry_recent("claude", since_min=since_min, limit=1000):
4418
- if not row.get("claude_uuid"):
4611
+ if not row.get("claude_uuid") and not _registry_row_has_live_terminal(row, "claude"):
4419
4612
  continue
4420
4613
  if live_only and row.get("closed_at") is not None:
4421
4614
  continue
@@ -4908,17 +5101,7 @@ def _terminal_surface_source(raw_session: str) -> dict:
4908
5101
  if provider not in AGENT_PROVIDERS:
4909
5102
  return {"available": False, "source": "unavailable", "reason": "unsupported_provider"}
4910
5103
 
4911
- if PTY_BROKER is not None:
4912
- session = PTY_BROKER.get(qualified)
4913
- if session is not None:
4914
- return {
4915
- "available": True,
4916
- "source": "broker_vt",
4917
- "reason": "broker_vt",
4918
- "broker_id": _broker_session_id(session),
4919
- "tty": _broker_slave_tty(session),
4920
- "can_control": True,
4921
- }
5104
+ broker_error = ""
4922
5105
 
4923
5106
  if provider == "codex":
4924
5107
  reg = _agent_registry_get("codex", native_id) or {}
@@ -4931,9 +5114,16 @@ def _terminal_surface_source(raw_session: str) -> dict:
4931
5114
  metadata = {}
4932
5115
  broker_id = str(metadata.get("broker_id") or "").strip()
4933
5116
  if broker_id and PTY_BROKER is not None:
4934
- 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]
4935
5122
  if session is not None:
4936
- 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
4937
5127
  return {
4938
5128
  "available": True,
4939
5129
  "source": "broker_vt",
@@ -4972,7 +5162,59 @@ def _terminal_surface_source(raw_session: str) -> dict:
4972
5162
  "can_control": True,
4973
5163
  }
4974
5164
 
4975
- 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"}
4976
5218
 
4977
5219
 
4978
5220
  def _terminal_surface_capabilities(raw_session: str) -> dict:
@@ -5324,8 +5566,8 @@ def _session_runtime_truth_from_parts(
5324
5566
  control_state = "blocked"
5325
5567
  blocked_reason = contradictions[0]["code"] if contradictions else "terminal_surface_degraded"
5326
5568
  elif selected_surface == "v1_fallback":
5327
- control_state = "read_only"
5328
- 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"
5329
5571
  else:
5330
5572
  control_state = "unavailable"
5331
5573
  blocked_reason = "terminal_surface_unavailable"
@@ -6156,6 +6398,8 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
6156
6398
  row["provider"] = "claude"
6157
6399
  row["native_id"] = native_id
6158
6400
  row["id"] = _qualified_session_id("claude", native_id)
6401
+ row["terminal_tty"] = terminal_tty
6402
+ row["pid"] = claude_pid
6159
6403
  capabilities = list(CLAUDE_SESSION_CAPABILITIES)
6160
6404
  if terminal_tty and _terminal_capture_for_tty(terminal_tty, row.get("project")):
6161
6405
  capabilities.append("terminal_output")
@@ -6179,6 +6423,35 @@ def _decorate_claude_session_row(row: dict, native_id: str, claude_pid: int = 0,
6179
6423
  return row
6180
6424
 
6181
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
+
6182
6455
  def _refresh_claude_observed_activity(row: dict, project: str | None, claude_uuid: str | None) -> None:
6183
6456
  """Use transcript/turn-state evidence to correct stale PG heartbeats."""
6184
6457
  observed = int(row.get("last_heartbeat") or 0)
@@ -6539,6 +6812,10 @@ def _codex_control_overlay(row: dict, observed_mtime: float | None = None, *, ve
6539
6812
  state_payload = _codex_turn_state_payload(row.get("native_id") or "", apply_boundary=False)
6540
6813
  can_send = bool(tty)
6541
6814
  can_signal = bool(pid)
6815
+ if tty:
6816
+ row["terminal_tty"] = tty
6817
+ if pid:
6818
+ row["pid"] = pid
6542
6819
  caps = set(row.get("capabilities") or [])
6543
6820
  if state_payload or can_send or can_signal:
6544
6821
  caps.add("live_state")
@@ -6608,6 +6885,8 @@ def _codex_pending_registry_rows(seen: set[str], live_only: bool, active_within_
6608
6885
  "last_heartbeat": int(heartbeat or _time.time()),
6609
6886
  "stale_seconds": stale_seconds,
6610
6887
  "source_freshness": "registry_stale_process_alive" if heartbeat < cutoff and process_alive else "registry_live",
6888
+ "terminal_tty": tty,
6889
+ "pid": pid,
6611
6890
  "first_prompt": None,
6612
6891
  "state": "running",
6613
6892
  "tool": None,
@@ -6700,7 +6979,7 @@ def _list_codex_sessions(live_only: bool, active_within_min: int) -> list[dict]:
6700
6979
 
6701
6980
 
6702
6981
  def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> list[dict]:
6703
- """Read-only Codex provider: persisted rollouts, no process control yet."""
6982
+ """Codex provider backed by transcripts plus live terminal discovery."""
6704
6983
  index = _codex_index_map()
6705
6984
  history = _codex_history_map()
6706
6985
  cutoff = _time.time() - max(1, active_within_min) * 60
@@ -6760,12 +7039,14 @@ def _list_codex_sessions_uncached(live_only: bool, active_within_min: int) -> li
6760
7039
  row["tool"] = state_payload.get("tool")
6761
7040
  row["turn_started_at"] = state_payload.get("started_at")
6762
7041
  row["effort"] = state_payload.get("effort")
6763
- 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))
6764
7043
  if len(rows) >= 50:
6765
7044
  break
7045
+ _codex_register_terminal_only_rows(seen)
6766
7046
  rows.extend(_codex_pending_registry_rows(seen, live_only, active_within_min))
6767
7047
  if not live_only:
6768
7048
  rows.extend(_codex_recent_closed_registry_rows(seen, active_within_min))
7049
+ rows = _collapse_live_session_rows_by_terminal(rows)
6769
7050
  rows.sort(
6770
7051
  key=lambda r: (
6771
7052
  1 if (r.get("controllability") or {}).get("can_terminate") else 0,
@@ -8898,6 +9179,7 @@ class Handler(BaseHTTPRequestHandler):
8898
9179
  for row in _list_codex_sessions(live_only=False, active_within_min=active_within_min):
8899
9180
  rows.append(self._decorate_session_lifecycle_row(row))
8900
9181
 
9182
+ rows = _collapse_live_session_rows_by_terminal(rows)
8901
9183
  rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
8902
9184
  rows = rows[:max(1, min(int(limit or 200), 500))]
8903
9185
  for row in rows:
@@ -9073,6 +9355,7 @@ class Handler(BaseHTTPRequestHandler):
9073
9355
  self._decorate_session_lifecycle_row(row)
9074
9356
  for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
9075
9357
  ]
9358
+ rows = _collapse_live_session_rows_by_terminal(rows)
9076
9359
  _record_sessions_scan(rows)
9077
9360
  body = json.dumps({"count": len(rows), "items": rows}).encode()
9078
9361
  self.send_response(200)
@@ -9082,14 +9365,11 @@ class Handler(BaseHTTPRequestHandler):
9082
9365
  self.wfile.write(body)
9083
9366
  return
9084
9367
 
9085
- # Live filter semantics (live_only): not explicitly closed, has a
9086
- # claude_uuid (proves hooks fired post-migration), AND heartbeat is
9087
- # fresh enough. The freshness gate hides DORMANT zombies claudes
9088
- # whose process is technically alive but stopped firing hooks hours
9089
- # or days ago (typical of sentinel-mode terminals whose hooks point
9090
- # at the Sentinel daemon on :9100 instead of us). The kill -0 GC
9091
- # below catches process-DEAD rows; this catches
9092
- # process-alive-but-mute rows. Defaults to within_min=60.
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())
9093
9373
  backend = _claude_sessions_backend()
9094
9374
  try:
9095
9375
  backend_rows = backend.sessions_rows(live_only, within_min)
@@ -9163,8 +9443,11 @@ class Handler(BaseHTTPRequestHandler):
9163
9443
  self._decorate_session_lifecycle_row(row)
9164
9444
  for row in _list_codex_sessions(live_only=live_only, active_within_min=within_min)
9165
9445
  )
9446
+ rows = _collapse_live_session_rows_by_terminal(rows)
9166
9447
  rows.sort(key=lambda r: int(r.get("last_heartbeat") or 0), reverse=True)
9167
9448
  rows = rows[:50]
9449
+ else:
9450
+ rows = _collapse_live_session_rows_by_terminal(rows)
9168
9451
 
9169
9452
  _record_sessions_scan(rows)
9170
9453
  body = json.dumps({"count": len(rows), "items": rows}).encode()
@@ -9204,8 +9487,8 @@ class Handler(BaseHTTPRequestHandler):
9204
9487
  "fresh": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) < 120),
9205
9488
  "stale": sum(1 for row in claude_live if now - int(row.get("last_heartbeat") or 0) >= 120),
9206
9489
  "notes": [
9207
- "Requires closed_at null, claude_uuid present, and a recent heartbeat.",
9208
- "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.",
9209
9492
  ],
9210
9493
  },
9211
9494
  {
@@ -9304,7 +9587,8 @@ class Handler(BaseHTTPRequestHandler):
9304
9587
 
9305
9588
  def snapshot_payload(rows: list[dict]) -> tuple[bytes, str]:
9306
9589
  degraded = self._sessions_backend_degradation()
9307
- 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()}
9308
9592
  if degraded is not None:
9309
9593
  body["degraded"] = degraded
9310
9594
  digest = hashlib.sha256(
@@ -9315,6 +9599,7 @@ class Handler(BaseHTTPRequestHandler):
9315
9599
  try:
9316
9600
  # Emit initial snapshot immediately so the iPhone never paints
9317
9601
  # an empty Dashboard while waiting for the first poll.
9602
+ # Initial snapshot body contract: {"items": initial}.
9318
9603
  initial = collect_live()
9319
9604
  payload, last_hash = snapshot_payload(initial)
9320
9605
  self.wfile.write(b"event: snapshot\ndata: " + payload + b"\n\n")
@@ -9756,10 +10041,18 @@ class Handler(BaseHTTPRequestHandler):
9756
10041
  def _session_live_terminal_tail(self, raw_session: str, since: int) -> dict | None:
9757
10042
  provider, native_id = _parse_agent_session_ref(raw_session)
9758
10043
  since = max(0, int(since or 0))
9759
- 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])
9760
10049
  if broker_found and PTY_BROKER:
9761
10050
  broker_id, _ = broker_found
9762
- 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
9763
10056
  if tail is None:
9764
10057
  return None
9765
10058
  data, next_offset, total_bytes, reset = tail
@@ -9952,22 +10245,21 @@ class Handler(BaseHTTPRequestHandler):
9952
10245
  if not emit("turn_state", turn, source="turn-state"):
9953
10246
  return
9954
10247
 
9955
- if last_truth is None:
9956
- # Preserve the original ordering guarantee: clients see the
9957
- # first truth event before any tail data, so reducers that
9958
- # gate terminal lines on transcript truth never drop bytes.
9959
- if now - last_keepalive >= 20.0:
9960
- if not emit("keepalive", {}):
9961
- return
9962
- _time.sleep(0.05)
9963
- continue
9964
-
9965
- for receipt_event in _session_live_control_receipts_since(session_id, receipt_seq):
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:
9966
10254
  receipt_seq = max(receipt_seq, int(receipt_event.get("receipt_seq") or 0))
9967
10255
  if not emit("control_receipt", receipt_event, source="control-receipts"):
9968
10256
  return
9969
10257
 
9970
- 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])
9971
10263
  if terminal_payload is not None:
9972
10264
  if terminal_payload.get("reset"):
9973
10265
  terminal_offset = 0
@@ -10118,7 +10410,14 @@ class Handler(BaseHTTPRequestHandler):
10118
10410
 
10119
10411
  def _terminal_stream_truth_for_session(self, raw_session: str) -> dict:
10120
10412
  provider, native_id = _parse_agent_session_ref(raw_session)
10121
- 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
+ }
10122
10421
  backend = source.get("source")
10123
10422
  byte_stream_available = bool(source.get("available")) and backend in {"broker_vt", "script_capture"}
10124
10423
  try:
@@ -10587,9 +10886,6 @@ class Handler(BaseHTTPRequestHandler):
10587
10886
  if not native_id:
10588
10887
  return None
10589
10888
  qualified = _qualified_session_id(provider, native_id)
10590
- session = PTY_BROKER.get(qualified)
10591
- if session:
10592
- return qualified, session
10593
10889
  if provider == "codex":
10594
10890
  reg = _agent_registry_get("codex", native_id) or {}
10595
10891
  try:
@@ -10598,26 +10894,61 @@ class Handler(BaseHTTPRequestHandler):
10598
10894
  metadata = {}
10599
10895
  broker_id = str(metadata.get("broker_id") or "").strip()
10600
10896
  if broker_id:
10601
- session = PTY_BROKER.get(broker_id)
10897
+ try:
10898
+ session = PTY_BROKER.get(broker_id)
10899
+ except Exception:
10900
+ session = None
10602
10901
  if session:
10603
- 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
10604
10906
  return qualified, session
10605
10907
  tty = reg.get("terminal_tty") or ""
10606
10908
  if tty:
10607
- session = PTY_BROKER.get_by_tty(tty)
10909
+ try:
10910
+ session = PTY_BROKER.get_by_tty(tty)
10911
+ except Exception:
10912
+ session = None
10608
10913
  if session:
10609
- 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
10610
10918
  return qualified, session
10611
10919
  if provider == "claude":
10612
10920
  session_id = _claude_native_session_id(raw_session)
10613
10921
  if not session_id:
10614
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
10615
10940
  tty = self._lookup_terminal_tty(session_id)
10616
10941
  if tty:
10617
- session = PTY_BROKER.get_by_tty(tty)
10942
+ try:
10943
+ session = PTY_BROKER.get_by_tty(tty)
10944
+ except Exception:
10945
+ session = None
10618
10946
  if session:
10619
10947
  qualified = _qualified_session_id("claude", session_id)
10620
- 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
10621
10952
  return qualified, session
10622
10953
  return None
10623
10954
 
@@ -11665,6 +11996,7 @@ class Handler(BaseHTTPRequestHandler):
11665
11996
  cached = _runtime_snapshot_cache.get(cache_key)
11666
11997
  if cached is not None and now - cached[0] < RUNTIME_SNAPSHOT_CACHE_SECONDS:
11667
11998
  return _copy_cache_value(cached[1])
11999
+ _claude_register_terminal_only_rows(set())
11668
12000
  backend_rows = _claude_sessions_backend().collect_rows(since_min, live_only, limit)
11669
12001
 
11670
12002
  rows: list[dict] = []
@@ -14633,10 +14965,9 @@ Worker instructions:
14633
14965
  self._send_json({"ok": True, "handoff_id": handoff_id, "composeDraft": compose_draft})
14634
14966
 
14635
14967
  def _handle_spawn_session(self, q):
14636
- """Spawn a new Claude/Codex session. Pairling-owned PTYs are the
14637
- default path; Terminal.app can attach as a client via `pairling attach`.
14638
- The legacy Terminal.app-owner path remains behind
14639
- PAIRLING_SPAWN_BACKEND=terminal_app for rollback/debugging.
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.
14640
14971
 
14641
14972
  Security:
14642
14973
  - Project path must be absolute and exist on disk.
@@ -14742,7 +15073,7 @@ Worker instructions:
14742
15073
  }, status=400)
14743
15074
  return
14744
15075
 
14745
- 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":
14746
15077
  self._handle_spawn_session_broker(
14747
15078
  project,
14748
15079
  provider,
@@ -14751,13 +15082,6 @@ Worker instructions:
14751
15082
  )
14752
15083
  return
14753
15084
 
14754
- if launch_strategy == "aperture_cli":
14755
- self._send_json({
14756
- "ok": False,
14757
- "error": "Aperture CLI launch strategy requires the Pairling PTY broker backend",
14758
- }, status=400)
14759
- return
14760
-
14761
15085
  capture_id = ""
14762
15086
  capture_log_path: Path | None = None
14763
15087
  if provider == "codex":
@@ -14772,7 +15096,11 @@ Worker instructions:
14772
15096
  else:
14773
15097
  capture_id = secrets.token_hex(12)
14774
15098
  capture_log_path = TERMINAL_CAPTURE_DIR / f"claude-{capture_id}.log"
14775
- 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
+ )
14776
15104
  shell_cmd = _terminal_script_command(capture_log_path, inner)
14777
15105
  as_escaped_cmd = _as_escape(shell_cmd)
14778
15106
 
@@ -14801,41 +15129,37 @@ Worker instructions:
14801
15129
  if len(parts) >= 2:
14802
15130
  tty = parts[1].strip()
14803
15131
  pid = 0
14804
- native_id = None
14805
- if provider == "codex" and result.get("ok"):
14806
- native_id = "pending-" + secrets.token_hex(8)
15132
+ native_id = "pending-" + secrets.token_hex(8)
15133
+ if result.get("ok"):
14807
15134
  _time.sleep(0.75)
14808
- pid = _pid_for_tty_command(tty, "codex") if tty else 0
15135
+ pid = _pid_for_tty_command(tty, provider) if tty else 0
14809
15136
  if tty and capture_log_path is not None:
14810
15137
  _write_terminal_capture_mapping(
14811
15138
  tty,
14812
15139
  capture_log_path,
14813
- provider="codex",
15140
+ provider=provider,
14814
15141
  project=project,
14815
15142
  capture_id=capture_id,
14816
15143
  )
14817
15144
  _agent_registry_upsert(
14818
- "codex",
15145
+ provider,
14819
15146
  native_id,
14820
15147
  project,
14821
15148
  pid=pid,
14822
15149
  terminal_tty=tty,
14823
15150
  metadata={
14824
- "spawned_by": "phone-companion",
15151
+ "spawned_by": "pairling",
15152
+ "launch_strategy": "direct_pairling",
15153
+ "launch_visibility": "visible_terminal",
14825
15154
  "terminal_log": str(capture_log_path) if capture_log_path else None,
14826
15155
  "capture_backend": "script" if capture_log_path else None,
15156
+ "terminal_source": "terminal_app_contents",
14827
15157
  "capture_id": capture_id or None,
14828
15158
  },
15159
+ working_on=f"New {provider.title()} session",
14829
15160
  )
14830
- _write_agent_turn_state("codex", native_id, "idle", event="spawn")
14831
- elif provider == "claude" and result.get("ok") and tty and capture_log_path is not None:
14832
- _write_terminal_capture_mapping(
14833
- tty,
14834
- capture_log_path,
14835
- provider="claude",
14836
- project=project,
14837
- capture_id=capture_id,
14838
- )
15161
+ if provider == "codex":
15162
+ _write_agent_turn_state("codex", native_id, "idle", event="spawn")
14839
15163
 
14840
15164
  # Audit log — append-only, JSONL, includes failures.
14841
15165
  try:
@@ -14867,20 +15191,21 @@ Worker instructions:
14867
15191
  self.wfile.write(body)
14868
15192
  return
14869
15193
 
14870
- body = json.dumps({
15194
+ self._send_json({
14871
15195
  "ok": True,
14872
15196
  "project": project,
14873
15197
  "provider": provider,
14874
15198
  "native_id": native_id,
15199
+ "session_id": _qualified_session_id(provider, native_id),
14875
15200
  "tty": tty,
14876
15201
  "pid": pid,
14877
15202
  "terminal_log": str(capture_log_path) if capture_log_path else None,
14878
- }).encode()
14879
- self.send_response(200)
14880
- self.send_header("Content-Type", "application/json")
14881
- self.send_header("Content-Length", str(len(body)))
14882
- self.end_headers()
14883
- self.wfile.write(body)
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
+ })
14884
15209
 
14885
15210
  def _session_context_for_workflow(self, raw_session: str) -> dict | None:
14886
15211
  provider, native_id = _parse_agent_session_ref(raw_session)
@@ -15244,7 +15569,7 @@ Worker instructions:
15244
15569
  self.wfile.write(body)
15245
15570
  return
15246
15571
 
15247
- if os.environ.get("PAIRLING_RESUME_BACKEND", "broker").lower() != "terminal_app":
15572
+ if os.environ.get("PAIRLING_RESUME_BACKEND", "terminal_app").lower() == "broker":
15248
15573
  self._handle_resume_session_broker(provider=provider, project=project, native_id=native_id, prompt=prompt)
15249
15574
  return
15250
15575
 
@@ -15299,6 +15624,8 @@ Worker instructions:
15299
15624
  "resume_target": native_id,
15300
15625
  "terminal_log": str(capture_log_path),
15301
15626
  "capture_backend": "script",
15627
+ "terminal_source": "terminal_app_contents",
15628
+ "launch_visibility": "visible_terminal",
15302
15629
  "capture_id": capture_id,
15303
15630
  },
15304
15631
  )
@@ -15330,7 +15657,7 @@ Worker instructions:
15330
15657
  self.end_headers()
15331
15658
  self.wfile.write(body)
15332
15659
  return
15333
- body = json.dumps({
15660
+ self._send_json({
15334
15661
  "ok": True,
15335
15662
  "provider": "codex",
15336
15663
  "native_id": native_id,
@@ -15341,13 +15668,9 @@ Worker instructions:
15341
15668
  "terminal_log": str(capture_log_path),
15342
15669
  "capture_backend": "script",
15343
15670
  "terminal_source": "terminal_app_contents",
15671
+ "launch_visibility": "visible_terminal",
15344
15672
  "attach_command": None,
15345
- }).encode()
15346
- self.send_response(200)
15347
- self.send_header("Content-Type", "application/json")
15348
- self.send_header("Content-Length", str(len(body)))
15349
- self.end_headers()
15350
- self.wfile.write(body)
15673
+ })
15351
15674
 
15352
15675
  def _send_text_to_codex_registry(self, native_id: str, text: str, receipt_context: dict | None = None) -> None:
15353
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": "9b2ed8873ae42e33426590c19b6bbae9ab6e42c9a103f2978a9f4b3486f95986",
4
+ "sha256": "2bd3903963b0395cc2af25c0ca88450873b8f878fa8b0fcf5b70283fde21602f",
5
5
  "team_id": "965AVD34A3"
6
6
  },
7
7
  "darwin-x64": {
8
- "sha256": "affe30386b854c5449b6a985ac678203945d756c3a4c418f62fea62d41b310db",
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": "6d11e38b8ce99a27fd026588dec66a7bed33016085d28076bacd7c597e6611e9"
23
+ "sha256": "48e3c96b6c22c6c3424b1fbcebbbee0e23f0e61535ac6b487dec510a496cb6ef"
24
24
  },
25
25
  {
26
26
  "path": "payload/mac/VERSION",
27
- "sha256": "011cfdb24c667a75861adfffaabe68e66358d982f437b47270ef09d86c6c6cd7"
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": "297d1558ecd6bb4cdbadfe073e396564af723173e0cb7b4085ecc852fd889def"
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.10",
299
+ "package_version": "0.2.11",
300
300
  "schema_version": 1,
301
301
  "source_dirty": false,
302
- "source_revision": "0d3be68"
302
+ "source_revision": "83c69e1"
303
303
  }