pairling 0.2.0 → 0.2.1

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.
Files changed (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/payload/mac/SOURCE_BRANCH +1 -1
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/app_attest_lan.py +87 -0
  7. package/payload/mac/companiond/codex_approval.py +69 -0
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +374 -70
  10. package/payload/mac/companiond/pairling_psk.py +100 -0
  11. package/payload/mac/companiond/pairling_tools.py +2 -2
  12. package/payload/mac/companiond/pairlingd.py +977 -104
  13. package/payload/mac/companiond/pty_broker.py +441 -3
  14. package/payload/mac/companiond/pty_broker_client.py +167 -0
  15. package/payload/mac/companiond/pty_broker_service.py +84 -0
  16. package/payload/mac/companiond/runtime_contract.py +0 -2
  17. package/payload/mac/companiond/standard_push_publisher.py +7 -0
  18. package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
  19. package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
  22. package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
  23. package/payload/mac/connectd/internal/status/status.go +9 -0
  24. package/payload/mac/install/doctor.sh +160 -18
  25. package/payload/mac/install/install-runtime.sh +329 -12
  26. package/payload/mac/install/psk_dependency_check.py +40 -0
  27. package/payload/mac/install/render-launchd.py +23 -0
  28. package/payload/mac/install/uninstall-runtime.sh +4 -12
  29. package/payload-manifest.json +51 -23
@@ -43,10 +43,11 @@ Runtime endpoints use per-device scoped Authorization: Bearer tokens:
43
43
  POST /pairling-tools/run body:JSON daemon-first MCP tool router
44
44
  POST /phone-tools/availability body:JSON foreground iPhone tool-listener availability
45
45
 
46
- Listens on PAIRLING_WEBHOOK_HOST/NOTIFY_WEBHOOK_HOST, loopback by default unless explicitly configured.
46
+ Listens on PAIRLING_WEBHOOK_HOST, loopback by default unless explicitly configured.
47
47
  """
48
48
  from __future__ import annotations
49
49
 
50
+ import base64
50
51
  import hashlib
51
52
  import html
52
53
  import json
@@ -82,7 +83,7 @@ try:
82
83
  fetch_connectd_status,
83
84
  redacted_connectd_summary,
84
85
  )
85
- from pairling_pairing import DEFAULT_PAIR_TTL_SECONDS, PairingAdvertiser, PairingError, PairingStore
86
+ from pairling_pairing import DEFAULT_PAIR_TTL_SECONDS, PairingAdvertiser, PairingError, PairingStore, ReauthStore
86
87
  from pairdrop_store import PairDropStore, PairDropStoreError
87
88
  except Exception:
88
89
  RUNTIME_AUTH_MODE = "scoped-device-bearer"
@@ -101,6 +102,7 @@ except Exception:
101
102
  PairingAdvertiser = None
102
103
  PairingError = None
103
104
  PairingStore = None
105
+ ReauthStore = None
104
106
  PairDropStore = None
105
107
  PairDropStoreError = ValueError
106
108
  app_support_root = None
@@ -186,9 +188,15 @@ except Exception:
186
188
  SafetyMonitorBridge = None
187
189
 
188
190
  try:
189
- from pty_broker import PTYBrokerManager
191
+ from pty_broker_client import PTYBrokerClient, ensure_pty_broker_token
190
192
  except Exception:
191
- PTYBrokerManager = None
193
+ PTYBrokerClient = None
194
+ ensure_pty_broker_token = None
195
+
196
+ try:
197
+ from codex_approval import classify_codex_approval
198
+ except Exception:
199
+ classify_codex_approval = None
192
200
 
193
201
  try:
194
202
  from terminal_text_sanitizer import (
@@ -297,7 +305,8 @@ AGENT_REGISTRY_DB = COMPANION_DIR / "agent-sessions.sqlite"
297
305
  TERMINAL_CAPTURE_DIR = COMPANION_DIR / "terminal-capture"
298
306
  TERMINAL_CAPTURE_MAP_DIR = TERMINAL_CAPTURE_DIR / "by-tty"
299
307
  PTY_BROKER_SOCKET = COMPANION_DIR / "pty-broker.sock"
300
- PTY_BROKER = PTYBrokerManager(PTY_BROKER_SOCKET, TERMINAL_CAPTURE_DIR) if PTYBrokerManager else None
308
+ PTY_BROKER_TOKEN = ensure_pty_broker_token(COMPANION_DIR) if ensure_pty_broker_token else ""
309
+ PTY_BROKER = PTYBrokerClient(PTY_BROKER_SOCKET, PTY_BROKER_TOKEN) if PTYBrokerClient and PTY_BROKER_TOKEN else None
301
310
  APP_SUPPORT_ROOT = app_support_root() if app_support_root else Path(os.environ.get(
302
311
  "PAIRLING_APP_SUPPORT_ROOT",
303
312
  str(HOME / "Library" / "Application Support" / "Pairling"),
@@ -314,6 +323,7 @@ PAIRING_STORE = (
314
323
  else None
315
324
  )
316
325
  PAIRING_ADVERTISER = PairingAdvertiser() if PairingAdvertiser else None
326
+ REAUTH_STORE = ReauthStore(DEVICE_REGISTRY) if ReauthStore and DEVICE_REGISTRY else None
317
327
  RELAY_CLAIM_VERIFIER = (
318
328
  RelayClaimVerifier.from_environment(mac_install_id=PAIRING_STORE.install_id)
319
329
  if RelayClaimVerifier and PAIRING_STORE
@@ -335,6 +345,37 @@ STANDARD_TURN_PUSH_PUBLISHER = None
335
345
  MAC_HEALTH_PUSH_PUBLISHER = None
336
346
  SENTINEL_PUSH_PUBLISHER = None
337
347
 
348
+
349
+ def _broker_value(session, key: str, default=None):
350
+ if isinstance(session, dict):
351
+ value = session.get(key, default)
352
+ return default if value is None else value
353
+ return getattr(session, key, default)
354
+
355
+
356
+ def _broker_session_id(session) -> str:
357
+ return str(_broker_value(session, "session_id", "") or "")
358
+
359
+
360
+ def _broker_slave_tty(session) -> str:
361
+ return str(_broker_value(session, "slave_tty", "") or "")
362
+
363
+
364
+ def _broker_pid(session) -> int:
365
+ try:
366
+ return int(_broker_value(session, "pid", 0) or 0)
367
+ except Exception:
368
+ return 0
369
+
370
+
371
+ def _broker_raw_log_path(session) -> Path | None:
372
+ raw = _broker_value(session, "raw_log_path", None)
373
+ if isinstance(raw, Path):
374
+ return raw
375
+ if raw:
376
+ return Path(str(raw))
377
+ return None
378
+
338
379
  QUEUE_DIR.mkdir(parents=True, exist_ok=True)
339
380
  ORCHESTRATIONS_DIR.mkdir(parents=True, exist_ok=True)
340
381
  HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)
@@ -347,9 +388,7 @@ TERMINAL_CAPTURE_MAP_DIR.mkdir(parents=True, exist_ok=True)
347
388
  def _bind_host() -> str:
348
389
  if os.environ.get("PAIRLING_WEBHOOK_HOST"):
349
390
  return os.environ["PAIRLING_WEBHOOK_HOST"]
350
- if os.environ.get("NOTIFY_WEBHOOK_HOST"):
351
- return os.environ["NOTIFY_WEBHOOK_HOST"]
352
- mode = os.environ.get("PAIRLING_BIND_MODE", os.environ.get("NOTIFY_WEBHOOK_BIND_MODE", "loopback")).strip().lower()
391
+ mode = os.environ.get("PAIRLING_BIND_MODE", "loopback").strip().lower()
353
392
  if mode in ("all", "tailnet_lan"):
354
393
  return "0.0.0.0"
355
394
  if mode == "loopback":
@@ -544,7 +583,7 @@ class _RuntimeAdmission:
544
583
  pass
545
584
  self._released = True
546
585
 
547
- PUBLIC_ENDPOINTS = {"/health", "/healthz", "/readyz", "/manifest", "/pair/start", "/pair/claim"}
586
+ PUBLIC_ENDPOINTS = {"/health", "/healthz", "/readyz", "/manifest", "/pair/start", "/pair/claim", "/pair/psk-claim", "/pair/reauth-challenge", "/pair/reauth-claim"}
548
587
 
549
588
  # Internal hook tier: loopback-only endpoints used by Claude Code hooks to
550
589
  # write the session registry without device pairing. Gated by client IP AND
@@ -555,6 +594,7 @@ INTERNAL_LOOPBACK_PATHS = {
555
594
  "/internal/session-heartbeat",
556
595
  "/internal/session-close",
557
596
  "/internal/active-sessions",
597
+ "/internal/permission-request",
558
598
  }
559
599
  INTERNAL_HOOK_TOKEN_FILE = COMPANION_DIR / "internal-hook-token"
560
600
 
@@ -586,6 +626,42 @@ def _ensure_internal_hook_token() -> str:
586
626
  INTERNAL_HOOK_TOKEN = _ensure_internal_hook_token()
587
627
 
588
628
 
629
+ SPAWN_SETTINGS_PATH = COMPANION_DIR / "pairling-spawn-settings.json"
630
+
631
+
632
+ def _ensure_spawn_settings() -> None:
633
+ """Write the per-spawn claude settings overlay (the PermissionRequest producer
634
+ hook) into a Pairling-managed file passed to phone-spawned sessions via
635
+ --settings. This keeps the user's GLOBAL ~/.claude/settings.json UNTOUCHED:
636
+ the hook exists ONLY in sessions Pairling spawns, and the permission posture
637
+ is still inherited from the user's own settings (we add an observer hook,
638
+ never a mode). --settings hooks are auto-trusted (no review gate)."""
639
+ payload = {
640
+ "hooks": {
641
+ "PermissionRequest": [
642
+ {"hooks": [{
643
+ "type": "command",
644
+ "command": "node $HOME/.claude/hooks/dist/permission-request.mjs",
645
+ "timeout": 10,
646
+ }]}
647
+ ]
648
+ }
649
+ }
650
+ try:
651
+ SPAWN_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
652
+ tmp = SPAWN_SETTINGS_PATH.with_name(SPAWN_SETTINGS_PATH.name + ".tmp")
653
+ with open(tmp, "w", encoding="utf-8") as fh:
654
+ json.dump(payload, fh, indent=2)
655
+ fh.flush()
656
+ os.fsync(fh.fileno())
657
+ os.replace(tmp, SPAWN_SETTINGS_PATH)
658
+ except OSError:
659
+ pass
660
+
661
+
662
+ _ensure_spawn_settings()
663
+
664
+
589
665
  def _session_backend() -> str:
590
666
  """Which store serves claude session reads: 'pg' (Docker Postgres) or
591
667
  'sqlite' (daemon-owned agent registry). Rollback at any point is
