noah-code 0.2.4__tar.gz → 0.3.0__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 (141) hide show
  1. {noah_code-0.2.4 → noah_code-0.3.0}/PKG-INFO +2 -1
  2. {noah_code-0.2.4 → noah_code-0.3.0}/docs/configuration.md +18 -1
  3. {noah_code-0.2.4 → noah_code-0.3.0}/docs/extensions.md +44 -5
  4. noah_code-0.3.0/docs/releases/v0.3.0.md +75 -0
  5. {noah_code-0.2.4 → noah_code-0.3.0}/pyproject.toml +2 -1
  6. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/__init__.py +1 -1
  7. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/agent.py +42 -1
  8. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/approvals.py +22 -10
  9. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/budget.py +44 -12
  10. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/cli.py +55 -11
  11. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/config.py +33 -0
  12. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/event_bridge.py +13 -0
  13. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/host.py +48 -17
  14. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/permissions.py +114 -4
  15. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/sessions.py +18 -3
  16. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/snapshots.py +23 -6
  17. noah_code-0.3.0/src/noah_code/telemetry.py +801 -0
  18. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tool_output.py +16 -9
  19. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/git_tools.py +25 -8
  20. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/github_tools.py +51 -5
  21. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/lsp_tools.py +77 -30
  22. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/process_tools.py +15 -5
  23. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/question_tools.py +19 -4
  24. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/web_tools.py +49 -11
  25. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/workspace_tools.py +10 -1
  26. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/updates.py +74 -19
  27. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/worktree.py +10 -3
  28. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_agent_security.py +6 -4
  29. noah_code-0.3.0/tests/test_approvals.py +113 -0
  30. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_budget.py +43 -0
  31. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_cache_context.py +50 -0
  32. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_cli.py +56 -3
  33. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_config.py +21 -0
  34. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_event_bridge.py +50 -4
  35. noah_code-0.3.0/tests/test_git_tools.py +270 -0
  36. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_github.py +52 -0
  37. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_host.py +60 -2
  38. noah_code-0.3.0/tests/test_lsp_tools.py +262 -0
  39. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_permissions.py +108 -0
  40. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_process_tools.py +168 -2
  41. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_question_tools.py +57 -0
  42. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_sessions.py +63 -0
  43. noah_code-0.3.0/tests/test_snapshots.py +377 -0
  44. noah_code-0.3.0/tests/test_telemetry.py +217 -0
  45. noah_code-0.3.0/tests/test_tool_output.py +192 -0
  46. noah_code-0.3.0/tests/test_updates.py +399 -0
  47. noah_code-0.3.0/tests/test_web_tools.py +565 -0
  48. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_workspace_tools.py +25 -0
  49. noah_code-0.3.0/tests/test_worktree.py +356 -0
  50. {noah_code-0.2.4 → noah_code-0.3.0}/uv.lock +72 -1
  51. noah_code-0.2.4/tests/test_approvals.py +0 -54
  52. noah_code-0.2.4/tests/test_git_tools.py +0 -107
  53. noah_code-0.2.4/tests/test_lsp_tools.py +0 -90
  54. noah_code-0.2.4/tests/test_snapshots.py +0 -99
  55. noah_code-0.2.4/tests/test_updates.py +0 -102
  56. noah_code-0.2.4/tests/test_web_tools.py +0 -166
  57. noah_code-0.2.4/tests/test_worktree.py +0 -92
  58. {noah_code-0.2.4 → noah_code-0.3.0}/.github/workflows/ci.yml +0 -0
  59. {noah_code-0.2.4 → noah_code-0.3.0}/.github/workflows/release.yml +0 -0
  60. {noah_code-0.2.4 → noah_code-0.3.0}/.gitignore +0 -0
  61. {noah_code-0.2.4 → noah_code-0.3.0}/.pre-commit-config.yaml +0 -0
  62. {noah_code-0.2.4 → noah_code-0.3.0}/README.md +0 -0
  63. {noah_code-0.2.4 → noah_code-0.3.0}/docs/assets/noah-in-action.svg +0 -0
  64. {noah_code-0.2.4 → noah_code-0.3.0}/docs/assets/noah-logo.svg +0 -0
  65. {noah_code-0.2.4 → noah_code-0.3.0}/docs/development.md +0 -0
  66. {noah_code-0.2.4 → noah_code-0.3.0}/docs/interactive-reference.md +0 -0
  67. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.1.0.md +0 -0
  68. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.1.1.md +0 -0
  69. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.2.0.md +0 -0
  70. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.2.1.md +0 -0
  71. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.2.2.md +0 -0
  72. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.2.3.md +0 -0
  73. {noah_code-0.2.4 → noah_code-0.3.0}/docs/releases/v0.2.4.md +0 -0
  74. {noah_code-0.2.4 → noah_code-0.3.0}/docs/reliability.md +0 -0
  75. {noah_code-0.2.4 → noah_code-0.3.0}/docs/security.md +0 -0
  76. {noah_code-0.2.4 → noah_code-0.3.0}/install.sh +0 -0
  77. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/__main__.py +0 -0
  78. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/agents.py +0 -0
  79. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/checkpoints.py +0 -0
  80. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/commands.py +0 -0
  81. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/composer.py +0 -0
  82. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/credentials.py +0 -0
  83. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/custom_commands.py +0 -0
  84. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/events.py +0 -0
  85. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/github.py +0 -0
  86. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/hooks.py +0 -0
  87. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/llm.py +0 -0
  88. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/llm_replies.py +0 -0
  89. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/macos_sandbox.py +0 -0
  90. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/mcp_setup.py +0 -0
  91. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/nooa_compat.py +0 -0
  92. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/project_notes.py +0 -0
  93. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/providers.py +0 -0
  94. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/redaction.py +0 -0
  95. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/runtime_state.py +0 -0
  96. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/secure_files.py +0 -0
  97. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/skills_setup.py +0 -0
  98. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/steer.py +0 -0
  99. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/summarization.py +0 -0
  100. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/themes.py +0 -0
  101. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/__init__.py +0 -0
  102. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/diff_tools.py +0 -0
  103. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/media_tools.py +0 -0
  104. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/memory_tools.py +0 -0
  105. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/plan_tools.py +0 -0
  106. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/tools/task_tools.py +0 -0
  107. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/ui/__init__.py +0 -0
  108. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/ui/console.py +0 -0
  109. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/ui/protocol.py +0 -0
  110. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/ui/textual.css +0 -0
  111. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/ui/textual_app.py +0 -0
  112. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/usage.py +0 -0
  113. {noah_code-0.2.4 → noah_code-0.3.0}/src/noah_code/workspace.py +0 -0
  114. {noah_code-0.2.4 → noah_code-0.3.0}/tests/conftest.py +0 -0
  115. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_agents.py +0 -0
  116. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_checkpoints.py +0 -0
  117. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_compaction_eviction.py +0 -0
  118. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_composer.py +0 -0
  119. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_console_ui.py +0 -0
  120. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_credentials.py +0 -0
  121. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_custom_commands.py +0 -0
  122. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_diff_tools.py +0 -0
  123. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_efficiency.py +0 -0
  124. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_event_bridge_and_shell.py +0 -0
  125. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_hooks.py +0 -0
  126. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_installer.py +0 -0
  127. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_llm.py +0 -0
  128. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_llm_replies.py +0 -0
  129. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_mcp_setup.py +0 -0
  130. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_plan_memory_tools.py +0 -0
  131. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_project_notes.py +0 -0
  132. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_providers.py +0 -0
  133. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_redaction.py +0 -0
  134. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_run_exit.py +0 -0
  135. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_runtime_state.py +0 -0
  136. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_skills_setup.py +0 -0
  137. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_steer.py +0 -0
  138. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_summarization.py +0 -0
  139. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_task_tools.py +0 -0
  140. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_textual_tui.py +0 -0
  141. {noah_code-0.2.4 → noah_code-0.3.0}/tests/test_wave1_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: noah-code
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Noah Code terminal coding agent, built on NVIDIA OO Agents (NOOA)
5
5
  Project-URL: Homepage, https://github.com/skundu42/noah-code
