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.
Files changed (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. 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)
connector/local_ops.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from connector.local import LocalOps, StaleFileError, create_local_ops
4
+
5
+ __all__ = ["LocalOps", "StaleFileError", "create_local_ops"]