noah-code 0.2.0__tar.gz → 0.2.1__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 (95) hide show
  1. {noah_code-0.2.0 → noah_code-0.2.1}/.github/workflows/ci.yml +16 -0
  2. {noah_code-0.2.0 → noah_code-0.2.1}/.github/workflows/release.yml +7 -1
  3. {noah_code-0.2.0 → noah_code-0.2.1}/PKG-INFO +10 -7
  4. {noah_code-0.2.0 → noah_code-0.2.1}/README.md +9 -5
  5. {noah_code-0.2.0 → noah_code-0.2.1}/docs/configuration.md +9 -5
  6. {noah_code-0.2.0 → noah_code-0.2.1}/docs/extensions.md +19 -0
  7. {noah_code-0.2.0 → noah_code-0.2.1}/docs/interactive-reference.md +16 -0
  8. noah_code-0.2.1/docs/releases/v0.2.1.md +36 -0
  9. {noah_code-0.2.0 → noah_code-0.2.1}/pyproject.toml +1 -2
  10. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/__init__.py +1 -1
  11. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/agent.py +46 -2
  12. noah_code-0.2.1/src/noah_code/agents.py +112 -0
  13. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/approvals.py +20 -4
  14. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/commands.py +14 -6
  15. noah_code-0.2.1/src/noah_code/composer.py +127 -0
  16. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/config.py +18 -0
  17. noah_code-0.2.1/src/noah_code/credentials.py +190 -0
  18. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/host.py +46 -5
  19. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/mcp_setup.py +59 -3
  20. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/permissions.py +124 -41
  21. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/providers.py +9 -3
  22. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/skills_setup.py +5 -2
  23. noah_code-0.2.1/src/noah_code/tools/__init__.py +17 -0
  24. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/tools/git_tools.py +40 -9
  25. noah_code-0.2.1/src/noah_code/tools/media_tools.py +31 -0
  26. noah_code-0.2.1/src/noah_code/tools/question_tools.py +109 -0
  27. noah_code-0.2.1/src/noah_code/tools/task_tools.py +126 -0
  28. noah_code-0.2.1/src/noah_code/tools/web_tools.py +176 -0
  29. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/tools/workspace_tools.py +65 -7
  30. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/ui/console.py +7 -0
  31. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/ui/protocol.py +5 -1
  32. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/ui/textual_app.py +114 -5
  33. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_agent_security.py +5 -0
  34. noah_code-0.2.1/tests/test_agents.py +52 -0
  35. noah_code-0.2.1/tests/test_approvals.py +29 -0
  36. noah_code-0.2.1/tests/test_composer.py +75 -0
  37. noah_code-0.2.1/tests/test_credentials.py +140 -0
  38. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_git_tools.py +51 -0
  39. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_host.py +21 -2
  40. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_mcp_setup.py +41 -2
  41. noah_code-0.2.1/tests/test_permissions.py +152 -0
  42. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_providers.py +16 -1
  43. noah_code-0.2.1/tests/test_question_tools.py +54 -0
  44. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_run_exit.py +46 -0
  45. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_skills_setup.py +7 -0
  46. noah_code-0.2.1/tests/test_task_tools.py +77 -0
  47. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_textual_tui.py +19 -1
  48. noah_code-0.2.1/tests/test_wave1_e2e.py +197 -0
  49. noah_code-0.2.1/tests/test_web_tools.py +86 -0
  50. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_workspace_tools.py +37 -0
  51. {noah_code-0.2.0 → noah_code-0.2.1}/uv.lock +1 -93
  52. noah_code-0.2.0/src/noah_code/credentials.py +0 -103
  53. noah_code-0.2.0/src/noah_code/tools/__init__.py +0 -6
  54. noah_code-0.2.0/tests/test_credentials.py +0 -60
  55. noah_code-0.2.0/tests/test_permissions.py +0 -70
  56. {noah_code-0.2.0 → noah_code-0.2.1}/.gitignore +0 -0
  57. {noah_code-0.2.0 → noah_code-0.2.1}/docs/development.md +0 -0
  58. {noah_code-0.2.0 → noah_code-0.2.1}/docs/releases/v0.1.0.md +0 -0
  59. {noah_code-0.2.0 → noah_code-0.2.1}/docs/releases/v0.1.1.md +0 -0
  60. {noah_code-0.2.0 → noah_code-0.2.1}/docs/releases/v0.2.0.md +0 -0
  61. {noah_code-0.2.0 → noah_code-0.2.1}/docs/security.md +0 -0
  62. {noah_code-0.2.0 → noah_code-0.2.1}/install.sh +0 -0
  63. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/__main__.py +0 -0
  64. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/benchmark.py +0 -0
  65. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/cli.py +0 -0
  66. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/custom_commands.py +0 -0
  67. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/event_bridge.py +0 -0
  68. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/events.py +0 -0
  69. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/llm.py +0 -0
  70. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/macos_sandbox.py +0 -0
  71. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/sessions.py +0 -0
  72. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/snapshots.py +0 -0
  73. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/summarization.py +0 -0
  74. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/tool_output.py +0 -0
  75. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/tools/lsp_tools.py +0 -0
  76. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/tools/process_tools.py +0 -0
  77. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/ui/__init__.py +0 -0
  78. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/ui/textual.css +0 -0
  79. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/updates.py +0 -0
  80. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/usage.py +0 -0
  81. {noah_code-0.2.0 → noah_code-0.2.1}/src/noah_code/workspace.py +0 -0
  82. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_cli.py +0 -0
  83. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_config.py +0 -0
  84. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_custom_commands.py +0 -0
  85. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_efficiency.py +0 -0
  86. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_event_bridge.py +0 -0
  87. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_event_bridge_and_shell.py +0 -0
  88. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_installer.py +0 -0
  89. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_llm.py +0 -0
  90. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_lsp_tools.py +0 -0
  91. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_process_tools.py +0 -0
  92. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_sessions.py +0 -0
  93. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_snapshots.py +0 -0
  94. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_summarization.py +0 -0
  95. {noah_code-0.2.0 → noah_code-0.2.1}/tests/test_updates.py +0 -0