6
6
  Project-URL: Documentation, https://github.com/skundu42/noah-code#readme
@@ -40,6 +40,7 @@ Provides-Extra: mcp
40
40
  Requires-Dist: nooa[mcp]==0.0.9; extra == 'mcp'
41
41
  Provides-Extra: tracing
42
42
  Requires-Dist: nooa[tracing]==0.0.9; extra == 'tracing'
43
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0.0,>=1.44.0; extra == 'tracing'
43
44
  Description-Content-Type: text/markdown
44
45
 
45
46
  <div align="center">
@@ -179,8 +179,19 @@ target_chars = 2500
179
179
 
180
180
  [tracing]
181
181
  enabled = true
182
- # By default JSONL traces live inside each session directory.
182
+ # Local traces remain available without a collector.
183
+ jsonl_enabled = true
183
184
  # jsonl_dir = "~/.local/share/noah-code/traces"
185
+ # Send traces, metrics, and structured logs over OTLP/HTTP.
186
+ # otlp_endpoint = "http://localhost:4318"
187
+ logs_enabled = true
188
+ metrics_enabled = true
189
+ # Sensitive agent content is excluded unless explicitly opted in.
190
+ capture_content = false
191
+ max_span_attributes = 256
192
+ max_attribute_length = 4096
193
+ metric_export_interval_millis = 30000
194
+ export_timeout_millis = 5000
184
195
 
185
196
  [updates]
186
197
  auto_install = false
@@ -199,6 +210,12 @@ Supported environment overrides include:
199
210
  - `NOAH_CODE_UNSAFE_INPROCESS`
