buildwithnexus 0.10.1 → 0.10.4

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.
@@ -1,12 +1,14 @@
1
- // The orchestration that replaces LangGraph: plain Rust control flow for the
2
- // three modes. PLAN decomposes + gates, BUILD is a ReAct tool loop, BRAINSTORM
3
- // is streaming-free chat. Roles are a small data table, not an agent framework.
1
+ // The orchestration: plain Rust control flow for all three modes. PLAN
2
+ // decomposes + gates, BUILD is a ReAct tool loop, BRAINSTORM is conversational —
3
+ // but ALL modes have access to the full tool surface so the model can grep,
4
+ // fetch, read files, and run commands regardless of which mode the user is in.
4
5
 
5
6
  use std::io::IsTerminal;
6
7
  use std::path::{Path, PathBuf};
7
8
  use std::process::Command;
8
9
  use std::sync::atomic::{AtomicUsize, Ordering};
9
10
 
11
+ use crate::config;
10
12
  use crate::hooks::{self, PreDecision};
11
13
  use crate::provider::{self, complete, Msg, Provider, Reply, ToolResult};
12
14
  use crate::report;
@@ -15,20 +17,15 @@ use crate::tui;
15
17
 
16
18
  const MAX_ITERS: usize = 30;
17
19
  const MAX_DEPTH: usize = 3;
18
-
19
- // ── context compaction ──────────────────────────────────────────────────────
20
- // Small models have small windows; long tool loops overflow them. When the
21
- // running conversation passes ~70% of the model's window, distill the older
22
- // middle into a summary and keep the system prompt + recent tail verbatim.
23
20
  const KEEP_RECENT: usize = 6;
24
21
 
