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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- 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)
|