noah-code 0.4.1__tar.gz → 0.5.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 (140) hide show
  1. {noah_code-0.4.1 → noah_code-0.5.0}/.github/workflows/ci.yml +0 -2
  2. {noah_code-0.4.1 → noah_code-0.5.0}/PKG-INFO +9 -1
  3. {noah_code-0.4.1 → noah_code-0.5.0}/README.md +8 -0
  4. {noah_code-0.4.1 → noah_code-0.5.0}/docs/interactive-reference.md +24 -3
  5. noah_code-0.5.0/docs/releases/v0.5.0.md +64 -0
  6. {noah_code-0.4.1 → noah_code-0.5.0}/pyproject.toml +1 -1
  7. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/__init__.py +1 -1
  8. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/agent.py +33 -28
  9. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/budget.py +40 -13
  10. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/cli.py +15 -4
  11. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/commands.py +2 -4
  12. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/composer.py +27 -17
  13. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/host.py +37 -8
  14. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/llm_replies.py +3 -1
  15. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/runtime_state.py +12 -8
  16. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/sessions.py +4 -0
  17. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/steer.py +4 -0
  18. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tool_output.py +4 -4
  19. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/process_tools.py +78 -39
  20. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/task_tools.py +57 -17
  21. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/workspace_tools.py +74 -48
  22. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/ui/console.py +15 -12
  23. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/ui/textual.css +16 -6
  24. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/ui/textual_app.py +242 -52
  25. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_agent_security.py +56 -0
  26. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_budget.py +66 -0
  27. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_cli.py +123 -2
  28. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_commands.py +11 -1
  29. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_composer.py +70 -1
  30. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_console_ui.py +15 -0
  31. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_host.py +122 -0
  32. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_llm_replies.py +23 -3
  33. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_process_tools.py +172 -0
  34. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_runtime_state.py +26 -1
  35. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_sessions.py +23 -1
  36. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_task_tools.py +93 -1
  37. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_textual_tui.py +375 -25
  38. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_tool_output.py +21 -0
  39. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_workspace_tools.py +77 -7
  40. {noah_code-0.4.1 → noah_code-0.5.0}/uv.lock +20 -10
  41. {noah_code-0.4.1 → noah_code-0.5.0}/.github/workflows/release.yml +0 -0
  42. {noah_code-0.4.1 → noah_code-0.5.0}/.gitignore +0 -0
  43. {noah_code-0.4.1 → noah_code-0.5.0}/.pre-commit-config.yaml +0 -0
  44. {noah_code-0.4.1 → noah_code-0.5.0}/docs/assets/noah-in-action.svg +0 -0
  45. {noah_code-0.4.1 → noah_code-0.5.0}/docs/assets/noah-logo.svg +0 -0
  46. {noah_code-0.4.1 → noah_code-0.5.0}/docs/configuration.md +0 -0
  47. {noah_code-0.4.1 → noah_code-0.5.0}/docs/development.md +0 -0
  48. {noah_code-0.4.1 → noah_code-0.5.0}/docs/extensions.md +0 -0
  49. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.1.0.md +0 -0
  50. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.1.1.md +0 -0
  51. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.2.0.md +0 -0
  52. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.2.1.md +0 -0
  53. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.2.2.md +0 -0
  54. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.2.3.md +0 -0
  55. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.2.4.md +0 -0
  56. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.3.0.md +0 -0
  57. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.4.0.md +0 -0
  58. {noah_code-0.4.1 → noah_code-0.5.0}/docs/releases/v0.4.1.md +0 -0
  59. {noah_code-0.4.1 → noah_code-0.5.0}/docs/reliability.md +0 -0
  60. {noah_code-0.4.1 → noah_code-0.5.0}/docs/security.md +0 -0
  61. {noah_code-0.4.1 → noah_code-0.5.0}/install.sh +0 -0
  62. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/__main__.py +0 -0
  63. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/agents.py +0 -0
  64. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/approvals.py +0 -0
  65. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/checkpoints.py +0 -0
  66. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/config.py +0 -0
  67. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/credentials.py +0 -0
  68. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/custom_commands.py +0 -0
  69. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/event_bridge.py +0 -0
  70. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/events.py +0 -0
  71. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/github.py +0 -0
  72. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/hooks.py +0 -0
  73. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/llm.py +0 -0
  74. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/macos_sandbox.py +0 -0
  75. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/mcp_setup.py +0 -0
  76. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/nooa_compat.py +0 -0
  77. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/permissions.py +0 -0
  78. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/predict.py +0 -0
  79. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/project_notes.py +0 -0
  80. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/providers.py +0 -0
  81. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/redaction.py +0 -0
  82. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/secure_files.py +0 -0
  83. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/skills_setup.py +0 -0
  84. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/snapshots.py +0 -0
  85. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/summarization.py +0 -0
  86. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/telemetry.py +0 -0
  87. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/themes.py +0 -0
  88. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/__init__.py +0 -0
  89. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/diff_tools.py +0 -0
  90. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/git_tools.py +0 -0
  91. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/github_tools.py +0 -0
  92. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/lsp_tools.py +0 -0
  93. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/media_tools.py +0 -0
  94. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/memory_tools.py +0 -0
  95. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/plan_tools.py +0 -0
  96. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/question_tools.py +0 -0
  97. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/tools/web_tools.py +0 -0
  98. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/ui/__init__.py +0 -0
  99. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/ui/protocol.py +0 -0
  100. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/updates.py +0 -0
  101. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/usage.py +0 -0
  102. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/workspace.py +0 -0
  103. {noah_code-0.4.1 → noah_code-0.5.0}/src/noah_code/worktree.py +0 -0
  104. {noah_code-0.4.1 → noah_code-0.5.0}/tests/conftest.py +0 -0
  105. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_agents.py +0 -0
  106. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_approvals.py +0 -0
  107. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_cache_context.py +0 -0
  108. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_checkpoints.py +0 -0
  109. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_compaction_eviction.py +0 -0
  110. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_config.py +0 -0
  111. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_credentials.py +0 -0
  112. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_custom_commands.py +0 -0
  113. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_diff_tools.py +0 -0
  114. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_efficiency.py +0 -0
  115. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_event_bridge.py +0 -0
  116. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_event_bridge_and_shell.py +0 -0
  117. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_git_tools.py +0 -0
  118. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_github.py +0 -0
  119. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_hooks.py +0 -0
  120. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_installer.py +0 -0
  121. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_llm.py +0 -0
  122. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_lsp_tools.py +0 -0
  123. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_mcp_setup.py +0 -0
  124. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_permissions.py +0 -0
  125. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_plan_memory_tools.py +0 -0
  126. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_project_notes.py +0 -0
  127. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_prompt_scorecard.py +0 -0
  128. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_providers.py +0 -0
  129. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_question_tools.py +0 -0
  130. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_redaction.py +0 -0
  131. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_run_exit.py +0 -0
  132. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_skills_setup.py +0 -0
  133. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_snapshots.py +0 -0
  134. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_steer.py +0 -0
  135. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_summarization.py +0 -0
  136. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_telemetry.py +0 -0
  137. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_updates.py +0 -0
  138. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_wave1_e2e.py +0 -0
  139. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_web_tools.py +0 -0
  140. {noah_code-0.4.1 → noah_code-0.5.0}/tests/test_worktree.py +0 -0