@@ -629,6 +705,9 @@ def _discard_session_ready_event(session_id: str) -> None:
629
705
  POST_ONLY_ENDPOINTS = {
630
706
  "/pair/start",
631
707
  "/pair/claim",
708
+ "/pair/psk-claim",
709
+ "/pair/reauth-challenge",
710
+ "/pair/reauth-claim",
632
711
  "/pair/revoke",
633
712
  "/pair/rotate-token",
634
713
  "/aperture-cli/open",
@@ -660,6 +739,7 @@ POST_ONLY_ENDPOINTS = {
660
739
  "/cross-provider-action",
661
740
  "/send-text",
662
741
  "/terminal-control",
742
+ "/push/permission/allow",
663
743
  "/sigint",
664
744
  "/sigterm",
665
745
  "/upload",
@@ -675,6 +755,7 @@ HIGH_RISK_ENDPOINTS = {
675
755
  "/interrupt",
676
756
  "/llm-route",
677
757
  "/llm-route-stream",
758
+ "/push/permission/allow",
678
759
  "/pairling-tools/run",
679
760
  "/worker-kill",
680
761
  "/push/preferences",
@@ -860,6 +941,8 @@ def _required_scopes_for_request(path: str, method: str) -> set[str]:
860
941
  return {"health:read"}
861
942
  if path in {"/push/preferences", "/push/test", "/push/live-activity-token", "/push/live-activity-test"}:
862
943
  return {"pair:admin"}
944
+ if path == "/push/permission/allow":
945
+ return {"session:signal"}
863
946
  if path == "/sentinel/preferences" and method == "POST":
864
947
  return {"pair:admin"}
865
948
  if path in {"/sentinel/status", "/sentinel/preferences", "/sentinel/events"}:
@@ -878,6 +961,8 @@ def _required_scopes_for_request(path: str, method: str) -> set[str]:
878
961
  return {"session:signal"}
879
962
  if path in {"/spawn-session", "/resume-session", "/cross-provider-action"}:
880
963
  return {"session:spawn"}
964
+ if path == "/onestream-handoff":
965
+ return {"session:spawn"} if method == "POST" else {"sessions:read"}
881
966
  if path in {"/llm-route", "/llm-route-stream"}:
882
967
  return {"llm:route"}
883
968
  if path == "/pairling-tools/run":
@@ -1111,6 +1196,34 @@ def _agent_registry_bootstrap_schema(conn) -> None:
1111
1196
  "CREATE INDEX IF NOT EXISTS idx_agent_sessions_provider_live "
1112
1197
  "ON agent_sessions(provider, closed_at, last_heartbeat)"
1113
1198
  )
1199
+ # Per-tool approval queue (Lock-Screen "Permission request" card, Phase 2).
1200
+ # Same daemon-owned DB. NO deadline/expiry columns by design: an unanswered
1201
+ # prompt hangs at its native dialog forever until the user acts (Allow
1202
+ # keystroke / in-app). Rows are recorded by the PermissionRequest hook via
1203
+ # POST /internal/permission-request and resolved by the Allow path (Phase 3).
1204
+ conn.execute(
1205
+ """
1206
+ CREATE TABLE IF NOT EXISTS pending_approvals (
1207
+ request_nonce TEXT PRIMARY KEY,
1208
+ provider TEXT NOT NULL,
1209
+ session_id TEXT NOT NULL,
1210
+ native_id TEXT,
1211
+ broker_id TEXT,
1212
+ terminal_tty TEXT,
1213
+ tool_name TEXT NOT NULL,
1214
+ tool_input_json TEXT NOT NULL,
1215
+ command_preview TEXT,
1216
+ permission_mode TEXT,
1217
+ state TEXT NOT NULL DEFAULT 'pending',
1218
+ created_at REAL NOT NULL,
1219
+ resolved_at REAL
1220
+ )
1221
+ """
1222
+ )
1223
+ conn.execute(
1224
+ "CREATE INDEX IF NOT EXISTS idx_pending_approvals_session "
1225
+ "ON pending_approvals(session_id, state)"
1226
+ )
1114
1227
  if _AGENT_REGISTRY_SCHEMA_READY:
1115
1228
  return
1116
1229
  with _AGENT_REGISTRY_SCHEMA_LOCK:
@@ -1151,6 +1264,149 @@ def _agent_registry_conn():
1151
1264
  conn.close()
1152
1265
 
1153
1266
 
1267
+ def _approval_command_preview(tool_name: str, tool_input: dict) -> str:
1268
+ """Render the one-line card text from a tool call's structured input."""
1269
+ try:
1270
+ if tool_name == "Bash":
1271
+ return str(tool_input.get("command") or "").strip()[:300]
1272
+ if tool_name in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
1273
+ fp = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "").strip()
1274
+ base = os.path.basename(fp) if fp else ""
1275
+ return f"{tool_name} {base}".strip()[:300]
1276
+ if tool_name == "WebFetch":
1277
+ url = str(tool_input.get("url") or "").strip()
1278
+ try:
1279
+ host = urlparse(url).netloc or url
1280
+ except Exception:
1281
+ host = url
1282
+ return f"Fetch {host}".strip()[:300]
1283
+ return tool_name[:300]
1284
+ except Exception:
1285
+ return tool_name[:300]
1286
+
1287
+
1288
+ def _approval_resolve_session(provider: str, session_id: str) -> tuple[str, str, str]:
1289
+ """Best-effort map a hook's session_id -> (native_id, broker_id, terminal_tty)
1290
+ from the agent_sessions registry. For claude the hook session_id is the
1291
+ claude_uuid; for codex it is the registry native_id. Empties on miss — the
1292
+ Phase 3 Allow path re-resolves against the live broker before answering."""
1293
+ try:
1294
+ with _agent_registry_conn() as conn:
1295
+ if provider == "claude":
1296
+ row = conn.execute(
1297
+ "SELECT native_id, terminal_tty FROM agent_sessions "
1298
+ "WHERE provider='claude' AND claude_uuid=? AND closed_at IS NULL "
1299
+ "ORDER BY last_heartbeat DESC LIMIT 1",
1300
+ (session_id,),
1301
+ ).fetchone()
1302
+ else:
1303
+ lookup_native = session_id
1304
+ parsed_provider, parsed_native = _parse_agent_session_ref(session_id)
1305
+ if parsed_provider == provider and parsed_native:
1306
+ lookup_native = parsed_native
1307
+ row = conn.execute(
1308
+ "SELECT native_id, terminal_tty FROM agent_sessions "
1309
+ "WHERE provider=? AND native_id=? AND closed_at IS NULL "
1310
+ "ORDER BY last_heartbeat DESC LIMIT 1",
1311
+ (provider, lookup_native),
1312
+ ).fetchone()
1313
+ if not row:
1314
+ return ("", "", "")
1315
+ native_id = str(row["native_id"] or "")
1316
+ broker_id = _qualified_session_id(provider, native_id) if native_id else ""
1317
+ return (native_id, broker_id, str(row["terminal_tty"] or ""))
1318
+ except Exception:
1319
+ return ("", "", "")
1320
+
1321
+
1322
+ def _pending_approval_record(*, request_nonce: str, provider: str, session_id: str,
1323
+ tool_name: str, tool_input: dict, command_preview: str = "",
1324
+ permission_mode: str = "", broker_id: str = "",
1325
+ state: str = "pending") -> bool:
1326
+ """Idempotently record a pending tool approval (INSERT OR IGNORE on the
1327
+ hook-minted request_nonce). No deadline/expiry — by design. broker_id, when
1328
+ supplied by the hook (from the broker's PAIRLING_BROKER_SESSION_ID env), is the
1329
+ AUTHORITATIVE live broker session id — far more reliable than registry
1330
+ reconciliation, since the SessionStart tty capture fails for broker PTYs (the
1331
+ claude_uuid and the broker tty land on two unlinked rows)."""
1332
+ now = _time.time()
1333
+ native_id, resolved_broker, terminal_tty = _approval_resolve_session(provider, session_id)
1334
+ if not broker_id:
1335
+ broker_id = resolved_broker
1336
+ row_state = state if state in {"pending", "attention"} else "pending"
1337
+ try:
1338
+ with _agent_registry_conn() as conn:
1339
+ conn.execute(
1340
+ """
1341
+ INSERT OR IGNORE INTO pending_approvals
1342
+ (request_nonce, provider, session_id, native_id, broker_id,
1343
+ terminal_tty, tool_name, tool_input_json, command_preview,
1344
+ permission_mode, state, created_at, resolved_at)
1345
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
1346
+ """,
1347
+ (request_nonce, provider, session_id, native_id, broker_id,
1348
+ terminal_tty, tool_name, json.dumps(tool_input)[:8000],
1349
+ (command_preview or "")[:300], permission_mode, row_state, now),
1350
+ )
1351
+ return True
1352
+ except Exception:
1353
+ return False
1354
+
1355
+
1356
+ def _pending_approval_get(request_nonce: str) -> dict | None:
1357
+ try:
1358
+ with _agent_registry_conn() as conn:
1359
+ row = conn.execute(
1360
+ "SELECT * FROM pending_approvals WHERE request_nonce=?",
1361
+ (request_nonce,),
1362
+ ).fetchone()
1363
+ return dict(row) if row else None
1364
+ except Exception:
1365
+ return None
1366
+
1367
+
1368
+ def _pending_approval_cas(request_nonce: str, expected: str, new: str) -> bool:
1369
+ """Compare-and-set the approval state. Returns True iff THIS call performed the
1370
+ transition — so a duplicate Allow (state already != expected) is a safe no-op."""
1371
+ try:
1372
+ with _agent_registry_conn() as conn:
1373
+ cur = conn.execute(
1374
+ "UPDATE pending_approvals SET state=?, resolved_at=? "
1375
+ "WHERE request_nonce=? AND state=?",
1376
+ (new, _time.time(), request_nonce, expected),
1377
+ )
1378
+ return cur.rowcount > 0
1379
+ except Exception:
1380
+ return False
1381
+
1382
+
1383
+ def _pending_approvals_open() -> list[dict]:
1384
+ try:
1385
+ with _agent_registry_conn() as conn:
1386
+ rows = conn.execute(
1387
+ "SELECT * FROM pending_approvals WHERE state IN ('pending', 'attention') "
1388
+ "ORDER BY created_at ASC LIMIT 500"
1389
+ ).fetchall()
1390
+ return [dict(row) for row in rows]
1391
+ except Exception:
1392
+ return []
1393
+
1394
+
1395
+ def _pending_approval_resolve_terminal(request_nonce: str, new_state: str) -> bool:
1396
+ if new_state not in {"session_gone", "expired_session"}:
1397
+ return False
1398
+ try:
1399
+ with _agent_registry_conn() as conn:
1400
+ cur = conn.execute(
1401
+ "UPDATE pending_approvals SET state=?, resolved_at=? "
1402
+ "WHERE request_nonce=? AND state IN ('pending', 'attention')",
1403
+ (new_state, _time.time(), request_nonce),
1404
+ )
1405
+ return cur.rowcount > 0
1406
+ except Exception:
1407
+ return False
1408
+
1409
+
1154
1410
  def _pretrust_claude_project(project: str) -> None:
1155
1411
  """Mark `project` trusted in ~/.claude.json before a headless claude spawn.
1156
1412
 
@@ -1823,7 +2079,7 @@ def _guardian_listener_entries(power_state: dict | None) -> list[str]:
1823
2079
 
1824
2080
 
1825
2081
  def _probe_listener_entries() -> list[str]:
1826
- host = BOUND_HOST or os.environ.get("NOTIFY_WEBHOOK_BOUND_HOST", "")
2082
+ host = BOUND_HOST or os.environ.get("PAIRLING_BOUND_HOST", "")
1827
2083
  entries: list[str] = []
1828
2084
  ok, out, _ = _run_text(["/usr/sbin/lsof", "-nP", f"-iTCP:{PORT}", "-sTCP:LISTEN"], timeout=3)
1829
2085
  if ok:
@@ -1974,7 +2230,7 @@ def _normalize_guardian_state(state: dict | None) -> dict | None:
1974
2230
  "lan_ips": [],
1975
2231
  },
1976
2232
  "daemon": {
1977
- "notify_webhook_pid": os.getpid(),
2233
+ "pairling_pid": os.getpid(),
1978
2234
  "listen": (listener_entries := _listener_entries()),
1979
2235
  "reachable_local": bool(listener_entries),
1980
2236
  "reachable_tailnet": facts.get("daemon_reachable"),
@@ -2089,7 +2345,7 @@ def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[d
2089
2345
  "last_ok_at": None,
2090
2346
  })
2091
2347
  if not routes:
2092
- host = os.environ.get("NOTIFY_WEBHOOK_BOUND_HOST", f"0.0.0.0")
2348
+ host = os.environ.get("PAIRLING_BOUND_HOST", f"0.0.0.0")
2093
2349
  base_url = host if host.startswith("http") else f"http://{host}:{PORT}"
2094
2350
  routes.append({
2095
2351
  "kind": "manual",
@@ -2622,8 +2878,8 @@ def _inject_rate_check(session_id: str, max_per_min: int = 30) -> tuple[bool, in
2622
2878
  return True, 0
2623
2879
 
2624
2880
  # Project paths matching any of these glob-ish substrings are filtered out of
2625
- # /corpus, /sessions, and bucket rollups. Mergim has hundreds of one-shot
2626
- # biotech research scratch dirs that drown out signal — exclude by default.
2881
+ # /corpus, /sessions, and bucket rollups. Users can accumulate many one-shot
2882
+ # research scratch dirs that drown out signal — exclude by default.
2627
2883
  PROJECT_EXCLUDE_PATTERNS = [
2628
2884
  "biotech-labs/synth-synth-", # ephemeral synth-* worktrees
2629
2885
  "biotech-labs/crohns-research/scripts", # bench-research scratch
@@ -2731,8 +2987,8 @@ def _encode_project_dir(project_path: str) -> str:
2731
2987
  directory name under ~/.claude/projects/.
2732
2988
 
2733
2989
  Claude Code encodes ALL of `/`, `.`, and `_` as `-`. This means a path
2734
- like /Users/mghome/.claude/state/sentinel/projects/onestream-378da5/terminals/orange_team
2735
- becomes -Users-mghome--claude-state-sentinel-projects-onestream-378da5-terminals-orange-team
2990
+ like /Users/example/.claude/state/sentinel/projects/onestream-378da5/terminals/orange_team
2991
+ becomes -Users-example--claude-state-sentinel-projects-onestream-378da5-terminals-orange-team
2736
2992
  (note: `.claude` → `-claude` so two consecutive `-`; `orange_team` → `orange-team`).
2737
2993
 
2738
2994
  The previous implementation only handled `/` and broke for sentinel
@@ -2744,7 +3000,7 @@ def _encode_project_dir(project_path: str) -> str:
2744
3000
 
2745
3001
 
2746
3002
  # Sentinel session paths look like:
2747
- # /Users/mghome/.claude/state/sentinel/projects/<bucket>-<6hex>/terminals/<mode>
3003
+ # /Users/example/.claude/state/sentinel/projects/<bucket>-<6hex>/terminals/<mode>
2748
3004
  # We strip the sentinel prefix + the 6-hex suffix to recover the underlying
2749
3005
  # project bucket name (e.g. "proofforge"). Regular projects use their basename.
2750
3006
  _SENTINEL_PROJECTS_RE = re.compile(
@@ -4526,8 +4782,8 @@ def _terminal_surface_source(raw_session: str) -> dict:
4526
4782
  "available": True,
4527
4783
  "source": "broker_vt",
4528
4784
  "reason": "broker_vt",
4529
- "broker_id": session.session_id,
4530
- "tty": session.slave_tty,
4785
+ "broker_id": _broker_session_id(session),
4786
+ "tty": _broker_slave_tty(session),
4531
4787
  "can_control": True,
4532
4788
  }
4533
4789
 
@@ -4544,13 +4800,13 @@ def _terminal_surface_source(raw_session: str) -> dict:
4544
4800
  if broker_id and PTY_BROKER is not None:
4545
4801
  session = PTY_BROKER.get(broker_id)
4546
4802
  if session is not None:
4547
- PTY_BROKER.register_alias(qualified, session)
4803
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
4548
4804
  return {
4549
4805
  "available": True,
4550
4806
  "source": "broker_vt",
4551
4807
  "reason": "broker_vt",
4552
- "broker_id": session.session_id,
4553
- "tty": session.slave_tty,
4808
+ "broker_id": _broker_session_id(session),
4809
+ "tty": _broker_slave_tty(session),
4554
4810
  "can_control": True,
4555
4811
  }
4556
4812
  capture_path = _terminal_capture_from_metadata(metadata)
@@ -5573,7 +5829,9 @@ def _turn_state_path(provider: str, native_id: str) -> Path:
5573
5829
  def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
5574
5830
  tool: str | None = None, effort: str | None = None,
5575
5831
  started_at: float | None = None,
5576
- event: str = "daemon") -> dict:
5832
+ event: str = "daemon",
5833
+ request_nonce: str | None = None,
5834
+ mac_install_id: str | None = None) -> dict:
5577
5835
  now = _time.time()
5578
5836
  prior: dict = {}
5579
5837
  path = _turn_state_path(provider, native_id)
@@ -5583,7 +5841,7 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
5583
5841
  except Exception:
5584
5842
  prior = {}
5585
5843
  payload = {
5586
- "session_id": native_id,
5844
+ "session_id": _qualified_session_id(provider, native_id) if provider == "codex" else native_id,
5587
5845
  "state": state,
5588
5846
  "tool": tool,
5589
5847
  "started_at": float(started_at or prior.get("started_at") or now),
@@ -5591,6 +5849,10 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
5591
5849
  "effort": effort if effort is not None else prior.get("effort"),
5592
5850
  "event": event,
5593
5851
  }
5852
+ if request_nonce:
5853
+ payload["request_nonce"] = str(request_nonce)
5854
+ if mac_install_id:
5855
+ payload["mac_install_id"] = str(mac_install_id)
5594
5856
  try:
5595
5857
  TURN_STATE_DIR.mkdir(parents=True, exist_ok=True)
5596
5858
  tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
@@ -5602,6 +5864,132 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
5602
5864
  return payload
5603
5865
 
5604
5866
 
5867
+ _CODEX_APPROVAL_NONCES: dict[str, str] = {}
5868
+ _CODEX_APPROVAL_SCREEN_KEYS: dict[str, str] = {}
5869
+
5870
+
5871
+ def _rows_from_broker_snapshot(snapshot: dict | None) -> list[str]:
5872
+ if not isinstance(snapshot, dict):
5873
+ return []
5874
+ rows = snapshot.get("rows")
5875
+ if isinstance(rows, list):
5876
+ text_rows: list[str] = []
5877
+ for row in rows:
5878
+ if isinstance(row, str):
5879
+ text_rows.append(row)
5880
+ elif isinstance(row, dict):
5881
+ cells = row.get("cells")
5882
+ if isinstance(cells, list):
5883
+ text_rows.append("".join(str(cell.get("text") or "") for cell in cells if isinstance(cell, dict)).rstrip())
5884
+ return text_rows
5885
+ return []
5886
+
5887
+
5888
+ def _approval_screen_key(snapshot: dict | None) -> str:
5889
+ if not isinstance(snapshot, dict):
5890
+ return ""
5891
+ return ":".join(str(snapshot.get(key) or "") for key in ("screen_hash", "generation", "raw_offset", "nonce"))
5892
+
5893
+
5894
+ def _clear_codex_approval(broker_id: str, session: dict) -> None:
5895
+ nonce = _CODEX_APPROVAL_NONCES.pop(broker_id, None)
5896
+ _CODEX_APPROVAL_SCREEN_KEYS.pop(broker_id, None)
5897
+ native_id = str(session.get("native_id") or "")
5898
+ if nonce:
5899
+ row = _pending_approval_get(nonce)
5900
+ row_state = str((row or {}).get("state") or "")
5901
+ if row_state in {"attention", "pending"}:
5902
+ _pending_approval_cas(nonce, row_state, "resolved_local")
5903
+ if native_id:
5904
+ _write_agent_turn_state("codex", native_id, "idle", event="codex_approval_cleared")
5905
+
5906
+
5907
+ def _scan_codex_approvals_once() -> None:
5908
+ if PTY_BROKER is None or classify_codex_approval is None:
5909
+ return
5910
+ try:
5911
+ live = {
5912
+ str(session.get("session_id") or ""): session
5913
+ for session in PTY_BROKER.list_sessions()
5914
+ if isinstance(session, dict)
5915
+ and session.get("provider") == "codex"
5916
+ and session.get("session_id")
5917
+ and session.get("native_id")
5918
+ }
5919
+ except Exception:
5920
+ return
5921
+ for broker_id, session in live.items():
5922
+ try:
5923
+ snapshot = PTY_BROKER.snapshot(broker_id)
5924
+ except Exception:
5925
+ continue
5926
+ rows = _rows_from_broker_snapshot(snapshot)
5927
+ screen_key = _approval_screen_key(snapshot)
5928
+ if _CODEX_APPROVAL_SCREEN_KEYS.get(broker_id) == screen_key and _CODEX_APPROVAL_NONCES.get(broker_id):
5929
+ continue
5930
+ pending = (snapshot or {}).get("pending_input") if isinstance(snapshot, dict) else None
5931
+ if not isinstance(pending, dict):
5932
+ pending = None
5933
+ approval = classify_codex_approval(pending, rows, screen_key=screen_key)
5934
+ if not approval:
5935
+ if broker_id in _CODEX_APPROVAL_NONCES:
5936
+ _clear_codex_approval(broker_id, session)
5937
+ continue
5938
+ summary = str(approval.get("summary") or approval.get("command") or "codex approval")[:300]
5939
+ dialog_material = "|".join([broker_id, str(approval.get("dialog_key") or screen_key), summary])
5940
+ nonce = "codex-scrape-" + hashlib.sha256(dialog_material.encode("utf-8")).hexdigest()[:24]
5941
+ _CODEX_APPROVAL_SCREEN_KEYS[broker_id] = screen_key
5942
+ if _CODEX_APPROVAL_NONCES.get(broker_id) == nonce:
5943
+ continue
5944
+ native_id = str(session.get("native_id") or "")
5945
+ _pending_approval_record(
5946
+ request_nonce=nonce,
5947
+ provider="codex",
5948
+ session_id=_qualified_session_id("codex", native_id),
5949
+ tool_name="Bash",
5950
+ tool_input={"command": str(approval.get("command") or ""), "summary": summary},
5951
+ command_preview=summary,
5952
+ permission_mode="",
5953
+ broker_id=broker_id,
5954
+ state="attention",
5955
+ )
5956
+ _CODEX_APPROVAL_NONCES[broker_id] = nonce
5957
+ _write_agent_turn_state(
5958
+ "codex",
5959
+ native_id,
5960
+ "attention",
5961
+ tool=summary[:80],
5962
+ event="codex_approval",
5963
+ request_nonce=nonce,
5964
+ mac_install_id=getattr(PAIRING_STORE, "install_id", "") if PAIRING_STORE else "",
5965
+ )
5966
+ for broker_id in list(_CODEX_APPROVAL_NONCES.keys()):
5967
+ if broker_id not in live:
5968
+ _CODEX_APPROVAL_NONCES.pop(broker_id, None)
5969
+ _CODEX_APPROVAL_SCREEN_KEYS.pop(broker_id, None)
5970
+
5971
+
5972
+ def _start_codex_approval_scanner() -> threading.Thread | None:
5973
+ if PTY_BROKER is None or classify_codex_approval is None:
5974
+ return None
5975
+ try:
5976
+ interval = max(0.25, float(os.environ.get("PAIRLING_CODEX_APPROVAL_POLL_S", "1.0")))
5977
+ except Exception:
5978
+ interval = 1.0
5979
+
5980
+ def run() -> None:
5981
+ while True:
5982
+ try:
5983
+ _scan_codex_approvals_once()
5984
+ except Exception as exc:
5985
+ print(f"[codex-approval-scan] skipped: {type(exc).__name__}: {str(exc)[:120]}", file=sys.stderr, flush=True)
5986
+ _time.sleep(interval)
5987
+
5988
+ thread = threading.Thread(target=run, name="pairling-codex-approval-scan", daemon=True)
5989
+ thread.start()
5990
+ return thread
5991
+
5992
+
5605
5993
  def _publish_live_activity_turn_state(provider: str, native_id: str, payload: dict) -> None:
5606
5994
  publisher = LIVE_ACTIVITY_PUBLISHER
5607
5995
  if publisher is None or not hasattr(publisher, "publish_turn_state_payload"):
@@ -6846,6 +7234,8 @@ class Handler(BaseHTTPRequestHandler):
6846
7234
  self._handle_internal_session_close(q)
6847
7235
  elif u.path == "/internal/active-sessions":
6848
7236
  self._handle_internal_active_sessions(q)
7237
+ elif u.path == "/internal/permission-request":
7238
+ self._handle_internal_permission_request(q)
6849
7239
  finally:
6850
7240
  admission.release()
6851
7241
  return
@@ -7006,6 +7396,12 @@ class Handler(BaseHTTPRequestHandler):
7006
7396
  self._handle_pair_start(q)
7007
7397
  elif u.path == "/pair/claim":
7008
7398
  self._handle_pair_claim(q)
7399
+ elif u.path == "/pair/psk-claim":
7400
+ self._handle_pair_psk_claim(q)
7401
+ elif u.path == "/pair/reauth-challenge":
7402
+ self._handle_pair_reauth_challenge(q)
7403
+ elif u.path == "/pair/reauth-claim":
7404
+ self._handle_pair_reauth_claim(q)
7009
7405
  elif u.path == "/pair/revoke":
7010
7406
  self._handle_pair_revoke(q)
7011
7407
  elif u.path == "/pair/rotate-token":
@@ -7088,6 +7484,8 @@ class Handler(BaseHTTPRequestHandler):
7088
7484
  self._handle_push_preferences(q)
7089
7485
  elif u.path == "/push/test":
7090
7486
  self._handle_push_test(q)
7487
+ elif u.path == "/push/permission/allow":
7488
+ self._handle_push_permission_allow(q)
7091
7489
  elif u.path == "/push/live-activity-token":
7092
7490
  self._handle_push_live_activity_token(q)
7093
7491
  elif u.path == "/push/live-activity-test":
@@ -7157,6 +7555,8 @@ class Handler(BaseHTTPRequestHandler):
7157
7555
  self._handle_worker_kill(q)
7158
7556
  elif u.path == "/spawn-session":
7159
7557
  self._handle_spawn_session(q)
7558
+ elif u.path == "/onestream-handoff":
7559
+ self._handle_onestream_handoff(q)
7160
7560
  elif u.path == "/resume-session":
7161
7561
  self._handle_resume_session(q)
7162
7562
  elif u.path == "/cross-provider-action":
@@ -7349,6 +7749,45 @@ class Handler(BaseHTTPRequestHandler):
7349
7749
  items.sort(key=lambda r: r["started_at"], reverse=True)
7350
7750
  self._send_json({"ok": True, "count": len(items), "sessions": items})
7351
7751
 
7752
+ def _handle_internal_permission_request(self, q):
7753
+ # PermissionRequest hook producer (claude + codex). Notify-only: record
7754
+ # the pending approval; the agent's native dialog is the durable block.
7755
+ # Phase 3 fires the APNs card + wires the Allow keystroke here.
7756
+ payload = self._read_internal_json()
7757
+ if payload is None:
7758
+ return
7759
+ provider = str(payload.get("provider") or "claude").strip().lower()
7760
+ if provider not in ("claude", "codex"):
7761
+ provider = "claude"
7762
+ session_id = str(payload.get("session_id") or "").strip()
7763
+ tool_name = str(payload.get("tool_name") or "").strip()
7764
+ tool_input = payload.get("tool_input")
7765
+ if not isinstance(tool_input, dict):
7766
+ tool_input = {}
7767
+ if not session_id or not tool_name:
7768
+ self._send_json({
7769
+ "ok": False,
7770
+ "error": {"code": "bad_request", "message": "session_id and tool_name required"},
7771
+ }, status=400)
7772
+ return
7773
+ request_nonce = str(payload.get("request_nonce") or "").strip() or secrets.token_hex(16)
7774
+ command_preview = _approval_command_preview(tool_name, tool_input)
7775
+ ok = _pending_approval_record(
7776
+ request_nonce=request_nonce,
7777
+ provider=provider,
7778
+ session_id=session_id,
7779
+ tool_name=tool_name,
7780
+ tool_input=tool_input,
7781
+ command_preview=command_preview,
7782
+ permission_mode=str(payload.get("permission_mode") or "")[:40],
7783
+ broker_id=str(payload.get("broker_session_id") or "").strip(),
7784
+ )
7785
+ self._send_json({
7786
+ "ok": bool(ok),
7787
+ "request_nonce": request_nonce,
7788
+ "command_preview": command_preview,
7789
+ })
7790
+
7352
7791
  # ----- /open: open path on Mac (existing behavior) -----
7353
7792
  def _handle_open(self, q):
7354
7793
  path = q.get("path", [""])[0]
@@ -7604,6 +8043,21 @@ class Handler(BaseHTTPRequestHandler):
7604
8043
  },
7605
8044
  }, status=503)
7606
8045
  return
8046
+ # P0-B: rate-limit unauthenticated pair starts per source IP so an
8047
+ # on-LAN attacker cannot mint a flood of invitations.
8048
+ allowed, retry_after = _request_rate_check(
8049
+ f"pair_start:{self.client_address[0]}", max_per_min=5
8050
+ )
8051
+ if not allowed:
8052
+ self.send_response(429)
8053
+ self.send_header("Retry-After", str(retry_after))
8054
+ self.send_header("Content-Type", "application/json")
8055
+ self.end_headers()
8056
+ self.wfile.write(json.dumps({
8057
+ "ok": False,
8058
+ "error": {"code": "rate_limited", "message": "too many pair starts"},
8059
+ }).encode("utf-8"))
8060
+ return
7607
8061
  try:
7608
8062
  payload = self._read_json_object()
7609
8063
  ttl = int(payload.get("ttl_seconds") or DEFAULT_PAIR_TTL_SECONDS)
@@ -7621,6 +8075,8 @@ class Handler(BaseHTTPRequestHandler):
7621
8075
  "ok": True,
7622
8076
  "pair_id": started.pair_id,
7623
8077
  "secret": started.secret,
8078
+ "attest_challenge": started.attest_challenge,
8079
+ "mac_ake_pub": started.mac_ake_pub,
7624
8080
  "expires_at": started.expires_at,
7625
8081
  "install_id": started.install_id,
7626
8082
  "runtime_port": PORT,
@@ -7635,6 +8091,10 @@ class Handler(BaseHTTPRequestHandler):
7635
8091
  "url": "pairling://pair",
7636
8092
  "pair_id": started.pair_id,
7637
8093
  "secret": started.secret,
8094
+ "pairing_nonce": started.pairing_nonce,
8095
+ "attest_challenge": started.attest_challenge,
8096
+ "mac_ake_pub": started.mac_ake_pub,
8097
+ "pv": "2" if started.mac_ake_pub else "1",
7638
8098
  },
7639
8099
  })
7640
8100
 
@@ -7657,6 +8117,11 @@ class Handler(BaseHTTPRequestHandler):
7657
8117
  device_name=str(payload.get("device_name") or "Pairling iPhone"),
7658
8118
  host_chain=host_chain,
7659
8119
  cert_pin=None,
8120
+ pairing_nonce=str(payload.get("pairing_nonce") or ""),
8121
+ se_public_key_der=str(payload.get("se_public_key_der") or ""),
8122
+ attest_object=(payload.get("direct_attest_object") if isinstance(payload.get("direct_attest_object"), dict) else None),
8123
+ attest_key_id=str(payload.get("attest_key_id") or ""),
8124
+ attest_environment=str(payload.get("attest_environment") or ""),
7660
8125
  attested_claim_ticket=payload.get("attested_claim_ticket"),
7661
8126
  relay_device_id=payload.get("relay_device_id"),
7662
8127
  relay_required=bool(relay_claims_required and relay_claims_required()),
@@ -7698,6 +8163,127 @@ class Handler(BaseHTTPRequestHandler):
7698
8163
  },
7699
8164
  })
7700
8165
 
8166
+ def _handle_pair_psk_claim(self, q):
8167
+ # WS3: PSK-authenticated ECDH claim. The secret is NEVER received; the
8168
+ # phone proves knowledge of it by completing the key exchange. The
8169
+ # bearer token is returned AES-GCM-sealed under K_token, so a passive
8170
+ # on-LAN sniffer learns nothing.
8171
+ if PAIRING_STORE is None:
8172
+ self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "Pairing store is unavailable"}}, status=503)
8173
+ return
8174
+ allowed, retry_after = _request_rate_check(f"pair_psk:{self.client_address[0]}", max_per_min=5)
8175
+ if not allowed:
8176
+ self.send_response(429)
8177
+ self.send_header("Retry-After", str(retry_after))
8178
+ self.send_header("Content-Type", "application/json")
8179
+ self.end_headers()
8180
+ self.wfile.write(json.dumps({"ok": False, "error": {"code": "rate_limited", "message": "too many psk claims"}}).encode("utf-8"))
8181
+ return
8182
+ try:
8183
+ payload = self._read_json_object()
8184
+ host_chain = self._pairing_host_chain()
8185
+ claim, k_token, aad, mac_confirm = PAIRING_STORE.psk_claim_pair(
8186
+ pair_id=str(payload.get("pair_id") or ""),
8187
+ b_pub_b64=str(payload.get("b_pub") or ""),
8188
+ confirm_b64=str(payload.get("confirm") or ""),
8189
+ device_name=str(payload.get("device_name") or "Pairling iPhone"),
8190
+ host_chain=host_chain,
8191
+ se_public_key_der=str(payload.get("se_public_key_der") or ""),
8192
+ attest_object=(payload.get("direct_attest_object") if isinstance(payload.get("direct_attest_object"), dict) else None),
8193
+ attest_key_id=str(payload.get("attest_key_id") or ""),
8194
+ attest_environment=str(payload.get("attest_environment") or ""),
8195
+ attested_claim_ticket=payload.get("attested_claim_ticket"),
8196
+ relay_device_id=payload.get("relay_device_id"),
8197
+ relay_required=bool(relay_claims_required and relay_claims_required()),
8198
+ relay_claim_verifier=RELAY_CLAIM_VERIFIER,
8199
+ )
8200
+ nonce, enc_token = PAIRING_STORE.seal_psk_token(k_token, claim.device.token, aad)
8201
+ if PAIRING_ADVERTISER is not None:
8202
+ PAIRING_ADVERTISER.stop()
8203
+ except PairingError as exc:
8204
+ self._send_json({"ok": False, "error": {"code": exc.code, "message": exc.message}}, status=exc.status)
8205
+ return
8206
+ except ValueError as exc:
8207
+ self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
8208
+ return
8209
+ runtime_routes = self._pairing_runtime_routes(list(claim.host_chain))
8210
+ transport = "pairling-connect" if any(
8211
+ route.get("source") == "pairling_connectd" and route.get("status") == "ready"
8212
+ for route in runtime_routes
8213
+ ) else "http-local"
8214
+ self._send_json({
8215
+ "ok": True,
8216
+ "device": {
8217
+ "id": claim.device.device_id,
8218
+ "proof_secret": claim.device.proof_secret,
8219
+ "scopes": list(claim.device.scopes),
8220
+ "relay_device_id": claim.relay_device_id,
8221
+ "attestation_status": claim.attestation_status,
8222
+ },
8223
+ "enc_token": base64.b64encode(enc_token).decode("ascii"),
8224
+ "nonce": base64.b64encode(nonce).decode("ascii"),
8225
+ "mac_confirm": base64.b64encode(mac_confirm).decode("ascii"),
8226
+ "install_id": claim.device.install_id,
8227
+ "runtime": {
8228
+ "port": claim.runtime_port,
8229
+ "host_chain": list(claim.host_chain),
8230
+ "cert_pin": claim.cert_pin,
8231
+ "transport": transport,
8232
+ "routes": runtime_routes,
8233
+ },
8234
+ })
8235
+
8236
+ def _handle_pair_reauth_challenge(self, q):
8237
+ if REAUTH_STORE is None:
8238
+ self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "reauth unavailable"}}, status=503)
8239
+ return
8240
+ allowed, retry_after = _request_rate_check(f"pair_reauth:{self.client_address[0]}", max_per_min=10)
8241
+ if not allowed:
8242
+ self.send_response(429)
8243
+ self.send_header("Retry-After", str(retry_after))
8244
+ self.send_header("Content-Type", "application/json")
8245
+ self.end_headers()
8246
+ self.wfile.write(json.dumps({"ok": False, "error": {"code": "rate_limited", "message": "too many reauth attempts"}}).encode("utf-8"))
8247
+ return
8248
+ try:
8249
+ payload = self._read_json_object()
8250
+ except ValueError as exc:
8251
+ self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
8252
+ return
8253
+ device_id = str(payload.get("device_id") or "")
8254
+ if not device_id:
8255
+ self._send_json({"ok": False, "error": {"code": "device_id_required", "message": "device_id required"}}, status=400)
8256
+ return
8257
+ # A challenge is issued for ANY device_id (even unknown / revoked) so
8258
+ # this endpoint never reveals whether a device exists.
8259
+ challenge = REAUTH_STORE.issue_challenge(device_id)
8260
+ self._send_json({"ok": True, "challenge": challenge, "ttl_seconds": REAUTH_STORE.ttl_seconds})
8261
+
8262
+ def _handle_pair_reauth_claim(self, q):
8263
+ if REAUTH_STORE is None or DEVICE_REGISTRY is None:
8264
+ self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "reauth unavailable"}}, status=503)
8265
+ return
8266
+ try:
8267
+ payload = self._read_json_object()
8268
+ except ValueError as exc:
8269
+ self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
8270
+ return
8271
+ device_id = str(payload.get("device_id") or "")
8272
+ challenge = str(payload.get("challenge") or "")
8273
+ signature_b64 = str(payload.get("signature") or "")
8274
+ try:
8275
+ signature = base64.b64decode(signature_b64) if signature_b64 else b""
8276
+ except Exception:
8277
+ signature = b""
8278
+ verified = REAUTH_STORE.verify_and_consume(device_id, challenge, signature)
8279
+ new_token = DEVICE_REGISTRY.rotate_token(device_id) if verified else None
8280
+ if not verified or not new_token:
8281
+ # Uniform failure: never distinguish unknown device / no SE key /
8282
+ # bad signature / expired-or-used challenge. No enumeration oracle.
8283
+ self._send_json({"ok": False, "error": {"code": "reauth_failed", "message": "reauth failed"}}, status=401)
8284
+ return
8285
+ self._send_json({"ok": True, "device": {"id": device_id, "token": new_token}})
8286
+
7701
8287
  def _handle_pair_revoke(self, q):
7702
8288
  if DEVICE_REGISTRY is None:
7703
8289
  self._send_json({"ok": False, "error": {"code": "auth_unavailable"}}, status=503)
@@ -9814,13 +10400,13 @@ class Handler(BaseHTTPRequestHandler):
9814
10400
  if broker_id:
9815
10401
  session = PTY_BROKER.get(broker_id)
9816
10402
  if session:
9817
- PTY_BROKER.register_alias(qualified, session)
10403
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
9818
10404
  return qualified, session
9819
10405
  tty = reg.get("terminal_tty") or ""
9820
10406
  if tty:
9821
10407
  session = PTY_BROKER.get_by_tty(tty)
9822
10408
  if session:
9823
- PTY_BROKER.register_alias(qualified, session)
10409
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
9824
10410
  return qualified, session
9825
10411
  if provider == "claude":
9826
10412
  session_id = _claude_native_session_id(raw_session)
@@ -9831,7 +10417,7 @@ class Handler(BaseHTTPRequestHandler):
9831
10417
  session = PTY_BROKER.get_by_tty(tty)
9832
10418
  if session:
9833
10419
  qualified = _qualified_session_id("claude", session_id)
9834
- PTY_BROKER.register_alias(qualified, session)
10420
+ PTY_BROKER.register_alias(qualified, _broker_session_id(session))
9835
10421
  return qualified, session
9836
10422
  return None
9837
10423
 
@@ -9840,16 +10426,16 @@ class Handler(BaseHTTPRequestHandler):
9840
10426
  if not found:
9841
10427
  return None
9842
10428
  public_session_id, session = found
9843
- return session.snapshot(public_session_id=public_session_id)
10429
+ return PTY_BROKER.snapshot(_broker_session_id(session), public_session_id=public_session_id) if PTY_BROKER else None
9844
10430
 
9845
10431
  def _broker_surface_v2_snapshot(self, raw_session: str) -> dict | None:
9846
10432
  found = self._broker_session_for(raw_session)
9847
10433
  if not found:
9848
10434
  return None
9849
10435
  public_session_id, session = found
9850
- if not hasattr(session, "snapshot_v2"):
10436
+ if PTY_BROKER is None or not hasattr(PTY_BROKER, "snapshot_v2"):
9851
10437
  return None
9852
- return _terminal_surface_v2_payload_from_state(public_session_id, session.snapshot_v2())
10438
+ return PTY_BROKER.snapshot_v2(_broker_session_id(session), public_session_id=public_session_id)
9853
10439
 
9854
10440
  def _terminal_surface_tty(self, raw_session: str) -> tuple[str, str, str]:
9855
10441
  provider, native_id = _parse_agent_session_ref(raw_session)
@@ -10152,10 +10738,10 @@ class Handler(BaseHTTPRequestHandler):
10152
10738
  "provider": provider,
10153
10739
  "native_id": native_id,
10154
10740
  "session_id": public_session_id,
10155
- "broker_id": session.session_id,
10156
- "tty": session.slave_tty,
10157
- "tty_candidates": [session.slave_tty] if session.slave_tty else [],
10158
- "pid": session.pid,
10741
+ "broker_id": _broker_session_id(session),
10742
+ "tty": _broker_slave_tty(session),
10743
+ "tty_candidates": [_broker_slave_tty(session)] if _broker_slave_tty(session) else [],
10744
+ "pid": _broker_pid(session),
10159
10745
  }
10160
10746
 
10161
10747
  if provider == "claude":
@@ -10529,7 +11115,7 @@ class Handler(BaseHTTPRequestHandler):
10529
11115
  2. **PG claude_uuid lookup**: for continuous-claude `s-…` ids, fetch
10530
11116
  claude_uuid + project from PG and exact-match `<claude_uuid>.jsonl`
10531
11117
  in the encoded project dir. This is essential when N sessions
10532
- share a project (e.g. 4 terminals all in /Users/mghome) — without
11118
+ share a project (e.g. 4 terminals all in /Users/example) — without
10533
11119
  it, all N collide on the most-recent-JSONL fallback.
10534
11120
  3. Last-ditch: PG project lookup → most recent JSONL in dir. Only
10535
11121
  fires when claude_uuid is unknown (pre-migration zombie sessions).
@@ -11717,6 +12303,109 @@ class Handler(BaseHTTPRequestHandler):
11717
12303
  return
11718
12304
  self._send_json(result)
11719
12305
 
12306
+ def _handle_push_permission_allow(self, q):
12307
+ # "Allow" from the phone's Lock-Screen card: answer the waiting permission
12308
+ # dialog by injecting Enter into the broker PTY (the same key path
12309
+ # /terminal-control uses). Idempotent on request_nonce (duplicate Allow is a
12310
+ # no-op); deny lives in-app via "Open Session". NO timeout / NO auto-deny.
12311
+ try:
12312
+ payload = self._read_json_object()
12313
+ except Exception:
12314
+ self._send_json({"ok": False, "error": {"code": "bad_json", "message": "invalid JSON"}}, status=400)
12315
+ return
12316
+ request_nonce = str(payload.get("request_nonce") or "").strip()
12317
+ if not request_nonce:
12318
+ self._send_json({"ok": False, "error": {"code": "bad_request", "message": "request_nonce required"}}, status=400)
12319
+ return
12320
+ row = _pending_approval_get(request_nonce)
12321
+ if not row:
12322
+ self._send_json({"ok": False, "error": {"code": "not_found", "message": "unknown request_nonce"}}, status=404)
12323
+ return
12324
+ current_state = str(row.get("state") or "")
12325
+ if current_state == "allowed":
12326
+ self._send_json({
12327
+ "ok": False,
12328
+ "state": current_state,
12329
+ "error": {
12330
+ "code": "approval_in_progress",
12331
+ "message": "permission approval is already being injected",
12332
+ },
12333
+ }, status=409)
12334
+ return
12335
+ if current_state not in {"pending", "attention"}:
12336
+ # Already resolved (double-tap / re-delivery) — safe idempotent no-op.
12337
+ self._send_json({"ok": True, "state": str(row.get("state") or ""), "already_resolved": True})
12338
+ return
12339
+ if not _pending_approval_cas(request_nonce, current_state, "allowed"):
12340
+ latest = _pending_approval_get(request_nonce) or {}
12341
+ latest_state = str(latest.get("state") or "")
12342
+ if latest_state == "allowed":
12343
+ self._send_json({
12344
+ "ok": False,
12345
+ "state": latest_state,
12346
+ "error": {
12347
+ "code": "approval_in_progress",
12348
+ "message": "permission approval is already being injected",
12349
+ },
12350
+ }, status=409)
12351
+ else:
12352
+ self._send_json({"ok": True, "state": latest_state, "already_resolved": True})
12353
+ return
12354
+ provider = str(row.get("provider") or "claude")
12355
+ injected = {"ok": False, "reason": "no broker session"}
12356
+ # 1) Authoritative: the broker session id the hook captured from the broker's
12357
+ # own PAIRLING_BROKER_SESSION_ID env — no fragile tty reconciliation.
12358
+ broker_id = str(row.get("broker_id") or "")
12359
+ if broker_id and PTY_BROKER is not None:
12360
+ try:
12361
+ injected = PTY_BROKER.control(broker_id, {"type": "key", "key": "enter"})
12362
+ except Exception as e:
12363
+ injected = {"ok": False, "reason": f"{type(e).__name__}: {str(e)[:120]}"}
12364
+ # 2) Fallback: resolve via /terminal-control's resolver (registry -> broker).
12365
+ if not injected.get("ok"):
12366
+ native_id = str(row.get("native_id") or "")
12367
+ if not native_id:
12368
+ native_id, _b, _t = _approval_resolve_session(provider, str(row.get("session_id") or ""))
12369
+ if native_id and PTY_BROKER is not None:
12370
+ try:
12371
+ bid = self._terminal_control_target(_qualified_session_id(provider, native_id)).get("broker_id")
12372
+ if bid:
12373
+ injected = PTY_BROKER.control(bid, {"type": "key", "key": "enter"})
12374
+ except Exception:
12375
+ pass
12376
+ if not injected.get("ok"):
12377
+ _pending_approval_cas(request_nonce, "allowed", current_state)
12378
+ latest = _pending_approval_get(request_nonce) or {}
12379
+ self._send_json({
12380
+ "ok": False,
12381
+ "state": str(latest.get("state") or current_state),
12382
+ "broker_id": broker_id,
12383
+ "injected": injected,
12384
+ "error": {
12385
+ "code": "injection_failed",
12386
+ "message": "permission approval could not be delivered to the broker PTY",
12387
+ },
12388
+ }, status=409)
12389
+ return
12390
+ if not _pending_approval_cas(request_nonce, "allowed", "released"):
12391
+ latest = _pending_approval_get(request_nonce) or {}
12392
+ latest_state = str(latest.get("state") or "")
12393
+ if latest_state == "released":
12394
+ self._send_json({"ok": True, "state": "released", "broker_id": broker_id, "injected": injected})
12395
+ else:
12396
+ self._send_json({
12397
+ "ok": False,
12398
+ "state": latest_state or "allowed",
12399
+ "broker_id": broker_id,
12400
+ "injected": injected,
12401
+ "error": {
12402
+ "code": "release_state_failed",
12403
+ "message": "permission approval was injected but the state transition did not complete",
12404
+ },
12405
+ }, status=409)
12406
+ return
12407
+ self._send_json({"ok": True, "state": "released", "broker_id": broker_id, "injected": injected})
12408
+
11720
12409
  def _handle_push_test(self, q):
11721
12410
  if PUSH_DISPATCHER is None:
11722
12411
  self._send_json({
@@ -13493,12 +14182,22 @@ Worker instructions:
13493
14182
  broker_env = generated.get("env") if isinstance(generated.get("env"), dict) else None
13494
14183
  command = _aperture_cli_command_for_context(launch_context, project) if _aperture_cli_command_for_context else ""
13495
14184
  elif provider == "codex":
14185
+ # Inherit the user's OWN host posture (~/.codex/config.toml:
14186
+ # approval_policy + sandbox_mode). Pairling imposes NO permission flag
14187
+ # — we never twist the user's workspace to manufacture cards. The
14188
+ # approval card is opportunistic: it surfaces ONLY if the user's own
14189
+ # config prompts (codex approval detection is the Phase 5 screen-scrape,
14190
+ # which touches no config).
13496
14191
  command = (
13497
- f"exec codex --dangerously-bypass-approvals-and-sandbox "
14192
+ f"exec codex "
13498
14193
  f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)}"
13499
14194
  )
13500
14195
  else:
13501
- command = "exec claude --dangerously-skip-permissions"
14196
+ # Inherit the user's OWN host posture (~/.claude/settings.json). No
14197
+ # imposed permission flag. The PermissionRequest producer hook is
14198
+ # injected PER-SPAWN via --settings (phone sessions only) so the user's
14199
+ # global settings stay untouched; the hook is an observer, never a mode.
14200
+ command = f"exec claude --settings {shlex.quote(str(SPAWN_SETTINGS_PATH))}"
13502
14201
  if not command:
13503
14202
  self._send_json({"ok": False, "error": "Aperture CLI launch command unavailable"}, status=503)
13504
14203
  return
@@ -13520,20 +14219,27 @@ Worker instructions:
13520
14219
  command=command,
13521
14220
  rows=30,
13522
14221
  columns=120,
13523
- env=broker_env,
14222
+ # Mark phone-spawned sessions so the global PermissionRequest hook
14223
+ # self-enables ONLY here (no-op for the user's own claude sessions).
14224
+ env={**(broker_env or {}), "PAIRLING_PHONE_SESSION": "1",
14225
+ "PAIRLING_BROKER_SESSION_ID": broker_session_id},
13524
14226
  )
13525
14227
  ok = True
13526
14228
  except Exception as e:
13527
14229
  reason = f"{type(e).__name__}: {e}"
13528
14230
 
13529
14231
  if ok and session is not None:
13530
- _write_terminal_capture_mapping(
13531
- session.slave_tty,
13532
- session.raw_log_path,
13533
- provider=provider,
13534
- project=project,
13535
- capture_id=capture_id,
13536
- )
14232
+ session_tty = _broker_slave_tty(session)
14233
+ session_log = _broker_raw_log_path(session)
14234
+ session_pid = _broker_pid(session)
14235
+ if session_tty and session_log:
14236
+ _write_terminal_capture_mapping(
14237
+ session_tty,
14238
+ session_log,
14239
+ provider=provider,
14240
+ project=project,
14241
+ capture_id=capture_id,
14242
+ )
13537
14243
  if launch_context is not None:
13538
14244
  launch_meta = {
13539
14245
  "spawned_by": "pairling",
@@ -13560,10 +14266,10 @@ Worker instructions:
13560
14266
  provider,
13561
14267
  native_id,
13562
14268
  project,
13563
- pid=session.pid,
13564
- terminal_tty=session.slave_tty,
14269
+ pid=session_pid,
14270
+ terminal_tty=session_tty,
13565
14271
  metadata={
13566
- "terminal_log": str(session.raw_log_path) if session.raw_log_path else None,
14272
+ "terminal_log": str(session_log) if session_log else None,
13567
14273
  "capture_backend": "pty_broker",
13568
14274
  "capture_id": capture_id,
13569
14275
  "broker_id": broker_session_id,
@@ -13583,9 +14289,9 @@ Worker instructions:
13583
14289
  "project": project,
13584
14290
  "provider": provider,
13585
14291
  "native_id": native_id,
13586
- "tty": session.slave_tty if session else "",
13587
- "pid": session.pid if session else 0,
13588
- "terminal_log": str(session.raw_log_path) if session and session.raw_log_path else None,
14292
+ "tty": _broker_slave_tty(session) if session else "",
14293
+ "pid": _broker_pid(session) if session else 0,
14294
+ "terminal_log": str(_broker_raw_log_path(session)) if session and _broker_raw_log_path(session) else None,
13589
14295
  "capture_backend": "pty_broker",
13590
14296
  "broker_id": broker_session_id,
13591
14297
  "broker_socket": str(PTY_BROKER_SOCKET),
@@ -13614,9 +14320,9 @@ Worker instructions:
13614
14320
  "provider": provider,
13615
14321
  "native_id": native_id,
13616
14322
  "session_id": broker_session_id,
13617
- "tty": session.slave_tty,
13618
- "pid": session.pid,
13619
- "terminal_log": str(session.raw_log_path) if session.raw_log_path else None,
14323
+ "tty": _broker_slave_tty(session),
14324
+ "pid": _broker_pid(session),
14325
+ "terminal_log": str(_broker_raw_log_path(session)) if _broker_raw_log_path(session) else None,
13620
14326
  "capture_backend": "pty_broker",
13621
14327
  "terminal_source": "broker_vt",
13622
14328
  "broker_id": broker_session_id,
@@ -13634,6 +14340,98 @@ Worker instructions:
13634
14340
  })
13635
14341
 
13636
14342
  # ----- /spawn-session: open a new broker-owned agent CLI session -----
14343
+ def _handle_onestream_handoff(self, q):
14344
+ """OneStream -> Pairling handoff ingestion (W1b).
14345
+
14346
+ POST: validate (fail-closed, mirroring the iOS PairlingHandoffReader),
14347
+ compose the steering draft, and store the handoff under
14348
+ HANDOFFS_DIR using the schemaVersion-1 record shape. Returns the
14349
+ composed draft so the caller can confirm what a session would
14350
+ ingest.
14351
+ GET: list pending (unconsumed) OneStream handoffs.
14352
+
14353
+ Auth: POST requires session:spawn, GET requires sessions:read (see
14354
+ _required_scopes_for_request). Additive route — does not spawn directly
14355
+ (OneStream has no Mac project path); a consumer composes/spawns later.
14356
+ """
14357
+ if self.command == "GET":
14358
+ items = []
14359
+ try:
14360
+ paths = sorted(HANDOFFS_DIR.glob("onestream-*.json"))
14361
+ except OSError:
14362
+ paths = []
14363
+ for p in paths:
14364
+ try:
14365
+ rec = json.loads(p.read_text())
14366
+ except (OSError, json.JSONDecodeError):
14367
+ continue
14368
+ if not isinstance(rec, dict) or rec.get("consumed"):
14369
+ continue
14370
+ items.append({
14371
+ "handoff_id": rec.get("handoff_id") or p.stem,
14372
+ "source": rec.get("source") or "OneStream",
14373
+ "generatedAt": rec.get("generatedAt"),
14374
+ "suggestedPrompt": rec.get("suggestedPrompt") or "",
14375
+ "transcriptText": rec.get("transcriptText") or "",
14376
+ "composeDraft": rec.get("composeDraft") or "",
14377
+ "workflowHint": rec.get("workflowHint"),
14378
+ })
14379
+ self._send_json({"ok": True, "handoffs": items})
14380
+ return
14381
+
14382
+ # POST — store a new handoff.
14383
+ try:
14384
+ payload = json.loads(self._read_body() or b"{}")
14385
+ except json.JSONDecodeError:
14386
+ self.send_error(400, "body must be JSON")
14387
+ return
14388
+ if not isinstance(payload, dict):
14389
+ self.send_error(400, "body must be JSON object")
14390
+ return
14391
+
14392
+ # Fail-closed validation — mirror iOS PairlingHandoffReader.decode().
14393
+ schema_version = payload.get("schemaVersion")
14394
+ if schema_version != 1:
14395
+ self._send_json(
14396
+ {"ok": False, "error": f"unsupported schemaVersion {schema_version!r} (expected 1)"},
14397
+ status=400,
14398
+ )
14399
+ return
14400
+ transcript_text = str(payload.get("transcriptText") or "").strip()
14401
+ if not transcript_text:
14402
+ self._send_json({"ok": False, "error": "handoff contains no transcript"}, status=400)
14403
+ return
14404
+
14405
+ suggested_prompt = str(payload.get("suggestedPrompt") or "").strip()
14406
+ # composeDraft mirrors PairlingHandoffReader.composeDraft(from:).
14407
+ compose_draft = (
14408
+ f"{suggested_prompt}\n\n---\n{transcript_text}" if suggested_prompt else transcript_text
14409
+ )
14410
+
14411
+ handoff_id = "onestream-" + secrets.token_hex(6)
14412
+ record = {
14413
+ "schemaVersion": 1,
14414
+ "handoff_id": handoff_id,
14415
+ "source": str(payload.get("source") or "OneStream")[:64],
14416
+ "generatedAt": payload.get("generatedAt"),
14417
+ "receivedAt": _time.time(),
14418
+ "workflowHint": payload.get("workflowHint"),
14419
+ "suggestedPrompt": suggested_prompt,
14420
+ "transcriptText": transcript_text,
14421
+ "segments": payload.get("segments") if isinstance(payload.get("segments"), list) else [],
14422
+ "composeDraft": compose_draft,
14423
+ "consumed": False,
14424
+ }
14425
+ try:
14426
+ (HANDOFFS_DIR / f"{handoff_id}.json").write_text(
14427
+ json.dumps(record, indent=2, sort_keys=True)
14428
+ )
14429
+ except OSError as exc:
14430
+ self._send_json({"ok": False, "error": f"could not store handoff: {exc}"}, status=500)
14431
+ return
14432
+
14433
+ self._send_json({"ok": True, "handoff_id": handoff_id, "composeDraft": compose_draft})
14434
+
13637
14435
  def _handle_spawn_session(self, q):
13638
14436
  """Spawn a new Claude/Codex session. Pairling-owned PTYs are the
13639
14437
  default path; Terminal.app can attach as a client via `pairling attach`.
@@ -13767,20 +14565,20 @@ Worker instructions:
13767
14565
  capture_log_path = TERMINAL_CAPTURE_DIR / f"codex-{capture_id}.log"
13768
14566
  inner = (
13769
14567
  f"cd {shlex.quote(project)} && "
13770
- f"exec codex --dangerously-bypass-approvals-and-sandbox "
14568
+ f"exec codex "
13771
14569
  f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)}"