200
211
  - `NOAH_CODE_AUTO_UPDATE`
201
212
 
213
+ OpenTelemetry's standard environment variables are also honored. In particular,
214
+ `OTEL_EXPORTER_OTLP_ENDPOINT` enables remote traces, metrics, and logs without storing an endpoint
215
+ in Noah configuration. Signal-specific endpoint variables and `OTEL_EXPORTER_OTLP_HEADERS` are
216
+ supported by the OTLP exporters; keep authentication headers in the environment or a collector
217
+ secret store rather than TOML.
218
+
202
219
  Repository-controlled configuration cannot weaken the host trust boundary. Project config is
203
220
  ignored for `auto_approve`, `budget`, `efficiency`, `enabled_skills`, `hooks`, `lsp`, `mcp`,
204
221
  `permission_rules`, `processes`, `reliability`, `session_dir`, `tracing`, `updates`, and
@@ -149,8 +149,47 @@ local executables; define them only in `~/.config/noah-code/config.toml`.
149
149
 
150
150
  ## Tracing
151
151
 
152
- Noah Code integrates with NOOA tracing. Tracing is enabled by default, and JSONL output goes into
153
- the active session directory unless
154
- `tracing.jsonl_dir` selects another trusted location. Use `/trace` to inspect the destination and
155
- `/health` to inspect bounded runtime-event and artifact state. Trace files count toward the total
156
- session-storage quota when they use the default location.
152
+ Noah Code emits OpenTelemetry traces for the complete agent invocation and NOOA/OpenInference child
153
+ spans for model generations and tool execution. Local JSONL traces are enabled by default and go
154
+ into the active session directory unless `tracing.jsonl_dir` selects another trusted location.
155
+ Use `/trace` to inspect active destinations and `/health` to inspect bounded runtime-event and
156
+ artifact state. Trace files count toward the session-storage quota when they use the default path.
157
+
158
+ Set an OTLP/HTTP collector endpoint to additionally export batched traces, metrics, and structured
159
+ logs:
160
+
161
+ ```toml
162
+ [tracing]
163
+ otlp_endpoint = "http://localhost:4318"
164
+ logs_enabled = true
165
+ metrics_enabled = true
166
+ capture_content = false
167
+ ```
168
+
169
+ Install the `tracing` extra when Noah was installed without the standard installer. Configured
170
+ remote endpoints must use HTTPS; plain HTTP is accepted only for a loopback collector.
171
+
172
+ Or use standard OpenTelemetry environment configuration:
173
+
174
+ ```bash
175
+ export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
176
+ export OTEL_EXPORTER_OTLP_HEADERS='authorization=Bearer%20...'
177
+ ```
178
+
179
+ Operational telemetry includes agent/LLM/tool duration, token usage, cached and reasoning token
180
+ counts, estimated cost, retries, outcomes, model/provider names, and trace-correlated lifecycle
181
+ events. Metric labels deliberately exclude session IDs, run IDs, workspace paths, prompts, and tool
182
+ targets to avoid high-cardinality series.
183
+
184
+ Content capture is off by default. Prompt and response messages, reasoning, generated code, tool
185
+ arguments/results, exception messages, stack traces, and file contents are removed at the exporter
186
+ boundary. `capture_content = true` is intended only for controlled development environments; values
187
+ and span attribute counts remain bounded, and NOOA's secret scrubber still runs before export. For
188
+ production, keep content disabled and apply an allowlist/redaction processor in the collector as a
189
+ second boundary.
190
+
191
+ The emitted standard instruments include `gen_ai.invoke_agent.duration`,
192
+ `gen_ai.client.operation.duration`, and `gen_ai.client.token.usage`. Noah-specific instruments cover
193
+ estimated cost, retries, and tool execution counts/duration. Export is best-effort and batched;
194
+ collector failure never blocks or fails an agent turn, and providers are flushed during orderly
195
+ shutdown.
@@ -0,0 +1,75 @@
1
+ # Noah Code v0.3.0
2
+
3
+ This release adds production-grade OpenTelemetry observability, more control over long-running
4
+ agents, and a broad hardening pass across permissions, updates, sessions, worktrees, language
5
+ servers, and persisted tool state.
6
+
7
+ ## Observability
8
+
9
+ - Added privacy-first OpenTelemetry support for the complete agent lifecycle, with OTLP/HTTP
10
+ export of traces, metrics, and structured logs alongside the existing local JSONL traces.
11
+ - Added standard GenAI instruments for agent and model duration and token usage, plus Noah-specific
12
+ cost, retry, and tool-execution metrics with deliberately low-cardinality labels.
13
+ - Content capture remains off by default. Exporters strip prompts, responses, reasoning, tool
14
+ arguments and results, exception details, host identity, and repository metadata unless content
15
+ capture is explicitly enabled; exported values and attribute counts remain bounded.
16
+ - Added secure collector configuration through `[tracing]` and standard OpenTelemetry environment
17
+ variables. Remote collectors require HTTPS, while plain HTTP remains available for loopback
18
+ development collectors.
19
+
20
+ ## Agent controls and permissions
21
+
22
+ - Added `--max-iterations` and `NOAH_CODE_MAX_ITERATIONS` so larger or slower models can be given a
23
+ higher per-request tool-call budget than the default of 40.
24
+ - Added an explicit `--yolo` mode and matching configuration option for fully unrestricted local
25
+ operation. This bypasses every permission check, including secret, plan-mode, and destructive
26
+ command protections, and should only be used in an appropriately isolated environment.
27
+ - Added `run(..., read_only=True)` for trusted execution of whitelisted inspection commands inside
28
+ the sandbox.
29
+ - Read-only pipelines such as `git show | sed` and `rg | sort | wc` are now recognized when every
30
+ segment is independently read-only. Stream discards to `/dev/null` are also accepted without
31
+ weakening protections for file redirection or control-flow chains.
32
+ - Repaired disk-destruction deny rules so raw device writes are rejected correctly, and placed
33
+ ordinary `dd` use on the elevated-risk approval floor.
34
+ - Closed a concurrent approval race that could show duplicate prompts or miss a newly registered
35
+ session rule.
36
+
37
+ ## Reliability and correctness
38
+
39
+ - Hardened language-server lifecycle management against timeout races, dead readers, failed
40
+ startup, and zombie processes; stale servers are now terminated and reaped before restart.
41
+ - Host shutdown now completes all teardown steps even under cancellation, and session switches
42
+ validate their targets before releasing the active session.
43
+ - Damaged session metadata and partially corrupted undo/redo journals now degrade to clear recovery
44
+ errors or skip only the affected records instead of crashing startup.
45
+ - Managed tool output is published atomically by content hash, with clean recovery from disk-full
46
+ and concurrent-writer races.
47
+ - Durable background-process log rotation now preserves the latest output line even when the final
48
+ pipe read contains only a small trailing fragment.
49
+ - Auto-update state paths, network reads, and version parsing are hardened, and a stale-aware lock
50
+ prevents concurrent processes from running duplicate upgrades.
51
+ - Orphaned worktrees whose directories were removed outside Noah can now be pruned and cleaned up.
52
+ - Git and GitHub helpers now turn missing binaries, timeouts, authentication failures, and network
53
+ failures into actionable user-facing errors.
54
+ - Fixed question timeout and background-process stdin races, bounded HTTP 429 retries using
55
+ `Retry-After`, and excluded skipped HTML blocks such as `noscript` from fetched page text.
56
+ - Non-finite cost and token reports can no longer disable budget enforcement or crash accounting.
57
+
58
+ ## Agent guidance and tests
59
+
60
+ - Refined the model-facing tool guidance for synchronous messages, `return_result`, safe read-only
61
+ execution, forbidden imports, edit arguments, and iteration-limit recovery to avoid wasted turns.
62
+ - Expanded coverage across permissions, web transport, updates, snapshots, tool output, worktrees,
63
+ process jobs, Git, GitHub, sessions, LSP lifecycle, budgets, and host teardown.
64
+
65
+ ## Upgrade
66
+
67
+ Existing managed installations can run:
68
+
69
+ ```bash
70
+ noah update
71
+ ```
72
+
73
+ New installations can use the one-command installer from the README.
74
+
75
+ **Full changelog:** https://github.com/skundu42/noah-code/compare/v0.2.4...v0.3.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "noah-code"
3
- version = "0.2.4"
3
+ version = "0.3.0"
4
4
  description = "Noah Code terminal coding agent, built on NVIDIA OO Agents (NOOA)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.14"