@@ -34,6 +34,12 @@ jobs:
34
34
  - name: Install locked dependencies
35
35
  run: uv sync --locked --all-extras --dev
36
36
 
37
+ - name: Install ripgrep
38
+ run: |
39
+ sudo apt-get update
40
+ sudo apt-get install -y ripgrep
41
+ rg --version
42
+
37
43
  - name: Check lock file
38
44
  run: uv lock --check
39
45
 
@@ -76,6 +82,16 @@ jobs:
76
82
  - name: Install locked dependencies
77
83
  run: uv sync --locked --all-extras --dev
78
84
 
85
+ - name: Install ripgrep
86
+ run: |
87
+ if [ "${{ runner.os }}" = "Linux" ]; then
88
+ sudo apt-get update
89
+ sudo apt-get install -y ripgrep
90
+ else
91
+ HOMEBREW_NO_AUTO_UPDATE=1 brew install ripgrep
92
+ fi
93
+ rg --version
94
+
79
95
  - name: Run tests
80
96
  run: uv run pytest tests -W error::pytest.PytestUnraisableExceptionWarning
81
97
 
@@ -47,6 +47,12 @@ jobs:
47
47
  - name: Install locked dependencies
48
48
  run: uv sync --locked --all-extras --dev
49
49
 
50
+ - name: Install ripgrep
51
+ run: |
52
+ sudo apt-get update
53
+ sudo apt-get install -y ripgrep
54
+ rg --version
55
+
50
56
  - name: Run release checks
51
57
  run: |
52
58
  uv run ruff check src tests
@@ -68,7 +74,7 @@ jobs:
68
74
  "${UV_TOOL_BIN_DIR}/noah" --version
69
75
 
70
76
  - name: Upload release distributions
71
- uses: actions/upload-artifact@v5
77
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
72
78
  with:
73
79
  name: python-package-distributions
74
80
  path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: noah-code
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -20,7 +20,6 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Software Development
21
21
  Requires-Python: <3.14,>=3.12
22
22
  Requires-Dist: click>=8.1.0
23
- Requires-Dist: keyring>=25.0.0
24
23
  Requires-Dist: litellm<1.92.0,>=1.84.0
25
24
  Requires-Dist: nooa-cli==0.0.9
26
25
  Requires-Dist: nooa==0.0.9
@@ -92,6 +91,9 @@ and seccomp support. You also need an LLM provider account such as OpenAI, Anthr
92
91
  - Inspect token, prompt-cache, model-wait, and tool-output usage with `/tokens`; switch live
93
92
  `fast`, `balanced`, and `deep` budgets with `/efficiency`.
94
93
  - Switch AI models between turns, with optional cross-repository defaults.