13772
14570
  )
13773
14571
  shell_cmd = _terminal_script_command(capture_log_path, inner, interactive_shell=True)
13774
14572
  else:
13775
14573
  capture_id = secrets.token_hex(12)
13776
14574
  capture_log_path = TERMINAL_CAPTURE_DIR / f"claude-{capture_id}.log"
13777
- inner = f"cd {shlex.quote(project)} && claude --dangerously-skip-permissions"
14575
+ inner = f"cd {shlex.quote(project)} && claude"
13778
14576
  shell_cmd = _terminal_script_command(capture_log_path, inner)
13779
14577
  as_escaped_cmd = _as_escape(shell_cmd)
13780
14578
 
13781
14579
  # Set Terminal's custom title to the project basename so /inject-now's
13782
14580
  # window-title matcher can find this window later. Terminal's auto-
13783
- # title shows running command + cwd (e.g. "mghome — claude") which
14581
+ # title shows running command + cwd (e.g. "project — claude") which
13784
14582
  # rarely contains the project name; explicit custom title fixes that.
13785
14583
  basename = os.path.basename(project.rstrip("/")) or provider
13786
14584
  title = f"{provider}:{basename}" if provider == "codex" else basename
@@ -13942,12 +14740,12 @@ Worker instructions:
13942
14740
  if provider == "codex":