@@ -57,6 +57,7 @@ mcp = [
57
57
  ]
58
58
  tracing = [
59
59
  "nooa[tracing]==0.0.9",
60
+ "opentelemetry-exporter-otlp-proto-http>=1.44.0,<2.0.0",
60
61
  ]
61
62
 
62
63
  [project.scripts]
@@ -1,3 +1,3 @@
1
1
  """Noah Code: terminal coding agent on NVIDIA OO Agents."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.3.0"
@@ -124,6 +124,7 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
124
124
  ("ws", "read_output"),
125
125
  ("ws", "replace"),
126
126
  ("ws", "run"),
127
+ ("ws", "run_trusted_readonly"),
127
128
  ("ws", "search"),
128
129
  ("ws", "write"),
129
130
  ("ws", "write_file"),
@@ -469,6 +470,7 @@ class CodingAgent(InteractiveAgent):
469
470
  config.permission_rules,
470
471
  mode=config.mode,
471
472
  auto_approve=config.auto_approve,
473
+ yolo=config.yolo,
472
474
  )
473
475
  self._engine.mode = config.mode
474
476
  self._approvals = approvals or ApprovalBroker(
@@ -859,6 +861,11 @@ class CodingAgent(InteractiveAgent):
859
861
  in the notification. Understand the requested end state before acting.
860
862
  Conversational questions are first-class: answer them with
861
863
  ``self.message(...)`` then return DONE. Do not emit bare assistant prose.
864
+ IMPORTANT: ``self.message(...)`` is SYNCHRONOUS — call it WITHOUT
865
+ ``await`` (``self.message("...")``). Prefixing ``await`` raises
866
+ ``TypeError: object NoneType can't be used in 'await' expression``
867
+ because it returns ``None``. All ``self.ws.*``, ``self.web.*``,
868
+ ``self.lsp.*``, ``self.processes.*`` calls ARE async and need ``await``.
862
869
 
863
870
  Minimal tool cookbook:
864
871
  - ``await self.ws.list("**/*.py")`` lists files.
@@ -866,7 +873,10 @@ class CodingAgent(InteractiveAgent):
866
873
  - ``match = await self.ws.read("path.py", lines=(10, 30))`` returns an
867
874
  editable Match; ``await self.ws.replace(match, "replacement")`` edits it.
868
875
  - ``await self.ws.edit("path.py", "unique old text", "new text")`` is the
869
- simple string-edit form. ``await self.ws.write("new.py", content)`` creates files.
876
+ simple string-edit form and takes exactly three arguments (path, old,
877
+ new); two-argument calls are invalid. For a Match from read(), edit
878
+ with ``await self.ws.replace(match, "replacement")`` instead.
879
+ ``await self.ws.write("new.py", content)`` creates files.
870
880
  - Prefer ``await self.ws.apply_patch(changes)`` for coherent edits. Each change is
871
881
  ``{"path": ..., "old": exact_text_or_None, "new": replacement_or_None}``;
872
882
  one call validates and atomically commits the full batch.
@@ -878,6 +888,19 @@ class CodingAgent(InteractiveAgent):
878
888
  long-running commands. Consume logs by cursor; do not poll without new work.
879
889
  - ``result = await self.ws.run("pytest -q")`` runs validation; inspect
880
890
  ``result.returncode``, ``result.stdout``, and ``result.stderr``.
891
+ ``read_only=True`` skips approval ONLY for commands the engine
892
+ recognizes as read-only (``git status/log/diff/show``, ``rg``,
893
+ ``grep``, ``ls``, ``find`` (no -delete/-exec), ``head``, ``tail``,
894
+ ``wc``, ``sed``, ``awk``, ``sort``, ``uniq``, ``cut``, ``tr``,
895
+ ``tac``, ``column``, ``pwd``, ``file``, ``stat``, ``test``). It is
896
+ REJECTED for anything else — including ``pytest``, ``uv``, ``python``,
897
+ and build commands — so do NOT pass ``read_only=True`` for those; run
898
+ them with plain ``await self.ws.run(cmd)`` (YOLO auto-approves;
899
+ ``--auto`` prompts).
900
+ - If the host was launched with ``--yolo``, every approval is granted
901
+ automatically without prompting. That mode exists for throwaway or
902
+ sandboxed environments only; do not assume it is active — write code
903
+ that works under normal permission gating.
881
904
  - ``await self.web.fetch(url)`` reads a page; ``await self.web.search(query)``
882
905
  searches the public web. Both are read-only and allowed by default.
883
906
  - ``await self.github.list()`` / ``view(number)`` inspect pull requests.
@@ -919,10 +942,28 @@ class CodingAgent(InteractiveAgent):
919
942
  or mutating ``gh pr`` through the shell.
920
943
  - Do not read secrets or expose sensitive environment values.
921
944
 
945
+ Forbidden inside sandboxed code cells (do NOT attempt - they always fail
946
+ and burn turns): ``import os``, ``import sys``, ``import subprocess``,
947
+ ``import shutil``, ``import nooa``, ``import noah_code``, and any of
948
+ ``eval()``, ``exec()``, ``compile()``, ``__import__()``. If you need to
949
+ run shell logic or a host feature, do it with ``self.ws.run(...)``
950
+ (read-only shell is auto-approved), ``self.web``, ``self.lsp``, or the
951
+ dedicated tools - never by importing a blocked module. Use
952
+ ``self.ws.inspect(...)`` on Python data; do not try to reach host
953
+ objects from a cell.
954
+
922
955
  Return exactly one valid RespondResult:
923
956
  - DONE - request complete
924
957
  - NEED_INPUT - user input genuinely required
925
958
  - WAIT - a registered background job is still running
959
+
960
+ To end the turn, call ``return_result(RespondReason.DONE,
961
+ explanation="...")`` — ``return_result`` takes a ``RespondReason``
962
+ and an ``explanation`` string, NEVER a bare string as ``result=``
963
+ (that raises ``return_result validation error: 'result' has wrong
964
+ type``). To show the user text, call the synchronous
965
+ ``self.message("...")`` first (no ``await``), then
966
+ ``return_result(RespondReason.DONE, explanation="summary")``.
926
967
  """