94
+ - Delegate research to nested **explore** and **general** subagents, or markdown agents in
95
+ `.noah-code/agents/`.
96
+ - Fetch pages and search the web, ask structured questions mid-turn, and attach `@files` or images.
95
97
  - Extend workflows with slash commands, opt-in skills, MCP servers, model selection, and tracing.
96
98
 
97
99
  ## Quick start
@@ -141,9 +143,10 @@ noah benchmark .
141
143
  ```
142
144
 
143
145
  Bring your own API key from inside the TUI by entering `/model`: choose the provider, paste the
144
- key into the masked field, choose the model, then select its reasoning effort. Noah saves the key to the operating system's
145
- credential store when one is available; otherwise it remains available only to that Noah process.
146
- API-key values are never written to Noah config, repository, or session files.
146
+ key into the masked field, choose the model, then select its reasoning effort. Like OpenCode,
147
+ Noah saves provider credentials in `~/.local/share/noah-code/auth.json`; the directory is
148
+ owner-only and the file uses mode `0600`. API-key values are never written to Noah config,
149
+ repository, or session files. Set `XDG_DATA_HOME` to relocate the data directory.
147
150
 
148
151
  Environment variables and the CLI remain available for scripts and headless use:
149
152
 
@@ -167,8 +170,8 @@ Mistral, xAI, DeepSeek, Together AI, and Perplexity are also supported. See the
167
170
  [provider configuration guide](docs/configuration.md#bring-your-own-api-provider).
168
171
 
169
172
  The package also installs `noah-code` and `nc` as equivalent entry points. Because `nc` commonly
170
- refers to netcat, `noah` or `noah-code` is recommended. Keep provider API keys in the OS
171
- credential store or environment, never in a repository or Noah Code session metadata.
173
+ refers to netcat, `noah` or `noah-code` is recommended. Keep provider API keys in Noah's private
174
+ auth file or environment, never in a repository or Noah Code session metadata.
172
175
 
173
176
  Inside a session, bare `/model` opens guided provider, API-key, model, and reasoning setup. `/model MODEL`
174
177
  switches the active model immediately and remembers it when that session is resumed. It does not
@@ -48,6 +48,9 @@ and seccomp support. You also need an LLM provider account such as OpenAI, Anthr
48
48
  - Inspect token, prompt-cache, model-wait, and tool-output usage with `/tokens`; switch live
49
49
  `fast`, `balanced`, and `deep` budgets with `/efficiency`.
50
50
  - Switch AI models between turns, with optional cross-repository defaults.
51
+ - Delegate research to nested **explore** and **general** subagents, or markdown agents in
52
+ `.noah-code/agents/`.
53
+ - Fetch pages and search the web, ask structured questions mid-turn, and attach `@files` or images.
51
54
  - Extend workflows with slash commands, opt-in skills, MCP servers, model selection, and tracing.
52
55
 
53
56
  ## Quick start
@@ -97,9 +100,10 @@ noah benchmark .
97
100
  ```
98
101
 
99
102
  Bring your own API key from inside the TUI by entering `/model`: choose the provider, paste the
100
- key into the masked field, choose the model, then select its reasoning effort. Noah saves the key to the operating system's
101
- credential store when one is available; otherwise it remains available only to that Noah process.
102
- API-key values are never written to Noah config, repository, or session files.
103
+ key into the masked field, choose the model, then select its reasoning effort. Like OpenCode,
104
+ Noah saves provider credentials in `~/.local/share/noah-code/auth.json`; the directory is
105
+ owner-only and the file uses mode `0600`. API-key values are never written to Noah config,
106
+ repository, or session files. Set `XDG_DATA_HOME` to relocate the data directory.
103
107
 
104
108
  Environment variables and the CLI remain available for scripts and headless use:
105
109
 
