pairling 0.0.1 → 0.1.0

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 (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ APERTURE_CLI_KNOWN_VERSION = "v0.0.8"
15
+ APERTURE_CLI_STATUS_PATH = "/aperture-cli/status"
16
+ APERTURE_CLI_PROVIDER_PATH = "/aperture-cli/providers"
17
+ DEFAULT_ENDPOINT = "http://ai"
18
+ PROVIDER_TIMEOUT_SECONDS = 10
19
+
20
+
21
+ def _redact_home(path: Path, home: Path) -> str:
22
+ try:
23
+ return "~/" + str(path.resolve().relative_to(home.resolve()))
24
+ except Exception:
25
+ return str(path)
26
+
27
+
28
+ def _run_text(args: list[str], timeout: int = 3) -> tuple[bool, str]:
29
+ try:
30
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
31
+ except Exception as exc:
32
+ return False, f"{type(exc).__name__}: {str(exc)[:160]}"
33
+ text = (proc.stdout or proc.stderr or "").strip()
34
+ return proc.returncode == 0, text[:400]
35
+
36
+
37
+ def _dedupe_paths(paths: list[Path]) -> list[Path]:
38
+ seen: set[str] = set()
39
+ result: list[Path] = []
40
+ for path in paths:
41
+ key = str(path.expanduser())
42
+ if key in seen:
43
+ continue
44
+ seen.add(key)
45
+ result.append(path.expanduser())
46
+ return result
47
+
48
+
49
+ def _safe_str(value: Any, limit: int = 240) -> str | None:
50
+ if not isinstance(value, str):
51
+ return None
52
+ value = value.strip()
53
+ if not value:
54
+ return None
55
+ return value[:limit]
56
+
57
+
58
+ def _safe_bool(value: Any) -> bool:
59
+ return bool(value) if isinstance(value, bool) else False
60
+
61
+
62
+ def _canonical_endpoint_url(raw: str) -> tuple[str, str | None, str | None, bool]:
63
+ value = raw.strip().rstrip("/")
64
+ if not value:
65
+ return "", None, None, False
66
+ normalized = value if "://" in value else f"http://{value}"
67
+ parsed = urllib.parse.urlsplit(normalized)
68
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
69
+ return value, None, None, normalized != value
70
+ path = parsed.path.rstrip("/")
71
+ canonical = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
72
+ return canonical, parsed.netloc, parsed.hostname, canonical != value
73
+
74
+
75
+ def _normalize_endpoint(obj: Any) -> dict[str, Any] | None:
76
+ if not isinstance(obj, dict):
77
+ return None
78
+ url = _safe_str(obj.get("url"))
79
+ if not url:
80
+ return None
81
+ bridge_id = _safe_str(obj.get("bridgeId"), 96)
82
+ normalized_url, display_host, host, normalized = _canonical_endpoint_url(url)
83
+ return {
84
+ "url": normalized_url,
85
+ "mode": "bridge" if bridge_id else "direct",
86
+ "bridge_id": bridge_id,
87
+ "runtime_url": normalized_url,
88
+ "display_host": display_host or url.rstrip("/"),
89
+ "normalized_url": normalized_url,
90
+ "host": host,
91
+ "normalized": normalized,
92
+ "stale": False,
93
+ }
94
+
95
+
96
+ def _dedupe_endpoints(endpoints: list[dict[str, Any]]) -> list[dict[str, Any]]:
97
+ seen: set[tuple[str, str, str | None]] = set()
98
+ result: list[dict[str, Any]] = []
99
+ for endpoint in endpoints:
100
+ key = (
101
+ str(endpoint.get("normalized_url") or endpoint.get("url") or ""),
102
+ str(endpoint.get("mode") or "direct"),
103
+ endpoint.get("bridge_id"),
104
+ )
105
+ if key in seen:
106
+ continue
107
+ seen.add(key)
108
+ result.append(endpoint)
109
+ return result
110
+
111
+
112
+ def _normalize_bridge(obj: Any) -> dict[str, Any] | None:
113
+ if not isinstance(obj, dict):
114
+ return None
115
+ bridge_id = _safe_str(obj.get("id"), 96)
116
+ name = _safe_str(obj.get("name"), 120)
117
+ if not bridge_id:
118
+ return None
119
+ return {"id": bridge_id, "name": name or bridge_id}
120
+
121
+
122
+ def _parse_provider_items(raw: Any) -> list[dict[str, Any]]:
123
+ if not isinstance(raw, list):
124
+ return []
125
+ items: list[dict[str, Any]] = []
126
+ for obj in raw:
127
+ if not isinstance(obj, dict):
128
+ continue
129
+ provider_id = _safe_str(obj.get("id"), 120)
130
+ if not provider_id:
131
+ continue
132
+ compatibility = obj.get("compatibility")
133
+ if not isinstance(compatibility, dict):
134
+ compatibility = {}
135
+ models = obj.get("models")
136
+ if not isinstance(models, list):
137
+ models = []
138
+ items.append({
139
+ "id": provider_id,
140
+ "name": _safe_str(obj.get("name"), 160) or provider_id,
141
+ "description": _safe_str(obj.get("description"), 500) or "",
142
+ "models": [m[:240] for m in models if isinstance(m, str)],
143
+ "compatibility": {str(k): bool(v) for k, v in compatibility.items()},
144
+ })
145
+ return items
146
+
147
+
148
+ class ApertureCLIProbe:
149
+ """Read-only probe for Aperture CLI's local launcher state."""
150
+
151
+ def __init__(
152
+ self,
153
+ home: Path | None = None,
154
+ env: dict[str, str] | None = None,
155
+ now: float | None = None,
156
+ ) -> None:
157
+ self.home = (home or Path.home()).expanduser()
158
+ self.env = env if env is not None else os.environ
159
+ self.now = now
160
+
161
+ @property
162
+ def config_root(self) -> Path:
163
+ return self.home / "Library" / "Application Support" / "aperture"
164
+
165
+ @property
166
+ def settings_path(self) -> Path:
167
+ return self.config_root / "settings.json"
168
+
169
+ @property
170
+ def launcher_path(self) -> Path:
171
+ return self.config_root / "launcher.json"
172
+
173
+ def resolve_binary(self) -> tuple[Path | None, str | None]:
174
+ candidates: list[tuple[Path, str]] = [
175
+ (self.home / "go" / "bin" / "aperture", "known_gopath"),
176
+ (self.home / ".local" / "bin" / "aperture", "known_local"),
177
+ (Path("/opt/homebrew/bin/aperture"), "known_homebrew"),
178
+ (Path("/usr/local/bin/aperture"), "known_usr_local"),
179
+ ]
180
+ for prefix in self.env.get("PATH", "").split(":"):
181
+ if prefix:
182
+ candidates.append((Path(prefix) / "aperture", "path"))
183
+ seen: set[str] = set()
184
+ for path, source in candidates:
185
+ path = path.expanduser()
186
+ key = str(path)
187
+ if key in seen:
188
+ continue
189
+ seen.add(key)
190
+ if path.exists() and os.access(path, os.X_OK):
191
+ return path, source
192
+ return None, None
193
+
194
+ def _read_json(self, path: Path) -> tuple[bool, dict[str, Any], str | None]:
195
+ if not path.is_file():
196
+ return False, {}, None
197
+ try:
198
+ obj = json.loads(path.read_text(errors="replace"))
199
+ except Exception as exc:
200
+ return True, {}, f"{type(exc).__name__}: {str(exc)[:160]}"
201
+ if not isinstance(obj, dict):
202
+ return True, {}, "JSON root is not an object"
203
+ return True, obj, None
204
+
205
+ def _settings_payload(self) -> dict[str, Any]:
206
+ found, obj, error = self._read_json(self.settings_path)
207
+ endpoints = []
208
+ for item in obj.get("endpoints", []) if isinstance(obj.get("endpoints"), list) else []:
209
+ endpoint = _normalize_endpoint(item)
210
+ if endpoint:
211
+ endpoints.append(endpoint)
212
+ raw_endpoint_count = len(endpoints)
213
+ endpoints = _dedupe_endpoints(endpoints)
214
+ if not endpoints:
215
+ endpoints = [_normalize_endpoint({"url": DEFAULT_ENDPOINT}) or {
216
+ "url": DEFAULT_ENDPOINT,
217
+ "mode": "direct",
218
+ "bridge_id": None,
219
+ "runtime_url": DEFAULT_ENDPOINT,
220
+ "display_host": "ai",
221
+ "normalized_url": DEFAULT_ENDPOINT,
222
+ "host": "ai",
223
+ "normalized": False,
224
+ "stale": False,
225
+ }]
226
+ raw_endpoint_count = 0
227
+ bridges = []
228
+ for item in obj.get("bridges", []) if isinstance(obj.get("bridges"), list) else []:
229
+ bridge = _normalize_bridge(item)
230
+ if bridge:
231
+ bridges.append(bridge)
232
+ return {
233
+ "found": found,
234
+ "path_redacted": _redact_home(self.settings_path, self.home),
235
+ "parse_error": error,
236
+ "active_endpoint": endpoints[0],
237
+ "endpoints": endpoints,
238
+ "bridges": bridges,
239
+ "endpoint_count": len(endpoints),
240
+ "raw_endpoint_count": raw_endpoint_count,
241
+ "bridge_count": len(bridges),
242
+ "yolo_mode": _safe_bool(obj.get("yoloMode")),
243
+ }
244
+
245
+ def _last_launch_payload(self) -> dict[str, Any]:
246
+ found, obj, error = self._read_json(self.launcher_path)
247
+ return {
248
+ "found": found,
249
+ "path_redacted": _redact_home(self.launcher_path, self.home),
250
+ "parse_error": error,
251
+ "client_name": _safe_str(obj.get("lastClientName") or obj.get("lastProfileName"), 120),
252
+ "backend_type": _safe_str(obj.get("lastBackendType"), 120),
253
+ "provider_id": _safe_str(obj.get("lastProviderId"), 120),
254
+ "model": _safe_str(obj.get("lastModel"), 240),
255
+ }
256
+
257
+ def fetch_providers(self, endpoint: dict[str, Any]) -> dict[str, Any]:
258
+ base_url = str(endpoint.get("runtime_url") or endpoint.get("normalized_url") or endpoint.get("url") or DEFAULT_ENDPOINT).rstrip("/")
259
+ url = base_url + "/api/providers"
260
+ mode = endpoint.get("mode") or "direct"
261
+ if mode == "bridge":
262
+ return {
263
+ "reachable": False,
264
+ "items": [],
265
+ "count": 0,
266
+ "compatibility_keys": [],
267
+ "source_endpoint": endpoint,
268
+ "last_error": "Bridge endpoints require an active Aperture CLI tsnet proxy; Pairling native bridge probing is not enabled yet.",
269
+ }
270
+ try:
271
+ with urllib.request.urlopen(url, timeout=PROVIDER_TIMEOUT_SECONDS) as response:
272
+ status = getattr(response, "status", 200)
273
+ body = response.read(1024 * 1024)
274
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
275
+ source_endpoint = {**endpoint, "stale": True}
276
+ return {
277
+ "reachable": False,
278
+ "items": [],
279
+ "count": 0,
280
+ "compatibility_keys": [],
281
+ "source_endpoint": source_endpoint,
282
+ "last_error": f"{type(exc).__name__}: {str(exc)[:220]}",
283
+ }
284
+ if status < 200 or status >= 300:
285
+ source_endpoint = {**endpoint, "stale": True}
286
+ return {
287
+ "reachable": False,
288
+ "items": [],
289
+ "count": 0,
290
+ "compatibility_keys": [],
291
+ "source_endpoint": source_endpoint,
292
+ "last_error": f"unexpected status {status} from {url}",
293
+ }
294
+ try:
295
+ raw = json.loads(body.decode("utf-8", errors="replace"))
296
+ except Exception as exc:
297
+ source_endpoint = {**endpoint, "stale": True}
298
+ return {
299
+ "reachable": False,
300
+ "items": [],
301
+ "count": 0,
302
+ "compatibility_keys": [],
303
+ "source_endpoint": source_endpoint,
304
+ "last_error": f"provider JSON parse failed: {type(exc).__name__}: {str(exc)[:160]}",
305
+ }
306
+ items = _parse_provider_items(raw)
307
+ compatibility_keys = sorted({
308
+ key
309
+ for item in items
310
+ for key, enabled in item.get("compatibility", {}).items()
311
+ if enabled
312
+ })
313
+ return {
314
+ "reachable": True,
315
+ "items": items,
316
+ "count": len(items),
317
+ "ids": [item["id"] for item in items],
318
+ "compatibility_keys": compatibility_keys,
319
+ "source_endpoint": endpoint,
320
+ "last_error": None,
321
+ }
322
+
323
+ def status(self) -> dict[str, Any]:
324
+ observed_at = self.now if self.now is not None else time.time()
325
+ binary, source = self.resolve_binary()
326
+ version = None
327
+ help_text = ""
328
+ help_flags: list[str] = []
329
+ if binary:
330
+ ok, text = _run_text([str(binary), "-version"], timeout=3)
331
+ if ok:
332
+ version = text
333
+ ok, help_text = _run_text([str(binary), "--help"], timeout=3)
334
+ if ok:
335
+ for flag in ("-debug", "-version"):
336
+ if flag in help_text:
337
+ help_flags.append(flag)
338
+ settings = self._settings_payload()
339
+ last_launch = self._last_launch_payload()
340
+ providers = self.fetch_providers(settings["active_endpoint"])
341
+ warnings: list[str] = [
342
+ "Aperture CLI is experimental; Pairling is using a version-pinned v0.0.8 contract."
343
+ ]
344
+ if binary is None:
345
+ warnings.append("Aperture CLI binary was not found.")
346
+ elif version and version != APERTURE_CLI_KNOWN_VERSION:
347
+ warnings.append(f"Aperture CLI version {version} differs from tested {APERTURE_CLI_KNOWN_VERSION}.")
348
+ if settings.get("parse_error"):
349
+ warnings.append("Aperture CLI settings could not be parsed; Pairling is using the default endpoint.")
350
+ return {
351
+ "ok": True,
352
+ "schema_version": 1,
353
+ "installed": binary is not None,
354
+ "version": version,
355
+ "known_version": APERTURE_CLI_KNOWN_VERSION,
356
+ "binary_path": str(binary) if binary else None,
357
+ "binary_path_source": source,
358
+ "help_flags": help_flags,
359
+ "settings": settings,
360
+ "last_launch": last_launch,
361
+ "providers": {k: v for k, v in providers.items() if k != "items"},
362
+ "capabilities": {
363
+ "native_pairling_launch_supported": False,
364
+ "raw_aperture_tui_supported": binary is not None,
365
+ "bridge_probe_supported": False,
366
+ },
367
+ "warnings": warnings,
368
+ "observed_at": observed_at,
369
+ }
370
+
371
+ def providers_payload(self) -> dict[str, Any]:
372
+ settings = self._settings_payload()
373
+ providers = self.fetch_providers(settings["active_endpoint"])
374
+ return {
375
+ "ok": True,
376
+ "schema_version": 1,
377
+ "source_endpoint": providers["source_endpoint"],
378
+ "reachable": providers["reachable"],
379
+ "items": providers["items"],
380
+ "count": providers["count"],
381
+ "ids": providers.get("ids", []),
382
+ "compatibility_keys": providers["compatibility_keys"],
383
+ "last_error": providers["last_error"],
384
+ "observed_at": self.now if self.now is not None else time.time(),
385
+ }
386
+
387
+
388
+ def status_payload(home: Path | None = None, env: dict[str, str] | None = None) -> dict[str, Any]:
389
+ return ApertureCLIProbe(home=home, env=env).status()
390
+
391
+
392
+ def provider_payload(home: Path | None = None, env: dict[str, str] | None = None) -> dict[str, Any]:
393
+ return ApertureCLIProbe(home=home, env=env).providers_payload()