927
968
  ...
928
969
 
@@ -85,15 +85,6 @@ class ApprovalBroker:
85
85
  raise PermissionError(
86
86
  f"rejected [{decision.category}] {decision.target}: {decision.reason}"
87
87
  )
88
- if choice == ApprovalChoice.SESSION:
89
- self._engine.add_session_rule(
90
- PermissionRule(
91
- category=decision.category,
92
- pattern=decision.remember_pattern,
93
- action="allow",
94
- reason="remembered for session",
95
- )
96
- )
97
88
 
98
89
  async def _ask(self, decision: PermissionDecision) -> ApprovalChoice:
99
90
  req_id = str(uuid.uuid4())
@@ -128,7 +119,28 @@ class ApprovalBroker:
128
119
  async def _resolve() -> None:
129
120
  try:
130
121
  async with self._ui_lock:
131
- choice = await handler(request)
122
+ # A concurrent twin of this request may have just been
123
+ # granted a session-wide allow. Re-check before putting
124
+ # a second identical prompt in front of the user, and
125
+ # register new session rules inside the same critical
126
+ # section so queued twins observe them immediately.
127
+ fresh = self._engine.decide(decision.category, decision.target)
128
+ if fresh.allowed:
129
+ choice = ApprovalChoice.ONCE
130
+ else:
131
+ choice = await handler(request)
132
+ if (
133
+ choice == ApprovalChoice.SESSION
134
+ and not fut.done()
135
+ ):
136
+ self._engine.add_session_rule(
137
+ PermissionRule(
138
+ category=decision.category,
139
+ pattern=decision.remember_pattern,
140
+ action="allow",
141
+ reason="remembered for session",
142
+ )
143
+ )
132
144
  except Exception as exc:
