agentlink-cli 0.1.0__py3-none-any.whl
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.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from connector.local.common import Notify, nearest_existing_dir, required_string, resolve_path, workspace_root
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TerminalOutput = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
19
|
+
TERMINAL_SCROLLBACK_MAX_BYTES = 512 * 1024
|
|
20
|
+
TERMINAL_IDLE_TTL_SECONDS = 30 * 60
|
|
21
|
+
TERMINAL_CLOSED_TTL_SECONDS = 15 * 60
|
|
22
|
+
TERMINAL_MAX_RECORDS = 32
|
|
23
|
+
TERMINAL_REAPER_POLL_SECONDS = 30
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TerminalBackend:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
notify: Notify | None = None,
|
|
30
|
+
*,
|
|
31
|
+
idle_ttl_seconds: float | None = None,
|
|
32
|
+
closed_ttl_seconds: float | None = None,
|
|
33
|
+
reaper_poll_seconds: float | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.notify = notify
|
|
36
|
+
self._terminals: dict[str, dict[str, Any]] = {}
|
|
37
|
+
self._idle_ttl_seconds = _env_float(
|
|
38
|
+
"AGENT_CONNECTOR_TERMINAL_IDLE_TTL_SECONDS",
|
|
39
|
+
TERMINAL_IDLE_TTL_SECONDS,
|
|
40
|
+
idle_ttl_seconds,
|
|
41
|
+
)
|
|
42
|
+
self._closed_ttl_seconds = _env_float(
|
|
43
|
+
"AGENT_CONNECTOR_TERMINAL_CLOSED_TTL_SECONDS",
|
|
44
|
+
TERMINAL_CLOSED_TTL_SECONDS,
|
|
45
|
+
closed_ttl_seconds,
|
|
46
|
+
)
|
|
47
|
+
self._reaper_poll_seconds = max(
|
|
48
|
+
0.1,
|
|
49
|
+
_env_float(
|
|
50
|
+
"AGENT_CONNECTOR_TERMINAL_REAPER_POLL_SECONDS",
|
|
51
|
+
TERMINAL_REAPER_POLL_SECONDS,
|
|
52
|
+
reaper_poll_seconds,
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def create(
|
|
57
|
+
self,
|
|
58
|
+
params: dict[str, Any],
|
|
59
|
+
*,
|
|
60
|
+
output: TerminalOutput | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
self._gc()
|
|
63
|
+
root = workspace_root(params)
|
|
64
|
+
raw_cwd = params.get("cwd")
|
|
65
|
+
if isinstance(raw_cwd, str) and raw_cwd.strip():
|
|
66
|
+
cwd = resolve_path(root, raw_cwd)
|
|
67
|
+
else:
|
|
68
|
+
cwd = root
|
|
69
|
+
cwd = nearest_existing_dir(cwd, fallback=root)
|
|
70
|
+
terminal_id = required_string(params, "terminalId")
|
|
71
|
+
session_id = required_string(params, "sessionId")
|
|
72
|
+
cols = int(params.get("cols") or 80)
|
|
73
|
+
rows = int(params.get("rows") or 24)
|
|
74
|
+
label = params.get("label")
|
|
75
|
+
if not isinstance(label, str) or not label.strip():
|
|
76
|
+
label = "Shell"
|
|
77
|
+
command = params.get("command")
|
|
78
|
+
raw_args = params.get("args")
|
|
79
|
+
if command is not None and not isinstance(command, str):
|
|
80
|
+
raise ValueError("command must be a string")
|
|
81
|
+
args: list[str] = []
|
|
82
|
+
if raw_args is not None:
|
|
83
|
+
if not isinstance(raw_args, list) or not all(isinstance(arg, str) for arg in raw_args):
|
|
84
|
+
raise ValueError("args must be a list of strings")
|
|
85
|
+
args = list(raw_args)
|
|
86
|
+
shell_cmd = self._default_shell(params.get("shell"))
|
|
87
|
+
argv = [command, *args] if isinstance(command, str) and command.strip() else self._default_argv(shell_cmd)
|
|
88
|
+
env_override = params.get("env") or {}
|
|
89
|
+
env = {**os.environ}
|
|
90
|
+
env.setdefault("TERM", "xterm-256color")
|
|
91
|
+
env.setdefault("COLORTERM", "truecolor")
|
|
92
|
+
for k, v in env_override.items():
|
|
93
|
+
if isinstance(k, str) and isinstance(v, str):
|
|
94
|
+
env[k] = v
|
|
95
|
+
if terminal_id in self._terminals:
|
|
96
|
+
raise ValueError(f"terminal already exists: {terminal_id}")
|
|
97
|
+
|
|
98
|
+
pty = self._spawn(argv, cwd=cwd, env=env, rows=rows, cols=cols)
|
|
99
|
+
now_mono = time.monotonic()
|
|
100
|
+
record: dict[str, Any] = {
|
|
101
|
+
"id": terminal_id,
|
|
102
|
+
"sessionId": session_id,
|
|
103
|
+
"pty": pty,
|
|
104
|
+
"cols": cols,
|
|
105
|
+
"rows": rows,
|
|
106
|
+
"label": label.strip(),
|
|
107
|
+
"cwd": str(cwd),
|
|
108
|
+
"shell": shell_cmd,
|
|
109
|
+
"command": command,
|
|
110
|
+
"args": args,
|
|
111
|
+
"closed": False,
|
|
112
|
+
"status": "running",
|
|
113
|
+
"exitCode": None,
|
|
114
|
+
"createdAt": datetime.now(UTC).isoformat(),
|
|
115
|
+
"createdAtMono": now_mono,
|
|
116
|
+
"lastActivityAtMono": now_mono,
|
|
117
|
+
"closedAt": None,
|
|
118
|
+
"closedAtMono": None,
|
|
119
|
+
"scrollback": bytearray(),
|
|
120
|
+
"scrollbackBaseSeq": 0,
|
|
121
|
+
"chunks": [],
|
|
122
|
+
"chunksBytes": 0,
|
|
123
|
+
"seq": 0,
|
|
124
|
+
"output": output,
|
|
125
|
+
}
|
|
126
|
+
record["task"] = asyncio.create_task(self._pump_terminal_output(record))
|
|
127
|
+
record["reaperTask"] = asyncio.create_task(self._reap_terminal(record))
|
|
128
|
+
self._terminals[terminal_id] = record
|
|
129
|
+
return {
|
|
130
|
+
"terminalId": terminal_id,
|
|
131
|
+
"sessionId": session_id,
|
|
132
|
+
"label": record["label"],
|
|
133
|
+
"purpose": "user",
|
|
134
|
+
"pid": self._pid(pty),
|
|
135
|
+
"cwd": str(cwd),
|
|
136
|
+
"cols": cols,
|
|
137
|
+
"rows": rows,
|
|
138
|
+
"shell": shell_cmd,
|
|
139
|
+
"command": command,
|
|
140
|
+
"args": args,
|
|
141
|
+
"status": record["status"],
|
|
142
|
+
"exitCode": record["exitCode"],
|
|
143
|
+
"scrollbackBytes": 0,
|
|
144
|
+
"scrollbackSeq": 0,
|
|
145
|
+
"createdAt": record["createdAt"],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async def write(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
149
|
+
self._gc()
|
|
150
|
+
terminal_id = required_string(params, "terminalId")
|
|
151
|
+
record = self._terminals.get(terminal_id)
|
|
152
|
+
if record is None:
|
|
153
|
+
raise KeyError(f"terminal not found: {terminal_id}")
|
|
154
|
+
if record["closed"]:
|
|
155
|
+
raise ValueError(f"terminal already closed: {terminal_id}")
|
|
156
|
+
data_b64 = required_string(params, "dataBase64")
|
|
157
|
+
try:
|
|
158
|
+
data = base64.b64decode(data_b64)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
raise ValueError("dataBase64 must be valid base64") from exc
|
|
161
|
+
self._touch(record)
|
|
162
|
+
await asyncio.to_thread(self._write_all, record["pty"], data)
|
|
163
|
+
return {"terminalId": terminal_id, "bytesWritten": len(data)}
|
|
164
|
+
|
|
165
|
+
async def resize(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
self._gc()
|
|
167
|
+
terminal_id = required_string(params, "terminalId")
|
|
168
|
+
record = self._terminals.get(terminal_id)
|
|
169
|
+
if record is None:
|
|
170
|
+
return {"terminalId": terminal_id, "closed": True}
|
|
171
|
+
cols = int(params.get("cols") or record["cols"])
|
|
172
|
+
rows = int(params.get("rows") or record["rows"])
|
|
173
|
+
cols = max(1, min(500, cols))
|
|
174
|
+
rows = max(1, min(200, rows))
|
|
175
|
+
try:
|
|
176
|
+
self._setwinsize(record["pty"], rows, cols)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
self._touch(record)
|
|
180
|
+
record["cols"] = cols
|
|
181
|
+
record["rows"] = rows
|
|
182
|
+
return {"terminalId": terminal_id, "cols": cols, "rows": rows}
|
|
183
|
+
|
|
184
|
+
async def close(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
185
|
+
self._gc()
|
|
186
|
+
terminal_id = required_string(params, "terminalId")
|
|
187
|
+
record = self._terminals.get(terminal_id)
|
|
188
|
+
if record is None:
|
|
189
|
+
return {"terminalId": terminal_id, "closed": True}
|
|
190
|
+
await self._kill_terminal(record)
|
|
191
|
+
self._forget_terminal(terminal_id)
|
|
192
|
+
return {"terminalId": terminal_id, "closed": True}
|
|
193
|
+
|
|
194
|
+
async def rename(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
self._gc()
|
|
196
|
+
terminal_id = required_string(params, "terminalId")
|
|
197
|
+
record = self._terminals.get(terminal_id)
|
|
198
|
+
if record is None:
|
|
199
|
+
raise KeyError(f"terminal not found: {terminal_id}")
|
|
200
|
+
label = required_string(params, "label").strip()
|
|
201
|
+
if not label:
|
|
202
|
+
raise ValueError("label is required")
|
|
203
|
+
record["label"] = label
|
|
204
|
+
return self._terminal_view(record)
|
|
205
|
+
|
|
206
|
+
async def release(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
207
|
+
self._gc()
|
|
208
|
+
terminal_id = required_string(params, "terminalId")
|
|
209
|
+
record = self._terminals.get(terminal_id)
|
|
210
|
+
if record is None:
|
|
211
|
+
return {"terminalId": terminal_id, "released": True}
|
|
212
|
+
record["output"] = None
|
|
213
|
+
return {"terminalId": terminal_id, "released": True}
|
|
214
|
+
|
|
215
|
+
async def list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
216
|
+
self._gc()
|
|
217
|
+
session_id = params.get("sessionId")
|
|
218
|
+
items: list[dict[str, Any]] = []
|
|
219
|
+
for record in self._terminals.values():
|
|
220
|
+
if session_id is not None and record["sessionId"] != session_id:
|
|
221
|
+
continue
|
|
222
|
+
items.append({
|
|
223
|
+
"terminalId": record["id"],
|
|
224
|
+
"sessionId": record["sessionId"],
|
|
225
|
+
"label": record["label"],
|
|
226
|
+
"purpose": "user",
|
|
227
|
+
"pid": self._pid(record["pty"]) if not record["closed"] else None,
|
|
228
|
+
"cols": record["cols"],
|
|
229
|
+
"rows": record["rows"],
|
|
230
|
+
"cwd": record["cwd"],
|
|
231
|
+
"shell": record["shell"],
|
|
232
|
+
"closed": record["closed"],
|
|
233
|
+
"status": record["status"],
|
|
234
|
+
"exitCode": record["exitCode"],
|
|
235
|
+
"scrollbackBytes": len(record["scrollback"]),
|
|
236
|
+
"scrollbackSeq": record["seq"],
|
|
237
|
+
"createdAt": record["createdAt"],
|
|
238
|
+
})
|
|
239
|
+
return {"terminals": items}
|
|
240
|
+
|
|
241
|
+
async def snapshot(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
242
|
+
self._gc()
|
|
243
|
+
terminal_id = required_string(params, "terminalId")
|
|
244
|
+
record = self._terminals.get(terminal_id)
|
|
245
|
+
if record is None:
|
|
246
|
+
raise KeyError(f"terminal not found: {terminal_id}")
|
|
247
|
+
from_seq = int(params.get("fromSeq") or 0)
|
|
248
|
+
outputs = [
|
|
249
|
+
{"seq": chunk["seq"], "dataBase64": chunk["dataBase64"]}
|
|
250
|
+
for chunk in record["chunks"]
|
|
251
|
+
if chunk["seq"] > from_seq
|
|
252
|
+
]
|
|
253
|
+
return {
|
|
254
|
+
"terminal": self._terminal_view(record),
|
|
255
|
+
"baseSeq": record["scrollbackBaseSeq"],
|
|
256
|
+
"seq": record["seq"],
|
|
257
|
+
"dataBase64": base64.b64encode(bytes(record["scrollback"])).decode("ascii"),
|
|
258
|
+
"outputs": outputs,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async def _pump_terminal_output(self, record: dict[str, Any]) -> None:
|
|
262
|
+
pty = record["pty"]
|
|
263
|
+
loop = asyncio.get_running_loop()
|
|
264
|
+
try:
|
|
265
|
+
while True:
|
|
266
|
+
try:
|
|
267
|
+
data = await loop.run_in_executor(None, self._read, pty)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
if exc.errno in (errno.EIO,):
|
|
270
|
+
break
|
|
271
|
+
raise
|
|
272
|
+
if not data:
|
|
273
|
+
break
|
|
274
|
+
self._touch(record)
|
|
275
|
+
record["seq"] += 1
|
|
276
|
+
self._append_scrollback(record, data)
|
|
277
|
+
await self._notify(
|
|
278
|
+
"terminal.output",
|
|
279
|
+
{
|
|
280
|
+
"terminalId": record["id"],
|
|
281
|
+
"sessionId": record["sessionId"],
|
|
282
|
+
"seq": record["seq"],
|
|
283
|
+
"dataBase64": base64.b64encode(data).decode("ascii"),
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
except asyncio.CancelledError:
|
|
287
|
+
raise
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
await self._notify(
|
|
290
|
+
"terminal.exited",
|
|
291
|
+
{
|
|
292
|
+
"terminalId": record["id"],
|
|
293
|
+
"sessionId": record["sessionId"],
|
|
294
|
+
"exitCode": None,
|
|
295
|
+
"reason": f"pump_error: {exc.__class__.__name__}: {exc}",
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
await self._cleanup_terminal(record)
|
|
299
|
+
return
|
|
300
|
+
exit_code = self._wait_exit_code(pty)
|
|
301
|
+
record["exitCode"] = exit_code
|
|
302
|
+
await self._notify(
|
|
303
|
+
"terminal.exited",
|
|
304
|
+
{
|
|
305
|
+
"terminalId": record["id"],
|
|
306
|
+
"sessionId": record["sessionId"],
|
|
307
|
+
"exitCode": exit_code,
|
|
308
|
+
"reason": "exit",
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
await self._cleanup_terminal(record)
|
|
312
|
+
|
|
313
|
+
async def _kill_terminal(self, record: dict[str, Any]) -> None:
|
|
314
|
+
if not record["closed"]:
|
|
315
|
+
pty = record["pty"]
|
|
316
|
+
self._terminate(pty)
|
|
317
|
+
task = record.get("task")
|
|
318
|
+
if isinstance(task, asyncio.Task):
|
|
319
|
+
task.cancel()
|
|
320
|
+
await self._cleanup_terminal(record)
|
|
321
|
+
|
|
322
|
+
async def _cleanup_terminal(self, record: dict[str, Any]) -> None:
|
|
323
|
+
if record["closed"]:
|
|
324
|
+
return
|
|
325
|
+
record["closed"] = True
|
|
326
|
+
record["status"] = "exited"
|
|
327
|
+
record["closedAt"] = time.time()
|
|
328
|
+
record["closedAtMono"] = time.monotonic()
|
|
329
|
+
self._close(record["pty"])
|
|
330
|
+
|
|
331
|
+
async def _reap_terminal(self, record: dict[str, Any]) -> None:
|
|
332
|
+
terminal_id = record["id"]
|
|
333
|
+
try:
|
|
334
|
+
while self._terminals.get(terminal_id) is record:
|
|
335
|
+
now = time.monotonic()
|
|
336
|
+
if record["closed"]:
|
|
337
|
+
closed_at = record.get("closedAtMono")
|
|
338
|
+
if not isinstance(closed_at, (int, float)):
|
|
339
|
+
closed_at = now
|
|
340
|
+
if self._closed_ttl_seconds <= 0 or now - closed_at >= self._closed_ttl_seconds:
|
|
341
|
+
self._forget_terminal(terminal_id)
|
|
342
|
+
return
|
|
343
|
+
await asyncio.sleep(
|
|
344
|
+
min(
|
|
345
|
+
self._reaper_poll_seconds,
|
|
346
|
+
max(0.1, closed_at + self._closed_ttl_seconds - now),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
last_activity_at = record.get("lastActivityAtMono")
|
|
352
|
+
if not isinstance(last_activity_at, (int, float)):
|
|
353
|
+
last_activity_at = record.get("createdAtMono") or now
|
|
354
|
+
if self._idle_ttl_seconds <= 0:
|
|
355
|
+
await asyncio.sleep(self._reaper_poll_seconds)
|
|
356
|
+
continue
|
|
357
|
+
idle_for = now - last_activity_at
|
|
358
|
+
if idle_for >= self._idle_ttl_seconds:
|
|
359
|
+
await self._expire_idle_terminal(record)
|
|
360
|
+
return
|
|
361
|
+
await asyncio.sleep(
|
|
362
|
+
min(
|
|
363
|
+
self._reaper_poll_seconds,
|
|
364
|
+
max(0.1, self._idle_ttl_seconds - idle_for),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
except asyncio.CancelledError:
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
async def _expire_idle_terminal(self, record: dict[str, Any]) -> None:
|
|
371
|
+
if record["closed"] or self._terminals.get(record["id"]) is not record:
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
await self._notify(
|
|
375
|
+
"terminal.exited",
|
|
376
|
+
{
|
|
377
|
+
"terminalId": record["id"],
|
|
378
|
+
"sessionId": record["sessionId"],
|
|
379
|
+
"exitCode": None,
|
|
380
|
+
"reason": "idle_timeout",
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
finally:
|
|
384
|
+
await self._kill_terminal(record)
|
|
385
|
+
self._forget_terminal(record["id"])
|
|
386
|
+
|
|
387
|
+
async def _notify(self, method: str, params: dict[str, Any]) -> None:
|
|
388
|
+
terminal_id = params.get("terminalId")
|
|
389
|
+
if isinstance(terminal_id, str):
|
|
390
|
+
record = self._terminals.get(terminal_id)
|
|
391
|
+
output = record.get("output") if record is not None else None
|
|
392
|
+
if output is not None:
|
|
393
|
+
await output(method, params)
|
|
394
|
+
return
|
|
395
|
+
if self.notify is not None:
|
|
396
|
+
await self.notify(method, params)
|
|
397
|
+
|
|
398
|
+
def _terminal_view(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
399
|
+
return {
|
|
400
|
+
"terminalId": record["id"],
|
|
401
|
+
"sessionId": record["sessionId"],
|
|
402
|
+
"label": record["label"],
|
|
403
|
+
"purpose": "user",
|
|
404
|
+
"pid": self._pid(record["pty"]) if not record["closed"] else None,
|
|
405
|
+
"cols": record["cols"],
|
|
406
|
+
"rows": record["rows"],
|
|
407
|
+
"cwd": record["cwd"],
|
|
408
|
+
"shell": record["shell"],
|
|
409
|
+
"closed": record["closed"],
|
|
410
|
+
"status": record["status"],
|
|
411
|
+
"exitCode": record["exitCode"],
|
|
412
|
+
"scrollbackBytes": len(record["scrollback"]),
|
|
413
|
+
"scrollbackSeq": record["seq"],
|
|
414
|
+
"createdAt": record["createdAt"],
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def _append_scrollback(self, record: dict[str, Any], data: bytes) -> None:
|
|
418
|
+
data_base64 = base64.b64encode(data).decode("ascii")
|
|
419
|
+
record["chunks"].append({"seq": record["seq"], "dataBase64": data_base64, "bytes": len(data)})
|
|
420
|
+
record["chunksBytes"] += len(data)
|
|
421
|
+
while record["chunks"] and record["chunksBytes"] > TERMINAL_SCROLLBACK_MAX_BYTES:
|
|
422
|
+
removed = record["chunks"].pop(0)
|
|
423
|
+
record["chunksBytes"] -= removed["bytes"]
|
|
424
|
+
scrollback = record["scrollback"]
|
|
425
|
+
scrollback.extend(data)
|
|
426
|
+
overflow = len(scrollback) - TERMINAL_SCROLLBACK_MAX_BYTES
|
|
427
|
+
if overflow <= 0:
|
|
428
|
+
return
|
|
429
|
+
del scrollback[:overflow]
|
|
430
|
+
record["scrollbackBaseSeq"] = (record["chunks"][0]["seq"] - 1) if record["chunks"] else record["seq"]
|
|
431
|
+
|
|
432
|
+
def _gc(self) -> None:
|
|
433
|
+
now = time.monotonic()
|
|
434
|
+
for terminal_id, record in list(self._terminals.items()):
|
|
435
|
+
if not record["closed"]:
|
|
436
|
+
continue
|
|
437
|
+
closed_at = record.get("closedAtMono")
|
|
438
|
+
if isinstance(closed_at, (int, float)) and now - closed_at > self._closed_ttl_seconds:
|
|
439
|
+
self._forget_terminal(terminal_id)
|
|
440
|
+
if len(self._terminals) <= TERMINAL_MAX_RECORDS:
|
|
441
|
+
return
|
|
442
|
+
closed_records = sorted(
|
|
443
|
+
(record for record in self._terminals.values() if record["closed"]),
|
|
444
|
+
key=lambda record: record.get("closedAtMono") or record.get("createdAtMono") or 0,
|
|
445
|
+
)
|
|
446
|
+
for record in closed_records:
|
|
447
|
+
if len(self._terminals) <= TERMINAL_MAX_RECORDS:
|
|
448
|
+
break
|
|
449
|
+
self._forget_terminal(record["id"])
|
|
450
|
+
|
|
451
|
+
def _touch(self, record: dict[str, Any]) -> None:
|
|
452
|
+
record["lastActivityAtMono"] = time.monotonic()
|
|
453
|
+
|
|
454
|
+
def _forget_terminal(self, terminal_id: str) -> None:
|
|
455
|
+
record = self._terminals.pop(terminal_id, None)
|
|
456
|
+
if record is None:
|
|
457
|
+
return
|
|
458
|
+
reaper_task = record.get("reaperTask")
|
|
459
|
+
try:
|
|
460
|
+
current_task = asyncio.current_task()
|
|
461
|
+
except RuntimeError:
|
|
462
|
+
current_task = None
|
|
463
|
+
if isinstance(reaper_task, asyncio.Task) and reaper_task is not current_task:
|
|
464
|
+
reaper_task.cancel()
|
|
465
|
+
|
|
466
|
+
def _default_shell(self, requested: Any) -> str:
|
|
467
|
+
if isinstance(requested, str) and requested.strip():
|
|
468
|
+
return requested
|
|
469
|
+
return os.environ.get("SHELL") or "/bin/bash"
|
|
470
|
+
|
|
471
|
+
def _default_argv(self, shell_cmd: str) -> list[str]:
|
|
472
|
+
return [shell_cmd, "-l"] if shell_cmd.endswith(("bash", "zsh", "sh")) else [shell_cmd]
|
|
473
|
+
|
|
474
|
+
def _spawn(self, argv: list[str], *, cwd: Path, env: dict[str, str], rows: int, cols: int) -> Any:
|
|
475
|
+
raise NotImplementedError
|
|
476
|
+
|
|
477
|
+
def _read(self, pty: Any) -> bytes:
|
|
478
|
+
raise NotImplementedError
|
|
479
|
+
|
|
480
|
+
def _write_all(self, pty: Any, data: bytes) -> None:
|
|
481
|
+
raise NotImplementedError
|
|
482
|
+
|
|
483
|
+
def _setwinsize(self, pty: Any, rows: int, cols: int) -> None:
|
|
484
|
+
raise NotImplementedError
|
|
485
|
+
|
|
486
|
+
def _terminate(self, pty: Any) -> None:
|
|
487
|
+
raise NotImplementedError
|
|
488
|
+
|
|
489
|
+
def _close(self, pty: Any) -> None:
|
|
490
|
+
raise NotImplementedError
|
|
491
|
+
|
|
492
|
+
def _wait_exit_code(self, pty: Any) -> int | None:
|
|
493
|
+
raise NotImplementedError
|
|
494
|
+
|
|
495
|
+
def _pid(self, pty: Any) -> int | None:
|
|
496
|
+
return getattr(pty, "pid", None)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _env_float(name: str, default: float, override: float | None) -> float:
|
|
500
|
+
if override is not None:
|
|
501
|
+
return max(0.0, float(override))
|
|
502
|
+
raw = os.environ.get(name)
|
|
503
|
+
if raw is None:
|
|
504
|
+
return float(default)
|
|
505
|
+
try:
|
|
506
|
+
return max(0.0, float(raw))
|
|
507
|
+
except ValueError:
|
|
508
|
+
return float(default)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class UnixPtyTerminalBackend(TerminalBackend):
|
|
512
|
+
def _spawn(self, argv: list[str], *, cwd: Path, env: dict[str, str], rows: int, cols: int) -> Any:
|
|
513
|
+
import ptyprocess
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
return ptyprocess.PtyProcess.spawn(
|
|
517
|
+
argv,
|
|
518
|
+
cwd=str(cwd),
|
|
519
|
+
env=env,
|
|
520
|
+
dimensions=(rows, cols),
|
|
521
|
+
)
|
|
522
|
+
except FileNotFoundError as exc:
|
|
523
|
+
raise FileNotFoundError(f"terminal command not found: {argv[0]}") from exc
|
|
524
|
+
|
|
525
|
+
def _read(self, pty: Any) -> bytes:
|
|
526
|
+
try:
|
|
527
|
+
chunk = os.read(pty.fd, 4096)
|
|
528
|
+
except OSError as exc:
|
|
529
|
+
if exc.errno in (errno.EIO,):
|
|
530
|
+
return b""
|
|
531
|
+
raise
|
|
532
|
+
return _sanitize_pty_output(chunk)
|
|
533
|
+
|
|
534
|
+
def _write_all(self, pty: Any, data: bytes) -> None:
|
|
535
|
+
written = 0
|
|
536
|
+
while written < len(data):
|
|
537
|
+
try:
|
|
538
|
+
n = os.write(pty.fd, data[written:])
|
|
539
|
+
except OSError as exc:
|
|
540
|
+
if exc.errno in (errno.EIO, errno.EPIPE):
|
|
541
|
+
return
|
|
542
|
+
raise
|
|
543
|
+
if not n:
|
|
544
|
+
return
|
|
545
|
+
written += n
|
|
546
|
+
|
|
547
|
+
def _setwinsize(self, pty: Any, rows: int, cols: int) -> None:
|
|
548
|
+
pty.setwinsize(rows, cols)
|
|
549
|
+
|
|
550
|
+
def _terminate(self, pty: Any) -> None:
|
|
551
|
+
try:
|
|
552
|
+
pty.terminate(force=True)
|
|
553
|
+
except Exception:
|
|
554
|
+
try:
|
|
555
|
+
pty.kill(signal.SIGKILL)
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
def _close(self, pty: Any) -> None:
|
|
560
|
+
try:
|
|
561
|
+
pty.close(force=True)
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
def _wait_exit_code(self, pty: Any) -> int | None:
|
|
566
|
+
try:
|
|
567
|
+
pty.wait()
|
|
568
|
+
return pty.exitstatus
|
|
569
|
+
except Exception:
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class WinPtyTerminalBackend(TerminalBackend):
|
|
574
|
+
def _default_shell(self, requested: Any) -> str:
|
|
575
|
+
if isinstance(requested, str) and requested.strip():
|
|
576
|
+
return requested
|
|
577
|
+
return "powershell.exe"
|
|
578
|
+
|
|
579
|
+
def _default_argv(self, shell_cmd: str) -> list[str]:
|
|
580
|
+
if shell_cmd.lower().endswith("powershell.exe") or shell_cmd.lower() == "powershell":
|
|
581
|
+
return [shell_cmd, "-NoLogo"]
|
|
582
|
+
return [shell_cmd]
|
|
583
|
+
|
|
584
|
+
def _spawn(self, argv: list[str], *, cwd: Path, env: dict[str, str], rows: int, cols: int) -> Any:
|
|
585
|
+
try:
|
|
586
|
+
from winpty import PtyProcess
|
|
587
|
+
except ImportError as exc:
|
|
588
|
+
raise RuntimeError("pywinpty is required for Windows terminal support") from exc
|
|
589
|
+
try:
|
|
590
|
+
return PtyProcess.spawn(
|
|
591
|
+
argv,
|
|
592
|
+
cwd=str(cwd),
|
|
593
|
+
env=env,
|
|
594
|
+
dimensions=(rows, cols),
|
|
595
|
+
)
|
|
596
|
+
except Exception as exc:
|
|
597
|
+
raise RuntimeError(
|
|
598
|
+
"failed to create Windows ConPTY terminal; run the connector in an interactive user session"
|
|
599
|
+
) from exc
|
|
600
|
+
|
|
601
|
+
def _read(self, pty: Any) -> bytes:
|
|
602
|
+
try:
|
|
603
|
+
data = pty.read(4096)
|
|
604
|
+
except EOFError:
|
|
605
|
+
return b""
|
|
606
|
+
if isinstance(data, str):
|
|
607
|
+
return _sanitize_pty_output(data.encode("utf-8", errors="replace"))
|
|
608
|
+
return _sanitize_pty_output(data or b"")
|
|
609
|
+
|
|
610
|
+
def _write_all(self, pty: Any, data: bytes) -> None:
|
|
611
|
+
pty.write(data.decode("utf-8", errors="replace"))
|
|
612
|
+
|
|
613
|
+
def _setwinsize(self, pty: Any, rows: int, cols: int) -> None:
|
|
614
|
+
pty.setwinsize(rows, cols)
|
|
615
|
+
|
|
616
|
+
def _terminate(self, pty: Any) -> None:
|
|
617
|
+
try:
|
|
618
|
+
pty.terminate(force=True)
|
|
619
|
+
except Exception:
|
|
620
|
+
try:
|
|
621
|
+
pty.kill()
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
def _close(self, pty: Any) -> None:
|
|
626
|
+
try:
|
|
627
|
+
pty.close()
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
def _wait_exit_code(self, pty: Any) -> int | None:
|
|
632
|
+
try:
|
|
633
|
+
pty.wait()
|
|
634
|
+
return pty.exitstatus
|
|
635
|
+
except Exception:
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _sanitize_pty_output(data: bytes) -> bytes:
|
|
640
|
+
"""Drop Device Attributes responses that Windows shells often echo as junk.
|
|
641
|
+
|
|
642
|
+
Example visible garbage: ``[?1;2c`` after the PowerShell prompt.
|
|
643
|
+
"""
|
|
644
|
+
if not data or (b"[" not in data and b"\x1b" not in data):
|
|
645
|
+
return data
|
|
646
|
+
import re
|
|
647
|
+
|
|
648
|
+
text = data.decode("utf-8", errors="replace")
|
|
649
|
+
cleaned = re.sub(r"\x1b\[\?[0-9;]*c|\[\?[0-9;]*c", "", text)
|
|
650
|
+
if cleaned == text:
|
|
651
|
+
return data
|
|
652
|
+
return cleaned.encode("utf-8", errors="replace")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def default_terminal_backend(notify: Notify | None = None) -> TerminalBackend:
|
|
656
|
+
if sys.platform == "win32":
|
|
657
|
+
return WinPtyTerminalBackend(notify=notify)
|
|
658
|
+
return UnixPtyTerminalBackend(notify=notify)
|