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
connector/json_rpc.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import sys
7
+ import threading
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any
10
+
11
+
12
+ JsonRpcHandler = Callable[[Any], Any | Awaitable[Any]]
13
+
14
+
15
+ class JsonRpcError(RuntimeError):
16
+ def __init__(self, code: int, message: str, data: Any = None) -> None:
17
+ super().__init__(message)
18
+ self.code = code
19
+ self.message = message
20
+ self.data = data
21
+
22
+
23
+ class JsonRpcStdioServer:
24
+ def __init__(
25
+ self,
26
+ reader: asyncio.StreamReader,
27
+ writer: asyncio.StreamWriter,
28
+ handlers: dict[str, JsonRpcHandler],
29
+ ) -> None:
30
+ self.reader = reader
31
+ self.writer = writer
32
+ self.handlers = handlers
33
+ self._write_lock = asyncio.Lock()
34
+
35
+ async def serve_forever(self) -> None:
36
+ while line := await self.reader.readline():
37
+ await self.handle_line(line)
38
+
39
+ async def handle_line(self, line: bytes) -> None:
40
+ try:
41
+ payload = json.loads(line)
42
+ except json.JSONDecodeError as exc:
43
+ await self._write_error(None, -32700, "Parse error", {"detail": str(exc)})
44
+ return
45
+
46
+ if not isinstance(payload, dict):
47
+ await self._write_error(None, -32600, "Invalid Request")
48
+ return
49
+
50
+ request_id = payload.get("id")
51
+ method = payload.get("method")
52
+ if payload.get("jsonrpc") != "2.0" or not isinstance(method, str):
53
+ if request_id is not None:
54
+ await self._write_error(request_id, -32600, "Invalid Request")
55
+ return
56
+
57
+ handler = self.handlers.get(method)
58
+ if handler is None:
59
+ if request_id is not None:
60
+ await self._write_error(request_id, -32601, "Method not found")
61
+ return
62
+
63
+ try:
64
+ result = handler(payload.get("params"))
65
+ if inspect.isawaitable(result):
66
+ result = await result
67
+ except JsonRpcError as exc:
68
+ if request_id is not None:
69
+ await self._write_error(request_id, exc.code, exc.message, exc.data)
70
+ return
71
+ except Exception as exc:
72
+ if request_id is not None:
73
+ await self._write_error(request_id, -32000, str(exc) or exc.__class__.__name__)
74
+ return
75
+
76
+ if request_id is not None:
77
+ await self.write({"jsonrpc": "2.0", "id": request_id, "result": result})
78
+
79
+ async def notify(self, method: str, params: Any = None) -> None:
80
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
81
+ if params is not None:
82
+ payload["params"] = params
83
+ await self.write(payload)
84
+
85
+ async def write(self, payload: dict[str, Any]) -> None:
86
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
87
+ async with self._write_lock:
88
+ self.writer.write(data)
89
+ await self.writer.drain()
90
+
91
+ async def _write_error(self, request_id: Any, code: int, message: str, data: Any = None) -> None:
92
+ error: dict[str, Any] = {"code": code, "message": message}
93
+ if data is not None:
94
+ error["data"] = data
95
+ await self.write({"jsonrpc": "2.0", "id": request_id, "error": error})
96
+
97
+
98
+ class ThreadedStdioWriter:
99
+ def __init__(self, stream: Any) -> None:
100
+ self._stream = stream
101
+ self._lock = threading.Lock()
102
+
103
+ def write(self, data: bytes) -> None:
104
+ self._data = data
105
+
106
+ async def drain(self) -> None:
107
+ data = self._data
108
+ await asyncio.to_thread(self._write_sync, data)
109
+
110
+ def _write_sync(self, data: bytes) -> None:
111
+ with self._lock:
112
+ self._stream.write(data)
113
+ self._stream.flush()
114
+
115
+
116
+ def _start_threaded_stdin_reader(reader: asyncio.StreamReader, stream: Any) -> None:
117
+ loop = asyncio.get_running_loop()
118
+
119
+ def read_stdin() -> None:
120
+ try:
121
+ while line := stream.readline():
122
+ loop.call_soon_threadsafe(reader.feed_data, line)
123
+ except BaseException as exc: # noqa: BLE001 - forward fatal pipe failures into the async reader.
124
+ loop.call_soon_threadsafe(reader.set_exception, exc)
125
+ return
126
+ loop.call_soon_threadsafe(reader.feed_eof)
127
+
128
+ thread = threading.Thread(target=read_stdin, name="json-rpc-stdio-reader", daemon=True)
129
+ thread.start()
130
+
131
+
132
+ async def open_stdio_server(handlers: dict[str, JsonRpcHandler]) -> JsonRpcStdioServer:
133
+ loop = asyncio.get_running_loop()
134
+ reader = asyncio.StreamReader()
135
+ if sys.platform == "win32":
136
+ _start_threaded_stdin_reader(reader, sys.stdin.buffer)
137
+ return JsonRpcStdioServer(reader, ThreadedStdioWriter(sys.stdout.buffer), handlers) # type: ignore[arg-type]
138
+
139
+ reader_protocol = asyncio.StreamReaderProtocol(reader)
140
+ await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin.buffer)
141
+ writer_transport, writer_protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout.buffer)
142
+ writer = asyncio.StreamWriter(writer_transport, writer_protocol, None, loop)
143
+ return JsonRpcStdioServer(reader, writer, handlers)
connector/launch.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ Launcher = Literal["direct", "powershell", "cmd"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class LaunchTarget:
19
+ """How to spawn a CLI agent binary on this host.
20
+
21
+ ``path`` is the user-facing / discovery path (agent.cmd, gemini.cmd, …).
22
+ ``exec_argv`` when set is the *actual* argv prefix used for CreateProcess
23
+ (e.g. resolved ``node.exe`` + ``index.js`` for Cursor), avoiding slow
24
+ shell wrappers on Windows.
25
+ """
26
+
27
+ source: str
28
+ path: str
29
+ launcher: Launcher = "direct"
30
+ exec_argv: tuple[str, ...] | None = None
31
+
32
+ def command(self, args: list[str] | tuple[str, ...] = ()) -> list[str]:
33
+ argv = list(args)
34
+ if self.exec_argv is not None:
35
+ return [*self.exec_argv, *argv]
36
+ if self.launcher == "powershell":
37
+ return [
38
+ _powershell_bin(),
39
+ "-NoProfile",
40
+ "-ExecutionPolicy",
41
+ "Bypass",
42
+ "-File",
43
+ self.path,
44
+ *argv,
45
+ ]
46
+ if self.launcher == "cmd":
47
+ # Prefer cmd.exe over PowerShell for .cmd/.bat — ~0.5–1s faster cold
48
+ # start, and avoids nested PowerShell when the script itself invokes PS.
49
+ return [_cmd_bin(), "/d", "/c", self.path, *argv]
50
+ return [self.path, *argv]
51
+
52
+ def report_path(self) -> str:
53
+ return self.path
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class LaunchCommand:
58
+ """A user-selected executable plus arguments, without shell evaluation."""
59
+
60
+ raw: str
61
+ target: LaunchTarget
62
+ args: tuple[str, ...] = ()
63
+
64
+ def command(self, managed_args: list[str] | tuple[str, ...] = ()) -> list[str]:
65
+ return self.target.command([*self.args, *managed_args])
66
+
67
+ @property
68
+ def launch_id(self) -> str:
69
+ payload = json.dumps(
70
+ [self.target.report_path(), *self.args],
71
+ ensure_ascii=False,
72
+ separators=(",", ":"),
73
+ ).encode("utf-8")
74
+ return "codex:" + hashlib.sha256(payload).hexdigest()[:24]
75
+
76
+ def report(self, *, mode: str = "command") -> dict[str, str]:
77
+ return {
78
+ "mode": mode,
79
+ "command": self.raw,
80
+ "launchId": self.launch_id,
81
+ "resolvedExecutable": self.target.report_path(),
82
+ }
83
+
84
+
85
+ _UNSUPPORTED_SHELL_TOKENS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}
86
+ _CMD_METACHARACTERS = frozenset('&|<>^()%!"')
87
+
88
+
89
+ def parse_launch_command(value: str) -> LaunchCommand:
90
+ """Parse a frontend command as argv and resolve its executable locally.
91
+
92
+ Shell operators are deliberately rejected. The resulting command is
93
+ always passed to ``create_subprocess_exec`` as an argv list.
94
+ """
95
+
96
+ raw = value.strip()
97
+ if not raw:
98
+ raise ValueError("launch command is empty")
99
+ if "\n" in raw or "\r" in raw:
100
+ raise ValueError("launch command must be a single line")
101
+ try:
102
+ argv = _split_command_line(raw)
103
+ except ValueError as exc:
104
+ raise ValueError(f"invalid launch command: {exc}") from exc
105
+ if not argv:
106
+ raise ValueError("launch command is empty")
107
+ if any(token in _UNSUPPORTED_SHELL_TOKENS for token in argv):
108
+ raise ValueError("shell operators are not supported; enter an executable and arguments only")
109
+
110
+ executable = expand_vars(argv[0])
111
+ resolved = shutil.which(executable)
112
+ if resolved is None and Path(executable).is_file():
113
+ resolved = executable
114
+ if resolved is None:
115
+ raise ValueError(f"executable not found: {argv[0]}")
116
+ target = launch_target("custom", resolved)
117
+ if target.launcher == "cmd" and any(
118
+ character in _CMD_METACHARACTERS
119
+ for token in argv[1:]
120
+ for character in token
121
+ ):
122
+ raise ValueError(
123
+ "shell metacharacters are not supported in Windows .cmd/.bat commands"
124
+ )
125
+ return LaunchCommand(raw=raw, target=target, args=tuple(argv[1:]))
126
+
127
+
128
+ def launch_command_from_target(target: LaunchTarget, *, raw: str | None = None) -> LaunchCommand:
129
+ return LaunchCommand(raw=raw or target.report_path(), target=target)
130
+
131
+
132
+ def _split_command_line(value: str) -> list[str]:
133
+ if sys.platform != "win32":
134
+ return shlex.split(value, posix=True)
135
+
136
+ # Match the parsing used by CreateProcess/CommandLineToArgvW so quoted
137
+ # Windows paths round-trip exactly. Import lazily to keep Unix clean.
138
+ import ctypes
139
+ from ctypes import wintypes
140
+
141
+ argc = ctypes.c_int()
142
+ command_line_to_argv = ctypes.windll.shell32.CommandLineToArgvW
143
+ command_line_to_argv.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
144
+ command_line_to_argv.restype = ctypes.POINTER(wintypes.LPWSTR)
145
+ argv_ptr = command_line_to_argv(value, ctypes.byref(argc))
146
+ if not argv_ptr:
147
+ raise ValueError("Windows could not parse the command line")
148
+ try:
149
+ return [argv_ptr[index] for index in range(argc.value)]
150
+ finally:
151
+ ctypes.windll.kernel32.LocalFree(argv_ptr)
152
+
153
+
154
+ def launch_target(source: str, path: str) -> LaunchTarget:
155
+ path = expand_vars(path)
156
+ # Cursor agent.cmd → powershell → node is extremely slow. Prefer direct node.
157
+ cursor_argv = _resolve_cursor_agent_exec(path)
158
+ if cursor_argv is not None:
159
+ return LaunchTarget(
160
+ source=source,
161
+ path=path,
162
+ launcher="direct",
163
+ exec_argv=cursor_argv,
164
+ )
165
+
166
+ launcher: Launcher = "direct"
167
+ if sys.platform == "win32":
168
+ suffix = Path(path).suffix.lower()
169
+ if suffix == ".ps1":
170
+ launcher = "powershell"
171
+ elif suffix in {".cmd", ".bat"}:
172
+ launcher = "cmd"
173
+ return LaunchTarget(source=source, path=path, launcher=launcher)
174
+
175
+
176
+ def command_name(name: str) -> LaunchTarget | None:
177
+ found = shutil.which(name)
178
+ if not found:
179
+ return None
180
+ return launch_target("cli", found)
181
+
182
+
183
+ def existing_launch_targets(candidates: list[tuple[str, str]]) -> list[LaunchTarget]:
184
+ seen: set[str] = set()
185
+ out: list[LaunchTarget] = []
186
+ for source, raw in candidates:
187
+ path = expand_vars(raw)
188
+ if not path or path in seen:
189
+ continue
190
+ seen.add(path)
191
+ out.append(launch_target(source, path))
192
+ return out
193
+
194
+
195
+ def expand_vars(value: str) -> str:
196
+ return os.path.expandvars(os.path.expanduser(value))
197
+
198
+
199
+ def path_exists_for_launch(path: str) -> bool:
200
+ if not Path(path).is_file():
201
+ return False
202
+ if sys.platform == "win32":
203
+ return True
204
+ return os.access(path, os.X_OK)
205
+
206
+
207
+ def _resolve_cursor_agent_exec(path: str) -> tuple[str, ...] | None:
208
+ """If *path* is a Cursor agent shim, return ``(node.exe, index.js)``.
209
+
210
+ Official Windows install layout::
211
+
212
+ %LOCALAPPDATA%/cursor-agent/agent.cmd
213
+ → powershell → cursor-agent.ps1
214
+ → versions/<ver>/node.exe versions/<ver>/index.js
215
+
216
+ Spawning node directly cuts multi-second PowerShell cold-start cost that
217
+ previously caused ACP ``initialize`` timeouts.
218
+ """
219
+ try:
220
+ p = Path(path)
221
+ except Exception:
222
+ return None
223
+ name = p.name.lower()
224
+ if name not in {"agent.cmd", "agent.ps1", "cursor-agent.cmd", "cursor-agent.ps1", "agent.exe"}:
225
+ # Only resolve known Cursor install shims / paths under cursor-agent.
226
+ if "cursor-agent" not in str(p).lower().replace("\\", "/"):
227
+ return None
228
+ if name not in {"agent", "cursor-agent"} and not name.startswith("agent"):
229
+ return None
230
+
231
+ # Locate install root (directory containing versions/ or node.exe).
232
+ candidates: list[Path] = []
233
+ if p.is_file():
234
+ candidates.append(p.parent)
235
+ candidates.append(p.parent.parent)
236
+ elif p.is_dir():
237
+ candidates.append(p)
238
+
239
+ local = os.environ.get("LOCALAPPDATA")
240
+ if local:
241
+ candidates.append(Path(local) / "cursor-agent")
242
+
243
+ for root in candidates:
244
+ resolved = _cursor_node_from_root(root)
245
+ if resolved is not None:
246
+ return resolved
247
+ return None
248
+
249
+
250
+ _CURSOR_VERSION_RE = re.compile(r"^\d{4}\.\d{1,2}\.\d{1,2}-.+$")
251
+
252
+
253
+ def _cursor_node_from_root(root: Path) -> tuple[str, ...] | None:
254
+ if not root.is_dir():
255
+ return None
256
+ # Same-dir layout (dev / unpacked)
257
+ local_node = root / "node.exe"
258
+ local_index = root / "index.js"
259
+ if local_node.is_file() and local_index.is_file():
260
+ return (str(local_node), str(local_index))
261
+
262
+ versions = root / "versions"
263
+ if not versions.is_dir():
264
+ return None
265
+
266
+ version_dirs = [
267
+ d
268
+ for d in versions.iterdir()
269
+ if d.is_dir() and _CURSOR_VERSION_RE.match(d.name)
270
+ ]
271
+ if not version_dirs:
272
+ return None
273
+
274
+ def _sort_key(d: Path) -> tuple[int, str]:
275
+ # YYYY.MM.DD-... → integer date for newest-first
276
+ date_part = d.name.split("-", 1)[0]
277
+ parts = date_part.split(".")
278
+ try:
279
+ y, m, day = int(parts[0]), int(parts[1]), int(parts[2])
280
+ return (y * 10000 + m * 100 + day, d.name)
281
+ except (ValueError, IndexError):
282
+ return (0, d.name)
283
+
284
+ latest = max(version_dirs, key=_sort_key)
285
+ node = latest / "node.exe"
286
+ index = latest / "index.js"
287
+ if node.is_file() and index.is_file():
288
+ return (str(node), str(index))
289
+ return None
290
+
291
+
292
+ def _cmd_quote(value: str) -> str:
293
+ escaped = value.replace('"', r'\"')
294
+ return f'"{escaped}"'
295
+
296
+
297
+ def _powershell_quote(value: str) -> str:
298
+ return "'" + value.replace("'", "''") + "'"
299
+
300
+
301
+ def _powershell_bin() -> str:
302
+ return shutil.which("powershell.exe") or shutil.which("powershell") or "powershell.exe"
303
+
304
+
305
+ def _cmd_bin() -> str:
306
+ return (
307
+ shutil.which("cmd.exe")
308
+ or shutil.which("cmd")
309
+ or os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "cmd.exe")
310
+ )
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from connector.local.common import StaleFileError
4
+ from connector.local.ops import LocalOps, create_local_ops
5
+
6
+ __all__ = ["LocalOps", "StaleFileError", "create_local_ops"]
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Awaitable, Callable
6
+
7
+
8
+ MAX_DIR_ENTRIES = 500
9
+ MAX_OUTPUT_CHARS = 64_000
10
+ MAX_READ_TEXT_BYTES = 4 * 1024 * 1024
11
+
12
+ Notify = Callable[[str, dict[str, Any]], Awaitable[None]]
13
+
14
+
15
+ class StaleFileError(Exception):
16
+ """Raised when fs.writeFile's ifMatch check fails."""
17
+
18
+ code = "stale"
19
+
20
+
21
+ def workspace_root(params: dict[str, Any]) -> Path:
22
+ raw_root = params.get("root") or params.get("cwd")
23
+ if not isinstance(raw_root, str) or not raw_root.strip():
24
+ raise ValueError("root is required")
25
+ return Path(raw_root).expanduser().resolve(strict=False)
26
+
27
+
28
+ def resolve_path(root: Path, raw_path: str) -> Path:
29
+ path = Path(raw_path).expanduser()
30
+ if not path.is_absolute():
31
+ path = root / path
32
+ return path.resolve(strict=False)
33
+
34
+
35
+ def nearest_existing_dir(path: Path, *, fallback: Path | None = None) -> Path:
36
+ """Return `path` if it is a directory, otherwise the closest existing parent.
37
+
38
+ Workspace paths can point at projects that were deleted or moved after a
39
+ session was recorded. Runtime panels should still open somewhere useful
40
+ instead of failing on a stale cwd/path.
41
+ """
42
+ current = path
43
+ while True:
44
+ if current.is_dir():
45
+ return current
46
+ parent = current.parent
47
+ if parent == current:
48
+ break
49
+ current = parent
50
+ if fallback is not None:
51
+ fallback_current = fallback
52
+ while True:
53
+ if fallback_current.is_dir():
54
+ return fallback_current
55
+ parent = fallback_current.parent
56
+ if parent == fallback_current:
57
+ break
58
+ fallback_current = parent
59
+ return Path.cwd()
60
+
61
+
62
+ def required_string(params: dict[str, Any], key: str) -> str:
63
+ value = params.get(key)
64
+ if not isinstance(value, str) or not value:
65
+ raise ValueError(f"{key} is required")
66
+ return value
67
+
68
+
69
+ def required_text(params: dict[str, Any], key: str) -> str:
70
+ value = params.get(key)
71
+ if not isinstance(value, str):
72
+ raise ValueError(f"{key} is required")
73
+ return value
74
+
75
+
76
+ def required_int(params: dict[str, Any], key: str) -> int:
77
+ value = params.get(key)
78
+ if not isinstance(value, int):
79
+ raise ValueError(f"{key} is required")
80
+ return value
81
+
82
+
83
+ def encoding(params: dict[str, Any]) -> str:
84
+ value = params.get("encoding", "utf8")
85
+ if value not in {"utf8", "utf-8"}:
86
+ raise ValueError("only utf8 encoding is supported")
87
+ return "utf-8"
88
+
89
+
90
+ def decode_output(output: bytes) -> tuple[str, bool]:
91
+ text = output.decode("utf-8", errors="replace")
92
+ if len(text) <= MAX_OUTPUT_CHARS:
93
+ return text, False
94
+ return text[:MAX_OUTPUT_CHARS], True
95
+
96
+
97
+ def shell_result(
98
+ cwd: Path,
99
+ command: str,
100
+ exit_code: int | None,
101
+ timed_out: bool,
102
+ start: float,
103
+ stdout: bytes,
104
+ stderr: bytes,
105
+ ) -> dict[str, Any]:
106
+ stdout_text, stdout_truncated = decode_output(stdout)
107
+ stderr_text, stderr_truncated = decode_output(stderr)
108
+ return {
109
+ "cwd": str(cwd),
110
+ "command": command,
111
+ "exitCode": exit_code,
112
+ "timedOut": timed_out,
113
+ "durationMs": int((time.monotonic() - start) * 1000),
114
+ "stdout": stdout_text,
115
+ "stderr": stderr_text,
116
+ "stdoutTruncated": stdout_truncated,
117
+ "stderrTruncated": stderr_truncated,
118
+ }