133
145
  if not fut.done():
134
146
  fut.set_exception(exc)
@@ -14,6 +14,7 @@ Enforcement points:
14
14
  from __future__ import annotations
15
15
 
16
16
  import asyncio
17
+ import math
17
18
  import threading
18
19
  import time
19
20
  from typing import Any
@@ -25,6 +26,30 @@ class BudgetExceeded(RuntimeError):
25
26
  """Raised when a configured session cap would be exceeded."""
26
27
 
27
28
 
29
+ def _sanitize_tokens(value: Any) -> int:
30
+ """Garbage-proof a token count; providers occasionally report NaN/inf."""
31
+
32
+ try:
33
+ number = float(value)
34
+ except (TypeError, ValueError):
35
+ return 0
36
+ if not math.isfinite(number):
37
+ return 0
38
+ return max(int(number), 0)
39
+
40
+
41
+ def _sanitize_cost(value: Any) -> float:
42
+ """Drop NaN/garbage cost, keep +inf so a broken pricing feed fails closed."""
43
+
44
+ try:
45
+ number = float(value)
46
+ except (TypeError, ValueError):
47
+ return 0.0
48
+ if math.isnan(number):
49
+ return 0.0
50
+ return max(number, 0.0)
51
+
52
+
28
53
  class BudgetGuard:
29
54
  """Thread-safe accumulator against optional token/cost/wall-clock caps."""
30
55
 
@@ -66,9 +91,9 @@ class BudgetGuard:
66
91
  cost_usd: float = 0.0,
67
92
  ) -> None:
68
93
  with self._lock:
69
- self._prompt_tokens += max(int(prompt_tokens), 0)
70
- self._completion_tokens += max(int(completion_tokens), 0)
71
- self._cost_usd += max(float(cost_usd), 0.0)
94
+ self._prompt_tokens += _sanitize_tokens(prompt_tokens)
95
+ self._completion_tokens += _sanitize_tokens(completion_tokens)
96
+ self._cost_usd += _sanitize_cost(cost_usd)
72
97
 
73
98
  def enforce(self) -> None:
74
99
  """Raise BudgetExceeded when any configured cap is breached."""
@@ -92,7 +117,7 @@ class BudgetGuard:
92
117
  """
93
118
 
94
119
  with self._lock:
95
- self._cost_usd = max(self._cost_usd, max(float(total_cost_usd), 0.0))
120
+ self._cost_usd = max(self._cost_usd, _sanitize_cost(total_cost_usd))
96
121
  self.enforce()
97
122
 
98
123
  def observe_cost_usd(self, total_cost_usd: float) -> None:
@@ -104,7 +129,7 @@ class BudgetGuard:
104
129
  """
105
130
 
106
131
  with self._lock:
107
- self._cost_usd = max(self._cost_usd, max(float(total_cost_usd), 0.0))
132
+ self._cost_usd = max(self._cost_usd, _sanitize_cost(total_cost_usd))
108
133
  if self.exceeded is None:
109
134
  self.exceeded = self._breach()
110
135
 
@@ -143,9 +168,9 @@ class BudgetGuard:
143
168
  if not data:
