nooa-cli 0.0.9__tar.gz → 0.0.11__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 (112) hide show
  1. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/PKG-INFO +18 -5
  2. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/README.md +15 -3
  3. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/pyproject.toml +1 -0
  4. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/AGENTS.md +7 -2
  5. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/__init__.py +53 -0
  6. nooa_cli-0.0.11/src/nooa_cli/coding/__init__.py +43 -0
  7. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/coding/activity.py +166 -38
  8. nooa_cli-0.0.11/src/nooa_cli/coding/agent.py +228 -0
  9. nooa_cli-0.0.11/src/nooa_cli/coding/instructions.py +80 -0
  10. nooa_cli-0.0.11/src/nooa_cli/coding/settings.py +140 -0
  11. nooa_cli-0.0.11/src/nooa_cli/coding/slash_commands.py +124 -0
  12. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_advertised_media.py +142 -0
  13. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_input_policy.py +124 -0
  14. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_prompts.py +713 -0
  15. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_registry.py +257 -0
  16. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_stages.py +844 -0
  17. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_view.py +897 -0
  18. nooa_cli-0.0.11/src/nooa_cli/commands/_connect_wizard.py +3912 -0
  19. nooa_cli-0.0.11/src/nooa_cli/commands/_otlp_helpers.py +387 -0
  20. nooa_cli-0.0.11/src/nooa_cli/commands/connect.py +481 -0
  21. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/delete_traces.py +11 -6
  22. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/import_harbor.py +11 -2
  23. nooa_cli-0.0.11/src/nooa_cli/commands/import_traces.py +406 -0
  24. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/_tree_sitter_backend.py +21 -10
  25. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/repo_tools.py +13 -2
  26. nooa_cli-0.0.11/tests/conftest.py +29 -0
  27. nooa_cli-0.0.11/tests/test_cli_platform_check.py +37 -0
  28. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_cli_smoke.py +18 -0
  29. nooa_cli-0.0.11/tests/test_cli_utf8_output.py +57 -0
  30. nooa_cli-0.0.11/tests/test_coding_activity.py +442 -0
  31. nooa_cli-0.0.11/tests/test_coding_agent.py +278 -0
  32. nooa_cli-0.0.11/tests/test_coding_settings.py +229 -0
  33. nooa_cli-0.0.11/tests/test_coding_slash_commands.py +213 -0
  34. nooa_cli-0.0.11/tests/test_connect_advertised_media.py +602 -0
  35. nooa_cli-0.0.11/tests/test_connect_baseline_safety.py +240 -0
  36. nooa_cli-0.0.11/tests/test_connect_cache_control.py +272 -0
  37. nooa_cli-0.0.11/tests/test_connect_change_settings.py +321 -0
  38. nooa_cli-0.0.11/tests/test_connect_checkbox_pty.py +193 -0
  39. nooa_cli-0.0.11/tests/test_connect_coderabbit_budget.py +60 -0
  40. nooa_cli-0.0.11/tests/test_connect_command.py +1767 -0
  41. nooa_cli-0.0.11/tests/test_connect_current_verification.py +145 -0
  42. nooa_cli-0.0.11/tests/test_connect_decision_catalogue.py +230 -0
  43. nooa_cli-0.0.11/tests/test_connect_decision_listing.py +471 -0
  44. nooa_cli-0.0.11/tests/test_connect_decision_smoke.py +213 -0
  45. nooa_cli-0.0.11/tests/test_connect_diagnostic.py +118 -0
  46. nooa_cli-0.0.11/tests/test_connect_docs.py +59 -0
  47. nooa_cli-0.0.11/tests/test_connect_docs_contract.py +695 -0
  48. nooa_cli-0.0.11/tests/test_connect_edit.py +307 -0
  49. nooa_cli-0.0.11/tests/test_connect_encrypted_reasoning.py +168 -0
  50. nooa_cli-0.0.11/tests/test_connect_endpoint_scope.py +45 -0
  51. nooa_cli-0.0.11/tests/test_connect_entrypoint.py +86 -0
  52. nooa_cli-0.0.11/tests/test_connect_exact_context.py +215 -0
  53. nooa_cli-0.0.11/tests/test_connect_initial_order.py +469 -0
  54. nooa_cli-0.0.11/tests/test_connect_initial_order_pty.py +161 -0
  55. nooa_cli-0.0.11/tests/test_connect_interface_selection.py +778 -0
  56. nooa_cli-0.0.11/tests/test_connect_listing_provenance.py +340 -0
  57. nooa_cli-0.0.11/tests/test_connect_live_followups.py +134 -0
  58. nooa_cli-0.0.11/tests/test_connect_manual_limits.py +623 -0
  59. nooa_cli-0.0.11/tests/test_connect_media_inputs.py +119 -0
  60. nooa_cli-0.0.11/tests/test_connect_media_onboarding.py +965 -0
  61. nooa_cli-0.0.11/tests/test_connect_media_review.py +571 -0
  62. nooa_cli-0.0.11/tests/test_connect_metadata_first.py +663 -0
  63. nooa_cli-0.0.11/tests/test_connect_model_first.py +450 -0
  64. nooa_cli-0.0.11/tests/test_connect_native_media_policy.py +348 -0
  65. nooa_cli-0.0.11/tests/test_connect_native_profile.py +270 -0
  66. nooa_cli-0.0.11/tests/test_connect_practical_ui.py +525 -0
  67. nooa_cli-0.0.11/tests/test_connect_prompts.py +197 -0
  68. nooa_cli-0.0.11/tests/test_connect_proposal_layout.py +120 -0
  69. nooa_cli-0.0.11/tests/test_connect_real_pty.py +176 -0
  70. nooa_cli-0.0.11/tests/test_connect_reasoning_choice.py +397 -0
  71. nooa_cli-0.0.11/tests/test_connect_reply_budget.py +309 -0
  72. nooa_cli-0.0.11/tests/test_connect_review_followups.py +233 -0
  73. nooa_cli-0.0.11/tests/test_connect_review_readiness.py +115 -0
  74. nooa_cli-0.0.11/tests/test_connect_save_policy.py +769 -0
  75. nooa_cli-0.0.11/tests/test_connect_serial_ux.py +662 -0
  76. nooa_cli-0.0.11/tests/test_connect_server_details.py +768 -0
  77. nooa_cli-0.0.11/tests/test_connect_severin_review.py +83 -0
  78. nooa_cli-0.0.11/tests/test_connect_skill.py +227 -0
  79. nooa_cli-0.0.11/tests/test_connect_stages.py +521 -0
  80. nooa_cli-0.0.11/tests/test_connect_transcripts.py +252 -0
  81. nooa_cli-0.0.11/tests/test_connect_ux.py +443 -0
  82. nooa_cli-0.0.11/tests/test_connect_view.py +1159 -0
  83. nooa_cli-0.0.11/tests/test_connect_windows.py +79 -0
  84. nooa_cli-0.0.11/tests/test_import_traces.py +576 -0
  85. nooa_cli-0.0.11/tests/test_tree_sitter_backend.py +85 -0
  86. nooa_cli-0.0.9/src/nooa_cli/coding/__init__.py +0 -19
  87. nooa_cli-0.0.9/src/nooa_cli/commands/_otlp_helpers.py +0 -177
  88. nooa_cli-0.0.9/src/nooa_cli/commands/import_traces.py +0 -197
  89. nooa_cli-0.0.9/tests/test_coding_activity.py +0 -191
  90. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/.gitignore +0 -0
  91. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/docs/activity-introspection-design.md +0 -0
  92. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/__main__.py +0 -0
  93. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/_common.py +0 -0
  94. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/__init__.py +0 -0
  95. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/_template.py +0 -0
  96. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/config.py +0 -0
  97. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/eval.py +0 -0
  98. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/start_dev.py +0 -0
  99. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/traces.py +0 -0
  100. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/completion.py +0 -0
  101. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/__init__.py +0 -0
  102. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/events.py +0 -0
  103. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/store.py +0 -0
  104. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/__init__.py +0 -0
  105. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/__init__.py +0 -0
  106. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/errors.py +0 -0
  107. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/sources.py +0 -0
  108. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/stream.py +0 -0
  109. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
  110. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
  111. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_command_plugins.py +0 -0
  112. {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_sessions.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nooa-cli
3
- Version: 0.0.9
3
+ Version: 0.0.11
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
@@ -9,6 +9,7 @@ License: Apache-2.0
9
9
  Requires-Python: <3.14,>=3.12
10
10
  Requires-Dist: click>=8.1.0
11
11
  Requires-Dist: nooa
12
+ Requires-Dist: prompt-toolkit>=3.0.52
12
13
  Requires-Dist: pyyaml>=6.0
13
14
  Provides-Extra: ast
14
15
  Requires-Dist: tree-sitter-go>=0.23.0; extra == 'ast'
@@ -40,13 +41,25 @@ uv add "nooa-cli[datascience]"
40
41
 
41
42
  `nooa-cli` automatically pulls in matching `nemo-oo-agents` (the core framework). The `[datascience]` extra adds libraries the LLM can use in REPL-generated code.
42
43
 
44
+ > **Windows:** use [WSL 2](https://learn.microsoft.com/windows/wsl/install). Native Windows is not supported; see the [main README](https://github.com/NVIDIA-NeMo/labs-OO-Agents#installation).
45
+
43
46
  ## Usage
44
47
 
45
48
  ```bash
46
49
  nooa --help
47
- nooa start-dev # launch the trace viewer
48
- nooa eval ... # eval pipeline runner
49
- nooa traces ... # inspect/manage trace files
50
+ nooa start-dev # launch the trace viewer
51
+ nooa eval ... # eval pipeline runner
52
+ nooa traces ... # inspect/manage trace files
53
+ ```
54
+
55
+ Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
56
+ run the NOOA coding agent from an ACP-compatible client:
57
+
58
+ ```bash
59
+ uv add nooa-acp
60
+ export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
61
+ export NVIDIA_API_KEY=nvapi-...
62
+ uv run nooa-acp
50
63
  ```
51
64
 
52
65
  See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
@@ -13,13 +13,25 @@ uv add "nooa-cli[datascience]"
13
13
 
14
14
  `nooa-cli` automatically pulls in matching `nemo-oo-agents` (the core framework). The `[datascience]` extra adds libraries the LLM can use in REPL-generated code.
15
15
 
16
+ > **Windows:** use [WSL 2](https://learn.microsoft.com/windows/wsl/install). Native Windows is not supported; see the [main README](https://github.com/NVIDIA-NeMo/labs-OO-Agents#installation).
17
+
16
18
  ## Usage
17
19
 
18
20
  ```bash
19
21
  nooa --help
20
- nooa start-dev # launch the trace viewer
21
- nooa eval ... # eval pipeline runner
22
- nooa traces ... # inspect/manage trace files
22
+ nooa start-dev # launch the trace viewer
23
+ nooa eval ... # eval pipeline runner
24
+ nooa traces ... # inspect/manage trace files
25
+ ```
26
+
27
+ Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
28
+ run the NOOA coding agent from an ACP-compatible client:
29
+
30
+ ```bash
31
+ uv add nooa-acp
32
+ export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
33
+ export NVIDIA_API_KEY=nvapi-...
34
+ uv run nooa-acp
23
35
  ```
24
36
 
25
37
  See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "click>=8.1.0",
15
15
  # CLI config files
16
16
  "pyyaml>=6.0",
17
+ "prompt-toolkit>=3.0.52",
17
18
  ]
18
19
 
19
20
  [project.urls]
@@ -127,6 +127,11 @@ same contract as an in-repo command module.
127
127
  - The **Performance Rule** below applies with extra force: every registered
128
128
  entry point is loaded on *every* `nooa` invocation, including `nooa --help`.
129
129
  Keep heavy imports inside the handler.
130
+ - **Importing `nooa_cli` must not import `nooa`** (`test_connect_entrypoint.py`
131
+ checks this). `main()` checks the native-Windows policy itself
132
+ (`_refuse_unsupported_platform`, kept equal to `nooa._platform` by
133
+ `packages/nooa-cli/tests/test_cli_platform_check.py`) and switches redirected output to UTF-8
134
+ on Windows. New console entry points should go through `main()`.
130
135
 
131
136
  This mirrors the existing `nooa.skills` and `nooa.bundled_configs` groups.
132
137
 
@@ -186,6 +191,6 @@ New commands get tab-completion automatically. To enable it:
186
191
  nooa completion install # auto-detects your shell
187
192
 
188
193
  # Or manually:
189
- eval "$(_NEMO_COMPLETE=zsh_source nemo)" # zsh
190
- eval "$(_NEMO_COMPLETE=bash_source nemo)" # bash
194
+ eval "$(_NOOA_COMPLETE=zsh_source nooa)" # zsh
195
+ eval "$(_NOOA_COMPLETE=bash_source nooa)" # bash
191
196
  ```
@@ -19,6 +19,9 @@ Shell completion:
19
19
  eval "$(_NOOA_COMPLETE=zsh_source nooa)" # zsh
20
20
  """
21
21
 
22
+ import os
23
+ import sys
24
+
22
25
  import click
23
26
 
24
27
  from .commands import discover_commands
@@ -72,6 +75,56 @@ for _name, _cmd in discover_commands():
72
75
  oo.add_command(completion)
73
76
 
74
77
 
78
+ # Same policy and wording as nooa._platform, checked here without importing
79
+ # nooa so the CLI stays lazy (packages/nooa-cli/tests/test_cli_platform_check.py keeps them equal).
80
+ _SKIP_PLATFORM_CHECK_ENV = "NOOA_SKIP_PLATFORM_CHECK"
81
+ _UNSUPPORTED_PLATFORM_MESSAGE = (
82
+ "nooa does not support native Windows; use WSL 2 "
83
+ "(https://learn.microsoft.com/windows/wsl/install). "
84
+ f"Setting the {_SKIP_PLATFORM_CHECK_ENV} environment variable to 1 skips this check: "
85
+ "the pure-Python core and `nooa connect` are tested on native Windows; "
86
+ "other features may work but are unsupported."
87
+ )
88
+
89
+
90
+ def _refuse_unsupported_platform() -> None:
91
+ """Exit with one clean line on native Windows unless the user opted in."""
92
+ if sys.platform != "win32":
93
+ return
94
+ if os.environ.get(_SKIP_PLATFORM_CHECK_ENV, "").strip().lower() in {"1", "true", "yes"}:
95
+ return
96
+ raise SystemExit(f"Error: {_UNSUPPORTED_PLATFORM_MESSAGE}")
97
+
98
+
99
+ def _is_utf8(stream) -> bool:
100
+ encoding = getattr(stream, "encoding", None) or ""
101
+ return encoding.lower().replace("-", "").replace("_", "") == "utf8"
102
+
103
+
104
+ def _use_utf8_output() -> None:
105
+ """Use UTF-8 for redirected output and piped input on native Windows.
106
+
107
+ Redirected stdout/stderr and piped stdin use the ANSI code page there (for
108
+ example cp1252), which cannot encode the CLI's check marks and arrows and
109
+ would crash the command midway. Consoles already use UTF-8, so this changes
110
+ nothing there.
111
+ """
112
+ if sys.platform != "win32":
113
+ return
114
+ for stream in (sys.stdout, sys.stderr):
115
+ if stream is not None and not _is_utf8(stream) and hasattr(stream, "reconfigure"):
116
+ stream.reconfigure(encoding="utf-8", errors="replace")
117
+ stdin = sys.stdin
118
+ try:
119
+ piped = stdin is not None and not stdin.isatty()
120
+ except (OSError, ValueError):
121
+ piped = False
122
+ if piped and not _is_utf8(stdin) and hasattr(stdin, "reconfigure"):
123
+ stdin.reconfigure(encoding="utf-8", errors="replace")
124
+
125
+
75
126
  def main():
76
127
  """Entry point for console_scripts: nooa <subcommand>."""
128
+ _refuse_unsupported_platform()
129
+ _use_utf8_output()
77
130
  oo()
@@ -0,0 +1,43 @@
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 importlib import import_module
6
+
7
+ _EXPORT_MODULES = {
8
+ "ActivityShellTools": "activity",
9
+ "FileEdit": "activity",
10
+ "TerminalCommandFinished": "activity",
11
+ "TerminalCommandOutput": "activity",
12
+ "TerminalCommandStarted": "activity",
13
+ "CodingAgent": "agent",
14
+ "discover_agent_instruction_files": "instructions",
15
+ "render_agent_instructions": "instructions",
16
+ "load_coding_skills_dirs": "settings",
17
+ "CodingSlashCommand": "slash_commands",
18
+ "CodingSlashCommandRegistry": "slash_commands",
19
+ }
20
+
21
+
22
+ def __getattr__(name: str):
23
+ """Load host components only when requested, not for leaf utility imports."""
24
+ if name not in _EXPORT_MODULES:
25
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
26
+ value = getattr(import_module(f"{__name__}.{_EXPORT_MODULES[name]}"), name)
27
+ globals()[name] = value
28
+ return value
29
+
30
+
31
+ __all__ = [
32
+ "ActivityShellTools",
33
+ "CodingAgent",
34
+ "CodingSlashCommand",
35
+ "CodingSlashCommandRegistry",
36
+ "FileEdit",
37
+ "TerminalCommandFinished",
38
+ "TerminalCommandOutput",
39
+ "TerminalCommandStarted",
40
+ "discover_agent_instruction_files",
41
+ "load_coding_skills_dirs",
42
+ "render_agent_instructions",
43
+ ]
@@ -4,7 +4,9 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import asyncio
7
8
  import logging
9
+ import re
8
10
  from collections.abc import AsyncIterator
9
11
  from difflib import unified_diff
10
12
  from functools import wraps
@@ -27,6 +29,8 @@ logger = logging.getLogger(__name__)
27
29
 
28
30
  _MAX_EVENT_TEXT_CHARS = 10_000
29
31
  _MAX_COMMAND_OUTPUT_CHARS = 30_000
32
+ _MAX_DIFF_INPUT_CHARS = 1_000_000
33
+ _MAX_DIFF_INPUT_LINES = 20_000
30
34
 
31
35
 
32
36
  def _line_count(value: str) -> int:
@@ -35,23 +39,74 @@ def _line_count(value: str) -> int:
35
39
  return value.count("\n") + (0 if value.endswith("\n") else 1)
36
40
 
37
41
 
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
- )
42
+ def _diff_input_is_too_large(value: str) -> bool:
43
+ return len(value) > _MAX_DIFF_INPUT_CHARS or _line_count(value) > _MAX_DIFF_INPUT_LINES
44
+
45
+
46
+ def _omitted_diff(path: str, reason: str) -> tuple[str, bool]:
47
+ return (
48
+ f"--- a/{path}\n+++ b/{path}\n@@ -0,0 +0,0 @@\n Diff omitted: {reason}.\n",
49
+ False,
46
50
  )
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)
51
+
52
+
53
+ _HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
54
+
55
+
56
+ def _offset_hunk_header(line: str, offset: int) -> str:
57
+ """Shift one hunk header into file coordinates, keeping difflib's counts."""
58
+ match = _HUNK_HEADER.match(line.rstrip("\n"))
59
+ if match is None:
60
+ return line
61
+ old_start, old_count, new_start, new_count, trailer = match.groups()
62
+ old_part = f"-{int(old_start) + offset}" + (f",{old_count}" if old_count else "")
63
+ new_part = f"+{int(new_start) + offset}" + (f",{new_count}" if new_count else "")
64
+ return f"@@ {old_part} {new_part} @@{trailer}\n"
65
+
66
+
67
+ def _edit_diff(
68
+ path: str,
69
+ old_text: str,
70
+ new_text: str,
71
+ start_line: int | None,
72
+ *,
73
+ whole_file: bool = False,
74
+ ) -> tuple[str, bool]:
75
+ """Return a bounded, line-oriented unified diff and its completeness.
76
+
77
+ ``whole_file`` marks both texts as complete file contents, where a missing
78
+ final newline is a real file state worth reporting. Fragment diffs stop
79
+ mid-file, so the EOF marker there was noise.
80
+ """
81
+ if _diff_input_is_too_large(old_text) or _diff_input_is_too_large(new_text):
82
+ return _omitted_diff(path, "file content exceeds the safe diff preview limit")
83
+
84
+ output = TruncatingStringIO(limit=_MAX_EVENT_TEXT_CHARS)
85
+ # A region diff is generated in region coordinates; every hunk shifts by the
86
+ # same amount to reach file coordinates. Rewriting only the first one, with
87
+ # the whole region's counts, left later hunks region-relative — able to point
88
+ # before the first hunk, and unappliable by patch.
89
+ offset = start_line - 1 if start_line is not None else 0
90
+ for line in unified_diff(
91
+ old_text.splitlines(keepends=True),
92
+ new_text.splitlines(keepends=True),
93
+ fromfile=f"a/{path}",
94
+ tofile=f"b/{path}",
95
+ ):
96
+ if offset and line.startswith("@@ "):
97
+ line = _offset_hunk_header(line, offset)
98
+ if not line.endswith("\n"):
99
+ # difflib emits the source line verbatim, so unterminated content
100
+ # would run the next marker onto the same line ("-a+b").
101
+ if whole_file:
102
+ line = f"{line}\n\\n"
103
+ else:
104
+ # A fragment routinely stops before the file's final
105
+ # newline; marking that as "no newline at end of file"
106
+ # reported a file state that does not exist.
107
+ line = f"{line}\n"
108
+ output.write(line)
109
+ return output.getvalue(), not output.was_truncated
55
110
 
56
111
 
57
112
  class FileEdit(EventBase): # type: ignore[misc]
@@ -82,6 +137,10 @@ class FileEdit(EventBase): # type: ignore[misc]
82
137
  bool,
83
138
  Field(description="Whether old_text and new_text contain the complete affected text"),
84
139
  ] = True
140
+ diff_complete: Annotated[
141
+ bool,
142
+ Field(description="Whether diff contains the complete unified diff"),
143
+ ] = True
85
144
 
86
145
 
87
146
  class TerminalCommandStarted(EventBase): # type: ignore[misc]
@@ -107,7 +166,11 @@ class TerminalCommandStarted(EventBase): # type: ignore[misc]
107
166
 
108
167
 
109
168
  class TerminalCommandOutput(EventBase): # type: ignore[misc]
110
- """One streaming output chunk from a coding-agent terminal command."""
169
+ """The output of one coding-agent terminal command.
170
+
171
+ Emitted once, when the command finishes: output is buffered and bounded
172
+ rather than streamed, so hosts receive a single event per command.
173
+ """
111
174
 
112
175
  _role: ClassVar[Role] = Role.RUNTIME_EVENT
113
176
 
@@ -129,6 +192,10 @@ class TerminalCommandFinished(EventBase): # type: ignore[misc]
129
192
  exit_code: Annotated[int | None, Field(description="Process exit code when available")] = None
130
193
  timed_out: Annotated[bool, Field(description="Whether the command timed out")] = False
131
194
  error: Annotated[str, Field(description="Failure before an exit code was available")] = ""
195
+ cancelled: Annotated[
196
+ bool,
197
+ Field(description="Whether the command was stopped by cancellation rather than failing"),
198
+ ] = False
132
199
  output_truncated: Annotated[
133
200
  bool,
134
201
  Field(description="Whether output was omitted from command activity events"),
@@ -178,6 +245,12 @@ class ActivityShellTools(Skill):
178
245
  def _resolve_path(self, path: str) -> Path:
179
246
  return self._shell._resolve_path(path)
180
247
 
248
+ def _diff_path(self, resolved: Path) -> str:
249
+ try:
250
+ return resolved.relative_to(self.cwd).as_posix()
251
+ except ValueError:
252
+ return resolved.as_posix().lstrip("/")
253
+
181
254
  def _emit(self, event: EventBase) -> None:
182
255
  try:
183
256
  self._event_manager.add(event)
@@ -192,7 +265,9 @@ class ActivityShellTools(Skill):
192
265
  stdin: Annotated[
193
266
  str | None, spec(description="Text piped to stdin (replaces heredocs)")
194
267
  ] = None,
195
- timeout: Annotated[float, spec(description="Max seconds")] = 30.0,
268
+ timeout: Annotated[
269
+ float | None, spec(description="Max seconds; None uses the shell's default_timeout")
270
+ ] = None,
196
271
  ) -> ShellResult:
197
272
  command_id = str(uuid4())
198
273
  bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
@@ -215,10 +290,12 @@ class ActivityShellTools(Skill):
215
290
  try:
216
291
  result = await self._shell.run(command, stdin=stdin, timeout=timeout)
217
292
  except BaseException as error:
293
+ cancelled = isinstance(error, asyncio.CancelledError)
218
294
  self._emit(
219
295
  TerminalCommandFinished(
220
296
  command_id=command_id,
221
- error=str(error) or type(error).__name__,
297
+ error="" if cancelled else (str(error) or type(error).__name__),
298
+ cancelled=cancelled,
222
299
  )
223
300
  )
224
301
  raise
@@ -251,7 +328,13 @@ class ActivityShellTools(Skill):
251
328
  async def run_stream(
252
329
  self,
253
330
  command: Annotated[str, spec(description="Shell command to execute")],
254
- timeout: Annotated[float, spec(description="Max seconds to wait before timeout")] = 30.0,
331
+ *,
332
+ stdin: Annotated[
333
+ str | None, spec(description="Text piped to stdin (replaces heredocs)")
334
+ ] = None,
335
+ timeout: Annotated[
336
+ float | None, spec(description="Max seconds; None uses the shell's default_timeout")
337
+ ] = None,
255
338
  ) -> AsyncIterator[StreamEvent | StreamDone]:
256
339
  command_id = str(uuid4())
257
340
  bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
@@ -262,12 +345,18 @@ class ActivityShellTools(Skill):
262
345
  command=bounded_command,
263
346
  working_directory=str(self.cwd),
264
347
  command_truncated=command_truncated,
348
+ stdin=(
349
+ pformat(stdin, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
350
+ if stdin is not None
351
+ else None
352
+ ),
353
+ stdin_truncated=stdin is not None and len(stdin) > _MAX_EVENT_TEXT_CHARS,
265
354
  )
266
355
  )
267
356
  finished = False
268
357
  stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
269
358
  stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
270
- stream = self._shell.run_stream(command, timeout=timeout)
359
+ stream = self._shell.run_stream(command, stdin=stdin, timeout=timeout)
271
360
  try:
272
361
  async for item in stream:
273
362
  if isinstance(item, StreamDone):
@@ -306,10 +395,12 @@ class ActivityShellTools(Skill):
306
395
  truncated=output_truncated,
307
396
  )
308
397
  )
398
+ cancelled = isinstance(error, asyncio.CancelledError)
309
399
  self._emit(
310
400
  TerminalCommandFinished(
311
401
  command_id=command_id,
312
- error=str(error) or type(error).__name__,
402
+ error="" if cancelled else (str(error) or type(error).__name__),
403
+ cancelled=cancelled,
313
404
  output_truncated=output_truncated,
314
405
  )
315
406
  )
@@ -320,7 +411,7 @@ class ActivityShellTools(Skill):
320
411
  @wraps(ShellTools.read)
321
412
  async def read(
322
413
  self,
323
- path: Annotated[str, spec(description="File path (relative to cwd)")],
414
+ path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
324
415
  lines: Annotated[
325
416
  tuple[int, int] | None,
326
417
  spec(description="(start, end) 1-indexed inclusive, or None for whole file"),
@@ -348,7 +439,9 @@ class ActivityShellTools(Skill):
348
439
  if not isinstance(path, str):
349
440
  return await self._shell.replace(target, old_or_new, new)
350
441
 
351
- resolved = self._resolve_path(path)
442
+ resolved = (
443
+ Path(target.resolved_path) if isinstance(target, Match) else self._resolve_path(path)
444
+ )
352
445
  if isinstance(target, Match):
353
446
  old_text = target.text
354
447
  start_line = target.start
@@ -375,6 +468,12 @@ class ActivityShellTools(Skill):
375
468
  written_text = result.new_text
376
469
  bounded_new = pformat(written_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
377
470
  new_truncated = len(written_text) > _MAX_EVENT_TEXT_CHARS
471
+ diff, diff_complete = _edit_diff(
472
+ self._diff_path(resolved),
473
+ old_text,
474
+ written_text,
475
+ start_line,
476
+ )
378
477
  self._emit(
379
478
  FileEdit(
380
479
  path=str(resolved),
@@ -383,8 +482,9 @@ class ActivityShellTools(Skill):
383
482
  new_text=bounded_new,
384
483
  start_line=start_line,
385
484
  end_line=end_line,
386
- diff=_edit_diff(path, bounded_old, bounded_new, start_line),
485
+ diff=diff,
387
486
  content_complete=not old_truncated and not new_truncated,
487
+ diff_complete=diff_complete,
388
488
  )
389
489
  )
390
490
  return result
@@ -392,40 +492,68 @@ class ActivityShellTools(Skill):
392
492
  @wraps(ShellTools.write_file)
393
493
  async def write_file(
394
494
  self,
395
- path: Annotated[str, spec(description="File path (relative to cwd)")],
495
+ path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
396
496
  content: Annotated[str, spec(description="Full file content")],
397
497
  ) -> FileWrite:
398
498
  resolved = self._resolve_path(path)
399
499
  existed = resolved.exists()
400
- old_text: str | None = None
401
- old_complete = True
500
+ old_diff_text: str | None = None
501
+ old_content_complete = not existed
402
502
  if existed:
403
503
  try:
404
504
  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)
505
+ old_diff_text = stream.read(_MAX_DIFF_INPUT_CHARS + 1)
506
+ old_content_complete = len(old_diff_text) <= _MAX_EVENT_TEXT_CHARS
408
507
  except (OSError, UnicodeError):
409
508
  # Observation must not make an otherwise valid overwrite fail.
410
- old_complete = False
509
+ old_content_complete = False
411
510
 
412
511
  result = await self._shell.write_file(path, content)
512
+ bounded_old = (
513
+ pformat(
514
+ old_diff_text,
515
+ max_string=_MAX_EVENT_TEXT_CHARS,
516
+ unquote_strings=True,
517
+ )
518
+ if old_diff_text is not None
519
+ else None
520
+ )
413
521
  bounded_content = pformat(content, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
414
522
  content_truncated = len(content) > _MAX_EVENT_TEXT_CHARS
523
+ if existed and old_diff_text is None:
524
+ diff, diff_complete = _omitted_diff(
525
+ self._diff_path(resolved),
526
+ "previous file content could not be read",
527
+ )
528
+ else:
529
+ diff, diff_complete = _edit_diff(
530
+ self._diff_path(resolved),
531
+ old_diff_text or "",
532
+ content,
533
+ None,
534
+ whole_file=True,
535
+ )
415
536
  self._emit(
416
537
  FileEdit(
417
538
  path=str(resolved),
418
539
  operation="update" if existed else "create",
419
- old_text=old_text,
540
+ old_text=bounded_old,
420
541
  new_text=bounded_content,
421
- start_line=1 if existed and old_complete and old_text is not None else None,
542
+ # An existing *empty* file has no original line to point at, so
543
+ # reporting 1..1 names lines that never existed.
544
+ start_line=(
545
+ 1
546
+ if existed and old_content_complete and _line_count(old_diff_text or "")
547
+ else None
548
+ ),
422
549
  end_line=(
423
- max(1, _line_count(old_text))
424
- if existed and old_complete and old_text is not None
550
+ _line_count(old_diff_text or "")
551
+ if existed and old_content_complete and _line_count(old_diff_text or "")
425
552
  else None
426
553
  ),
427
- diff=_edit_diff(path, old_text or "", bounded_content, 1),
428
- content_complete=old_complete and not content_truncated,
554
+ diff=diff,
555
+ content_complete=old_content_complete and not content_truncated,
556
+ diff_complete=diff_complete,
429
557
  )
430
558
  )
431
559
  return result