nooa-cli 0.0.7__tar.gz → 0.0.9__tar.gz

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 (40) hide show
  1. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/.gitignore +2 -3
  2. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/PKG-INFO +9 -1
  3. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/README.md +8 -0
  4. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/AGENTS.md +29 -0
  5. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/__init__.py +10 -3
  6. nooa_cli-0.0.9/src/nooa_cli/coding/__init__.py +19 -0
  7. nooa_cli-0.0.9/src/nooa_cli/coding/activity.py +431 -0
  8. nooa_cli-0.0.9/src/nooa_cli/commands/__init__.py +208 -0
  9. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/_otlp_helpers.py +55 -0
  10. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/import_harbor.py +11 -3
  11. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/import_traces.py +11 -3
  12. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/start_dev.py +23 -0
  13. nooa_cli-0.0.9/src/nooa_cli/sessions/__init__.py +31 -0
  14. nooa_cli-0.0.9/src/nooa_cli/sessions/events.py +43 -0
  15. nooa_cli-0.0.9/src/nooa_cli/sessions/store.py +436 -0
  16. nooa_cli-0.0.9/tests/test_coding_activity.py +191 -0
  17. nooa_cli-0.0.9/tests/test_command_plugins.py +238 -0
  18. nooa_cli-0.0.9/tests/test_sessions.py +270 -0
  19. nooa_cli-0.0.7/src/nooa_cli/commands/__init__.py +0 -106
  20. nooa_cli-0.0.7/tests/__init__.py +0 -0
  21. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/docs/activity-introspection-design.md +0 -0
  22. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/pyproject.toml +0 -0
  23. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/__main__.py +0 -0
  24. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/_common.py +0 -0
  25. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/_template.py +0 -0
  26. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/config.py +0 -0
  27. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/delete_traces.py +0 -0
  28. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/eval.py +0 -0
  29. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/commands/traces.py +0 -0
  30. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/completion.py +0 -0
  31. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/__init__.py +0 -0
  32. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/_tree_sitter_backend.py +0 -0
  33. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/__init__.py +0 -0
  34. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/errors.py +0 -0
  35. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/sources.py +0 -0
  36. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/stream.py +0 -0
  37. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
  38. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
  39. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/src/nooa_cli/tools/repo_tools.py +0 -0
  40. {nooa_cli-0.0.7 → nooa_cli-0.0.9}/tests/test_cli_smoke.py +0 -0
@@ -1,4 +1,4 @@
1
- # Rust toolchain (installed for nemo_flow PyO3 build)
1
+ # Rust toolchain (installed for nemo_relay PyO3 build)
2
2
  .cargo/
3
3
  .rustup/
4
4
 
@@ -296,7 +296,7 @@ agents/**/traces/
296
296
  .config/
297
297
  .local/
298
298
 
299
- # Rust toolchain (installed by rustup for nemo-flow build)
299
+ # Rust toolchain (installed by rustup for nemo-relay build)
300
300
  .cargo/
301
301
  .rustup/
302
302
 
@@ -366,7 +366,6 @@ sft_data/**/*.jsonl
366
366
 
367
367
  # Git credentials (must not be committed)
368
368
  .git-credentials
369
- -e
370
369
  # Bridge watchpoint file — per-worktree, never committed
371
370
  .bridge_watch.json
372
371
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nooa-cli
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Summary: CLI for nemo-oo-agents (`nooa` command: eval runner, trace viewer, config)
5
5
  Project-URL: Homepage, https://github.com/NVIDIA-NeMo/labs-OO-Agents
6
6
  Project-URL: Repository, https://github.com/NVIDIA-NeMo/labs-OO-Agents
@@ -50,3 +50,11 @@ nooa traces ... # inspect/manage trace files
50
50
  ```
51
51
 
52
52
  See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
53
+
54
+ ## Interactive coding sessions
55
+
56
+ `nooa_cli.sessions` owns durable coding-agent session identity, metadata, and
57
+ conversation replay shared by CLI hosts such as the native TUI and ACP. The
58
+ process running an agent owns the writable session handle; other hosts attach
59
+ through their transport or use read-only discovery. Generic event and SQLite
60
+ storage primitives remain in the core `nooa` package.
@@ -23,3 +23,11 @@ nooa traces ... # inspect/manage trace files
23
23
  ```