144
169
  return
145
170
  with self._lock:
146
- self._prompt_tokens = max(int(data.get("prompt_tokens", 0)), 0)
147
- self._completion_tokens = max(int(data.get("completion_tokens", 0)), 0)
148
- self._cost_usd = max(float(data.get("cost_usd", 0.0)), 0.0)
171
+ self._prompt_tokens = _sanitize_tokens(data.get("prompt_tokens", 0))
172
+ self._completion_tokens = _sanitize_tokens(data.get("completion_tokens", 0))
173
+ self._cost_usd = _sanitize_cost(data.get("cost_usd", 0.0))
149
174
  started_at = float(data.get("started_at", time.time()))
150
175
  self._started_wall = min(started_at, time.time())
151
176
  elapsed = max(time.time() - self._started_wall, 0.0)
@@ -176,7 +201,7 @@ def _cost_from_response(response: Any, usage: dict[str, Any]) -> float:
176
201
  import litellm
177
202
 
178
203
  cost = litellm.completion_cost(completion_response=raw)
179
- return max(float(cost or 0.0), 0.0)
204
+ return _sanitize_cost(cost)
180
205
  except Exception: # noqa: BLE001 - pricing must never break a turn
181
206
  return 0.0
182
207
 
@@ -192,12 +217,19 @@ def _usage_from_response(response: Any) -> tuple[int, int, float]:
192
217
 
193
218
  usage = getattr(response, "usage", None)
194
219
  usage_dict = usage if isinstance(usage, dict) else {}
195
- prompt = int(usage_dict.get("prompt_tokens") or usage_dict.get("input_tokens") or 0)
196
- completion = int(usage_dict.get("completion_tokens") or usage_dict.get("output_tokens") or 0)
220
+ prompt = _sanitize_tokens(usage_dict.get("prompt_tokens") or usage_dict.get("input_tokens"))
221
+ completion = _sanitize_tokens(
222
+ usage_dict.get("completion_tokens") or usage_dict.get("output_tokens")
223
+ )
197
224
  cost = _cost_from_response(response, usage_dict)
198
225
  try:
199
226
  if isinstance(usage, dict):
200
- usage.setdefault("cost_usd", cost)
227
+ existing = usage.get("cost_usd")
228
+ if existing is None or not math.isfinite(float(existing)):
229
+ # Never leave provider-reported NaN/inf on the telemetry seam.
230
+ usage["cost_usd"] = cost
231
+ else:
232
+ usage.setdefault("cost_usd", cost)
201
233
  elif cost > 0.0:
202
234
  response.usage = {"cost_usd": cost}
203
235
  except Exception: # noqa: BLE001 - telemetry must never break a turn
@@ -79,6 +79,11 @@ def _common_options(fn): # noqa: ANN001
79
79
  is_flag=True,
80
80
  help="Auto-approve routine asks (never overrides deny or elevated-risk approval)",
81
81
  )(fn)
82
+ fn = click.option(
83
+ "--yolo",
84
+ is_flag=True,
85
+ help="Skip all approvals and permission checks (dangerous; for isolated/throwaway environments)",
86
+ )(fn)
82
87
  fn = click.option("--model", "model", default=None, help="Override the model for this launch")(
83
88
  fn
84
89
  )
@@ -88,6 +93,13 @@ def _common_options(fn): # noqa: ANN001
88
93
  default=None,
89
94
  help="Reasoning effort for compatible models; default lets the provider decide",
90
95
  )(fn)
96
+ fn = click.option(
97
+ "--max-iterations",
98
+ "max_iterations",
99
+ type=click.IntRange(min=1),
100
+ default=None,
101
+ help="Cap model tool-call turns per user request (default 40; budgets still brake)",
102
+ )(fn)
91
103
  return fn
92
104
 
93
105
 