25
- // Rough token estimate (~4 chars/token) — protocol-agnostic, enough to decide
26
- // when to compact without parsing each vendor's usage payload.
22
+ // ── context compaction ────────────────────────────────────────────────────────
27
23
  fn estimate_tokens(msgs: &[Msg]) -> usize {
28
24
  let mut chars = 0usize;
29
25
  for m in msgs {
30
26
  chars += match m {
31
27
  Msg::System(s) | Msg::User(s) => s.len(),
28
+ Msg::UserImages { text, images } => text.len() + images.iter().map(|(_, d)| d.len() / 3).sum::<usize>(),
32
29
  Msg::Assistant { text, calls } => {
33
30
  text.len() + calls.iter().map(|c| c.name.len() + c.input.to_string().len()).sum::<usize>()
34
31
  }
@@ -38,15 +35,12 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
38
35
  chars / 4
39
36
  }
40
37
 
41
- // Split indices: msgs[..sys_end] are leading system messages (kept) and
42
- // msgs[tail_start..] are the recent tail (kept); the middle is summarized.
43
38
  fn compaction_split(msgs: &[Msg]) -> (usize, usize) {
44
39
  let sys_end = msgs.iter().take_while(|m| matches!(m, Msg::System(_))).count();
45
40
  let tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
46
41
  (sys_end, tail_start)
47
42
  }
48
43
 
49
- // Deterministic, no-model fallback: list the actions taken in the middle.
50
44
  fn structural_summary(middle: &[Msg]) -> String {
51
45
  let mut actions: Vec<String> = Vec::new();
52
46
  for m in middle {
@@ -63,13 +57,16 @@ fn structural_summary(middle: &[Msg]) -> String {
63
57
  }
64
58
  }
65
59
 
66
- // Flatten messages to text for the summarizer (bounded per message).
67
60
  fn render_msgs(msgs: &[Msg]) -> String {
68
61
  let mut s = String::new();
69
62
  for m in msgs {
70
63
  match m {
71
64
  Msg::System(t) => { s.push_str("system: "); s.push_str(t); }
72
65
  Msg::User(t) => { s.push_str("user: "); s.push_str(t); }
66
+ Msg::UserImages { text, images } => {
67
+ s.push_str(&format!("user [+{} image(s)]: ", images.len()));
68
+ s.push_str(text);
69
+ }
73
70
  Msg::Assistant { text, calls } => {
74
71
  s.push_str("assistant: ");
75
72
  s.push_str(text);
@@ -89,8 +86,6 @@ fn render_msgs(msgs: &[Msg]) -> String {
89
86
  s
90
87
  }
91
88
 
92
- // Rebuild: system prefix + a single summary note + recent tail. `summarize` is a
93
- // closure so tests can inject a deterministic summary instead of a model call.
94
89
  fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec<Msg> {
95
90
  let (sys_end, tail_start) = compaction_split(&msgs);
96
91
  if tail_start <= sys_end {
@@ -107,7 +102,6 @@ fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec
107
102
  v
108
103
  }
109
104
 
110
- // Ask the model to summarize; fall back to the structural summary on error.
111
105
  fn model_summary(p: &Provider, middle: &[Msg]) -> String {
112
106
  let sys = "Summarize this AI coding-agent conversation into a terse brief that preserves the task, key findings, decisions, files changed, and the current state. Drop pleasantries; use compact bullet points.";
113
107
  let q = vec![Msg::System(sys.into()), Msg::User(render_msgs(middle))];
@@ -117,9 +111,8 @@ fn model_summary(p: &Provider, middle: &[Msg]) -> String {
117
111
  }
118
112
  }
119
113
 
120
- // Compact in place when the running context exceeds ~70% of the window.
121
114
  fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
122
- let budget = p.context_tokens.saturating_mul(7) / 10;
115
+ let budget = p.context_tokens.saturating_mul(8) / 10;
123
116
  if budget == 0 || estimate_tokens(msgs) <= budget {
124
117
  return;
125
118
  }
@@ -132,6 +125,7 @@ fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
132
125
  *msgs = compact_with(taken, |middle| model_summary(p, middle));
133
126
  }
134
127
 
128
+ // ── permissions ───────────────────────────────────────────────────────────────
135
129
  #[derive(Clone, Copy)]
136
130
  pub enum Permission {
137
131
  Ask,
@@ -140,13 +134,15 @@ pub enum Permission {
140
134
  }
141
135
 
142
136
  pub fn permission(s: &str) -> Permission {
143
- match s {
144
- "auto" => Permission::Auto,
145
- "readonly" => Permission::ReadOnly,
137
+ let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
138
+ match normalized.as_str() {
139
+ "auto" | "acceptedits" | "acceptedit" | "bypasspermissions" => Permission::Auto,
140
+ "readonly" | "read" | "plan" | "dontask" => Permission::ReadOnly,
146
141
  _ => Permission::Ask,
147
142
  }
148
143
  }
149
144
 
145
+ // ── roles ─────────────────────────────────────────────────────────────────────
150
146
  pub struct Role {
151
147
  pub system: &'static str,
152
148
  }
@@ -154,14 +150,118 @@ pub struct Role {
154
150
  pub fn role(id: &str) -> Role {
155
151
  let system = match id {
156
152
  "researcher" => "You are a meticulous research engineer. Investigate the codebase with the read and list tools before drawing conclusions. Cite file paths. Do not modify files unless explicitly asked.",
157
- _ => "You are an autonomous senior software engineer. Use the tools to inspect and modify the project directly. Prefer small, verifiable edits. Read before you write. When the task is complete, call the finish tool with a one-paragraph summary.",
153
+ _ => "You are an autonomous senior software engineer. \
154
+ Use the tools to inspect and modify the project directly. \
155
+ Prefer small, verifiable edits. Read before you write. \
156
+ When the task is complete, call the finish tool with a one-paragraph summary.\n\n\
157
+ IMPORTANT — tool discipline:\n\
158
+ • Before using run_command to install anything (npm install, pip install, cargo add, brew install, \
159
+ apt install, etc.), FIRST check whether an existing package or built-in tool can do the job. \
160
+ Look at package.json / Cargo.toml / requirements.txt / pyproject.toml to see what is already available. \
161
+ Use run_command with grep/find/jq/awk/sed/git/curl/which before reaching for new installs.\n\
162
+ • fetch_url is built-in — do NOT install curl/wget just to make an HTTP request.\n\
163
+ • read_file / list_dir / edit_file are built-in — do NOT install file-management packages for basic I/O.\n\
164
+ • If a system tool is already present (check with `which <tool>` or `command -v <tool>`) prefer it \
165
+ over installing an alternative.",
158
166
  };
159
167
  Role { system }
160
168
  }
161
169
 
170
+ // Build the system prompt prefix from memory and skills/agents files.
171
+ fn context_prefix() -> String {
172
+ let mut parts: Vec<String> = Vec::new();
173
+
174
+ // Always include the tool manifest so the model knows what's built-in
175
+ // and doesn't try to install external tools to do things we already handle.
176
+ parts.push(tool_manifest());
177
+
178
+ // Probe which common system tools are actually present so the model can
179
+ // pick the right one without guessing or installing alternatives.
180
+ let env_snap = env_snapshot();
181
+ if !env_snap.is_empty() {
182
+ parts.push(format!("[Environment — tools already installed]\n{env_snap}"));
183
+ }
184
+
185
+ if let Some(mem) = config::load_memory() {
186
+ parts.push(format!("[Memory from previous sessions]\n{mem}"));
187
+ }
188
+ if let Some(agents) = config::load_agents() {
189
+ parts.push(format!("[Agent knowledge — Agents.md]\n{agents}"));
190
+ }
191
+ let skills = config::load_skills();
192
+ if !skills.is_empty() {
193
+ let joined = skills.iter()
194
+ .map(|(name, content)| format!("## Skill: {name}\n{content}"))
195
+ .collect::<Vec<_>>().join("\n\n");
196
+ parts.push(format!("[Available skills]\n{joined}"));
197
+ }
198
+
199
+ format!("{}\n\n", parts.join("\n\n"))
200
+ }
201
+
202
+ fn tool_manifest() -> String {
203
+ "[Built-in tools — always available, no install needed]\n\
204
+ • read_file / list_dir — read any file or directory on the filesystem; read_file supports start_line/end_line\n\
205
+ • find_files / grep_files — search the codebase without shelling out; prefer these for navigation before run_command\n\
206
+ • write_file / edit_file — create or surgically modify files\n\
207
+ • run_command — run any shell command: grep, find, git, cargo, make, npm, python3, etc.\n\
208
+ • fetch_url — HTTP GET (no curl/wget needed)\n\
209
+ • web_search — DuckDuckGo web search (no API key, live results)\n\
210
+ • save_memory — persist a note across sessions\n\
211
+ • spawn_subagent — delegate a sub-task to a fresh agent with its own context\n\
212
+ • finish — mark the task complete with a summary"
213
+ .to_string()
214
+ }
215
+
216
+ fn env_snapshot() -> String {
217
+ // Probe a fixed list of common tools so the model knows what's available.
218
+ // We run everything in parallel-ish with short timeouts via sequential calls —
219
+ // the total overhead is ~10ms on a modern machine.
220
+ let probes: &[(&str, &str)] = &[
221
+ ("node", "node --version"),
222
+ ("npm", "npm --version"),
223
+ ("npx", "npx --version"),
224
+ ("python3", "python3 --version"),
225
+ ("pip3", "pip3 --version"),
226
+ ("cargo", "cargo --version"),
227
+ ("rustc", "rustc --version"),
228
+ ("git", "git --version"),
229
+ ("docker", "docker --version"),
230
+ ("jq", "jq --version"),
231
+ ("rg", "rg --version"),
232
+ ("gh", "gh --version"),
233
+ ("bun", "bun --version"),
234
+ ("deno", "deno --version"),
235
+ ("go", "go version"),
236
+ ("ruby", "ruby --version"),
237
+ ("java", "java --version"),
238
+ ];
239
+
240
+ use std::process::Command;
241
+ let mut found: Vec<String> = Vec::new();
242
+ for (label, cmd) in probes {
243
+ let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
244
+ let bin = parts[0];
245
+ let arg = parts.get(1).copied().unwrap_or("--version");
246
+ let ok = Command::new(bin).arg(arg)
247
+ .stdout(std::process::Stdio::piped())
248
+ .stderr(std::process::Stdio::piped())
249
+ .output()
250
+ .map(|o| {
251
+ let txt = String::from_utf8_lossy(&o.stdout).trim().to_string();
252
+ let txt = if txt.is_empty() { String::from_utf8_lossy(&o.stderr).trim().to_string() } else { txt };
253
+ txt.lines().next().unwrap_or("").to_string()
254
+ })
255
+ .ok()
256
+ .filter(|v| !v.is_empty());
257
+ if let Some(ver) = ok {
258
+ found.push(format!("• {label}: {ver}"));
259
+ }
260
+ }
261
+ found.join("\n")
262
+ }
263
+
162
264
  fn confirm(label: &str) -> Option<String> {
163
- // Never block waiting for input when no human can answer (JSON/automation or
164
- // a non-terminal stdin) — deny by default instead of hanging.
165
265
  if report::is_json() || !std::io::stdin().is_terminal() {
166
266
  return Some(format!("blocked (no interactive terminal to confirm: {label})"));
167
267
  }
@@ -174,23 +274,32 @@ fn confirm(label: &str) -> Option<String> {
174
274
  }
175
275
  }
176
276
 
177
- // Returns Some(reason) when a call is blocked, None when allowed. Layers:
178
- // sensitive paths and catastrophic commands always require an explicit yes
179
- // (in auto/headless an un-answerable prompt safely resolves to denial); then
180
- // the chosen permission mode governs mutations and out-of-cwd reads.
181
- fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
277
+ // Permission gate — returns Some(reason) when blocked.
278
+ // Read operations outside CWD are allowed in all modes (no CWD confinement for
279
+ // reads); the user specifically asked for full filesystem access.
280
+ pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
182
281
  let path = tools::touched_path(name, input, cwd);
183
282
 
184
283
  if let Some(p) = &path {
185
284
  if tools::is_sensitive(p) {
186
285
  return confirm(&format!("access sensitive path {}", p.display()));
187
286
  }
287
+ // In WSL2, writing to a Windows drive mount (/mnt/c/, /mnt/d/, etc.)
288
+ // crosses the OS boundary — always confirm, even in Auto mode.
289
+ if tools::is_mutating(name) && tools::is_wsl_windows_mount(p) {
290
+ return confirm(&format!("write to Windows filesystem {} (WSL2 boundary)", p.display()));
291
+ }
188
292
  }
189
293
  if name == "run_command" {
190
294
  if let Some(c) = input["command"].as_str() {
191
295
  if tools::catastrophic(c) {
192
296
  return confirm(&format!("run dangerous command `{c}`"));
193
297
  }
298
+ // In WSL2, commands that reference /mnt/<drive>/ target the Windows
299
+ // filesystem — confirm before running, even in Auto mode.
300
+ if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
301
+ return confirm(&format!("command targets Windows filesystem (WSL2): `{c}`"));
302
+ }
194
303
  }
195
304
  }
196
305
 
@@ -198,44 +307,58 @@ fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) ->
198
307
  Permission::Auto => None,
199
308
  Permission::ReadOnly => {
200
309
  if tools::is_mutating(name) {
201
- return Some("read-only mode: mutation skipped".into());
202
- }
203
- if let Some(p) = &path {
204
- if tools::escapes_cwd(p, cwd) {
205
- return Some(format!("read-only: refusing to read outside the working directory ({})", p.display()));
310
+ // run_command with clearly read-only shell tools (grep, find, etc.)
311
+ // should pass through even in ReadOnly mode.
312
+ if name == "run_command" {
313
+ if let Some(c) = input["command"].as_str() {
314
+ if tools::is_readonly_command(c) {
315
+ return None;
316
+ }
317
+ }
206
318
  }
319
+ return Some("read-only mode: mutation skipped".into());
207
320
  }
208
- None
321
+ None // reads anywhere are allowed in readonly mode
209
322
  }
210
323
  Permission::Ask => {
324
+ // run_command calls whose binary appears in allowed_commands skip the
325
+ // confirmation prompt — git, cargo, npm, etc. should just work.
326
+ if name == "run_command" {
327
+ if let Some(c) = input["command"].as_str() {
328
+ let first = c.split_whitespace().next().unwrap_or("");
329
+ // Accept both bare names and full paths (/usr/bin/git → git).
330
+ let bin = std::path::Path::new(first)
331
+ .file_name().and_then(|n| n.to_str()).unwrap_or(first);
332
+ if config::load_allowed_commands().iter().any(|a| a == bin) {
333
+ return None;
334
+ }
335
+ }
336
+ }
211
337
  if tools::is_mutating(name) {
212
338
  return confirm(&tools::preview(name, input));
213
339
  }
214
- if let Some(p) = &path {
215
- if tools::escapes_cwd(p, cwd) {
216
- return confirm(&format!("read outside working directory: {}", p.display()));
217
- }
218
- }
340
+ // Out-of-cwd reads: just note it instead of hard-blocking.
341
+ // The user asked for full filesystem access.
219
342
  None
220
343
  }
221
344
  }
222
345
  }
223
346
 
224
- // One ReAct turn-and-tools loop. Shared by BUILD and the execution half of PLAN.
225
- // The Stop hook always fires once the turn ends, however it ended.
347
+ // Public compact helper for the /compact REPL command.
348
+ pub fn compact_msgs(p: &Provider, msgs: Vec<Msg>) -> Vec<Msg> {
349
+ compact_with(msgs, |middle| model_summary(p, middle))
350
+ }
351
+
352
+ // ── BUILD mode ────────────────────────────────────────────────────────────────
226
353
  pub fn run_build(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path) -> Result<(), String> {
227
354
  let mut transcript: Vec<Msg> = Vec::new();
228
355
  run_build_session(p, perm, role_id, task, cwd, &mut transcript, &crate::session::new_id())
229
356
  }
230
357
 
231
- // Continue a restored session: `seed` is the prior transcript, persisted under
232
- // the same `sid` so resume updates the session rather than forking it.
233
358
  pub fn run_build_resumed(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, mut seed: Vec<Msg>, sid: &str) -> Result<(), String> {
234
359
  run_build_session(p, perm, role_id, task, cwd, &mut seed, sid)
235
360
  }
236
361
 
237
- // Run a BUILD task into a caller-owned transcript, persisting the session. The
238
- // REPL uses this to keep one continuous, resumable conversation across tasks.
239
362
  pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, transcript: &mut Vec<Msg>, sid: &str) -> Result<(), String> {
240
363
  let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
241
364
  hooks::notify("Stop", cwd);
@@ -243,9 +366,6 @@ pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &s
243
366
  r
244
367
  }
245
368
 
246
- // Returns the final assistant text / finish summary, so a parent can read a
247
- // spawned subagent's result. `depth` bounds recursion via spawn_subagent.
248
- // `msgs` is the working transcript (seeded for resume, otherwise empty).
249
369
  fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, depth: usize, msgs: &mut Vec<Msg>) -> Result<String, String> {
250
370
  let task = match hooks::user_prompt_submit(task, cwd) {
251
371
  Err(reason) => {
@@ -257,37 +377,62 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
257
377
  };
258
378
  let defs = tools::defs(depth < MAX_DEPTH);
259
379
  if msgs.is_empty() {
260
- msgs.push(Msg::System(role(role_id).system.into()));
380
+ let prefix = context_prefix();
381
+ let sys = format!("{prefix}{}", role(role_id).system);
382
+ msgs.push(Msg::System(sys));
383
+ }
384
+ // If the caller already pushed a UserImages message (multimodal input), use its
385
+ // text as the task without pushing another User turn; otherwise push normally.
386
+ let already_pushed = matches!(msgs.last(), Some(Msg::UserImages { .. }));
387
+ if !already_pushed {
388
+ msgs.push(Msg::User(task));
261
389
  }
262
- msgs.push(Msg::User(task));
263
390
 
264
- for _ in 0..MAX_ITERS {
391
+ // Track which files have been read this session so we can enforce read-before-write.
392
+ let mut read_paths: std::collections::HashSet<PathBuf> = Default::default();
393
+
394
+ for step in 1..=MAX_ITERS {
265
395
  if tui::interrupted() {
266
396
  report::notice("interrupted");
267
397
  return Ok(String::new());
268
398
  }
269
399
  maybe_compact(p, msgs);
270
- // JSON mode buffers (one clean event); human mode streams tokens live,
271
- // with a spinner covering the gap until the first token so a slow model
272
- // never looks frozen.
273
400
  let reply: Reply = if report::is_json() {
274
401
  let r = complete(p, msgs.as_slice(), &defs)?;
275
402
  report::assistant(&r.text);
276
403
  r
277
404
  } else {
405
+ // Show a step counter so the user can see multi-step reasoning progress.
406
+ if step > 1 {
407
+ tui::line(&tui::dim(&format!(" ↻ step {step}")));
408
+ }
278
409
  let mut spin = Some(tui::spinner_start("thinking…"));
279
410
  let mut streamed = false;
411
+ let mut thinking_buf = String::new();
412
+ let mut renderer = tui::StreamRenderer::new();
280
413
  let res = provider::stream(p, msgs.as_slice(), &defs, &mut |c| {
281
414
  if let Some(s) = spin.take() {
282
415
  tui::spinner_stop(s);
283
416
  }
284
- report::assistant_delta(c);
417
+ renderer.push(c);
285
418
  streamed = true;
419
+ }, &mut |t| {
420
+ // Extended thinking: buffer and display as dim internal monologue.
421
+ thinking_buf.push_str(t);
422
+ // Flush complete sentences/lines so output feels live.
423
+ while let Some(nl) = thinking_buf.find('\n') {
424
+ let line = thinking_buf[..nl].trim().to_string();
425
+ if !line.is_empty() {
426
+ tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&line)));
427
+ }
428
+ thinking_buf = thinking_buf[nl + 1..].to_string();
429
+ }
286
430
  });
287
431
  if let Some(s) = spin.take() {
288
- tui::spinner_stop(s); // no text streamed (tool-only turn) — clear it
432
+ tui::spinner_stop(s);
289
433
  }
290
434
  let r = res?;
435
+ renderer.flush();
291
436
  if streamed {
292
437
  report::assistant_end();
293
438
  }
@@ -297,25 +442,20 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
297
442
  if reply.text.trim().is_empty() {
298
443
  report::notice("model returned no output");
299
444
  }
300
- return Ok(reply.text); // model answered in prose
445
+ return Ok(reply.text);
301
446
  }
302
447
 
303
448
  let mut results = Vec::new();
304
449
  let mut summary: Option<String> = None;
305
450
  for call in &reply.calls {
306
- // Reject calls whose JSON arguments didn't parse, with a message the
307
- // model can act on (common failure mode for small local models).
308
451
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
309
452
  let msg = format!("tool arguments were not valid JSON: {}", raw.chars().take(200).collect::<String>());
310
453
  report::tool_denied(&msg);
311
454
  results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
312
455
  continue;
313
456
  }
314
- // Show the proposed action (with an inline diff for edits) first, so
315
- // the approval prompt that follows has visible context.
316
457
  report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
317
458
 
318
- // PreToolUse hook can allow (skip the gate), deny, or defer to it.
319
459
  let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
320
460
  PreDecision::Deny(r) => Some(r),
321
461
  PreDecision::Allow => None,
@@ -327,12 +467,54 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
327
467
  continue;
328
468
  }
329
469
 
470
+ // Record reads so we can enforce read-before-write below.
471
+ if call.name == "read_file" {
472
+ if let Some(raw) = call.input["path"].as_str() {
473
+ let p = if std::path::Path::new(raw).is_absolute() {
474
+ PathBuf::from(raw)
475
+ } else {
476
+ cwd.join(raw)
477
+ };
478
+ read_paths.insert(p);
479
+ }
480
+ }
481
+
482
+ // Require the model to read a file before overwriting or patching it.
483
+ if matches!(call.name.as_str(), "write_file" | "edit_file") {
484
+ if let Some(raw) = call.input["path"].as_str() {
485
+ let p = if std::path::Path::new(raw).is_absolute() {
486
+ PathBuf::from(raw)
487
+ } else {
488
+ cwd.join(raw)
489
+ };
490
+ if p.exists() && !read_paths.contains(&p) {
491
+ let msg = format!(
492
+ "read_file('{}') required before editing. Read the file first, then retry.",
493
+ p.display()
494
+ );
495
+ report::tool_denied(&msg);
496
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
497
+ continue;
498
+ }
499
+ }
500
+ }
501
+
330
502
  if call.name == "spawn_subagent" {
331
503
  let out = spawn_subagent(p, perm, &call.input, cwd, depth);
332
504
  report::tool_result(&call.name, &out, false);
333
505
  results.push(ToolResult { id: call.id.clone(), content: out, is_error: false });
334
506
  continue;
335
507
  }
508
+ // Memory-save tool: the model can call save_memory to persist facts.
509
+ if call.name == "save_memory" {
510
+ if let Some(note) = call.input["note"].as_str() {
511
+ config::append_memory(note);
512
+ let msg = "memory saved".to_string();
513
+ report::tool_result(&call.name, &msg, false);
514
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
515
+ continue;
516
+ }
517
+ }
336
518
 
337
519
  let out = tools::run(&call.name, &call.input, cwd);
338
520
  hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
@@ -346,6 +528,10 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
346
528
 
347
529
  msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
348
530
  msgs.push(Msg::Tool(results));
531
+ if !report::is_json() {
532
+ tui::context_meter(estimate_tokens(msgs), p.context_tokens);
533
+ tui::poll_typeahead();
534
+ }
349
535
  if let Some(s) = summary {
350
536
  return Ok(s);
351
537
  }
@@ -355,8 +541,6 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
355
541
 
356
542
  static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
357
543
 
358
- // Delegate a self-contained sub-task to a fresh agent (own context window).
359
- // `isolate` runs it in a new git worktree so parallel edits never collide.
360
544
  fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd: &Path, depth: usize) -> String {
361
545
  if depth + 1 >= MAX_DEPTH {
362
546
  return "subagent depth limit reached".into();
@@ -381,7 +565,6 @@ fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd
381
565
  };
382
566
 
383
567
  report::notice(&format!(" ↳ subagent: {task}"));
384
- // A subagent gets its own fresh context window (not persisted as a session).
385
568
  let mut child: Vec<Msg> = Vec::new();
386
569
  let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
387
570
  .unwrap_or_else(|e| format!("subagent error: {e}"));
@@ -401,13 +584,59 @@ fn make_worktree(cwd: &Path) -> Option<PathBuf> {
401
584
  out.status.success().then_some(wt)
402
585
  }
403
586
 
404
- // PLAN: ask for a numbered breakdown, let the user approve/edit, then execute.
587
+ // ── PLAN mode ─────────────────────────────────────────────────────────────────
588
+ // The planning phase now has tools available so the model can inspect the
589
+ // codebase while breaking down the task. Execution still runs through BUILD.
405
590
  pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
406
- let sys = "You are a planning engineer. Break the user's task into a concise numbered list of concrete steps (no prose, one step per line, max 8 steps). Output ONLY the list.";
407
- let msgs = vec![Msg::System(sys.into()), Msg::User(task.into())];
408
- let reply = tui::with_spinner("planning…", || complete(p, &msgs, &[]))?;
591
+ let prefix = context_prefix();
592
+ let sys = format!("{prefix}You are a planning engineer with full access to the codebase. \
593
+ Use read_file and list_dir and run_command to inspect the project as needed. \
594
+ Break the user's task into a concise numbered list of concrete steps \
595
+ (one step per line, max 8 steps). Output the numbered list and nothing else.");
596
+
597
+ let defs = tools::defs(false); // planning uses tools but not subagents
598
+ let mut msgs = vec![Msg::System(sys), Msg::User(task.into())];
599
+
600
+ // Let the model use tools while planning (e.g. read files to understand structure).
601
+ let plan_text = loop {
602
+ maybe_compact(p, &mut msgs);
603
+ let reply = tui::with_spinner("planning…", || complete(p, &msgs, &defs))?;
604
+
605
+ if reply.calls.is_empty() {
606
+ break reply.text;
607
+ }
608
+
609
+ // Execute tool calls during the planning phase.
610
+ let mut results = Vec::new();
611
+ for call in &reply.calls {
612
+ report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
613
+ let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
614
+ PreDecision::Deny(r) => Some(r),
615
+ PreDecision::Allow => None,
616
+ PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
617
+ };
618
+ if let Some(reason) = reason {
619
+ results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
620
+ continue;
621
+ }
622
+ if call.name == "save_memory" {
623
+ if let Some(note) = call.input["note"].as_str() {
624
+ config::append_memory(note);
625
+ let msg = "memory saved".to_string();
626
+ report::tool_result(&call.name, &msg, false);
627
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
628
+ continue;
629
+ }
630
+ }
631
+ let out = tools::run(&call.name, &call.input, cwd);
632
+ report::tool_result(&call.name, &out.content, out.is_error);
633
+ results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
634
+ }
635
+ msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
636
+ msgs.push(Msg::Tool(results));
637
+ };
409
638
 
410
- let mut steps: Vec<String> = reply.text.lines()
639
+ let mut steps: Vec<String> = plan_text.lines()
411
640
  .map(|l| l.trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | ')' | '-' | ' ')).trim())
412
641
  .filter(|l| !l.is_empty())
413
642
  .map(|l| l.to_string())
@@ -448,33 +677,129 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
448
677
  run_build(p, perm, "engineer", &full, cwd)
449
678
  }
450
679
 
451
- // BRAINSTORM: free-form chat, no tools, history retained across turns.
452
- pub fn run_brainstorm(p: &Provider, first: &str) -> Result<(), String> {
453
- let mut msgs: Vec<Msg> = vec![Msg::System(
454
- "You are a sharp, concise thought partner. Offer ideas, tradeoffs, and concrete suggestions. No fluff.".into(),
455
- )];
680
+ // ── BRAINSTORM mode ───────────────────────────────────────────────────────────
681
+ // Brainstorm is conversational but has full tool access. The model can grep,
682
+ // read files, fetch URLs, and run commands when the conversation calls for it.
683
+ // It also has a mode-transition sensor: if it detects the user wants to build
684
+ // or plan, it suggests switching.
685
+ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -> Result<Option<ModeHint>, String> {
686
+ let prefix = context_prefix();
687
+ let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
688
+ Use tools freely to look things up, read files, grep for patterns, or run commands — \
689
+ whatever helps the conversation. \
690
+ When you think the user is ready to stop discussing and start building or planning, \
691
+ end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
692
+ Otherwise just respond naturally. No fluff.");
693
+
694
+ let defs = tools::defs(false);
695
+ let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
456
696
  let mut question = first.to_string();
697
+
457
698
  loop {
458
699
  msgs.push(Msg::User(question.clone()));
459
700
  maybe_compact(p, &mut msgs);
460
- tui::line("");
461
- let mut streamed = false;
462
- let reply = provider::stream(p, &msgs, &[], &mut |c| {
463
- report::assistant_delta(c);
464
- streamed = true;
465
- })?;
466
- if streamed {
467
- report::assistant_end();
701
+
702
+ // Keep consuming tool calls until the model gives a text response.
703
+ let reply_text = loop {
704
+ tui::line("");
705
+ let mut spin = Some(tui::spinner_start("thinking…"));
706
+ let mut streamed = false;
707
+ let mut thinking_buf = String::new();
708
+ let mut renderer = if !report::is_json() { Some(tui::StreamRenderer::new()) } else { None };
709
+ let res = provider::stream(p, &msgs, &defs, &mut |c| {
710
+ if let Some(s) = spin.take() { tui::spinner_stop(s); }
711
+ if let Some(r) = &mut renderer { r.push(c); } else { report::assistant_delta(c); }
712
+ streamed = true;
713
+ }, &mut |t| {
714
+ thinking_buf.push_str(t);
715
+ while let Some(nl) = thinking_buf.find('\n') {
716
+ let tl = thinking_buf[..nl].trim().to_string();
717
+ if !tl.is_empty() {
718
+ tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&tl)));
719
+ }
720
+ thinking_buf = thinking_buf[nl + 1..].to_string();
721
+ }
722
+ });
723
+ if let Some(s) = spin.take() { tui::spinner_stop(s); }
724
+ let reply = res?;
725
+ if let Some(r) = &mut renderer { r.flush(); }
726
+ if streamed { report::assistant_end(); }
727
+
728
+ if reply.calls.is_empty() {
729
+ msgs.push(Msg::Assistant { text: reply.text.clone(), calls: vec![] });
730
+ if !report::is_json() {
731
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
732
+ }
733
+ break reply.text;
734
+ }
735
+
736
+ // Execute tool calls inline.
737
+ let mut results = Vec::new();
738
+ for call in &reply.calls {
739
+ report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
740
+ let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
741
+ PreDecision::Deny(r) => Some(r),
742
+ PreDecision::Allow => None,
743
+ PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
744
+ };
745
+ if let Some(reason) = reason {
746
+ results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
747
+ continue;
748
+ }
749
+ if call.name == "save_memory" {
750
+ if let Some(note) = call.input["note"].as_str() {
751
+ config::append_memory(note);
752
+ let msg = "memory saved".to_string();
753
+ report::tool_result(&call.name, &msg, false);
754
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
755
+ continue;
756
+ }
757
+ }
758
+ let out = tools::run(&call.name, &call.input, cwd);
759
+ hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
760
+ report::tool_result(&call.name, &out.content, out.is_error);
761
+ results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
762
+ }
763
+ msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
764
+ msgs.push(Msg::Tool(results));
765
+ if !report::is_json() {
766
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
767
+ tui::poll_typeahead();
768
+ }
769
+ };
770
+
771
+ // Check for mode-transition suggestion embedded in the reply.
772
+ let hint = if reply_text.contains("[SUGGEST:BUILD]") {
773
+ Some(ModeHint::Build)
774
+ } else if reply_text.contains("[SUGGEST:PLAN]") {
775
+ Some(ModeHint::Plan)
776
+ } else {
777
+ None
778
+ };
779
+
780
+ if let Some(ref h) = hint {
781
+ tui::line("");
782
+ let suggestion = match h {
783
+ ModeHint::Build => "switch to BUILD mode and implement this?",
784
+ ModeHint::Plan => "switch to PLAN mode and break this down?",
785
+ ModeHint::CycleMode => "cycle to the next mode?",
786
+ };
787
+ tui::line(&tui::yellow(&format!(" ↪ AI suggests: {suggestion}")));
788
+ tui::line(&tui::dim(" (y to switch, anything else to keep chatting)"));
789
+ let ans = tui::ask(" ").unwrap_or_default();
790
+ if matches!(ans.trim().to_lowercase().as_str(), "y" | "yes") {
791
+ return Ok(Some(h.clone()));
792
+ }
468
793
  }
469
- tui::line("");
470
- msgs.push(Msg::Assistant { text: reply.text, calls: vec![] });
471
794
 
795
+ tui::line("");
472
796
  match tui::ask_task(&format!("{} ", tui::blue("you ›"))) {
473
- None => return Ok(()),
474
- Some(f) => {
797
+ None => return Ok(None),
798
+ Some(tui::InputEvent::CycleMode) => return Ok(Some(ModeHint::CycleMode)),
799
+ Some(tui::InputEvent::Text(f)) => {
475
800
  let t = f.trim();
476
801
  if t.is_empty() || t == "exit" || t == "done" {
477
- return Ok(());
802
+ return Ok(None);
478
803
  }
479
804
  question = t.to_string();
480
805
  }
@@ -482,33 +807,38 @@ pub fn run_brainstorm(p: &Provider, first: &str) -> Result<(), String> {
482
807
  }
483
808
  }
484
809
 
810
+ #[derive(Clone)]
811
+ pub enum ModeHint {
812
+ Build,
813
+ Plan,
814
+ CycleMode,
815
+ }
816
+
485
817
  #[cfg(test)]
486
818
  mod tests {
487
819
  use super::*;
488
820
  use serde_json::json;
489
821
 
490
- // ── permission ──────────────────────────────────────────────────────────
491
822
  #[test]
492
823
  fn permission_parsing() {
493
824
  assert!(matches!(permission("auto"), Permission::Auto));
825
+ assert!(matches!(permission("acceptEdits"), Permission::Auto));
826
+ assert!(matches!(permission("bypass-permissions"), Permission::Auto));
494
827
  assert!(matches!(permission("readonly"), Permission::ReadOnly));
828
+ assert!(matches!(permission("read-only"), Permission::ReadOnly));
829
+ assert!(matches!(permission("plan"), Permission::ReadOnly));
495
830
  assert!(matches!(permission("ask"), Permission::Ask));
496
831
  assert!(matches!(permission("anything-else"), Permission::Ask));
497
832
  assert!(matches!(permission(""), Permission::Ask));
498
833
  }
499
834
 
500
- // ── role ────────────────────────────────────────────────────────────────
501
835
  #[test]
502
836
  fn role_selection() {
503
837
  assert!(role("researcher").system.contains("research"));
504
838
  assert!(role("engineer").system.contains("software engineer"));
505
- // Unknown roles fall back to the engineer prompt.
506
839
  assert!(role("ceo").system.contains("software engineer"));
507
840
  }
508
841
 
509
- // ── gate ────────────────────────────────────────────────────────────────
510
- // These run with a non-terminal stdin, so any path that would prompt resolves
511
- // to a denial (Some(reason)) rather than blocking.
512
842
  #[test]
513
843
  fn gate_auto_allows_ordinary_mutation() {
514
844
  let cwd = Path::new("/proj");
@@ -519,7 +849,6 @@ mod tests {
519
849
  #[test]
520
850
  fn gate_auto_still_confirms_sensitive_path() {
521
851
  let cwd = Path::new("/proj");
522
- // Sensitive even under auto → would prompt → denied in non-terminal tests.
523
852
  let r = gate(Permission::Auto, "read_file", &json!({"path": "/proj/.env"}), cwd);
524
853
  assert!(r.is_some());
525
854
  }
@@ -546,38 +875,48 @@ mod tests {
546
875
  }
547
876
 
548
877
  #[test]
549
- fn gate_readonly_blocks_out_of_cwd_read() {
878
+ fn gate_readonly_allows_reads_everywhere() {
879
+ // Reads outside CWD are now allowed — full filesystem access.
550
880
  let cwd = Path::new("/proj/work");
551
881
  let r = gate(Permission::ReadOnly, "read_file", &json!({"path": "/etc/passwd"}), cwd);
552
- assert!(r.unwrap().contains("outside"));
882
+ assert!(r.is_none());
553
883
  }
554
884
 
555
885
  #[test]
556
- fn gate_readonly_allows_in_cwd_read() {
557
- let cwd = Path::new("/proj/work");
558
- let r = gate(Permission::ReadOnly, "read_file", &json!({"path": "/proj/work/a.rs"}), cwd);
886
+ fn gate_ask_allows_out_of_cwd_read() {
887
+ // Full filesystem read access — no longer blocked in Ask mode.
888
+ let cwd = Path::new("/proj");
889
+ let r = gate(Permission::Ask, "read_file", &json!({"path": "/home/user/docs/README.md"}), cwd);
559
890
  assert!(r.is_none());
560
891
  }
561
892
 
562
893
  #[test]
563
894
  fn gate_ask_prompts_for_mutation() {
564
895
  let cwd = Path::new("/proj");
565
- // Would prompt → denied in non-terminal test env.
566
896
  let r = gate(Permission::Ask, "write_file", &json!({"path": "a", "content": "x"}), cwd);
567
- assert!(r.is_some());
897
+ assert!(r.is_some()); // non-terminal → denied
568
898
  }
569
899
 
570
900
  #[test]
571
- fn gate_ask_allows_in_cwd_read() {
901
+ fn gate_ask_allows_default_allowed_commands() {
572
902
  let cwd = Path::new("/proj");
573
- let r = gate(Permission::Ask, "read_file", &json!({"path": "/proj/a.rs"}), cwd);
574
- assert!(r.is_none());
903
+ // git, cargo, npm are in the default allowed_commands list
904
+ for cmd in &["git status", "cargo test", "npm install", "git commit -m 'x'"] {
905
+ let r = gate(Permission::Ask, "run_command", &json!({"command": cmd}), cwd);
906
+ assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
907
+ }
908
+ }
909
+
910
+ #[test]
911
+ fn gate_ask_still_prompts_for_unknown_command() {
912
+ let cwd = Path::new("/proj");
913
+ let r = gate(Permission::Ask, "run_command", &json!({"command": "mycustombinary --flag"}), cwd);
914
+ assert!(r.is_some()); // not in allowed list → denied (non-terminal)
575
915
  }
576
916
 
577
- // ── compaction ──────────────────────────────────────────────────────────
578
917
  #[test]
579
918
  fn estimate_tokens_counts_text() {
580
- assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10); // ~4 chars/token
919
+ assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10);
581
920
  }
582
921
 
583
922
  #[test]
@@ -605,10 +944,9 @@ mod tests {
605
944
  msgs.push(Msg::User(format!("u{i}")));
606
945
  }
607
946
  let out = compact_with(msgs, |_| "SUMMARY".into());
608
- assert_eq!(out.len(), 1 + 1 + KEEP_RECENT); // system + summary + tail
947
+ assert_eq!(out.len(), 1 + 1 + KEEP_RECENT);
609
948
  assert!(matches!(&out[0], Msg::System(s) if s == "sys"));
610
949
  assert!(matches!(&out[1], Msg::User(s) if s.contains("SUMMARY")));
611
- // The recent tail is preserved verbatim.
612
950
  assert!(matches!(out.last(), Some(Msg::User(s)) if s == "u9"));
613
951
  }
614
952