@@ -109,8 +109,6 @@ jobs:
109
109
  runner: ubuntu-24.04-arm
110
110
  - name: macOS arm64
111
111
  runner: macos-15
112
- - name: macOS x86_64
113
- runner: macos-15-intel
114
112
  steps:
115
113
  - name: Check out source
116
114
  uses: actions/checkout@v7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: noah-code
3
- Version: 0.4.1
3
+ Version: 0.5.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
@@ -170,11 +170,17 @@ Type `/` to search the full command and configuration reference. The most common
170
170
  | Drag, then `Cmd+C` / `Ctrl+Shift+C` | Select and copy TUI text |
171
171
  | `Ctrl+Shift+C` with no selection | Copy the latest Noah reply |
172
172
  | `Ctrl+G` | Open the searchable skills picker |
173
+ | `Ctrl+L` | Open the model picker |
174
+ | `Ctrl+R` | Search and recall a prior prompt |
175
+ | `Ctrl+T` | Expand or collapse live tool output |
176
+ | `Shift+Tab` | Open the reasoning-effort picker |
177
+ | `Alt+Up` | Recall the newest queued prompt for editing |
173
178
  | `Ctrl+]` | Return to live transcript output |
174
179
  | `Tab` | Switch between build and plan mode |
175
180
  | `F2` | Open execution activity |
176
181
  | `F3` | Open paginated conversation history |
177
182
  | `F4` | Open the live agent, terminal, and job ledger |
183
+ | `Shift+F7` | Focus or leave the scrollable context rail |
178
184
  | `/model` | Configure a provider or switch the session model |
179
185
  | `/theme` | Choose Atom One Dark, Noah Ocean, Graphite, or High Contrast |
180
186
  | `/diff` | Review staged and unstaged changes |
@@ -188,6 +194,8 @@ On wide terminals, the side rail prioritizes the active operation, delegated age
188
194
  terminals, Git branch and change counts, session, model usage, update state, and plan. Git status is refreshed in the background at turn