@@ -151,7 +163,9 @@ def interactive_cmd(
151
163
  model: str | None,
152
164
  reasoning_effort: str | None,
153
165
  auto: bool,
166
+ yolo: bool,
154
167
  mode: str | None,
168
+ max_iterations: int | None,
155
169
  continue_session: bool,
156
170
  session_id: str | None,
157
171
  use_console: bool,
@@ -169,9 +183,11 @@ def interactive_cmd(
169
183
  model=model,
170
184
  reasoning_effort=reasoning_effort,
171
185
  auto=auto,
186
+ yolo=yolo,
187
+ mode=mode,
188
+ max_iterations=max_iterations,
172
189
  continue_session=continue_session,
173
190
  session_id=session_id,
174
- mode=mode,
175
191
  use_console=use_console,
176
192
  unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
177
193
  )
@@ -196,7 +212,9 @@ def run_cmd(
196
212
  model: str | None,
197
213
  reasoning_effort: str | None,
198
214
  auto: bool,
215
+ yolo: bool,
199
216
  mode: str | None,
217
+ max_iterations: int | None,
200
218
  session_id: str | None,
201
219
  unsafe_inprocess_code_execution: bool,
202
220
  ) -> None:
@@ -208,7 +226,9 @@ def run_cmd(
208
226
  model=model,
209
227
  reasoning_effort=reasoning_effort,
210
228
  auto=auto,
229
+ yolo=yolo,
211
230
  mode=mode,
231
+ max_iterations=max_iterations,
212
232
  session_id=session_id,
213
233
  unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
214
234
  )
@@ -793,7 +813,9 @@ async def _prepare(
793
813
  model: str | None,
794
814
  reasoning_effort: str | None,
795
815
  auto: bool,
816
+ yolo: bool,
796
817
  mode: str | None,
818
+ max_iterations: int | None = None,
797
819
  continue_session: bool = False,
798
820
  session_id: str | None = None,
799
821
  frontend: Literal["tui", "console"] | None = None,
@@ -813,6 +835,10 @@ async def _prepare(
813
835
  overrides["reasoning_effort"] = reasoning_effort
814
836
  if auto:
815
837
  overrides["auto_approve"] = True
838
+ if yolo:
839
+ overrides["yolo"] = True
840
+ if max_iterations is not None:
841
+ overrides["max_iterations"] = max_iterations
816
842
  if mode:
817
843
  overrides["mode"] = mode
818
844
  if frontend is not None:
@@ -859,9 +885,11 @@ async def _interactive(
859
885
  model: str | None,
860
886
  reasoning_effort: str | None,
861
887
  auto: bool,
888
+ yolo: bool,
889
+ mode: str | None,
890
+ max_iterations: int | None,
862
891
  continue_session: bool,
863
892
  session_id: str | None,
864
- mode: str | None,
865
893
  use_console: bool,
866
894
  unsafe_inprocess_code_execution: bool,
867
895
  ) -> int:
@@ -882,7 +910,9 @@ async def _interactive(
882
910
  model=model,
883
911
  reasoning_effort=reasoning_effort,
884
912
  auto=auto,
913
+ yolo=yolo,
885
914
  mode=mode,
915
+ max_iterations=max_iterations,
886
916
  continue_session=continue_session,
887
917
  session_id=session_id,
888
918
  frontend=frontend,
@@ -906,7 +936,9 @@ async def _interactive(
906
936
  model=model,
907
937
  reasoning_effort=reasoning_effort,
908
938
  auto=auto,
939
+ yolo=yolo,
909
940
  mode=mode,
941
+ max_iterations=max_iterations,
910
942
  continue_session=continue_session,
911
943
  session_id=session_id,
912
944
  frontend=frontend,
@@ -925,12 +957,17 @@ async def _interactive(
925
957
  and config.model == NoahCodeConfig().model
926
958
  )
927
959
  try:
928
- return await host.run_tui(onboarding_required=onboarding_required)
929
- except RuntimeError as exc:
930
- click.echo(f"error: {exc}", err=True)
931
- return EXIT_CONFIG
932
- except KeyboardInterrupt:
933
- return EXIT_SIGINT
960
+ try:
961
+ return await host.run_tui(onboarding_required=onboarding_required)
962
+ except RuntimeError as exc:
963
+ click.echo(f"error: {exc}", err=True)
964
+ return EXIT_CONFIG
965
+ except KeyboardInterrupt:
966
+ return EXIT_SIGINT
967
+ finally:
968
+ # run_tui closes itself, but an import failure or a close that was
969
+ # cut short by cancellation must not leak leases, storage, or tools.
970
+ await host.close()
934
971
  host = AgentHost(
935
972
  workspace,
936
973
  config,
@@ -939,9 +976,12 @@ async def _interactive(
939
976
  ui=ConsoleUI(markdown=config.ui.markdown),
940
977
  )
941
978
  try:
942
- return await host.run_interactive()
943
- except KeyboardInterrupt:
944
- return EXIT_SIGINT
979
+ try:
980
+ return await host.run_interactive()
981
+ except KeyboardInterrupt:
982
+ return EXIT_SIGINT
983
+ finally:
984
+ await host.close()
945
985
 
946
986
 
947
987
  async def _run_session(
@@ -951,7 +991,9 @@ async def _run_session(
951
991
  model: str | None,
952
992
  reasoning_effort: str | None,
953
993
  auto: bool,
994
+ yolo: bool,
954
995
  mode: str | None,
996
+ max_iterations: int | None,
955
997
  session_id: str | None,
956
998
  unsafe_inprocess_code_execution: bool,
957
999
  ) -> int:
@@ -962,7 +1004,9 @@ async def _run_session(
962
1004
  model=model,
963
1005
  reasoning_effort=reasoning_effort,
964
1006
  auto=auto,
1007
+ yolo=yolo,
965
1008
  mode=mode,
1009
+ max_iterations=max_iterations,
966
1010
  session_id=session_id,
967
1011
  frontend="console",
968
1012
  unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,