24
24
 
25
25
  See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
26
+
27
+ ## Interactive coding sessions
28
+
29
+ `nooa_cli.sessions` owns durable coding-agent session identity, metadata, and
30
+ conversation replay shared by CLI hosts such as the native TUI and ACP. The
31
+ process running an agent owns the writable session handle; other hosts attach
32
+ through their transport or use read-only discovery. Generic event and SQLite
33
+ storage primitives remain in the core `nooa` package.
@@ -101,6 +101,35 @@ def command(args: tuple[str, ...]):
101
101
  subprocess.run([sys.executable, "-m", "some_other_tool", *args])
102
102
  ```
103
103
 
104
+ ## Commands From Another Package
105
+
106
+ If your command lives in a *different* installed package (not in this repo),
107
+ register it in the `nooa_cli.commands` entry-point group instead of dropping a
108
+ file here. The entry-point name becomes the subcommand name:
109
+
110
+ ```toml
111
+ # pyproject.toml of your package
112
+ [project.entry-points."nooa_cli.commands"]
113
+ tui = "my_package.cli.tui:command"
114
+ term = "my_package.cli.term:command"
115
+ ```
116
+
117
+ `my_package.cli.tui:command` must be a `click.Command` or `click.Group` — the
118
+ same contract as an in-repo command module.
119
+
120
+ - **Built-ins win name collisions.** A plugin can't shadow `eval`, `config`,
121
+ or anything else shipped here; it's logged and skipped.
122
+ - **A broken plugin is skipped, not fatal.** An entry point that fails to
123
+ import, or that resolves to a non-`click.Command`, logs a warning and is
124
+ left out. `nooa` keeps working.
125
+ - Plugins register in entry-point-name order, so `nooa --help` is stable
126
+ regardless of install order.
127
+ - The **Performance Rule** below applies with extra force: every registered
128
+ entry point is loaded on *every* `nooa` invocation, including `nooa --help`.
129
+ Keep heavy imports inside the handler.
130
+
131
+ This mirrors the existing `nooa.skills` and `nooa.bundled_configs` groups.
132
+
104
133
  ## Shared Utilities
105
134
 
106
135
  Common helpers live in `src/nooa_cli/_common.py`:
@@ -9,7 +9,10 @@ Usage:
9
9
  nooa completion install # Set up shell completions
10
10
 
11
11
  Adding new commands:
12
- Drop a .py file in nooa_cli/commands/ — see commands/_template.py
12
+ In this package: drop a .py file in nooa_cli/commands/ — see
13
+ commands/_template.py
14
+ From another package: register an entry point in the
15
+ "nooa_cli.commands" group — see nooa_cli/commands/__init__.py
13
16
 
14
17
  Shell completion:
15
18
  eval "$(_NOOA_COMPLETE=bash_source nooa)" # bash
@@ -36,7 +39,8 @@ def oo(ctx):
36
39
  """OO Agents — agent toolkit.
37
40
 
38
41
  Extensible CLI for running agents, evaluations, and trace management.
39
- Add new commands by dropping a .py file in nooa_cli/commands/.
42
+ Add new commands by dropping a .py file in nooa_cli/commands/, or from
43
+ another package via the "nooa_cli.commands" entry-point group.
40
44
  """
41
45
  if ctx.invoked_subcommand in _SKIP_SECRETS_PRELOAD:
42
46
  return
@@ -57,11 +61,14 @@ def oo(ctx):
57
61
  )
58
62
 
59
63
 
60
- # -- Auto-discover and register all commands from commands/ -----------------
64
+ # -- Auto-discover and register all commands (built-ins + entry-point plugins) --
61
65
  for _name, _cmd in discover_commands():
62
66
  oo.add_command(_cmd, name=_name)
63
67
 
64
68
  # -- Built-in infrastructure commands (not in commands/ because they're meta) -
69
+ # Registered *after* discovery on purpose: `completion` is not part of
70
+ # discover_commands(), so this ordering is what stops a third-party plugin
71
+ # named "completion" from taking the name.
65
72
  oo.add_command(completion)
66
73
 
67
74
 
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared coding-agent components used by terminal and protocol hosts."""
4
+
5
+ from nooa_cli.coding.activity import (
6
+ ActivityShellTools,
7
+ FileEdit,
8
+ TerminalCommandFinished,
9
+ TerminalCommandOutput,
10
+ TerminalCommandStarted,
11
+ )
12
+
13
+ __all__ = [
14
+ "ActivityShellTools",
15
+ "FileEdit",
16
+ "TerminalCommandFinished",
17
+ "TerminalCommandOutput",
18
+ "TerminalCommandStarted",
19
+ ]
@@ -0,0 +1,431 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Opt-in shell tools and events shared by interactive coding hosts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from collections.abc import AsyncIterator
9
+ from difflib import unified_diff
10
+ from functools import wraps
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, ClassVar, Literal
13
+ from uuid import uuid4
14
+
15
+ from pydantic import Field
16
+
17
+ from nooa.agentdoc import TruncatingStringIO, hidden, pformat, spec
18
+ from nooa.context_blocks import EventBase
19
+ from nooa.context_blocks.roles import Role
20
+ from nooa.runtime.event_manager import EventManager
21
+ from nooa.skill import Skill
22
+ from nooa.tools._bash_session import BashSession
23
+ from nooa.tools._results import StreamDone, StreamEvent
24
+ from nooa.tools.shell_tools import FileWrite, Match, ShellResult, ShellTools
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _MAX_EVENT_TEXT_CHARS = 10_000
29
+ _MAX_COMMAND_OUTPUT_CHARS = 30_000
30
+
31
+
32
+ def _line_count(value: str) -> int:
33
+ if not value:
34
+ return 0
35
+ return value.count("\n") + (0 if value.endswith("\n") else 1)
36
+
37
+
38
+ def _edit_diff(path: str, old_text: str, new_text: str, start_line: int | None) -> str:
39
+ lines = list(
40
+ unified_diff(
41
+ old_text.splitlines(keepends=True),
42
+ new_text.splitlines(keepends=True),
43
+ fromfile=f"a/{path}",
44
+ tofile=f"b/{path}",
45
+ )
46
+ )
47
+ if start_line is not None:
48
+ for index, line in enumerate(lines):
49
+ if line.startswith("@@ "):
50
+ old_count = max(1, _line_count(old_text))
51
+ new_count = max(1, _line_count(new_text))
52
+ lines[index] = f"@@ -{start_line},{old_count} +{start_line},{new_count} @@\n"
53
+ break
54
+ return pformat("".join(lines), max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
55
+
56
+
57
+ class FileEdit(EventBase): # type: ignore[misc]
58
+ """A successful structured filesystem edit made by the coding agent."""
59
+
60
+ _role: ClassVar[Role] = Role.RUNTIME_EVENT
61
+
62
+ path: Annotated[str, Field(description="Absolute path of the edited file")]
63
+ operation: Annotated[
64
+ Literal["create", "update"],
65
+ Field(description="Whether the file was created or updated"),
66
+ ]
67
+ old_text: Annotated[
68
+ str | None,
69
+ Field(description="Bounded affected text before the edit, when available"),
70
+ ] = None
71
+ new_text: Annotated[str, Field(description="Bounded affected text after the edit")] = ""
72
+ start_line: Annotated[
73
+ int | None,
74
+ Field(description="First affected line in the original file, 1-indexed"),
75
+ ] = None
76
+ end_line: Annotated[
77
+ int | None,
78
+ Field(description="Last affected line in the original file, 1-indexed and inclusive"),
79
+ ] = None
80
+ diff: Annotated[str, Field(description="Bounded unified diff of the affected text")] = ""
81
+ content_complete: Annotated[
82
+ bool,
83
+ Field(description="Whether old_text and new_text contain the complete affected text"),
84
+ ] = True
85
+
86
+
87
+ class TerminalCommandStarted(EventBase): # type: ignore[misc]
88
+ """A command began in a persistent coding-agent terminal."""
89
+
90
+ _role: ClassVar[Role] = Role.RUNTIME_EVENT
91
+
92
+ command_id: Annotated[str, Field(description="Correlation ID for this command")]
93
+ command: Annotated[str, Field(description="Bounded shell command text")]
94
+ working_directory: Annotated[str, Field(description="Working directory at command start")]
95
+ stdin: Annotated[
96
+ str | None,
97
+ Field(description="Bounded separate stdin text; never stored in session history"),
98
+ ] = None
99
+ command_truncated: Annotated[
100
+ bool,
101
+ Field(description="Whether command was truncated by pformat for this event"),
102
+ ] = False
103
+ stdin_truncated: Annotated[
104
+ bool,
105
+ Field(description="Whether stdin was truncated by pformat for this event"),
106
+ ] = False
107
+
108
+
109
+ class TerminalCommandOutput(EventBase): # type: ignore[misc]
110
+ """One streaming output chunk from a coding-agent terminal command."""
111
+
112
+ _role: ClassVar[Role] = Role.RUNTIME_EVENT
113
+
114
+ command_id: Annotated[str, Field(description="Correlation ID for this command")]
115
+ stdout: Annotated[str, Field(description="Output chunk received on stdout")] = ""
116
+ stderr: Annotated[str, Field(description="Output chunk received on stderr")] = ""
117
+ truncated: Annotated[
118
+ bool,
119
+ Field(description="Whether further command output was omitted from activity events"),
120
+ ] = False
121
+
122
+
123
+ class TerminalCommandFinished(EventBase): # type: ignore[misc]
124
+ """A coding-agent terminal command completed or failed to launch."""
125
+
126
+ _role: ClassVar[Role] = Role.RUNTIME_EVENT
127
+
128
+ command_id: Annotated[str, Field(description="Correlation ID for this command")]
129
+ exit_code: Annotated[int | None, Field(description="Process exit code when available")] = None
130
+ timed_out: Annotated[bool, Field(description="Whether the command timed out")] = False
131
+ error: Annotated[str, Field(description="Failure before an exit code was available")] = ""
132
+ output_truncated: Annotated[
133
+ bool,
134
+ Field(description="Whether output was omitted from command activity events"),
135
+ ] = False
136
+
137
+
138
+ class ActivityShellTools(Skill):
139
+ """A composed ``ShellTools`` substitute that emits transient activity events.
140
+
141
+ Interactive agents opt into this class explicitly. The underlying
142
+ ``ShellTools`` remains independent of event management and host UX.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ shell: ShellTools,
148
+ event_manager: EventManager,
149
+ ):
150
+ super().__init__()
151
+ self._shell = shell
152
+ self._event_manager = event_manager
153
+
154
+ def __repr__(self) -> str:
155
+ return f"ActivityShellTools(cwd={self.cwd!s})"
156
+
157
+ @property
158
+ @hidden
159
+ def cwd(self) -> Path:
160
+ """Current working directory of the wrapped persistent shell."""
161
+ return self._shell.cwd
162
+
163
+ @cwd.setter
164
+ def cwd(self, value: str | Path) -> None:
165
+ self._shell.cwd = Path(value)
166
+
167
+ @property
168
+ @hidden
169
+ def session(self) -> BashSession:
170
+ """The wrapped shell's persistent bash session."""
171
+ return self._shell.session
172
+
173
+ @hidden
174
+ async def close(self) -> None:
175
+ """Close the wrapped shell."""
176
+ await self._shell.close()
177
+
178
+ def _resolve_path(self, path: str) -> Path:
179
+ return self._shell._resolve_path(path)
180
+
181
+ def _emit(self, event: EventBase) -> None:
182
+ try:
183
+ self._event_manager.add(event)
184
+ except Exception:
185
+ logger.debug("Failed to emit shell activity", exc_info=True)
186
+
187
+ @wraps(ShellTools.run)
188
+ async def run(
189
+ self,
190
+ command: Annotated[str, spec(description="Shell command to execute")],
191
+ *,
192
+ stdin: Annotated[
193
+ str | None, spec(description="Text piped to stdin (replaces heredocs)")
194
+ ] = None,
195
+ timeout: Annotated[float, spec(description="Max seconds")] = 30.0,
196
+ ) -> ShellResult:
197
+ command_id = str(uuid4())
198
+ bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
199
+ command_truncated = len(command) > _MAX_EVENT_TEXT_CHARS
200
+ bounded_stdin: str | None = None
201
+ stdin_truncated = False
202
+ if stdin is not None:
203
+ bounded_stdin = pformat(stdin, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
204
+ stdin_truncated = len(stdin) > _MAX_EVENT_TEXT_CHARS
205
+ self._emit(
206
+ TerminalCommandStarted(
207
+ command_id=command_id,
208
+ command=bounded_command,
209
+ working_directory=str(self.cwd),
210
+ stdin=bounded_stdin,
211
+ command_truncated=command_truncated,
212
+ stdin_truncated=stdin_truncated,
213
+ )
214
+ )
215
+ try:
216
+ result = await self._shell.run(command, stdin=stdin, timeout=timeout)
217
+ except BaseException as error:
218
+ self._emit(
219
+ TerminalCommandFinished(
220
+ command_id=command_id,
221
+ error=str(error) or type(error).__name__,
222
+ )
223
+ )
224
+ raise
225
+
226
+ stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
227
+ stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
228
+ stdout_buffer.write(result.stdout)
229
+ stderr_buffer.write(result.stderr)
230
+ output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
231
+ if result.stdout or result.stderr:
232
+ self._emit(
233
+ TerminalCommandOutput(
234
+ command_id=command_id,
235
+ stdout=stdout_buffer.getvalue(),
236
+ stderr=stderr_buffer.getvalue(),
237
+ truncated=output_truncated,
238
+ )
239
+ )
240
+ self._emit(
241
+ TerminalCommandFinished(
242
+ command_id=command_id,
243
+ exit_code=result.returncode,
244
+ timed_out=result.timed_out,
245
+ output_truncated=output_truncated,
246
+ )
247
+ )
248
+ return result
249
+
250
+ @wraps(ShellTools.run_stream)
251
+ async def run_stream(
252
+ self,
253
+ command: Annotated[str, spec(description="Shell command to execute")],
254
+ timeout: Annotated[float, spec(description="Max seconds to wait before timeout")] = 30.0,
255
+ ) -> AsyncIterator[StreamEvent | StreamDone]:
256
+ command_id = str(uuid4())
257
+ bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
258
+ command_truncated = len(command) > _MAX_EVENT_TEXT_CHARS
259
+ self._emit(
260
+ TerminalCommandStarted(
261
+ command_id=command_id,
262
+ command=bounded_command,
263
+ working_directory=str(self.cwd),
264
+ command_truncated=command_truncated,
265
+ )
266
+ )
267
+ finished = False
268
+ stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
269
+ stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
270
+ stream = self._shell.run_stream(command, timeout=timeout)
271
+ try:
272
+ async for item in stream:
273
+ if isinstance(item, StreamDone):
274
+ output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
275
+ if stdout_buffer.chars_written or stderr_buffer.chars_written:
276
+ self._emit(
277
+ TerminalCommandOutput(
278
+ command_id=command_id,
279
+ stdout=stdout_buffer.getvalue(),
280
+ stderr=stderr_buffer.getvalue(),
281
+ truncated=output_truncated,
282
+ )
283
+ )
284
+ self._emit(
285
+ TerminalCommandFinished(
286
+ command_id=command_id,
287
+ exit_code=item.returncode,
288
+ timed_out=item.timed_out,
289
+ output_truncated=output_truncated,
290
+ )
291
+ )
292
+ finished = True
293
+ else:
294
+ buffer = stdout_buffer if item.kind == "stdout" else stderr_buffer
295
+ buffer.write(item.text)
296
+ yield item
297
+ except BaseException as error:
298
+ if not finished:
299
+ output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
300
+ if stdout_buffer.chars_written or stderr_buffer.chars_written:
301
+ self._emit(
302
+ TerminalCommandOutput(
303
+ command_id=command_id,
304
+ stdout=stdout_buffer.getvalue(),
305
+ stderr=stderr_buffer.getvalue(),
306
+ truncated=output_truncated,
307
+ )
308
+ )
309
+ self._emit(
310
+ TerminalCommandFinished(
311
+ command_id=command_id,
312
+ error=str(error) or type(error).__name__,
313
+ output_truncated=output_truncated,
314
+ )
315
+ )
316
+ raise
317
+ finally:
318
+ await stream.aclose()
319
+
320
+ @wraps(ShellTools.read)
321
+ async def read(
322
+ self,
323
+ path: Annotated[str, spec(description="File path (relative to cwd)")],
324
+ lines: Annotated[
325
+ tuple[int, int] | None,
326
+ spec(description="(start, end) 1-indexed inclusive, or None for whole file"),
327
+ ] = None,
328
+ ) -> Match:
329
+ return await self._shell.read(path, lines)
330
+
331
+ @wraps(ShellTools.replace)
332
+ async def replace(
333
+ self,
334
+ target: Annotated[
335
+ Any, spec(description="A Match (from read() or run().matches) or a file path string")
336
+ ],
337
+ old_or_new: Annotated[
338
+ str,
339
+ spec(
340
+ description="For Match: replacement text. For path: text to find (must be unique)"
341
+ ),
342
+ ] = "",
343
+ new: Annotated[
344
+ str | None, spec(description="For path: replacement text. Leave None for Match.")
345
+ ] = None,
346
+ ) -> FileWrite:
347
+ path = target.path if isinstance(target, Match) else target
348
+ if not isinstance(path, str):
349
+ return await self._shell.replace(target, old_or_new, new)
350
+
351
+ resolved = self._resolve_path(path)
352
+ if isinstance(target, Match):
353
+ old_text = target.text
354
+ start_line = target.start
355
+ end_line = target.end
356
+ else:
357
+ old_text = old_or_new
358
+ start_line = None
359
+ end_line = None
360
+ try:
361
+ with resolved.open("r") as stream:
362
+ content = stream.read(_MAX_EVENT_TEXT_CHARS + 1)
363
+ if len(content) <= _MAX_EVENT_TEXT_CHARS:
364
+ offset = content.index(old_text)
365
+ start_line = content.count("\n", 0, offset) + 1
366
+ end_line = start_line + max(1, _line_count(old_text)) - 1
367
+ except (OSError, UnicodeError, ValueError):
368
+ pass
369
+
370
+ bounded_old = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
371
+ old_truncated = len(old_text) > _MAX_EVENT_TEXT_CHARS
372
+ result = await self._shell.replace(target, old_or_new, new)
373
+ # replace() may re-terminate the region, so report what it wrote rather
374
+ # than what it was handed.
375
+ written_text = result.new_text
376
+ bounded_new = pformat(written_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
377
+ new_truncated = len(written_text) > _MAX_EVENT_TEXT_CHARS
378
+ self._emit(
379
+ FileEdit(
380
+ path=str(resolved),
381
+ operation="update",
382
+ old_text=bounded_old,
383
+ new_text=bounded_new,
384
+ start_line=start_line,
385
+ end_line=end_line,
386
+ diff=_edit_diff(path, bounded_old, bounded_new, start_line),
387
+ content_complete=not old_truncated and not new_truncated,
388
+ )
389
+ )
390
+ return result
391
+
392
+ @wraps(ShellTools.write_file)
393
+ async def write_file(
394
+ self,
395
+ path: Annotated[str, spec(description="File path (relative to cwd)")],
396
+ content: Annotated[str, spec(description="Full file content")],
397
+ ) -> FileWrite:
398
+ resolved = self._resolve_path(path)
399
+ existed = resolved.exists()
400
+ old_text: str | None = None
401
+ old_complete = True
402
+ if existed:
403
+ try:
404
+ with resolved.open("r") as stream:
405
+ old_text = stream.read(_MAX_EVENT_TEXT_CHARS + 1)
406
+ old_complete = len(old_text) <= _MAX_EVENT_TEXT_CHARS
407
+ old_text = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
408
+ except (OSError, UnicodeError):
409
+ # Observation must not make an otherwise valid overwrite fail.
410
+ old_complete = False
411
+
412
+ result = await self._shell.write_file(path, content)
413
+ bounded_content = pformat(content, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
414
+ content_truncated = len(content) > _MAX_EVENT_TEXT_CHARS
415
+ self._emit(
416
+ FileEdit(
417
+ path=str(resolved),
418
+ operation="update" if existed else "create",
419
+ old_text=old_text,
420
+ new_text=bounded_content,
421
+ start_line=1 if existed and old_complete and old_text is not None else None,
422
+ end_line=(
423
+ max(1, _line_count(old_text))
424
+ if existed and old_complete and old_text is not None
425
+ else None
426
+ ),
427
+ diff=_edit_diff(path, old_text or "", bounded_content, 1),
428
+ content_complete=old_complete and not content_truncated,
429
+ )
430
+ )
431
+ return result