@@ -123,8 +127,8 @@ Mistral, xAI, DeepSeek, Together AI, and Perplexity are also supported. See the
123
127
  [provider configuration guide](docs/configuration.md#bring-your-own-api-provider).
124
128
 
125
129
  The package also installs `noah-code` and `nc` as equivalent entry points. Because `nc` commonly
126
- refers to netcat, `noah` or `noah-code` is recommended. Keep provider API keys in the OS
127
- credential store or environment, never in a repository or Noah Code session metadata.
130
+ refers to netcat, `noah` or `noah-code` is recommended. Keep provider API keys in Noah's private
131
+ auth file or environment, never in a repository or Noah Code session metadata.
128
132
 
129
133
  Inside a session, bare `/model` opens guided provider, API-key, model, and reasoning setup. `/model MODEL`
130
134
  switches the active model immediately and remembers it when that session is resumed. It does not
@@ -30,9 +30,11 @@ Inside an interactive session, switch only the current session or replace the gl
30
30
  ```
31
31
 
32
32
  Bare `/model` opens a guided TUI flow: search for a provider, enter its API key in a masked
33
- field, enter the model ID, and select reasoning effort. Noah attempts to save that key in the operating system credential
34
- store. If no secure backend is available, the key remains active only in the current Noah process
35
- and the TUI says so. Keys are never written to Noah configuration or session metadata.
33
+ field, enter the model ID, and select reasoning effort. Noah saves the credential in
34
+ `~/.local/share/noah-code/auth.json`, using the same provider-keyed record shape as OpenCode.
35
+ The containing directory uses mode `0700` and the file uses mode `0600`. If the file cannot be
36
+ written, the key remains active only in the current Noah process and the TUI says so. Keys are
37
+ never written to Noah configuration or session metadata. `XDG_DATA_HOME` relocates the data root.
36
38
 
37
39
  Model switches take effect between turns and are stored in the current session metadata, so a
38
40
  resumed session continues with its most recently selected model. A session-only `/model MODEL`
@@ -237,10 +239,12 @@ Permission rules are evaluated in order, and the last matching rule wins. The de
237
239
  - Allows ordinary reads.
238
240
  - Denies likely secrets, including `.env` variants, private keys, `.git` internals, and session
239
241
  databases. `.env.example` remains readable.
240
- - Asks before workspace edits and shell commands.
242
+ - Asks before workspace edits, shell commands, web fetches, web searches, and subagents.
243
+ - Allows the question tool so the agent can pause for a structured choice.
241
244
  - Denies `git push`, `git clean`, and `git reset --hard`.
242
245
  - Keeps file tools inside the active workspace and asks before skill or MCP access.
243
- - Denies plan-mode mutations regardless of broader allow rules.
246
+ - Denies plan-mode mutations regardless of broader allow rules. Plan mode may still run
247
+ read-only subagents.
244
248
 
245
249
  `--auto` changes ask decisions to allow but never overrides an explicit deny. Compound shell
246
250
  commands and mutating or unrecognized Git commands cannot be silently auto-approved.
@@ -22,6 +22,25 @@ test.
22
22
  Invoke it as `/fix the parser`. Commands support `$ARGUMENTS` and positional placeholders `$1`
23
23
  through `$9`. Front matter may also select a mode or model.
24
24
 
25
+ ## Subagents
26
+
27
+ The parent agent invokes nested NOOA agents with `self.task.run("explore", prompt)` or
28
+ `self.task.run("general", prompt)`. Each child gets isolated in-memory session storage and the
29
+ same permission broker. Add project or user markdown agents:
30
+
31
+ - `~/.config/noah-code/agents/*.md`
32
+ - `.noah-code/agents/*.md`
33
+
34
+ ```markdown
35
+ ---
36
+ description: Review a diff without editing
37
+ readonly: true
38
+ ---
39
+ Review the assigned change. Cite files. Do not edit.
40
+ ```
41
+
42
+ `/agents` lists discovered names. Project files override user files with the same stem.
43
+
25
44
  ## Skills
26
45
 
27
46
  Open the dedicated searchable picker with `/skills` or `Ctrl+K`. Selecting a document skill
@@ -54,6 +54,8 @@ the configured `max_output_chars` per activity.
54
54
  | `/tokens` | Show tokens, cache hits, cost, model wait, and tool-output volume |
55
55
  | `/efficiency [fast|balanced|deep]` | Show or switch live iteration and output budgets |
56
56
  | `/todos` | Show the agent's current task list |
57
+ | `/agents` | List built-in and markdown subagents |
58
+ | `/attach PATH` | Attach a workspace file or image to the next turn |
57
59
  | `/status` | Inspect the current session and repository state |
58
60
  | `/diff` | Review staged and unstaged files, patches, diagnostics, and changed symbols |
59
61
  | `/undo`, `/redo` | Restore or reapply journaled file edits |
@@ -130,3 +132,17 @@ Long-running commands use `self.processes.start`, `logs`, `status`, `input`, and
130
132
  owned by the current session, run in separate process groups, have bounded runtime and retained
131
133
  output, and are terminated when Noah closes. `logs` accepts a cursor and returns only new output.
132
134
  Lifecycle updates appear in the TUI without copying continuous logs into model context.
135
+
136
+ ### Subagents, web, questions, and attachments
137
+
138
+ The parent agent can run isolated NOOA subagents with `self.task.run("explore", ...)` or
139
+ `self.task.run("general", ...)`. Explore is read-only. General can edit but does not own todos.
140
+ Custom agents are markdown files in `.noah-code/agents/` or `~/.config/noah-code/agents/`. List
141
+ them with `/agents`. Plan mode can run read-only agents only.
142
+
143
+ `self.web.fetch(url)` and `self.web.search(query)` ask before leaving the machine.
144
+ `self.ask.question(header, prompt, options)` pauses the turn for a structured choice.
145
+
146
+ Type `@path` in the composer to inline a workspace file or attach a PNG/JPEG/WebP/GIF as a NOOA
147
+ `Image` for `show()`. `/attach PATH` does the same from a slash command. Pasting an image path
148
+ into the composer also inserts an `@` mention.
@@ -0,0 +1,36 @@
1
+ # Noah Code v0.2.1
2
+
3
+ This release expands Noah's agentic workflows and hardens the boundaries around credentials,
4
+ generated code, extensions, and repository operations.
5
+
6
+ ## Highlights
7
+
8
+ - Added nested `explore` and `general` subagents, project and user agent discovery, and a
9
+ permission-gated task tool for isolated delegated work.
10
+ - Added public web fetch and search tools, structured user questions, and `@file` mentions that
11
+ attach text and images directly to a turn.
12
+ - Added console and Textual support for subagent activity, question prompts, attached media, and
13
+ the new permission categories.
14
+ - Exposed `RespondReason` in the CodeAct execution context so the documented inline completion
15
+ pattern finishes in one model call without a recovery turn.
16
+
17
+ ## Security and reliability
18
+
19
+ - Replaced platform-keychain reliance with an owner-only, atomic provider credential store under
20
+ Noah's data directory, while keeping secrets out of configuration and session metadata.
21
+ - Hardened custom-provider credential routing, command parsing, path containment, MCP and skill
22
+ setup, Git operations, generated-code sandboxing, and approval boundaries.
23
+ - Updated CI and release artifact handling, restored explicit ripgrep installation, and expanded
24
+ the hermetic suite to 222 tests.
25
+
26
+ ## Upgrade
27
+
28
+ Existing managed installations can run:
29
+
30
+ ```bash
31
+ noah update
32
+ ```
33
+
34
+ New installations can use the one-command installer from the README.
35
+
36
+ **Full changelog:** https://github.com/skundu42/noah-code/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "noah-code"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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"
@@ -27,7 +27,6 @@ dependencies = [
27
27
  "click>=8.1.0",
28
28
  "pydantic>=2.5.0",
29
29
  "PyYAML>=6.0.0",
30
- "keyring>=25.0.0",
31
30
  "packaging>=24.0",
32
31
  "tomli>=2.0.0; python_version < '3.11'",
33
32
  "rich>=13.0.0",
@@ -1,3 +1,3 @@
1
1
  """Noah Code: terminal coding harness on NVIDIA OO Agents."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -14,7 +14,7 @@ from typing import Annotated, Any, Literal
14
14
 
15
15
  from nooa import Context, hidden, strategy
16
16
  from nooa.config import CodeActConfig, PredictConfig
17
- from nooa.interactive import InteractiveAgent, RespondResult
17
+ from nooa.interactive import InteractiveAgent, RespondReason, RespondResult
18
18
  from nooa.runtime.restrictions import RESTRICTED_MODULES, RestrictionsConfig
19
19
  from nooa.runtime.sandbox.config import FileRule, SandboxConfig, resolve_spec
20
20
  from nooa.runtime.sandbox.executor import SandboxedExecutor
@@ -30,7 +30,11 @@ from noah_code.permissions import PermissionEngine
30
30
  from noah_code.snapshots import SnapshotJournal
31
31
  from noah_code.tools.git_tools import GitTools
32
32
  from noah_code.tools.lsp_tools import LSPTools
33
+ from noah_code.tools.media_tools import MediaTools
33
34
  from noah_code.tools.process_tools import ProcessTools
35
+ from noah_code.tools.question_tools import QuestionTools
36
+ from noah_code.tools.task_tools import TaskTools
37
+ from noah_code.tools.web_tools import WebTools
34
38
  from noah_code.tools.workspace_tools import WorkspaceTools
35
39
  from noah_code.workspace import Workspace
36
40
 
@@ -115,6 +119,13 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
115
119
  ("lsp", "rename_preview"),
116
120
  ("lsp", "repository_map"),
117
121
  ("lsp", "workspace_symbols"),
122
+ ("media", "consume"),
123
+ ("media", "pending"),
124
+ ("ask", "question"),
125
+ ("web", "fetch"),
126
+ ("web", "search"),
127
+ ("task", "list"),
128
+ ("task", "run"),
118
129
  ("processes", "input"),
119
130
  ("processes", "logs"),
120
131
  ("processes", "start"),
@@ -210,6 +221,13 @@ class _MacOSPermissionSandboxedExecutor(_PermissionSandboxedExecutor):
210
221
 
211
222
 
212
223
  class _PermissionCodeActStrategy(CodeActStrategy):
224
+ def _build_builtins(self, runtime: Any, call: Any) -> dict[str, Any]:
225
+ builtins = super()._build_builtins(runtime, call)
226
+ # InteractiveAgent documents this exact inline return pattern. Keep the
227
+ # enum explicit in case module-context filtering changes upstream.
228
+ builtins["RespondReason"] = RespondReason
229
+ return builtins
230
+
213
231
  def _create_sandbox_executor(self, runtime: Any, call: Any, builtins: dict[str, Any]) -> Any:
214
232
  framework_builtins = {**builtins, "_call": call}
215
233
  executor_type = (
@@ -287,6 +305,8 @@ class CodingAgent(InteractiveAgent):
287
305
  engine: PermissionEngine | None = None,
288
306
  approvals: ApprovalBroker | None = None,
289
307
  journal: SnapshotJournal | None = None,
308
+ nested: bool = False,
309
+ nested_prompt: str | None = None,
290
310
  **kwargs: Any,
291
311
  ) -> None:
292
312
  super().__init__(llm=llm, storage=storage, **kwargs)
@@ -294,6 +314,7 @@ class CodingAgent(InteractiveAgent):
294
314
  self.workspace_root = str(workspace.root)
295
315
  self.mode = config.mode
296
316
  self._config = config
317
+ self._nested = nested
297
318
 
298
319
  self._engine = engine or PermissionEngine(
299
320
  config.permission_rules,
@@ -341,11 +362,21 @@ class CodingAgent(InteractiveAgent):
341
362
  )
342
363
  self.todos = TodoManager()
343
364
  self.git = GitTools(self.ws)
365
+ self.web = WebTools(self._engine, self._approvals)
366
+ self.ask = QuestionTools(self._engine, self._approvals)
367
+ self.media = MediaTools()
344
368
  self._sandbox_approved_roots: set[str] = set()
369
+ if not nested:
370
+ self.task = TaskTools(
371
+ workspace,
372
+ self._engine,
373
+ self._approvals,
374
+ parent=self,
375
+ )
345
376
 
346
377
  from noah_code.skills_setup import install_skills
347
378
 
348
- self._skills_status = install_skills(self, workspace.root, config)
379
+ self._skills_status = "" if nested else install_skills(self, workspace.root, config)
349
380
 
350
381
  # Bounded live context - not full trees/diffs.
351
382
  self.context["workspace"] = Context(
@@ -355,6 +386,10 @@ class CodingAgent(InteractiveAgent):
355
386
  self._git_summary_value = self._git_summary()
356
387
  self.context["git"] = Context(expr="self._git_summary_value")
357
388
  self.context["background_jobs"] = Context(expr="self.processes.summary()")
389
+ if nested_prompt:
390
+ self.context["subagent"] = Context(nested_prompt, prefix=True)
391
+ if not nested:
392
+ self.context["agents"] = Context(expr="self.task.list()")
358
393
 
359
394
  if config.summarization.policy != "none":
360
395
  from nooa.config.summarizer_config import TokenBudgetConfig
@@ -546,10 +581,19 @@ class CodingAgent(InteractiveAgent):
546
581
  long-running commands. Consume logs by cursor; do not poll without new work.
547
582
  - ``result = await self.ws.run("pytest -q")`` runs validation; inspect
548
583
  ``result.returncode``, ``result.stdout``, and ``result.stderr``.
584
+ - ``await self.web.fetch(url)`` reads a page; ``await self.web.search(query)``
585
+ searches the public web. Both ask for approval by default.
586
+ - ``await self.ask.question(header, prompt, options)`` pauses for a user choice.
587
+ - ``await self.task.run("explore", "...")`` or ``"general"`` runs a nested
588
+ NOOA subagent with isolated history. ``self.task.list()`` shows markdown agents.
589
+ - If ``self.media.pending()`` is non-empty, ``show()`` each ``self.media.consume()``
590
+ image before reasoning. ``show`` is a CodeAct builtin; do not import ``nooa``.
549
591
 
550
592
  Workflow:
593
+ - If the user attached images, show them first.
551
594
  - Inspect relevant repository instructions and nearby code first.
552
595
  - Prefer ``self.ws.search`` / focused ``self.ws.read`` over dumping large files.
596
+ - Delegate bounded research or parallel units with ``self.task.run``.
553
597
  - Use ``self.todos`` for genuinely multi-step tasks; keep todos current.
554
598
  - Make the smallest coherent change, preferring one atomic ``self.ws.apply_patch``.
555
599
  - Preserve unrelated user modifications.
@@ -0,0 +1,112 @@
1
+ """Built-in and markdown-defined coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from noah_code.custom_commands import _parse_frontmatter
10
+
11
+ AgentKind = Literal["primary", "subagent"]
12
+ AgentMode = Literal["build", "plan"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AgentSpec:
17
+ """A specialized agent the parent can invoke with ``self.task.run``."""
18
+
19
+ name: str
20
+ description: str
21
+ prompt: str
22
+ kind: AgentKind = "subagent"
23
+ mode: AgentMode = "build"
24
+ readonly: bool = False
25
+ todos: bool = True
26
+ model: str | None = None
27
+ source: str = "builtin"
28
+
29
+
30
+ def builtin_agents() -> list[AgentSpec]:
31
+ """OpenCode-style Explore and General subagents."""
32
+
33
+ return [
34
+ AgentSpec(
35
+ name="explore",
36
+ description="Fast read-only agent for finding files, searching code, and answering codebase questions.",
37
+ prompt=(
38
+ "You are a fast, read-only explore agent. Do not modify files or run "
39
+ "mutating commands. Search and read until you can answer with file paths "
40
+ "and short evidence. Prefer self.ws.search, self.ws.read, and self.lsp."
41
+ ),
42
+ kind="subagent",
43
+ mode="plan",
44
+ readonly=True,
45
+ todos=False,
46
+ source="builtin",
47
+ ),
48
+ AgentSpec(
49
+ name="general",
50
+ description="General-purpose subagent for researching complex questions and executing multi-step work in parallel.",
51
+ prompt=(
52
+ "You are a focused general-purpose subagent. Complete the assigned unit of "
53
+ "work and return a concise result to the parent. Do not manage todos. "
54
+ "Make the smallest coherent change and report what you did."
55
+ ),
56
+ kind="subagent",
57
+ mode="build",
58
+ readonly=False,
59
+ todos=False,
60
+ source="builtin",
61
+ ),
62
+ ]
63
+
64
+
65
+ def discover_agents(workspace: Path, *, home: Path | None = None) -> list[AgentSpec]:
66
+ """Built-ins plus user and project markdown agents. Project names win."""
67
+
68
+ found = {spec.name: spec for spec in builtin_agents()}
69
+ user_home = (home or Path.home()).expanduser()
70
+ user_dir = user_home / ".config" / "noah-code" / "agents"
71
+ project_dir = workspace / ".noah-code" / "agents"
72
+ for directory, source in ((user_dir, "user"), (project_dir, "project")):
73
+ found.update(_load_markdown_agents(directory, source=source))
74
+ return list(found.values())
75
+
76
+
77
+ def _load_markdown_agents(directory: Path, *, source: str) -> dict[str, AgentSpec]:
78
+ out: dict[str, AgentSpec] = {}
79
+ if not directory.is_dir():
80
+ return out
81
+ for path in sorted(directory.glob("*.md")):
82
+ name = path.stem.strip().lower().lstrip("/")
83
+ if not name or name.startswith("."):
84
+ continue
85
+ try:
86
+ raw = path.read_text(encoding="utf-8")
87
+ except OSError:
88
+ continue
89
+ meta, body = _parse_frontmatter(raw)
90
+ mode_raw = str(meta.get("mode") or "build").strip().lower()
91
+ mode: AgentMode = "plan" if mode_raw in {"plan", "readonly", "read-only"} else "build"
92
+ readonly = _truthy(meta.get("readonly")) or mode == "plan"
93
+ out[name] = AgentSpec(
94
+ name=name,
95
+ description=str(meta.get("description") or name),
96
+ prompt=body.strip(),
97
+ kind="subagent",
98
+ mode="plan" if readonly else mode,
99
+ readonly=readonly,
100
+ todos=_truthy(meta.get("todos"), default=False),
101
+ model=str(meta["model"]) if meta.get("model") else None,
102
+ source=f"{source}:{path.name}",
103
+ )
104
+ return out
105
+
106
+
107
+ def _truthy(value: object, *, default: bool = False) -> bool:
108
+ if value is None or value == "":
109
+ return default
110
+ if isinstance(value, bool):
111
+ return value
112
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import uuid
7
8
  from collections.abc import Awaitable, Callable
8
9
  from dataclasses import dataclass, field
@@ -90,10 +91,25 @@ class ApprovalBroker:
90
91
  if self._handler is None:
91
92
  # Non-interactive without --auto: treat ask as deny.
92
93
  return ApprovalChoice.REJECT
93
- choice = await self._handler(request)
94
- if not fut.done():
95
- fut.set_result(choice)
96
- return choice
94
+
95
+ async def _resolve() -> None:
96
+ try:
97
+ choice = await self._handler(request)
98
+ except Exception as exc:
99
+ if not fut.done():
100
+ fut.set_exception(exc)
101
+ return
102
+ if not fut.done():
103
+ fut.set_result(choice)
104
+
105
+ task = asyncio.create_task(_resolve())
106
+ try:
107
+ return await fut
108
+ finally:
109
+ if not task.done():
110
+ task.cancel()
111
+ with contextlib.suppress(asyncio.CancelledError):
112
+ await task
97
113
  finally:
98
114
  async with self._lock:
99
115
  self._pending.pop(req_id, None)
@@ -31,7 +31,9 @@ BUILTIN_COMMANDS: list[CommandSpec] = [
31
31
  CommandSpec("help", "Show available commands", host_only=True),
32
32
  CommandSpec("config", "Show every resolved setting or one path", "config [PATH]", True),
33
33
  CommandSpec("mode", "Show or switch the active mode", "mode [build|plan]", True),
34
- CommandSpec("model", "Configure a provider or switch this session's model", "model [MODEL]", True),
34
+ CommandSpec(
35
+ "model", "Configure a provider or switch this session's model", "model [MODEL]", True
36
+ ),
35
37
  CommandSpec(
36
38
  "reasoning",
37
39
  "Show or set reasoning effort for compatible models",
@@ -61,7 +63,13 @@ BUILTIN_COMMANDS: list[CommandSpec] = [
61
63
  CommandSpec("diff", "Review staged and unstaged changes", host_only=True),
62
64
  CommandSpec("undo", "Undo last WorkspaceTools turn", host_only=True),
63
65
  CommandSpec("redo", "Redo last undone turn", host_only=True),
64
- CommandSpec("skills", "Search skills or add a compatible skill folder", "skills [add PATH]", True),
66
+ CommandSpec("agents", "List built-in and markdown subagents", host_only=True),
67
+ CommandSpec(
68
+ "attach", "Attach a workspace file or image to the next turn", "attach [PATH]", True
69
+ ),
70
+ CommandSpec(
71
+ "skills", "Search skills or add a compatible skill folder", "skills [add PATH]", True
72
+ ),
65
73
  CommandSpec("mcp", "Search, connect, or add MCP servers", "mcp [connect|add]", True),
66
74
  CommandSpec("trace", "Show tracing destination", host_only=True),
67
75
  CommandSpec("exit", "Exit Noah Code", host_only=True),
@@ -127,7 +135,9 @@ def config_text(config: Any, path: str = "") -> str:
127
135
  if not rows:
128
136
  raise KeyError(path)
129
137
  width = max(len(key) for key, _value in rows)
130
- title = f"Resolved configuration ({path.strip()}):" if path.strip() else "Resolved configuration:"
138
+ title = (
139
+ f"Resolved configuration ({path.strip()}):" if path.strip() else "Resolved configuration:"
140
+ )
131
141
  return "\n".join([title, "", *(f" {key:<{width}} {value}" for key, value in rows)])
132
142
 
133
143
 
@@ -167,9 +177,7 @@ def help_text(custom: dict[str, CustomCommand] | None = None) -> str:
167
177
  for cmd in BUILTIN_COMMANDS:
168
178
  lines.append(f" {cmd.invocation:<28} {cmd.description}")
169
179
  lines.append(f" {'/model --global MODEL':<28} Set the default model for every repository")
170
- lines.append(
171
- f" {'/reasoning --global EFFORT':<28} Set the global reasoning effort default"
172
- )
180
+ lines.append(f" {'/reasoning --global EFFORT':<28} Set the global reasoning effort default")
173
181
  if custom:
174
182
  lines.append("")
175
183
  lines.append("Custom commands:")