189
195
  boundaries, so the animated working state stays responsive. The main pane remains centered on the
190
196
  large Noah wordmark until the first prompt, then becomes the conversation and execution timeline.
197
+ The composer footer keeps contextual keys and compact token, cache, and cost telemetry visible at
198
+ every terminal width where they fit.
191
199
 
192
200
  ## Sessions and crash recovery
193
201
 
@@ -125,11 +125,17 @@ Type `/` to search the full command and configuration reference. The most common
125
125
  | Drag, then `Cmd+C` / `Ctrl+Shift+C` | Select and copy TUI text |
126
126
  | `Ctrl+Shift+C` with no selection | Copy the latest Noah reply |
127
127
  | `Ctrl+G` | Open the searchable skills picker |
128
+ | `Ctrl+L` | Open the model picker |
129
+ | `Ctrl+R` | Search and recall a prior prompt |
130
+ | `Ctrl+T` | Expand or collapse live tool output |
131
+ | `Shift+Tab` | Open the reasoning-effort picker |
132
+ | `Alt+Up` | Recall the newest queued prompt for editing |
128
133
  | `Ctrl+]` | Return to live transcript output |
129
134
  | `Tab` | Switch between build and plan mode |
130
135
  | `F2` | Open execution activity |
131
136
  | `F3` | Open paginated conversation history |
132
137
  | `F4` | Open the live agent, terminal, and job ledger |
138
+ | `Shift+F7` | Focus or leave the scrollable context rail |
133
139
  | `/model` | Configure a provider or switch the session model |
134
140
  | `/theme` | Choose Atom One Dark, Noah Ocean, Graphite, or High Contrast |
135
141
  | `/diff` | Review staged and unstaged changes |
@@ -143,6 +149,8 @@ On wide terminals, the side rail prioritizes the active operation, delegated age
143
149
  terminals, Git branch and change counts, session, model usage, update state, and plan. Git status is refreshed in the background at turn
144
150
  boundaries, so the animated working state stays responsive. The main pane remains centered on the
145
151
  large Noah wordmark until the first prompt, then becomes the conversation and execution timeline.
152
+ The composer footer keeps contextual keys and compact token, cache, and cost telemetry visible at
153
+ every terminal width where they fit.
146
154
 
147
155
  ## Sessions and crash recovery
148
156
 
@@ -9,7 +9,12 @@
9
9
  | `Tab` | Toggle `build`/`plan` mode; accept the highlighted slash option while suggestions are open |
10
10
  | `Ctrl+P` | Open the command palette |
11
11
  | `Ctrl+G` | Open the searchable skills picker |
12
+ | `Ctrl+L` | Open the model picker |
12
13
  | `Ctrl+O` | Open the session picker |
14
+ | `Ctrl+R` | Search prior prompts and recall one into the composer |
15
+ | `Ctrl+T` | Expand or collapse live tool output |
16
+ | `Alt+Up` | Recall the newest queued prompt into the composer |
17
+ | `Shift+Tab` | Open the reasoning-effort picker |
13
18
  | `Ctrl+N` | Start a new session |
14
19
  | `Ctrl+C` | Cancel the active turn and clear queued follow-ups; press twice while idle to quit |
15
20
  | `Ctrl+Q` | Quit |
@@ -17,6 +22,7 @@
17
22
  | `F2` | Open recent activity and full captured output |
18
23
  | `F3` | Open paginated persisted conversation history |
19
24
  | `F4` | Open the live work ledger for agents, terminals, and background jobs |
25
+ | `Shift+F7` | Focus or leave the context rail on wide terminals |
20
26
  | `Ctrl+]` | Return to live transcript output and clear the new-output counter |
21
27
 
22
28
  At an approval prompt, press `1` to approve once, `2` to remember the approval for the current
@@ -29,16 +35,29 @@ branch and staged/modified/new counts, session, model usage, and up to six open
29
35
  Narrow terminals retain the transcript, live activity, suggestions, and composer without the rail.
30
36
  Terminals 25 rows high or shorter use compact spacing.
31
37
 
38
+ The line below the composer keeps the active keyboard guidance on the left and, when space permits,
39
+ session input/output tokens, cache hit rate, and estimated cost on the right. This telemetry remains
40
+ available even when the wide context rail is hidden.
41
+
42
+ Live tool output stays in a compact two-line drawer so long commands do not push the conversation
43
+ away. Press `Ctrl+T` to expand the drawer in place; `F2` retains the complete output after the tool
44
+ finishes. The drawer replaces the animated working banner while a visible tool is active, avoiding
45
+ duplicate status lines; the banner returns between tools.
46
+
32
47
  Type `/` in the composer to open the inline command list; the list remains visible and filters
