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,887 @@
1
+ from __future__ import annotations
2
+
3
+ import fcntl
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import pty
8
+ import re
9
+ import select
10
+ import shlex
11
+ import signal
12
+ import socket
13
+ import struct
14
+ import subprocess
15
+ import termios
16
+ import threading
17
+ import time
18
+ import unicodedata
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from terminal_text_sanitizer import TERMINAL_TEXT_MAX_CHARS, sanitize_terminal_text_input
23
+ from terminal_screen_backend import create_terminal_screen_backend, detect_terminal_pending_input
24
+
25
+
26
+ _ABSOLUTE_PATH_ROOT_TOKENS = {
27
+ "Applications",
28
+ "Library",
29
+ "System",
30
+ "Users",
31
+ "Volumes",
32
+ "bin",
33
+ "dev",
34
+ "etc",
35
+ "home",
36
+ "opt",
37
+ "private",
38
+ "sbin",
39
+ "tmp",
40
+ "usr",
41
+ "var",
42
+ }
43
+ _ANSI_COLOR_NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
44
+
45
+
46
+ def _is_direct_slash_invocation_text(text: str) -> bool:
47
+ if "\n" in text or not text.startswith("/") or text.startswith("//"):
48
+ return False
49
+ token = text.split(maxsplit=1)[0]
50
+ if "/" in token[1:]:
51
+ return False
52
+ command = token[1:]
53
+ if not command or command in _ABSOLUTE_PATH_ROOT_TOKENS:
54
+ return False
55
+ return bool(re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*(?::[A-Za-z][A-Za-z0-9_-]*)*", command))
56
+
57
+
58
+ def _pending_input(rows: list[str]) -> dict | None:
59
+ return detect_terminal_pending_input(rows)
60
+
61
+
62
+ class VTScreen:
63
+ def __init__(self, rows: int = 30, columns: int = 120) -> None:
64
+ self.rows = max(1, min(int(rows or 30), 200))
65
+ self.columns = max(1, min(int(columns or 120), 500))
66
+ self.grid = [self._blank_text_row() for _ in range(self.rows)]
67
+ self.attrs = [self._blank_attr_row() for _ in range(self.rows)]
68
+ self.wrapped = [False for _ in range(self.rows)]
69
+ self.cursor_row = 0
70
+ self.cursor_col = 0
71
+ self._state = "normal"
72
+ self._csi = ""
73
+ self._osc = ""
74
+ self.title: str | None = None
75
+ self.alternate_screen = False
76
+ self.scroll_top = 0
77
+ self.scroll_bottom = self.rows - 1
78
+ self.current_attr = self._default_attr()
79
+ self.current_link_id: str | None = None
80
+ self.links: dict[str, str] = {}
81
+ self._primary_state: dict | None = None
82
+
83
+ @staticmethod
84
+ def _default_attr() -> dict:
85
+ return {
86
+ "fg": "default",
87
+ "bg": "default",
88
+ "bold": False,
89
+ "italic": False,
90
+ "underline": False,
91
+ "inverse": False,
92
+ "link_id": None,
93
+ }
94
+
95
+ def _blank_text_row(self) -> list[str]:
96
+ return [" " for _ in range(self.columns)]
97
+
98
+ def _blank_attr_row(self) -> list[dict]:
99
+ return [self._default_attr().copy() for _ in range(self.columns)]
100
+
101
+ def feed(self, data: bytes) -> None:
102
+ text = data.decode("utf-8", errors="replace")
103
+ for ch in text:
104
+ self._feed_char(ch)
105
+
106
+ def text_rows(self) -> list[str]:
107
+ return ["".join(row).rstrip() for row in self.grid]
108
+
109
+ def cell_rows(self) -> list[list[dict]]:
110
+ rows: list[list[dict]] = []
111
+ for row, attr_row in zip(self.grid, self.attrs):
112
+ last = -1
113
+ for idx, ch in enumerate(row):
114
+ if ch not in {" ", ""}:
115
+ last = idx
116
+ cells: list[dict] = []
117
+ for idx in range(last + 1):
118
+ ch = row[idx]
119
+ if ch == "":
120
+ continue
121
+ attr = attr_row[idx]
122
+ cells.append({
123
+ "text": ch,
124
+ "width": max(1, self._char_width(ch)),
125
+ **attr,
126
+ })
127
+ rows.append(cells)
128
+ return rows
129
+
130
+ def snapshot(self, session_id: str, generation: int, source: str = "broker_vt") -> dict:
131
+ rows = self.text_rows()
132
+ dimensions = {"columns": self.columns, "rows": self.rows}
133
+ cursor = {"row": self.cursor_row, "column": self.cursor_col, "visible": True}
134
+ material = {
135
+ "session_id": session_id,
136
+ "source": source,
137
+ "generation": generation,
138
+ "dimensions": dimensions,
139
+ "rows": rows,
140
+ "cursor": cursor,
141
+ }
142
+ screen_hash = hashlib.sha256(json.dumps(material, sort_keys=True).encode()).hexdigest()
143
+ payload = {
144
+ "session_id": session_id,
145
+ "source": source,
146
+ "screen_hash": screen_hash,
147
+ "nonce": screen_hash,
148
+ "generation": generation,
149
+ "dimensions": dimensions,
150
+ "rows": rows,
151
+ "cursor": cursor,
152
+ "changed_at": time.time(),
153
+ }
154
+ pending = _pending_input(rows)
155
+ if pending is not None:
156
+ payload["pending_input"] = pending
157
+ return payload
158
+
159
+ def _feed_char(self, ch: str) -> None:
160
+ if self._state == "osc":
161
+ if ch == "\x07":
162
+ self._handle_osc(self._osc)
163
+ self._state = "normal"
164
+ elif ch == "\x1b":
165
+ self._state = "osc_esc"
166
+ else:
167
+ self._osc += ch
168
+ return
169
+ if self._state == "osc_esc":
170
+ if ch == "\\":
171
+ self._handle_osc(self._osc)
172
+ self._state = "normal"
173
+ else:
174
+ self._osc += "\x1b" + ch
175
+ self._state = "osc"
176
+ return
177
+ if self._state == "esc":
178
+ if ch == "[":
179
+ self._state = "csi"
180
+ self._csi = ""
181
+ elif ch == "]":
182
+ self._state = "osc"
183
+ self._osc = ""
184
+ elif ch == "c":
185
+ self._reset()
186
+ self._state = "normal"
187
+ else:
188
+ self._state = "normal"
189
+ return
190
+ if self._state == "csi":
191
+ if "@" <= ch <= "~":
192
+ self._handle_csi(self._csi, ch)
193
+ self._state = "normal"
194
+ self._csi = ""
195
+ else:
196
+ self._csi += ch
197
+ return
198
+
199
+ if ch == "\x1b":
200
+ self._state = "esc"
201
+ elif ch == "\r":
202
+ self.cursor_col = 0
203
+ elif ch == "\n":
204
+ self._linefeed()
205
+ elif ch == "\b":
206
+ self.cursor_col = max(0, self.cursor_col - 1)
207
+ elif ch == "\t":
208
+ next_tab = min(self.columns - 1, ((self.cursor_col // 8) + 1) * 8)
209
+ while self.cursor_col < next_tab:
210
+ self._put(" ")
211
+ elif ch >= " ":
212
+ self._put(ch)
213
+
214
+ def _put(self, ch: str) -> None:
215
+ width = self._char_width(ch)
216
+ if width == 0:
217
+ self._append_combining(ch)
218
+ return
219
+ if self.cursor_col >= self.columns or self.cursor_col + width > self.columns:
220
+ self.wrapped[self.cursor_row] = True
221
+ self.cursor_col = 0
222
+ self._linefeed()
223
+ attr = self.current_attr.copy()
224
+ attr["link_id"] = self.current_link_id
225
+ self.grid[self.cursor_row][self.cursor_col] = ch
226
+ self.attrs[self.cursor_row][self.cursor_col] = attr
227
+ if width == 2 and self.cursor_col + 1 < self.columns:
228
+ self.grid[self.cursor_row][self.cursor_col + 1] = ""
229
+ self.attrs[self.cursor_row][self.cursor_col + 1] = attr.copy()
230
+ self.cursor_col += width
231
+
232
+ def _linefeed(self) -> None:
233
+ if self.cursor_row >= self.scroll_bottom:
234
+ self._scroll_up(self.scroll_top, self.scroll_bottom, 1)
235
+ self.cursor_row = self.scroll_bottom
236
+ else:
237
+ self.cursor_row += 1
238
+
239
+ def _handle_csi(self, params: str, final: str) -> None:
240
+ private = params.startswith("?")
241
+ clean = params[1:] if private else params
242
+ parts = [p for p in clean.split(";") if p != ""]
243
+
244
+ def value(index: int, default: int) -> int:
245
+ try:
246
+ return int(parts[index])
247
+ except Exception:
248
+ return default
249
+
250
+ if final == "A":
251
+ self.cursor_row = max(0, self.cursor_row - value(0, 1))
252
+ elif final == "B":
253
+ self.cursor_row = min(self.rows - 1, self.cursor_row + value(0, 1))
254
+ elif final == "C":
255
+ self.cursor_col = min(self.columns - 1, self.cursor_col + value(0, 1))
256
+ elif final == "D":
257
+ self.cursor_col = max(0, self.cursor_col - value(0, 1))
258
+ elif final in {"H", "f"}:
259
+ self.cursor_row = max(0, min(self.rows - 1, value(0, 1) - 1))
260
+ self.cursor_col = max(0, min(self.columns - 1, value(1, 1) - 1))
261
+ elif final == "J":
262
+ mode = value(0, 0)
263
+ if mode == 2:
264
+ self.grid = [self._blank_text_row() for _ in range(self.rows)]
265
+ self.attrs = [self._blank_attr_row() for _ in range(self.rows)]
266
+ self.wrapped = [False for _ in range(self.rows)]
267
+ self.cursor_row = 0
268
+ self.cursor_col = 0
269
+ elif mode == 0:
270
+ for c in range(self.cursor_col, self.columns):
271
+ self.grid[self.cursor_row][c] = " "
272
+ self.attrs[self.cursor_row][c] = self._default_attr().copy()
273
+ for r in range(self.cursor_row + 1, self.rows):
274
+ self.grid[r] = self._blank_text_row()
275
+ self.attrs[r] = self._blank_attr_row()
276
+ self.wrapped[r] = False
277
+ elif final == "K":
278
+ mode = value(0, 0)
279
+ if mode == 2:
280
+ self.grid[self.cursor_row] = self._blank_text_row()
281
+ self.attrs[self.cursor_row] = self._blank_attr_row()
282
+ self.wrapped[self.cursor_row] = False
283
+ elif mode == 1:
284
+ for c in range(0, self.cursor_col + 1):
285
+ self.grid[self.cursor_row][c] = " "
286
+ self.attrs[self.cursor_row][c] = self._default_attr().copy()
287
+ else:
288
+ for c in range(self.cursor_col, self.columns):
289
+ self.grid[self.cursor_row][c] = " "
290
+ self.attrs[self.cursor_row][c] = self._default_attr().copy()
291
+ elif final == "m":
292
+ self._handle_sgr([int(p) if p.isdigit() else 0 for p in parts] or [0])
293
+ elif final == "@":
294
+ self._insert_characters(value(0, 1))
295
+ elif final == "P":
296
+ self._delete_characters(value(0, 1))
297
+ elif final == "L":
298
+ self._insert_lines(value(0, 1))
299
+ elif final == "M":
300
+ self._delete_lines(value(0, 1))
301
+ elif final == "r":
302
+ top = max(0, min(self.rows - 1, value(0, 1) - 1))
303
+ bottom = max(top, min(self.rows - 1, value(1, self.rows) - 1))
304
+ self.scroll_top = top
305
+ self.scroll_bottom = bottom
306
+ self.cursor_row = 0
307
+ self.cursor_col = 0
308
+ elif final == "S":
309
+ self._scroll_up(self.scroll_top, self.scroll_bottom, value(0, 1))
310
+ elif final == "T":
311
+ self._scroll_down(self.scroll_top, self.scroll_bottom, value(0, 1))
312
+ elif final == "h" and private and 1049 in [value(i, 0) for i in range(len(parts) or 1)]:
313
+ self._enter_alternate_screen()
314
+ elif final == "l" and private and 1049 in [value(i, 0) for i in range(len(parts) or 1)]:
315
+ self._exit_alternate_screen()
316
+
317
+ @staticmethod
318
+ def _char_width(ch: str) -> int:
319
+ if not ch:
320
+ return 0
321
+ if unicodedata.combining(ch):
322
+ return 0
323
+ if unicodedata.east_asian_width(ch) in {"W", "F"}:
324
+ return 2
325
+ return 1
326
+
327
+ def _append_combining(self, ch: str) -> None:
328
+ positions = [(self.cursor_row, self.cursor_col - 1)]
329
+ if self.cursor_col == 0 and self.cursor_row > 0:
330
+ positions.append((self.cursor_row - 1, self.columns - 1))
331
+ for row, col in positions:
332
+ if 0 <= row < self.rows and 0 <= col < self.columns and self.grid[row][col] not in {"", " "}:
333
+ self.grid[row][col] = unicodedata.normalize("NFC", self.grid[row][col] + ch)
334
+ return
335
+
336
+ def _handle_sgr(self, params: list[int]) -> None:
337
+ i = 0
338
+ while i < len(params):
339
+ p = params[i]
340
+ if p == 0:
341
+ self.current_attr = self._default_attr()
342
+ elif p == 1:
343
+ self.current_attr["bold"] = True
344
+ elif p == 3:
345
+ self.current_attr["italic"] = True
346
+ elif p == 4:
347
+ self.current_attr["underline"] = True
348
+ elif p == 7:
349
+ self.current_attr["inverse"] = True
350
+ elif p == 22:
351
+ self.current_attr["bold"] = False
352
+ elif p == 23:
353
+ self.current_attr["italic"] = False
354
+ elif p == 24:
355
+ self.current_attr["underline"] = False
356
+ elif p == 27:
357
+ self.current_attr["inverse"] = False
358
+ elif 30 <= p <= 37:
359
+ self.current_attr["fg"] = f"ansi_{_ANSI_COLOR_NAMES[p - 30]}"
360
+ elif 90 <= p <= 97:
361
+ self.current_attr["fg"] = f"ansi_bright_{_ANSI_COLOR_NAMES[p - 90]}"
362
+ elif p == 39:
363
+ self.current_attr["fg"] = "default"
364
+ elif 40 <= p <= 47:
365
+ self.current_attr["bg"] = f"ansi_{_ANSI_COLOR_NAMES[p - 40]}"
366
+ elif 100 <= p <= 107:
367
+ self.current_attr["bg"] = f"ansi_bright_{_ANSI_COLOR_NAMES[p - 100]}"
368
+ elif p == 49:
369
+ self.current_attr["bg"] = "default"
370
+ elif p in {38, 48} and i + 2 < len(params):
371
+ target = "fg" if p == 38 else "bg"
372
+ mode = params[i + 1]
373
+ if mode == 5 and i + 2 < len(params):
374
+ self.current_attr[target] = f"ansi256_{params[i + 2]}"
375
+ i += 2
376
+ elif mode == 2 and i + 4 < len(params):
377
+ self.current_attr[target] = f"rgb({params[i + 2]},{params[i + 3]},{params[i + 4]})"
378
+ i += 4
379
+ i += 1
380
+
381
+ def _insert_characters(self, count: int) -> None:
382
+ count = max(1, min(count, self.columns - self.cursor_col))
383
+ row = self.grid[self.cursor_row]
384
+ attrs = self.attrs[self.cursor_row]
385
+ for _ in range(count):
386
+ row.insert(self.cursor_col, " ")
387
+ attrs.insert(self.cursor_col, self._default_attr().copy())
388
+ row.pop()
389
+ attrs.pop()
390
+
391
+ def _delete_characters(self, count: int) -> None:
392
+ count = max(1, min(count, self.columns - self.cursor_col))
393
+ row = self.grid[self.cursor_row]
394
+ attrs = self.attrs[self.cursor_row]
395
+ for _ in range(count):
396
+ row.pop(self.cursor_col)
397
+ attrs.pop(self.cursor_col)
398
+ row.append(" ")
399
+ attrs.append(self._default_attr().copy())
400
+
401
+ def _insert_lines(self, count: int) -> None:
402
+ if not (self.scroll_top <= self.cursor_row <= self.scroll_bottom):
403
+ return
404
+ count = max(1, min(count, self.scroll_bottom - self.cursor_row + 1))
405
+ for _ in range(count):
406
+ self.grid.insert(self.cursor_row, self._blank_text_row())
407
+ self.attrs.insert(self.cursor_row, self._blank_attr_row())
408
+ self.wrapped.insert(self.cursor_row, False)
409
+ del self.grid[self.scroll_bottom + 1]
410
+ del self.attrs[self.scroll_bottom + 1]
411
+ del self.wrapped[self.scroll_bottom + 1]
412
+
413
+ def _delete_lines(self, count: int) -> None:
414
+ if not (self.scroll_top <= self.cursor_row <= self.scroll_bottom):
415
+ return
416
+ count = max(1, min(count, self.scroll_bottom - self.cursor_row + 1))
417
+ for _ in range(count):
418
+ del self.grid[self.cursor_row]
419
+ del self.attrs[self.cursor_row]
420
+ del self.wrapped[self.cursor_row]
421
+ self.grid.insert(self.scroll_bottom, self._blank_text_row())
422
+ self.attrs.insert(self.scroll_bottom, self._blank_attr_row())
423
+ self.wrapped.insert(self.scroll_bottom, False)
424
+
425
+ def _scroll_up(self, top: int, bottom: int, count: int) -> None:
426
+ for _ in range(max(1, count)):
427
+ del self.grid[top]
428
+ del self.attrs[top]
429
+ del self.wrapped[top]
430
+ self.grid.insert(bottom, self._blank_text_row())
431
+ self.attrs.insert(bottom, self._blank_attr_row())
432
+ self.wrapped.insert(bottom, False)
433
+
434
+ def _scroll_down(self, top: int, bottom: int, count: int) -> None:
435
+ for _ in range(max(1, count)):
436
+ del self.grid[bottom]
437
+ del self.attrs[bottom]
438
+ del self.wrapped[bottom]
439
+ self.grid.insert(top, self._blank_text_row())
440
+ self.attrs.insert(top, self._blank_attr_row())
441
+ self.wrapped.insert(top, False)
442
+
443
+ def _handle_osc(self, payload: str) -> None:
444
+ if payload.startswith(("0;", "2;")):
445
+ self.title = payload.split(";", 1)[1]
446
+ elif payload.startswith("8;"):
447
+ parts = payload.split(";", 2)
448
+ uri = parts[2] if len(parts) >= 3 else ""
449
+ if uri:
450
+ link_id = "link-" + hashlib.sha256(uri.encode("utf-8")).hexdigest()[:12]
451
+ self.links[link_id] = uri
452
+ self.current_link_id = link_id
453
+ else:
454
+ self.current_link_id = None
455
+
456
+ def _reset(self) -> None:
457
+ self.grid = [self._blank_text_row() for _ in range(self.rows)]
458
+ self.attrs = [self._blank_attr_row() for _ in range(self.rows)]
459
+ self.wrapped = [False for _ in range(self.rows)]
460
+ self.cursor_row = 0
461
+ self.cursor_col = 0
462
+ self.scroll_top = 0
463
+ self.scroll_bottom = self.rows - 1
464
+ self.current_attr = self._default_attr()
465
+ self.current_link_id = None
466
+
467
+ def _enter_alternate_screen(self) -> None:
468
+ if self.alternate_screen:
469
+ return
470
+ self._primary_state = {
471
+ "grid": [row[:] for row in self.grid],
472
+ "attrs": [[cell.copy() for cell in row] for row in self.attrs],
473
+ "wrapped": self.wrapped[:],
474
+ "cursor_row": self.cursor_row,
475
+ "cursor_col": self.cursor_col,
476
+ }
477
+ self._reset()
478
+ self.alternate_screen = True
479
+
480
+ def _exit_alternate_screen(self) -> None:
481
+ if not self.alternate_screen:
482
+ return
483
+ if self._primary_state:
484
+ self.grid = [row[:] for row in self._primary_state["grid"]]
485
+ self.attrs = [[cell.copy() for cell in row] for row in self._primary_state["attrs"]]
486
+ self.wrapped = self._primary_state["wrapped"][:]
487
+ self.cursor_row = self._primary_state["cursor_row"]
488
+ self.cursor_col = self._primary_state["cursor_col"]
489
+ self.alternate_screen = False
490
+ self._primary_state = None
491
+
492
+ def resize(self, rows: int, columns: int) -> None:
493
+ old_text = self.text_rows()
494
+ self.rows = max(1, min(int(rows or self.rows), 200))
495
+ self.columns = max(1, min(int(columns or self.columns), 500))
496
+ self.grid = [self._blank_text_row() for _ in range(self.rows)]
497
+ self.attrs = [self._blank_attr_row() for _ in range(self.rows)]
498
+ self.wrapped = [False for _ in range(self.rows)]
499
+ tail = old_text[-self.rows:]
500
+ for idx, text in enumerate(tail):
501
+ for col, ch in enumerate(text[:self.columns]):
502
+ self.grid[idx][col] = ch
503
+ self.cursor_row = min(self.cursor_row, self.rows - 1)
504
+ self.cursor_col = min(self.cursor_col, self.columns - 1)
505
+ self.scroll_top = 0
506
+ self.scroll_bottom = self.rows - 1
507
+
508
+
509
+ @dataclass
510
+ class PTYBrokerSession:
511
+ session_id: str
512
+ provider: str
513
+ native_id: str
514
+ project: str
515
+ argv: list[str]
516
+ env: dict[str, str]
517
+ rows: int = 30
518
+ columns: int = 120
519
+ generation: int = 1
520
+ raw_log_path: Path | None = None
521
+ pid: int = 0
522
+ slave_tty: str = ""
523
+ started_at: float = field(default_factory=time.time)
524
+ last_activity: float = field(default_factory=time.time)
525
+
526
+ def __post_init__(self) -> None:
527
+ self.screen = VTScreen(rows=self.rows, columns=self.columns)
528
+ self.screen_backend = create_terminal_screen_backend(self.screen)
529
+ self.master_fd = -1
530
+ self.process: subprocess.Popen | None = None
531
+ self._closed = False
532
+ self._lock = threading.RLock()
533
+ self._condition = threading.Condition(self._lock)
534
+ self._raw = bytearray()
535
+ self._raw_offset = 0
536
+
537
+ def start(self) -> None:
538
+ master_fd, slave_fd = pty.openpty()
539
+ self.master_fd = master_fd
540
+ self.slave_tty = os.ttyname(slave_fd)
541
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.rows, self.columns, 0, 0))
542
+ self.process = subprocess.Popen(
543
+ self.argv,
544
+ cwd=self.project,
545
+ env=self.env,
546
+ stdin=slave_fd,
547
+ stdout=slave_fd,
548
+ stderr=slave_fd,
549
+ start_new_session=True,
550
+ close_fds=True,
551
+ )
552
+ self.pid = int(self.process.pid)
553
+ os.close(slave_fd)
554
+ os.set_blocking(self.master_fd, False)
555
+ threading.Thread(target=self._read_loop, name=f"pairling-pty-{self.session_id}", daemon=True).start()
556
+
557
+ def is_alive(self) -> bool:
558
+ return bool(self.process and self.process.poll() is None)
559
+
560
+ def close(self) -> None:
561
+ self.terminate(signal.SIGTERM)
562
+
563
+ def terminate(self, sig: int = signal.SIGTERM, wait_timeout: float = 2.0) -> dict:
564
+ self._closed = True
565
+ ok = True
566
+ error: str | None = None
567
+ try:
568
+ if self.process and self.process.poll() is None:
569
+ try:
570
+ os.killpg(os.getpgid(self.process.pid), sig)
571
+ except ProcessLookupError:
572
+ pass
573
+ if sig == signal.SIGTERM:
574
+ try:
575
+ self.process.wait(timeout=wait_timeout)
576
+ except subprocess.TimeoutExpired:
577
+ try:
578
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
579
+ except ProcessLookupError:
580
+ pass
581
+ self.process.wait(timeout=1.0)
582
+ elif self.process:
583
+ self.process.poll()
584
+ except Exception as exc:
585
+ ok = False
586
+ error = f"{type(exc).__name__}: {exc}"
587
+ try:
588
+ if self.master_fd >= 0:
589
+ os.close(self.master_fd)
590
+ self.master_fd = -1
591
+ except Exception:
592
+ pass
593
+ with self._condition:
594
+ self._condition.notify_all()
595
+ return {"ok": ok, "pid": self.pid, "signal": signal.Signals(sig).name, "error": error}
596
+
597
+ def snapshot(self, public_session_id: str | None = None) -> dict:
598
+ with self._lock:
599
+ return self.screen.snapshot(public_session_id or self.session_id, self.generation)
600
+
601
+ def snapshot_v2(self):
602
+ with self._lock:
603
+ return self.screen_backend.snapshot()
604
+
605
+ def raw_tail(self, since: int = 0) -> tuple[bytes, int, int, bool]:
606
+ with self._lock:
607
+ total = len(self._raw)
608
+ reset = since > total
609
+ start = 0 if reset else max(0, since)
610
+ return bytes(self._raw[start:]), total, total, reset
611
+
612
+ def write(self, data: bytes) -> None:
613
+ if self.master_fd < 0:
614
+ raise RuntimeError("session is not started")
615
+ os.write(self.master_fd, data)
616
+ self.last_activity = time.time()
617
+
618
+ def control(self, action: dict) -> dict:
619
+ kind = action.get("type")
620
+ if kind == "key":
621
+ key = action.get("key")
622
+ mapping = {
623
+ "enter": b"\r",
624
+ "escape": b"\x1b",
625
+ "up": b"\x1b[A",
626
+ "down": b"\x1b[B",
627
+ "left": b"\x1b[D",
628
+ "right": b"\x1b[C",
629
+ "ctrl_c": b"\x03",
630
+ }
631
+ data = mapping.get(str(key))
632
+ if data is None:
633
+ return {"ok": False, "reason": "unsupported key"}
634
+ self.write(data)
635
+ return {"ok": True}
636
+ if kind == "choice":
637
+ choice_id = str(action.get("choice_id") or "")
638
+ if not re.match(r"^[A-Za-z0-9_.:-]{1,64}$", choice_id):
639
+ return {"ok": False, "reason": "bad choice_id"}
640
+ self.write(choice_id.encode() + b"\r")
641
+ return {"ok": True}
642
+ if kind == "text":
643
+ text = str(action.get("text") or "")
644
+ if action.get("mode") != "submit" or "\n" in text:
645
+ return {"ok": False, "reason": "unsupported text mode"}
646
+ self.write(text.encode() + b"\r")
647
+ return {"ok": True}
648
+ if kind == "raw_key" and action.get("debug") is True:
649
+ key_code = int(action.get("key_code") or 0)
650
+ self.write(bytes([key_code]))
651
+ return {"ok": True}
652
+ return {"ok": False, "reason": "unsupported action"}
653
+
654
+ def send_text(self, text: str) -> dict:
655
+ text, err = sanitize_terminal_text_input(
656
+ str(text or ""),
657
+ allow_newline=True,
658
+ max_chars=TERMINAL_TEXT_MAX_CHARS,
659
+ )
660
+ if err:
661
+ return {"ok": False, "reason": err["code"], "message": err["message"], "status": err["status"]}
662
+ is_slash = _is_direct_slash_invocation_text(text)
663
+ if is_slash:
664
+ data = text.encode() + b"\r"
665
+ else:
666
+ data = b"\x1b[200~" + text.encode() + b"\x1b[201~\r"
667
+ self.write(data)
668
+ return {"ok": True}
669
+
670
+ def attach(self, conn: socket.socket) -> None:
671
+ with self._lock:
672
+ offset = max(0, len(self._raw) - 8192)
673
+ initial = bytes(self._raw[offset:])
674
+ if initial:
675
+ conn.sendall(initial)
676
+
677
+ stop = threading.Event()
678
+
679
+ def pump_output() -> None:
680
+ nonlocal offset
681
+ while not stop.is_set():
682
+ with self._condition:
683
+ self._condition.wait(timeout=0.5)
684
+ chunk = bytes(self._raw[offset:])
685
+ offset = len(self._raw)
686
+ if chunk:
687
+ try:
688
+ conn.sendall(chunk)
689
+ except OSError:
690
+ stop.set()
691
+ return
692
+
693
+ thread = threading.Thread(target=pump_output, daemon=True)
694
+ thread.start()
695
+ try:
696
+ while not stop.is_set():
697
+ data = conn.recv(4096)
698
+ if not data:
699
+ break
700
+ self.write(data)
701
+ finally:
702
+ stop.set()
703
+
704
+ def _read_loop(self) -> None:
705
+ log_f = None
706
+ try:
707
+ if self.raw_log_path is not None:
708
+ self.raw_log_path.parent.mkdir(parents=True, exist_ok=True)
709
+ log_f = open(self.raw_log_path, "ab")
710
+ while not self._closed:
711
+ if self.process and self.process.poll() is not None:
712
+ break
713
+ try:
714
+ ready, _, _ = select.select([self.master_fd], [], [], 0.25)
715
+ except OSError:
716
+ break
717
+ if not ready:
718
+ continue
719
+ try:
720
+ data = os.read(self.master_fd, 8192)
721
+ except BlockingIOError:
722
+ continue
723
+ except OSError:
724
+ break
725
+ if not data:
726
+ break
727
+ if log_f:
728
+ log_f.write(data)
729
+ log_f.flush()
730
+ with self._condition:
731
+ self._raw_offset += len(data)
732
+ self._raw.extend(data)
733
+ if len(self._raw) > 2_000_000:
734
+ del self._raw[:1_000_000]
735
+ self.screen_backend.feed(data, raw_offset=self._raw_offset)
736
+ self.generation = self.screen_backend.generation
737
+ self.last_activity = time.time()
738
+ self._condition.notify_all()
739
+ finally:
740
+ if log_f:
741
+ log_f.close()
742
+ if self.process:
743
+ try:
744
+ self.process.wait(timeout=0)
745
+ except subprocess.TimeoutExpired:
746
+ pass
747
+ except Exception:
748
+ pass
749
+ self._closed = True
750
+ with self._condition:
751
+ self._condition.notify_all()
752
+
753
+
754
+ class PTYBrokerManager:
755
+ def __init__(self, socket_path: Path, log_dir: Path) -> None:
756
+ self.socket_path = socket_path
757
+ self.log_dir = log_dir
758
+ self._sessions: dict[str, PTYBrokerSession] = {}
759
+ self._by_tty: dict[str, str] = {}
760
+ self._lock = threading.RLock()
761
+ self._server_started = False
762
+
763
+ def start_attach_server(self) -> None:
764
+ with self._lock:
765
+ if self._server_started:
766
+ return
767
+ self.socket_path.parent.mkdir(parents=True, exist_ok=True)
768
+ try:
769
+ self.socket_path.unlink()
770
+ except FileNotFoundError:
771
+ pass
772
+ thread = threading.Thread(target=self._serve_attach_socket, name="pairling-pty-attach", daemon=True)
773
+ thread.start()
774
+ self._server_started = True
775
+ deadline = time.time() + 1.0
776
+ while time.time() < deadline:
777
+ if self.socket_path.exists():
778
+ return
779
+ time.sleep(0.01)
780
+
781
+ def spawn(self, *, session_id: str, provider: str, native_id: str, project: str, command: str,
782
+ rows: int = 30, columns: int = 120, env: dict[str, str] | None = None) -> PTYBrokerSession:
783
+ self.start_attach_server()
784
+ safe_command = command
785
+ argv = ["/bin/zsh", "-ic", safe_command]
786
+ raw_log = self.log_dir / f"broker-{provider}-{native_id}.log"
787
+ merged_env = dict(os.environ)
788
+ if env:
789
+ merged_env.update(env)
790
+ session = PTYBrokerSession(
791
+ session_id=session_id,
792
+ provider=provider,
793
+ native_id=native_id,
794
+ project=project,
795
+ argv=argv,
796
+ env=merged_env,
797
+ rows=rows,
798
+ columns=columns,
799
+ raw_log_path=raw_log,
800
+ )
801
+ session.start()
802
+ with self._lock:
803
+ self._sessions[session_id] = session
804
+ if session.slave_tty:
805
+ self._by_tty[session.slave_tty] = session_id
806
+ return session
807
+
808
+ def get(self, session_id: str) -> PTYBrokerSession | None:
809
+ with self._lock:
810
+ return self._sessions.get(session_id)
811
+
812
+ def get_by_tty(self, tty_path: str) -> PTYBrokerSession | None:
813
+ with self._lock:
814
+ sid = self._by_tty.get(tty_path)
815
+ return self._sessions.get(sid or "")
816
+
817
+ def register_alias(self, alias_session_id: str, session: PTYBrokerSession) -> None:
818
+ with self._lock:
819
+ self._sessions[alias_session_id] = session
820
+
821
+ def snapshot(self, session_id: str, public_session_id: str | None = None) -> dict | None:
822
+ session = self.get(session_id)
823
+ if not session:
824
+ return None
825
+ return session.snapshot(public_session_id=public_session_id or session_id)
826
+
827
+ def control(self, session_id: str, action: dict) -> dict:
828
+ session = self.get(session_id)
829
+ if not session:
830
+ return {"ok": False, "reason": "broker session not found", "status": 404}
831
+ return session.control(action)
832
+
833
+ def terminate(self, session_id: str, sig: int = signal.SIGTERM) -> dict:
834
+ session = self.get(session_id)
835
+ if not session:
836
+ return {"ok": False, "reason": "broker session not found", "status": 404}
837
+ result = session.terminate(sig=sig)
838
+ if sig in {signal.SIGTERM, signal.SIGKILL}:
839
+ with self._lock:
840
+ for sid, existing in list(self._sessions.items()):
841
+ if existing is session:
842
+ self._sessions.pop(sid, None)
843
+ for tty, sid in list(self._by_tty.items()):
844
+ if sid == session_id or self._sessions.get(sid) is None:
845
+ self._by_tty.pop(tty, None)
846
+ return result
847
+
848
+ def send_text(self, session_id: str, text: str) -> dict:
849
+ session = self.get(session_id)
850
+ if not session:
851
+ return {"ok": False, "reason": "broker session not found", "status": 404}
852
+ return session.send_text(text)
853
+
854
+ def raw_tail(self, session_id: str, since: int = 0) -> tuple[bytes, int, int, bool] | None:
855
+ session = self.get(session_id)
856
+ if not session:
857
+ return None
858
+ return session.raw_tail(since=since)
859
+
860
+ def _serve_attach_socket(self) -> None:
861
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
862
+ server.bind(str(self.socket_path))
863
+ os.chmod(self.socket_path, 0o600)
864
+ server.listen(8)
865
+ while True:
866
+ conn, _ = server.accept()
867
+ threading.Thread(target=self._handle_attach_client, args=(conn,), daemon=True).start()
868
+
869
+ def _handle_attach_client(self, conn: socket.socket) -> None:
870
+ with conn:
871
+ line = b""
872
+ while not line.endswith(b"\n") and len(line) < 4096:
873
+ chunk = conn.recv(1)
874
+ if not chunk:
875
+ return
876
+ line += chunk
877
+ try:
878
+ hello = json.loads(line.decode("utf-8"))
879
+ except Exception:
880
+ conn.sendall(b"pairling attach: bad hello\n")
881
+ return
882
+ session_id = str(hello.get("session_id") or "").strip()
883
+ session = self.get(session_id)
884
+ if not session:
885
+ conn.sendall(f"pairling attach: no broker session {shlex.quote(session_id)}\n".encode())
886
+ return
887
+ session.attach(conn)