13943
14741
  shell_cmd = (
13944
14742
  f"cd '{shell_safe_path}' && "
13945
- f"codex --dangerously-bypass-approvals-and-sandbox -C '{shell_safe_path}' --add-dir '{shell_safe_path}' \"$(cat '{shell_safe_prompt}')\""
14743
+ f"codex -C '{shell_safe_path}' --add-dir '{shell_safe_path}' \"$(cat '{shell_safe_prompt}')\""
13946
14744
  )
13947
14745
  else:
13948
14746
  shell_cmd = (
13949
14747
  f"cd '{shell_safe_path}' && "
13950
- f"claude --dangerously-skip-permissions \"$(cat '{shell_safe_prompt}')\""
14748
+ f"claude \"$(cat '{shell_safe_prompt}')\""
13951
14749
  )
13952
14750
  script = f'''
13953
14751
  tell application "Terminal"
@@ -14079,19 +14877,19 @@ Worker instructions:
14079
14877
  "native_id": native_id,
14080
14878
  "session_id": broker_session_id,
14081
14879
  "project": project,
14082
- "tty": existing.slave_tty,
14083
- "pid": existing.pid,
14084
- "terminal_log": str(existing.raw_log_path) if existing.raw_log_path else None,
14880
+ "tty": _broker_slave_tty(existing),
14881
+ "pid": _broker_pid(existing),
14882
+ "terminal_log": str(_broker_raw_log_path(existing)) if _broker_raw_log_path(existing) else None,
14085
14883
  "capture_backend": "pty_broker",
14086
14884
  "terminal_source": "broker_vt",
14087
- "broker_id": existing.session_id,
14885
+ "broker_id": _broker_session_id(existing),
14088
14886
  "broker_socket": str(PTY_BROKER_SOCKET),
14089
14887
  "attach_command": f"pairling attach {broker_session_id}",
14090
14888
  })
14091
14889
  return
14092
14890
 
14093
14891
  command = (
14094
- f"exec codex resume --dangerously-bypass-approvals-and-sandbox "
14892
+ f"exec codex resume "
14095
14893
  f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)} "
14096
14894
  f"{shlex.quote(native_id)}"
14097
14895
  )
@@ -14109,6 +14907,8 @@ Worker instructions:
14109
14907
  command=command,
14110
14908
  rows=30,
14111
14909
  columns=120,
14910
+ env={"PAIRLING_PHONE_SESSION": "1",
14911
+ "PAIRLING_BROKER_SESSION_ID": broker_session_id},
14112
14912
  )
14113
14913
  ok = True
14114
14914
  except Exception as e:
@@ -14116,23 +14916,27 @@ Worker instructions:
14116
14916
 
14117
14917
  if ok and session is not None:
14118
14918
  capture_id = secrets.token_hex(12)
14119
- _write_terminal_capture_mapping(
14120
- session.slave_tty,
14121
- session.raw_log_path,
14122
- provider=provider,
14123
- project=project,
14124
- capture_id=capture_id,
14125
- )
14919
+ session_tty = _broker_slave_tty(session)
14920
+ session_log = _broker_raw_log_path(session)
14921
+ session_pid = _broker_pid(session)
14922
+ if session_tty and session_log:
14923
+ _write_terminal_capture_mapping(
14924
+ session_tty,
14925
+ session_log,
14926
+ provider=provider,
14927
+ project=project,
14928
+ capture_id=capture_id,
14929
+ )
14126
14930
  _agent_registry_upsert(
14127
14931
  "codex",
14128
14932
  native_id,
14129
14933
  project,
14130
- pid=session.pid,
14131
- terminal_tty=session.slave_tty,
14934
+ pid=session_pid,
14935
+ terminal_tty=session_tty,
14132
14936
  metadata={
14133
14937
  "spawned_by": "phone-companion",
14134
14938
  "resume_target": native_id,
14135
- "terminal_log": str(session.raw_log_path) if session.raw_log_path else None,
14939
+ "terminal_log": str(session_log) if session_log else None,
14136
14940
  "capture_backend": "pty_broker",
14137
14941
  "capture_id": capture_id,
14138
14942
  "terminal_source": "broker_vt",
@@ -14151,9 +14955,9 @@ Worker instructions:
14151
14955
  "provider": provider,
14152
14956
  "project": project,
14153
14957
  "native_id": native_id,
14154
- "tty": session.slave_tty if session else "",
14155
- "pid": session.pid if session else 0,
14156
- "terminal_log": str(session.raw_log_path) if session and session.raw_log_path else None,
14958
+ "tty": _broker_slave_tty(session) if session else "",
14959
+ "pid": _broker_pid(session) if session else 0,
14960
+ "terminal_log": str(_broker_raw_log_path(session)) if session and _broker_raw_log_path(session) else None,
14157
14961
  "capture_backend": "pty_broker",
14158
14962
  "terminal_source": "broker_vt",
14159
14963
  "broker_id": broker_session_id,
@@ -14175,9 +14979,9 @@ Worker instructions:
14175
14979
  "native_id": native_id,
14176
14980
  "session_id": broker_session_id,
14177
14981
  "project": project,
14178
- "tty": session.slave_tty,
14179
- "pid": session.pid,
14180
- "terminal_log": str(session.raw_log_path) if session.raw_log_path else None,
14982
+ "tty": _broker_slave_tty(session),
14983
+ "pid": _broker_pid(session),
14984
+ "terminal_log": str(_broker_raw_log_path(session)) if _broker_raw_log_path(session) else None,
14181
14985
  "capture_backend": "pty_broker",
14182
14986
  "terminal_source": "broker_vt",
14183
14987
  "broker_id": broker_session_id,
@@ -14248,7 +15052,7 @@ Worker instructions:
14248
15052
  capture_log_path = TERMINAL_CAPTURE_DIR / f"codex-{capture_id}.log"
14249
15053
  inner = (
14250
15054
  f"cd {shlex.quote(project)} && "
14251
- f"exec codex resume --dangerously-bypass-approvals-and-sandbox "
15055
+ f"exec codex resume "
14252
15056
  f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)} "
14253
15057
  f"{shlex.quote(native_id)}"
14254
15058
  )
@@ -14383,8 +15187,8 @@ Worker instructions:
14383
15187
  _agent_registry_update_control(
14384
15188
  "codex",
14385
15189
  native_id,
14386
- pid=session.pid,
14387
- terminal_tty=session.slave_tty,
15190
+ pid=_broker_pid(session),
15191
+ terminal_tty=_broker_slave_tty(session),
14388
15192
  state="running",
14389
15193
  reopen=True,
14390
15194
  )
@@ -14399,16 +15203,16 @@ Worker instructions:
14399
15203
  state="applied" if result.get("ok") else "failed",
14400
15204
  phases=_receipt_phases(validated=True, applied=bool(result.get("ok")), pty_written=bool(result.get("ok"))),
14401
15205
  backend="pty_broker",
14402
- tty=session.slave_tty,
14403
- pid=session.pid,
15206
+ tty=_broker_slave_tty(session),
15207
+ pid=_broker_pid(session),
14404
15208
  source_offset_after=source_offset_after,
14405
15209
  )
14406
15210
  _store_action_receipt(device_id, receipt_session_id, client_action_id, body_hash, receipt, action_kind="send_text", audit_action={"type": "send_text", "chars": len(text)})
14407
15211
  body = json.dumps({
14408
15212
  "ok": bool(result.get("ok")),
14409
- "tty": session.slave_tty,
14410
- "pid": session.pid,
14411
- "broker_id": session.session_id,
15213
+ "tty": _broker_slave_tty(session),
15214
+ "pid": _broker_pid(session),
15215
+ "broker_id": _broker_session_id(session),
14412
15216
  "reason": result.get("reason"),
14413
15217
  "receipt": receipt,
14414
15218
  }).encode()
@@ -14689,16 +15493,16 @@ Worker instructions:
14689
15493
  state="applied" if result.get("ok") else "failed",
14690
15494
  phases=_receipt_phases(validated=True, applied=bool(result.get("ok")), pty_written=bool(result.get("ok"))),
14691
15495
  backend="pty_broker",
14692
- tty=broker_session.slave_tty,
14693
- pid=broker_session.pid,
15496
+ tty=_broker_slave_tty(broker_session),
15497
+ pid=_broker_pid(broker_session),
14694
15498
  source_offset_after=source_offset_after,
14695
15499
  )
14696
15500
  _store_action_receipt(receipt_context["device_id"], receipt_session_id, receipt_context["client_action_id"], receipt_context["body_hash"], receipt, action_kind="send_text", audit_action={"type": "send_text", "chars": len(text)})
14697
15501
  body = json.dumps({
14698
15502
  "ok": bool(result.get("ok")),
14699
- "tty": broker_session.slave_tty,
14700
- "pid": broker_session.pid,
14701
- "broker_id": broker_session.session_id,
15503
+ "tty": _broker_slave_tty(broker_session),
15504
+ "pid": _broker_pid(broker_session),
15505
+ "broker_id": _broker_session_id(broker_session),
14702
15506
  "reason": result.get("reason"),
14703
15507
  "receipt": receipt,
14704
15508
  }).encode()
@@ -14918,10 +15722,11 @@ Worker instructions:
14918
15722
  broker_found = self._broker_session_for(_qualified_session_id("codex", native_id))
14919
15723
  if broker_found and PTY_BROKER:
14920
15724
  _, broker_session = broker_found
15725
+ broker_id = _broker_session_id(broker_session)
14921
15726
  if sig == signal.SIGINT:
14922
- result = PTY_BROKER.control(broker_session.session_id, {"type": "key", "key": "ctrl_c"})
15727
+ result = PTY_BROKER.control(broker_id, {"type": "key", "key": "ctrl_c"})
14923
15728
  else:
14924
- result = PTY_BROKER.terminate(broker_session.session_id, sig)
15729
+ result = PTY_BROKER.terminate(broker_id, sig)
14925
15730
  ok = bool(result.get("ok"))
14926
15731
  if ok:
14927
15732
  _write_agent_turn_state("codex", native_id, "idle", event=sig_name.lower())
@@ -14929,10 +15734,10 @@ Worker instructions:
14929
15734
  _agent_registry_mark_closed("codex", native_id)
14930
15735
  send_signal_result(
14931
15736
  ok,
14932
- result.get("pid") or broker_session.pid,
15737
+ result.get("pid") or _broker_pid(broker_session),
14933
15738
  result.get("error") or result.get("reason"),
14934
15739
  200 if ok else int(result.get("status") or 502),
14935
- broker_id=broker_session.session_id,
15740
+ broker_id=broker_id,
14936
15741
  )
14937
15742
  return
14938
15743
 
@@ -14979,19 +15784,20 @@ Worker instructions:
14979
15784
  broker_found = self._broker_session_for(_qualified_session_id("claude", session_id))
14980
15785
  if broker_found and PTY_BROKER:
14981
15786
  _, broker_session = broker_found
15787
+ broker_id = _broker_session_id(broker_session)
14982
15788
  if sig == signal.SIGINT:
14983
- result = PTY_BROKER.control(broker_session.session_id, {"type": "key", "key": "ctrl_c"})
15789
+ result = PTY_BROKER.control(broker_id, {"type": "key", "key": "ctrl_c"})
14984
15790
  else:
14985
- result = PTY_BROKER.terminate(broker_session.session_id, sig)
15791
+ result = PTY_BROKER.terminate(broker_id, sig)
14986
15792
  ok = bool(result.get("ok"))
14987
15793
  if ok and sig == signal.SIGTERM:
14988
15794
  self._mark_session_closed(session_id)
14989
15795
  send_signal_result(
14990
15796
  ok,
14991
- result.get("pid") or broker_session.pid,
15797
+ result.get("pid") or _broker_pid(broker_session),
14992
15798
  result.get("error") or result.get("reason"),
14993
15799
  200 if ok else int(result.get("status") or 502),
14994
- broker_id=broker_session.session_id,
15800
+ broker_id=broker_id,
14995
15801
  )
14996
15802
  return
14997
15803
 
@@ -17663,9 +18469,9 @@ Worker instructions:
17663
18469
  params. Save to ~/Pairling/uploads/<bucket>/<8-hex>-<name>
17664
18470
  where <bucket> is derived from the session's project path:
17665
18471
 
17666
- - regular project (e.g. /Users/mghome/projects/proofforge)
18472
+ - regular project (e.g. /Users/example/projects/proofforge)
17667
18473
  → bucket = "proofforge"
17668
- - sentinel session (e.g. /Users/mghome/.claude/state/sentinel/projects/proofforge-079c4a/terminals/orange_team)
18474
+ - sentinel session (e.g. /Users/example/.claude/state/sentinel/projects/proofforge-079c4a/terminals/orange_team)
17669
18475
  → bucket = "proofforge" (regex strips -<6hex> suffix)
17670
18476
  - fallback when session unknown
17671
18477
  → bucket = "misc"
@@ -18420,15 +19226,82 @@ def _maybe_backfill_claude_registry_from_pg() -> None:
18420
19226
  print(f"[registry-backfill] skipped: {type(exc).__name__}", file=sys.stderr, flush=True)
18421
19227
 
18422
19228
 
19229
+ def _reconcile_broker_sessions_on_boot() -> None:
19230
+ if PTY_BROKER is None:
19231
+ return
19232
+ survivors: dict[str, dict] = {}
19233
+ deadline = _time.time() + 10
19234
+ last_error = ""
19235
+ while _time.time() < deadline:
19236
+ try:
19237
+ survivors = {
19238
+ str(item.get("session_id") or ""): item
19239
+ for item in PTY_BROKER.list_sessions()
19240
+ if isinstance(item, dict) and item.get("session_id")
19241
+ }
19242
+ break
19243
+ except Exception as exc:
19244
+ last_error = f"{type(exc).__name__}: {str(exc)[:120]}"
19245
+ _time.sleep(0.25)
19246
+ if last_error and not survivors:
19247
+ print(f"[broker-reconcile] deferred: {last_error}", file=sys.stderr, flush=True)
19248
+ return
19249
+
19250
+ for provider in ("claude", "codex"):
19251
+ for row in _agent_registry_live(provider):
19252
+ metadata = _registry_metadata_from_row(row)
19253
+ broker_id = str(metadata.get("broker_id") or "").strip()
19254
+ native_id = str(row.get("native_id") or "").strip()
19255
+ if not broker_id or not native_id:
19256
+ continue
19257
+ desc = survivors.get(broker_id)
19258
+ if desc is None:
19259
+ _agent_registry_mark_closed(provider, native_id)
19260
+ continue
19261
+ _agent_registry_update_control(
19262
+ provider,
19263
+ native_id,
19264
+ pid=_broker_pid(desc),
19265
+ terminal_tty=_broker_slave_tty(desc),
19266
+ state="running",
19267
+ reopen=True,
19268
+ )
19269
+
19270
+ for approval in _pending_approvals_open():
19271
+ broker_id = str(approval.get("broker_id") or "").strip()
19272
+ request_nonce = str(approval.get("request_nonce") or "").strip()
19273
+ provider = str(approval.get("provider") or "").strip() or "claude"
19274
+ native_id = str(approval.get("native_id") or "").strip()
19275
+ if not native_id:
19276
+ _native, _broker, _tty = _approval_resolve_session(provider, str(approval.get("session_id") or ""))
19277
+ native_id = _native
19278
+ if broker_id and broker_id in survivors:
19279
+ if native_id:
19280
+ _write_agent_turn_state(
19281
+ provider,
19282
+ native_id,
19283
+ "attention",
19284
+ tool=str(approval.get("command_preview") or approval.get("tool_name") or "")[:80],
19285
+ event="broker_reconcile",
19286
+ request_nonce=request_nonce,
19287
+ mac_install_id=getattr(PAIRING_STORE, "install_id", "") if PAIRING_STORE else "",
19288
+ )
19289
+ continue
19290
+ if request_nonce:
19291
+ _pending_approval_resolve_terminal(request_nonce, "session_gone")
19292
+
19293
+
18423
19294
  if __name__ == "__main__":
18424
19295
  host = _bind_host()
18425
19296
  BOUND_HOST = host
18426
- os.environ["NOTIFY_WEBHOOK_BOUND_HOST"] = host
19297
+ os.environ["PAIRLING_BOUND_HOST"] = host
18427
19298
  _maybe_backfill_claude_registry_from_pg()
19299
+ _reconcile_broker_sessions_on_boot()
18428
19300
  LIVE_ACTIVITY_PUBLISHER = _start_live_activity_publisher()
18429
19301
  STANDARD_TURN_PUSH_PUBLISHER = _start_standard_turn_push_publisher()
18430
19302
  MAC_HEALTH_PUSH_PUBLISHER = _start_mac_health_push_publisher()
18431
19303
  SENTINEL_PUSH_PUBLISHER = _start_sentinel_push_publisher()
19304
+ _start_codex_approval_scanner()
18432
19305
  server = _PairlingThreadingHTTPServer((host, PORT), Handler)
18433
19306
  server.daemon_threads = True
18434
19307
  print(f"pairlingd listening on {host}:{PORT}", file=sys.stderr, flush=True)