33
48
  continuously. Use `Up`/`Down` to highlight a command, `Enter` or `Tab` to complete it, and `Esc` to
34
49
  close the list. Press `Enter` again to run the completed command. Typing `/config` expands the list
35
50
  to every resolved configuration path and its current redacted value.
36
51
 
52
+ Press `Ctrl+R` to search prompts already shown in the current session. Choosing one loads it into
53
+ the composer without sending it; pressing `Esc` keeps the current draft unchanged.
54
+
37
55
  Until a session has its first user prompt, the main pane keeps the large Noah wordmark centered. Startup,
38
56
  repository changes, model, mode, usage, and update state live in the context rail on wide
39
- terminals. Git status is collected in a background worker at startup and turn boundaries; the
40
- animated Noah path updates only the working banner and live activity. Existing sessions with user history restore
41
- their transcript normally.
57
+ terminals. The rail scrolls when its sections exceed the available rows; press `Shift+F7`, then use
58
+ arrows, Page Up/Down, Home, or End without leaving the keyboard. Git status is collected in a
59
+ background worker at startup and turn boundaries; the animated Noah path updates only the working
60
+ banner and live activity. Existing sessions with user history restore their transcript normally.
42
61
 
43
62
  Drag across transcript, activity, diff, or history text to select it. `Cmd+C` on macOS or
44
63
  `Ctrl+Shift+C` in other terminals copies the selection; when there is no selection, the same
@@ -59,6 +78,8 @@ While Noah is working, the composer stays open. `Enter` queues the current text
59
78
  a second turn. Chrome shows `queued · n`. When the in-flight `handle()` returns (`DONE`,
60
79
  `NEED_INPUT`, or `WAIT`), the host injects the next item in the same journaled turn — one persist
61
80
  and one checkpoint for the whole steered run. `/undo` therefore reverts every follow-up together.
81
+ Press `Alt+Up` with an empty composer to pull the newest queued prompt and its attachments back for
82
+ editing.
62
83
 
63
84
  The queue holds at most 100 items. A 101st `Enter` drops the oldest and status-prints
64
85
  `steer dropped oldest`. `@path` mentions and `/attach` paths expand when the item is injected, not
@@ -0,0 +1,64 @@
1
+ # Noah Code v0.5.0
2
+
3
+ This release improves task completion, terminal interaction, and bounded resource use. It includes
4
+ the unreleased prompt recall, model shortcuts, scrollable context rail, and composer usage telemetry.
5
+
6
+ ## More reliable task completion
7
+
8
+ - Token-limited model replies no longer report a partial answer as successfully completed. The
9
+ runtime retries within its existing limits and reports failure if the model cannot continue.
10
+ - Subagents wait for their background jobs and resume the task instead of closing their tools
11
+ early. Requests for user input remain visible even after result summarization.
12
+ - Session deadlines now bound asynchronous model requests, queued provider calls, and background
13
+ waits. Provider requests receive the remaining timeout as well.
14
+ - Resumed sessions honor newer mode, model, and reasoning choices over older checkpoints and agent
15
+ snapshots. Resuming another worktree loads its configuration while preserving explicit CLI
16
+ overrides. Failed one-shot startup releases its host resources; repeated cleanup preserves the
17
+ saved undo journal.
18
+
19
+ ## Smoother terminal UX
20
+
21
+ - Search previous prompts, recall queued prompts for editing, and use direct model and reasoning
22
+ shortcuts. The context rail scrolls and the composer shows compact usage telemetry.
23
+ - Transcript refreshes preserve your reading position. Switching sessions clears stale activity,
24
+ buffered output, and unread indicators.
25
+ - Cancelling keeps the interface busy until cleanup finishes, preventing overlapping turns and
26
+ session changes during teardown.
27
+ - File mentions support quoted paths, prioritize matching directories, and preserve nested image
28
+ paths when pasted. Text and image attachments share the same file-count limit.
29
+ - Console output renders literal tool/error text safely and preserves shell output chunk boundaries.
30
+ - Console resume preserves the saved model; `-h` and slash commands pasted with whitespace work.
31
+
32
+ ## Safer edits and execution
33
+
34
+ - Stale edit anchors cannot overwrite newly changed content, even after a file has been reread.
35
+ - Read-only shell calls enforce workspace permissions and retain their stdin and timeout options.
36
+ Generated code cannot invoke the internal trusted-command bypass.
37
+ - Attachment paths resolve symlinks before containment checks, preventing outside-workspace reads.
38
+ - Background launches serialize capacity and name checks. Split terminal completion markers and
39
+ oversized log events no longer break completion or stall pagination.
40
+ - Invalid process deadlines fail before execution. Log storage failures preserve bounded in-memory
41
+ output, keep pipes draining, and do not prevent process cleanup.
42
+
43
+ ## Less context and file I/O
44
+
45
+ - Subagent results obey their character budget, preserve the end of the findings, and bound the
46
+ input sent for distillation.
47
+ - Text attachment reads stop at their inline limit. Managed output ranges stream only as far as
48
+ the requested lines, avoiding whole-file allocation.
49
+ - Repeated glob patterns reuse compiled translations, with bracket pattern support.
50
+ - Runtime database connections close after each transaction, preventing connection accumulation
51
+ during long-running sessions.
52
+ - The existing prompt-size, cache-prefix stability, and helper-context scorecards remain release
53
+ checks. No new runtime dependencies were added by these fixes.
54
+
55
+ ## Upgrade
56
+
57
+ ```bash
58
+ noah update
59
+ ```
60
+
61
+ Validation uses deterministic provider responses and headless terminal tests; live provider billing
62
+ and end-to-end model quality are not benchmarked by this release.
63
+
64
+ **Full changelog:** https://github.com/skundu42/noah-code/compare/v0.4.1...v0.5.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "noah-code"
3
- version = "0.4.1"
3
+ version = "0.5.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"
@@ -1,3 +1,3 @@
1
1
  """Noah Code: terminal coding agent on NVIDIA OO Agents."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.5.0"
@@ -8,6 +8,7 @@ import platform
8
8
  import site
9
9
  import sys
10
10
  import sysconfig
11
+ import threading
11
12
  import types
12
13
  from collections.abc import Iterator
13
14
  from contextlib import contextmanager, suppress
@@ -126,7 +127,6 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
126
127
  ("ws", "read_output"),
127
128
  ("ws", "replace"),
128
129
  ("ws", "run"),
129
- ("ws", "run_trusted_readonly"),
130
130
  ("ws", "search"),
131
131
  ("ws", "write"),
132
132
  ("ws", "write_file"),
@@ -196,6 +196,8 @@ def _spawn_safe_local_agent(agent: Any) -> Any:
196
196
 
197
197
 
198
198
  _OMIT = object()
199
+ # ``spawnv_passfds`` is process-global; patch and restore it as one critical section.
200
+ _SPAWN_PASSFDS_LOCK = threading.RLock()
199
201
 
200
202
 
201
203
  def _pipe_picklable(value: Any) -> bool:
@@ -246,28 +248,29 @@ def _unique_spawn_passfds() -> Iterator[None]:
246
248
  import multiprocessing.util as util
247
249
  import os
248
250
 
249
- original = util.spawnv_passfds
250
-
251
- def _inherit_fds(passfds: Any) -> tuple[int, ...]:
252
- kept: list[int] = []
253
- for fd in sorted({int(fd) for fd in passfds}):
254
- if fd < 0:
255
- continue
256
- try:
257
- os.fstat(fd)
258
- except OSError:
259
- continue
260
- kept.append(fd)
261
- return tuple(kept)
262
-
263
- def _spawnv(path: Any, args: Any, passfds: Any) -> Any:
264
- return original(path, args, _inherit_fds(passfds))
265
-
266
- util.spawnv_passfds = _spawnv
267
- try:
268
- yield
269
- finally:
270
- util.spawnv_passfds = original
251
+ with _SPAWN_PASSFDS_LOCK:
252
+ original = util.spawnv_passfds
253
+
254
+ def _inherit_fds(passfds: Any) -> tuple[int, ...]:
255
+ kept: list[int] = []
256
+ for fd in sorted({int(fd) for fd in passfds}):
257
+ if fd < 0:
258
+ continue
259
+ try:
260
+ os.fstat(fd)
261
+ except OSError:
262
+ continue
263
+ kept.append(fd)
264
+ return tuple(kept)
265
+
266
+ def _spawnv(path: Any, args: Any, passfds: Any) -> Any:
267
+ return original(path, args, _inherit_fds(passfds))
268
+
269
+ util.spawnv_passfds = _spawnv
270
+ try:
271
+ yield
272
+ finally:
273
+ util.spawnv_passfds = original
271
274
 
272
275
 
273
276
  class _MacOSPermissionSandboxedExecutor(_PermissionSandboxedExecutor):
@@ -386,8 +389,9 @@ _NOAH_CODEACT_INSTRUCTIONS = """## Noah CodeAct
386
389
 
387
390
  Work through tool calls; plain assistant prose cannot run code or end a turn.
388
391
  Use `execute_python(code)` for work and `return_result(...)` to finish. Python
389
- cell state persists. Parameters and `self` are preloaded; call async `self.*`
390
- tools with `await`. `self.message(text)` is synchronous—never await it.
392
+ cell state persists. Parameters and `self` are preloaded. Every `self.ws.*`
393
+ call is async: always `await` it before iterating or accessing the result.
394
+ `self.message(text)` is synchronous—never await it.
391
395
 
392
396
  ### Tools
393
397
 
@@ -396,9 +400,10 @@ tools with `await`. `self.message(text)` is synchronous—never await it.
396
400
  three arguments; two-argument calls are invalid. Prefer one atomic
397
401
  `self.ws.apply_patch(changes)` for a
398
402
  coherent batch; `self.ws.apply_unified_diff` accepts git-style hunks.
399
- - Common shapes: `ws.search(pattern, path=".", paths=None, regex=True)` returns
400
- iterable Matches plus `.stdout`; `ws.read(path, lines=None)` returns a Match
401
- with `.text`/`.content`; `ws.list(pattern="**/*", path=".")` returns paths.
403
+ - Common shapes: `await ws.search(pattern, path=".", paths=None, regex=True)`
404
+ returns iterable Matches plus `.stdout`; `await ws.read(path, lines=None)`
405
+ returns text with `.text`/`.content`; `lines=60` reads the first 60 lines;
406
+ `await ws.list(pattern="**/*", path=".")` returns paths.
402
407
  - Run commands with `await self.ws.run(command)` and inspect
403
408
  returncode/stdout/stderr. `read_only=True` skips approval only for commands
404
409
  the engine recognizes as read-only (Git inspection, search, listing, text
@@ -83,6 +83,11 @@ class BudgetGuard:
83
83
  wall_elapsed = max(time.time() - self._started_wall, 0.0)
84
84
  return max(monotonic_elapsed, wall_elapsed)
85
85
 
86
+ def remaining_seconds(self) -> float | None:
87
+ if self._config.max_seconds is None:
88
+ return None
89
+ return max(self._config.max_seconds - self.elapsed_seconds(), 0.0)
90
+
86
91
  def add_usage(
87
92
  self,
88
93
  *,
@@ -257,28 +262,50 @@ class BudgetedLLM:
257
262
  if self._prefix_observer is not None:
258
263
  self._prefix_observer.observe_prefix(messages, route=route)
259
264
 
265
+ def _limit_timeout(self, kwargs: dict[str, Any]) -> None:
266
+ remaining = self._guard.remaining_seconds()
267
+ if remaining is not None:
268
+ configured_timeout = kwargs.get("timeout")
269
+ kwargs["timeout"] = (
270
+ min(configured_timeout, remaining)
271
+ if isinstance(configured_timeout, int | float)
272
+ else remaining
273
+ )
274
+
260
275
  async def acall(self, messages: list[dict], tools=None, output_model=None, **kwargs) -> Any:
261
276
  # Active caps serialize reservations across parent and subagent routes;
262
277
  # without this lane, concurrent calls can all pass the same preflight.
263
- async with self._guard._async_provider_lock:
264
- self._guard.enforce()
265
- self._observe_prefix(messages, route=kwargs.get("prompt_cache_key"))
266
- response = await self._inner.acall(
267
- messages, tools=tools, output_model=output_model, **kwargs
268
- )
269
- prompt, completion, cost = _usage_from_response(response)
270
- self._guard.add_usage(
271
- prompt_tokens=prompt,
272
- completion_tokens=completion,
273
- cost_usd=cost,
274
- )
278
+ self._guard.enforce()
279
+ try:
280
+ async with asyncio.timeout(self._guard.remaining_seconds()):
281
+ async with self._guard._async_provider_lock:
282
+ self._guard.enforce()
283
+ self._observe_prefix(messages, route=kwargs.get("prompt_cache_key"))
284
+ # NOOA shields LiteLLM's provider task from cancellation.
285
+ # Bound its own request timeout as well as our outer wait.
286
+ self._limit_timeout(kwargs)
287
+ response = await self._inner.acall(
288
+ messages, tools=tools, output_model=output_model, **kwargs
289
+ )
290
+ prompt, completion, cost = _usage_from_response(response)
291
+ self._guard.add_usage(
292
+ prompt_tokens=prompt,
293
+ completion_tokens=completion,
294
+ cost_usd=cost,
295
+ )
296
+ self._guard.enforce()
297
+ return response
298
+ except TimeoutError:
299
+ # Preserve a provider's own timeout unless the session deadline
300
+ # expired. A session timeout must surface as a sticky budget stop.
275
301
  self._guard.enforce()
276
- return response
302
+ raise
277
303
 
278
304
  def call(self, messages: list[dict], tools=None, output_model=None, **kwargs) -> Any:
279
305
  with self._guard._provider_lock:
280
306
  self._guard.enforce()
281
307
  self._observe_prefix(messages, route=kwargs.get("prompt_cache_key"))
308
+ self._limit_timeout(kwargs)
282
309
  response = self._inner.call(
283
310
  messages, tools=tools, output_model=output_model, **kwargs
284
311
  )
@@ -146,7 +146,7 @@ def _configure_first_run_model(model_override: str | None) -> str | None:
146
146
  return selected
147
147
 
148
148
 
149
- @click.command("noah-code")
149
+ @click.command("noah-code", context_settings={"help_option_names": ["-h", "--help"]})
150
150
  @click.version_option(__version__, prog_name="noah-code")
151
151
  @click.argument("path", required=False, type=click.Path())
152
152
  @click.option("--continue", "continue_session", is_flag=True, help="Resume latest session")
@@ -856,6 +856,7 @@ async def _prepare(
856
856
  store = SessionStore(config.session_dir)
857
857
 
858
858
  meta = None
859
+ launch_root = workspace.root
859
860
  try:
860
861
  if session_id:
861
862
  meta = store.load_meta(session_id)
@@ -866,11 +867,18 @@ async def _prepare(
866
867
  click.echo("error: no prior session for this workspace", err=True)
867
868
  return None, EXIT_CONFIG
868
869
  workspace = store.workspace_for_resume(meta, workspace)
869
- if meta is not None and (model is not None or reasoning_effort is not None):
870
+ if workspace.root != launch_root:
871
+ config = load_config(
872
+ workspace.root,
873
+ cli_overrides={**overrides, "session_dir": store.session_dir},
874
+ )
875
+ if meta is not None and any(value is not None for value in (model, reasoning_effort, mode)):
870
876
  if model is not None:
871
877
  meta.model = config.model
872
878
  if reasoning_effort is not None:
873
879
  meta.reasoning_effort = config.reasoning_effort
880
+ if mode is not None:
881
+ meta.mode = config.mode
874
882
  store.save_meta(meta)
875
883
  except SessionError as exc:
876
884
  click.echo(f"error: {exc}", err=True)
@@ -894,7 +902,7 @@ async def _interactive(
894
902
  unsafe_inprocess_code_execution: bool,
895
903
  ) -> int:
896
904
  first_run = user_default_model() is None
897
- if first_run and (model is not None or use_console):
905
+ if first_run and (model is not None or (use_console and not (continue_session or session_id))):
898
906
  try:
899
907
  model = _configure_first_run_model(model)
900
908
  except (OSError, ValueError) as exc:
@@ -922,7 +930,7 @@ async def _interactive(
922
930
  return code
923
931
  workspace, config, store, meta = prepared
924
932
  use_tui = config.ui.frontend == "tui" and not use_console
925
- if first_run and model is None and not use_tui:
933
+ if first_run and model is None and not use_tui and meta is None:
926
934
  try:
927
935
  model = _configure_first_run_model(model)
928
936
  except (OSError, ValueError) as exc:
@@ -1038,6 +1046,9 @@ async def _run_session(
1038
1046
 
1039
1047
  click.echo(f"error: {safe_error_message(exc)}", err=True)
1040
1048
  return EXIT_AGENT
1049
+ finally:
1050
+ # Startup can fail before run_once reaches its own cleanup block.
1051
+ await host.close()
1041
1052
 
1042
1053
 
1043
1054
  def main(argv: list[str] | None = None) -> None:
@@ -324,7 +324,5 @@ def parse_slash(text: str) -> tuple[str, str] | None:
324
324
  body = stripped[1:]
325
325
  if not body:
326
326
  return None
327
- if " " in body:
328
- name, rest = body.split(" ", 1)
329
- return name.lower(), rest.strip()
330
- return body.lower(), ""
327
+ parts = body.split(maxsplit=1)
328
+ return parts[0].lower(), parts[1].strip() if len(parts) > 1 else ""
@@ -20,7 +20,7 @@ IMAGE_TYPES = {
20
20
  ".bmp": "image/bmp",
21
21
  }
22
22
 
23
- _MENTION = re.compile(r"(?<![\w/])@([A-Za-z0-9_./-]+)")
23
+ _MENTION = re.compile(r'(?<![\w/])@(?:"([^"\r\n]+)"|([A-Za-z0-9_./-]+))')
24
24
  _MAX_INLINE_CHARS = 8_000
25
25
  _MAX_FILES = 8
26
26
 
@@ -57,6 +57,8 @@ def _workspace_files(root: Path) -> list[Path]:
57
57
  path = Path(dirpath) / filename
58
58
  if not path.is_file() or is_secret_path(path):
59
59
  continue
60
+ if path.is_symlink() and _resolve(root, str(path)) is None:
61
+ continue
60
62
  files.append(path)
61
63
  files.sort()
62
64
  _suggestion_cache[root] = (now, files)
@@ -67,25 +69,32 @@ def mention_suggestions(workspace: Path, prefix: str, *, limit: int = 8) -> list
67
69
  """Return workspace-relative paths matching a live ``@`` prefix."""
68
70
 
69
71
  raw = prefix.strip()
70
- if not raw.startswith("@"):
72
+ if not raw.startswith("@") or limit <= 0:
71
73
  return []
72
- query = raw[1:].lstrip("./")
74
+ query = raw[1:].removeprefix('"').removeprefix("./")
73
75
  if not query:
74
76
  return []
75
77
  root = workspace.resolve()
76
- matches: list[str] = []
78
+ starts: list[str] = []
79
+ rest: list[str] = []
77
80
  for path in _workspace_files(root):
78
81
  try:
79
82
  relative = path.relative_to(root).as_posix()
80
83
  except ValueError:
81
84
  continue
82
- if relative.startswith(query) or Path(relative).name.startswith(Path(query).name):
83
- matches.append(relative)
84
- if len(matches) >= limit:
85
+ if relative.startswith(query):
86
+ starts.append(relative)
87
+ elif len(rest) < limit and Path(relative).name.startswith(Path(query).name):
88
+ rest.append(relative)
89
+ if len(starts) >= limit:
85
90
  break
86
- starts = [item for item in matches if item.startswith(query)]
87
- rest = [item for item in matches if item not in starts]
88
- return starts + rest
91
+ return (starts + rest)[:limit]
92
+
93
+
94
+ def mention_text(path: str) -> str:
95
+ """Quote workspace paths that contain spaces or non-ASCII characters."""
96
+
97
+ return f"@{path}" if re.fullmatch(r"[A-Za-z0-9_./-]+", path) else f'@"{path}"'
89
98
 
90
99
 
91
100
  def expand_turn(
@@ -97,7 +106,7 @@ def expand_turn(
97
106
  """Inline ``@path`` text files and attach mentioned images via ``nooa.Image``."""
98
107
 
99
108
  root = workspace.resolve()
100
- mentioned = [match.group(1) for match in _MENTION.finditer(text)]
109
+ mentioned = [match.group(1) or match.group(2) for match in _MENTION.finditer(text)]
101
110
  extras = [Path(path) for path in attach_paths or []]
102
111
  sections: list[str] = []
103
112
  images: list[Any] = []
@@ -105,6 +114,8 @@ def expand_turn(
105
114
  seen: set[str] = set()
106
115
 
107
116
  for raw in [*mentioned, *[str(path) for path in extras]]:
117
+ if len(paths) >= _MAX_FILES:
118
+ break
108
119
  resolved = _resolve(root, raw)
109
120
  if resolved is None:
110
121
  continue
@@ -121,12 +132,11 @@ def expand_turn(
121
132
  images.append(Image.from_file(resolved))
122
133
  sections.append(f"Attached image `{relative}`. Call `show(image)` on pending media.")
123
134
  continue
124
- body = resolved.read_text(errors="replace")
135
+ with resolved.open(encoding="utf-8", errors="replace") as handle:
136
+ body = handle.read(_MAX_INLINE_CHARS + 1)
125
137
  if len(body) > _MAX_INLINE_CHARS:
126
138
  body = body[:_MAX_INLINE_CHARS] + "\n...(truncated)..."
127
139
  sections.append(f"### {relative}\n```\n{body}\n```")
128
- if len(sections) >= _MAX_FILES:
129
- break
130
140
 
131
141
  if not sections:
132
142
  return ExpandedTurn(text=text)
@@ -136,12 +146,12 @@ def expand_turn(
136
146
 
137
147
  def _resolve(root: Path, raw: str) -> Path | None:
138
148
  candidate = Path(raw).expanduser()
139
- path = candidate if candidate.is_absolute() else (root / raw).resolve()
140
149
  try:
150
+ path = (candidate if candidate.is_absolute() else root / candidate).resolve()
141
151
  path.relative_to(root)
142
- except ValueError:
152
+ except (OSError, RuntimeError, ValueError):
143
153
  return None
144
- if not path.is_file() or is_secret_path(path):
154
+ if not path.is_file() or is_secret_path(candidate) or is_secret_path(path):
145
155
  return None
146
156
  return path
147
157