buildwithnexus 0.10.1 → 0.10.3

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,
@@ -147,6 +141,7 @@ pub fn permission(s: &str) -> Permission {
147
141
  }
148
142
  }
149
143
 
144
+ // ── roles ─────────────────────────────────────────────────────────────────────
150
145
  pub struct Role {
151
146
  pub system: &'static str,
152
147
  }
@@ -154,14 +149,117 @@ pub struct Role {
154
149
  pub fn role(id: &str) -> Role {
155
150
  let system = match id {
156
151
  "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.",
152
+ _ => "You are an autonomous senior software engineer. \
153
+ Use the tools to inspect and modify the project directly. \
154
+ Prefer small, verifiable edits. Read before you write. \
155
+ When the task is complete, call the finish tool with a one-paragraph summary.\n\n\
156
+ IMPORTANT — tool discipline:\n\
157
+ • Before using run_command to install anything (npm install, pip install, cargo add, brew install, \
158
+ apt install, etc.), FIRST check whether an existing package or built-in tool can do the job. \
159
+ Look at package.json / Cargo.toml / requirements.txt / pyproject.toml to see what is already available. \
160
+ Use run_command with grep/find/jq/awk/sed/git/curl/which before reaching for new installs.\n\
161
+ • fetch_url is built-in — do NOT install curl/wget just to make an HTTP request.\n\
162
+ • read_file / list_dir / edit_file are built-in — do NOT install file-management packages for basic I/O.\n\
163
+ • If a system tool is already present (check with `which <tool>` or `command -v <tool>`) prefer it \
164
+ over installing an alternative.",
158
165
  };
159
166
  Role { system }
160
167
  }
161
168
 
169
+ // Build the system prompt prefix from memory and skills/agents files.
170
+ fn context_prefix() -> String {
171
+ let mut parts: Vec<String> = Vec::new();
172
+
173
+ // Always include the tool manifest so the model knows what's built-in
174
+ // and doesn't try to install external tools to do things we already handle.
175
+ parts.push(tool_manifest());
176
+
177
+ // Probe which common system tools are actually present so the model can
178
+ // pick the right one without guessing or installing alternatives.
179
+ let env_snap = env_snapshot();
180
+ if !env_snap.is_empty() {
181
+ parts.push(format!("[Environment — tools already installed]\n{env_snap}"));
182
+ }
183
+
184
+ if let Some(mem) = config::load_memory() {
185
+ parts.push(format!("[Memory from previous sessions]\n{mem}"));
186
+ }
187
+ if let Some(agents) = config::load_agents() {
188
+ parts.push(format!("[Agent knowledge — Agents.md]\n{agents}"));
189
+ }
190
+ let skills = config::load_skills();
191
+ if !skills.is_empty() {
192
+ let joined = skills.iter()
193
+ .map(|(name, content)| format!("## Skill: {name}\n{content}"))
194
+ .collect::<Vec<_>>().join("\n\n");
195
+ parts.push(format!("[Available skills]\n{joined}"));
196
+ }
197
+
198
+ format!("{}\n\n", parts.join("\n\n"))
199
+ }
200
+
201
+ fn tool_manifest() -> String {
202
+ "[Built-in tools — always available, no install needed]\n\
203
+ • read_file / list_dir — read any file or directory on the filesystem\n\
204
+ • write_file / edit_file — create or surgically modify files\n\
205
+ • run_command — run any shell command: grep, find, git, cargo, make, npm, python3, etc.\n\
206
+ • fetch_url — HTTP GET (no curl/wget needed)\n\
207
+ • web_search — DuckDuckGo web search (no API key, live results)\n\
208
+ • save_memory — persist a note across sessions\n\
209
+ • spawn_subagent — delegate a sub-task to a fresh agent with its own context\n\
210
+ • finish — mark the task complete with a summary"
211
+ .to_string()
212
+ }
213
+
214
+ fn env_snapshot() -> String {
215
+ // Probe a fixed list of common tools so the model knows what's available.
216
+ // We run everything in parallel-ish with short timeouts via sequential calls —
217
+ // the total overhead is ~10ms on a modern machine.
218
+ let probes: &[(&str, &str)] = &[
219
+ ("node", "node --version"),
220
+ ("npm", "npm --version"),
221
+ ("npx", "npx --version"),
222
+ ("python3", "python3 --version"),
223
+ ("pip3", "pip3 --version"),
224
+ ("cargo", "cargo --version"),
225
+ ("rustc", "rustc --version"),
226
+ ("git", "git --version"),
227
+ ("docker", "docker --version"),
228
+ ("jq", "jq --version"),
229
+ ("rg", "rg --version"),
230
+ ("gh", "gh --version"),
231
+ ("bun", "bun --version"),
232
+ ("deno", "deno --version"),
233
+ ("go", "go version"),
234
+ ("ruby", "ruby --version"),
235
+ ("java", "java --version"),
236
+ ];
237
+
238
+ use std::process::Command;
239
+ let mut found: Vec<String> = Vec::new();
240
+ for (label, cmd) in probes {
241
+ let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
242
+ let bin = parts[0];
243
+ let arg = parts.get(1).copied().unwrap_or("--version");
244
+ let ok = Command::new(bin).arg(arg)
245
+ .stdout(std::process::Stdio::piped())
246
+ .stderr(std::process::Stdio::piped())
247
+ .output()
248
+ .map(|o| {
249
+ let txt = String::from_utf8_lossy(&o.stdout).trim().to_string();
250
+ let txt = if txt.is_empty() { String::from_utf8_lossy(&o.stderr).trim().to_string() } else { txt };
251
+ txt.lines().next().unwrap_or("").to_string()
252
+ })
253
+ .ok()
254
+ .filter(|v| !v.is_empty());
255
+ if let Some(ver) = ok {
256
+ found.push(format!("• {label}: {ver}"));
257
+ }
258
+ }
259
+ found.join("\n")
260
+ }
261
+
162
262
  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
263
  if report::is_json() || !std::io::stdin().is_terminal() {
166
264
  return Some(format!("blocked (no interactive terminal to confirm: {label})"));
167
265
  }
@@ -174,23 +272,32 @@ fn confirm(label: &str) -> Option<String> {
174
272
  }
175
273
  }
176
274
 
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> {
275
+ // Permission gate — returns Some(reason) when blocked.
276
+ // Read operations outside CWD are allowed in all modes (no CWD confinement for
277
+ // reads); the user specifically asked for full filesystem access.
278
+ pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
182
279
  let path = tools::touched_path(name, input, cwd);
183
280
 
184
281
  if let Some(p) = &path {
185
282
  if tools::is_sensitive(p) {
186
283
  return confirm(&format!("access sensitive path {}", p.display()));
187
284
  }
285
+ // In WSL2, writing to a Windows drive mount (/mnt/c/, /mnt/d/, etc.)
286
+ // crosses the OS boundary — always confirm, even in Auto mode.
287
+ if tools::is_mutating(name) && tools::is_wsl_windows_mount(p) {
288
+ return confirm(&format!("write to Windows filesystem {} (WSL2 boundary)", p.display()));
289
+ }
188
290
  }
189
291
  if name == "run_command" {
190
292
  if let Some(c) = input["command"].as_str() {
191
293
  if tools::catastrophic(c) {
192
294
  return confirm(&format!("run dangerous command `{c}`"));
193
295
  }
296
+ // In WSL2, commands that reference /mnt/<drive>/ target the Windows
297
+ // filesystem — confirm before running, even in Auto mode.
298
+ if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
299
+ return confirm(&format!("command targets Windows filesystem (WSL2): `{c}`"));
300
+ }
194
301
  }
195
302
  }
196
303
 
@@ -198,44 +305,58 @@ fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) ->
198
305
  Permission::Auto => None,
199
306
  Permission::ReadOnly => {
200
307
  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()));
308
+ // run_command with clearly read-only shell tools (grep, find, etc.)
309
+ // should pass through even in ReadOnly mode.
310
+ if name == "run_command" {
311
+ if let Some(c) = input["command"].as_str() {
312
+ if tools::is_readonly_command(c) {
313
+ return None;
314
+ }
315
+ }
206
316
  }
317
+ return Some("read-only mode: mutation skipped".into());
207
318
  }
208
- None
319
+ None // reads anywhere are allowed in readonly mode
209
320
  }
210
321
  Permission::Ask => {
322
+ // run_command calls whose binary appears in allowed_commands skip the
323
+ // confirmation prompt — git, cargo, npm, etc. should just work.
324
+ if name == "run_command" {
325
+ if let Some(c) = input["command"].as_str() {
326
+ let first = c.split_whitespace().next().unwrap_or("");
327
+ // Accept both bare names and full paths (/usr/bin/git → git).
328
+ let bin = std::path::Path::new(first)
329
+ .file_name().and_then(|n| n.to_str()).unwrap_or(first);
330
+ if config::load_allowed_commands().iter().any(|a| a == bin) {
331
+ return None;
332
+ }
333
+ }
334
+ }
211
335
  if tools::is_mutating(name) {
212
336
  return confirm(&tools::preview(name, input));
213
337
  }
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
- }
338
+ // Out-of-cwd reads: just note it instead of hard-blocking.
339
+ // The user asked for full filesystem access.
219
340
  None
220
341
  }
221
342
  }
222
343
  }
223
344
 
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.
345
+ // Public compact helper for the /compact REPL command.
346
+ pub fn compact_msgs(p: &Provider, msgs: Vec<Msg>) -> Vec<Msg> {
347
+ compact_with(msgs, |middle| model_summary(p, middle))
348
+ }
349
+
350
+ // ── BUILD mode ────────────────────────────────────────────────────────────────
226
351
  pub fn run_build(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path) -> Result<(), String> {
227
352
  let mut transcript: Vec<Msg> = Vec::new();
228
353
  run_build_session(p, perm, role_id, task, cwd, &mut transcript, &crate::session::new_id())
229
354
  }
230
355
 
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
356
  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
357
  run_build_session(p, perm, role_id, task, cwd, &mut seed, sid)
235
358
  }
236
359
 
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
360
  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
361
  let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
241
362
  hooks::notify("Stop", cwd);
@@ -243,9 +364,6 @@ pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &s
243
364
  r
244
365
  }
245
366
 
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
367
  fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, depth: usize, msgs: &mut Vec<Msg>) -> Result<String, String> {
250
368
  let task = match hooks::user_prompt_submit(task, cwd) {
251
369
  Err(reason) => {
@@ -257,37 +375,62 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
257
375
  };
258
376
  let defs = tools::defs(depth < MAX_DEPTH);
259
377
  if msgs.is_empty() {
260
- msgs.push(Msg::System(role(role_id).system.into()));
378
+ let prefix = context_prefix();
379
+ let sys = format!("{prefix}{}", role(role_id).system);
380
+ msgs.push(Msg::System(sys));
381
+ }
382
+ // If the caller already pushed a UserImages message (multimodal input), use its
383
+ // text as the task without pushing another User turn; otherwise push normally.
384
+ let already_pushed = matches!(msgs.last(), Some(Msg::UserImages { .. }));
385
+ if !already_pushed {
386
+ msgs.push(Msg::User(task));
261
387
  }
262
- msgs.push(Msg::User(task));
263
388
 
264
- for _ in 0..MAX_ITERS {
389
+ // Track which files have been read this session so we can enforce read-before-write.
390
+ let mut read_paths: std::collections::HashSet<PathBuf> = Default::default();
391
+
392
+ for step in 1..=MAX_ITERS {
265
393
  if tui::interrupted() {
266
394
  report::notice("interrupted");
267
395
  return Ok(String::new());
268
396
  }
269
397
  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
398
  let reply: Reply = if report::is_json() {
274
399
  let r = complete(p, msgs.as_slice(), &defs)?;
275
400
  report::assistant(&r.text);
276
401
  r
277
402
  } else {
403
+ // Show a step counter so the user can see multi-step reasoning progress.
404
+ if step > 1 {
405
+ tui::line(&tui::dim(&format!(" ↻ step {step}")));
406
+ }
278
407
  let mut spin = Some(tui::spinner_start("thinking…"));
279
408
  let mut streamed = false;
409
+ let mut thinking_buf = String::new();
410
+ let mut renderer = tui::StreamRenderer::new();
280
411
  let res = provider::stream(p, msgs.as_slice(), &defs, &mut |c| {
281
412
  if let Some(s) = spin.take() {
282
413
  tui::spinner_stop(s);
283
414
  }
284
- report::assistant_delta(c);
415
+ renderer.push(c);
285
416
  streamed = true;
417
+ }, &mut |t| {
418
+ // Extended thinking: buffer and display as dim internal monologue.
419
+ thinking_buf.push_str(t);
420
+ // Flush complete sentences/lines so output feels live.
421
+ while let Some(nl) = thinking_buf.find('\n') {
422
+ let line = thinking_buf[..nl].trim().to_string();
423
+ if !line.is_empty() {
424
+ tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&line)));
425
+ }
426
+ thinking_buf = thinking_buf[nl + 1..].to_string();
427
+ }
286
428
  });
287
429
  if let Some(s) = spin.take() {
288
- tui::spinner_stop(s); // no text streamed (tool-only turn) — clear it
430
+ tui::spinner_stop(s);
289
431
  }
290
432
  let r = res?;
433
+ renderer.flush();
291
434
  if streamed {
292
435
  report::assistant_end();
293
436
  }
@@ -297,25 +440,20 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
297
440
  if reply.text.trim().is_empty() {
298
441
  report::notice("model returned no output");
299
442
  }
300
- return Ok(reply.text); // model answered in prose
443
+ return Ok(reply.text);
301
444
  }
302
445
 
303
446
  let mut results = Vec::new();
304
447
  let mut summary: Option<String> = None;
305
448
  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
449
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
309
450
  let msg = format!("tool arguments were not valid JSON: {}", raw.chars().take(200).collect::<String>());
310
451
  report::tool_denied(&msg);
311
452
  results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
312
453
  continue;
313
454
  }
314
- // Show the proposed action (with an inline diff for edits) first, so
315
- // the approval prompt that follows has visible context.
316
455
  report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
317
456
 
318
- // PreToolUse hook can allow (skip the gate), deny, or defer to it.
319
457
  let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
320
458
  PreDecision::Deny(r) => Some(r),
321
459
  PreDecision::Allow => None,
@@ -327,12 +465,54 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
327
465
  continue;
328
466
  }
329
467
 
468
+ // Record reads so we can enforce read-before-write below.
469
+ if call.name == "read_file" {
470
+ if let Some(raw) = call.input["path"].as_str() {
471
+ let p = if std::path::Path::new(raw).is_absolute() {
472
+ PathBuf::from(raw)
473
+ } else {
474
+ cwd.join(raw)
475
+ };
476
+ read_paths.insert(p);
477
+ }
478
+ }
479
+
480
+ // Require the model to read a file before overwriting or patching it.
481
+ if matches!(call.name.as_str(), "write_file" | "edit_file") {
482
+ if let Some(raw) = call.input["path"].as_str() {
483
+ let p = if std::path::Path::new(raw).is_absolute() {
484
+ PathBuf::from(raw)
485
+ } else {
486
+ cwd.join(raw)
487
+ };
488
+ if p.exists() && !read_paths.contains(&p) {
489
+ let msg = format!(
490
+ "read_file('{}') required before editing. Read the file first, then retry.",
491
+ p.display()
492
+ );
493
+ report::tool_denied(&msg);
494
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
495
+ continue;
496
+ }
497
+ }
498
+ }
499
+
330
500
  if call.name == "spawn_subagent" {
331
501
  let out = spawn_subagent(p, perm, &call.input, cwd, depth);
332
502
  report::tool_result(&call.name, &out, false);
333
503
  results.push(ToolResult { id: call.id.clone(), content: out, is_error: false });
334
504
  continue;
335
505
  }
506
+ // Memory-save tool: the model can call save_memory to persist facts.
507
+ if call.name == "save_memory" {
508
+ if let Some(note) = call.input["note"].as_str() {
509
+ config::append_memory(note);
510
+ let msg = "memory saved".to_string();
511
+ report::tool_result(&call.name, &msg, false);
512
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
513
+ continue;
514
+ }
515
+ }
336
516
 
337
517
  let out = tools::run(&call.name, &call.input, cwd);
338
518
  hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
@@ -346,6 +526,10 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
346
526
 
347
527
  msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
348
528
  msgs.push(Msg::Tool(results));
529
+ if !report::is_json() {
530
+ tui::context_meter(estimate_tokens(msgs), p.context_tokens);
531
+ tui::poll_typeahead();
532
+ }
349
533
  if let Some(s) = summary {
350
534
  return Ok(s);
351
535
  }
@@ -355,8 +539,6 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
355
539
 
356
540
  static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
357
541
 
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
542
  fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd: &Path, depth: usize) -> String {
361
543
  if depth + 1 >= MAX_DEPTH {
362
544
  return "subagent depth limit reached".into();
@@ -381,7 +563,6 @@ fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd
381
563
  };
382
564
 
383
565
  report::notice(&format!(" ↳ subagent: {task}"));
384
- // A subagent gets its own fresh context window (not persisted as a session).
385
566
  let mut child: Vec<Msg> = Vec::new();
386
567
  let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
387
568
  .unwrap_or_else(|e| format!("subagent error: {e}"));
@@ -401,13 +582,59 @@ fn make_worktree(cwd: &Path) -> Option<PathBuf> {
401
582
  out.status.success().then_some(wt)
402
583
  }
403
584
 
404
- // PLAN: ask for a numbered breakdown, let the user approve/edit, then execute.
585
+ // ── PLAN mode ─────────────────────────────────────────────────────────────────
586
+ // The planning phase now has tools available so the model can inspect the
587
+ // codebase while breaking down the task. Execution still runs through BUILD.
405
588
  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, &[]))?;
589
+ let prefix = context_prefix();
590
+ let sys = format!("{prefix}You are a planning engineer with full access to the codebase. \
591
+ Use read_file and list_dir and run_command to inspect the project as needed. \
592
+ Break the user's task into a concise numbered list of concrete steps \
593
+ (one step per line, max 8 steps). Output the numbered list and nothing else.");
594
+
595
+ let defs = tools::defs(false); // planning uses tools but not subagents
596
+ let mut msgs = vec![Msg::System(sys), Msg::User(task.into())];
597
+
598
+ // Let the model use tools while planning (e.g. read files to understand structure).
599
+ let plan_text = loop {
600
+ maybe_compact(p, &mut msgs);
601
+ let reply = tui::with_spinner("planning…", || complete(p, &msgs, &defs))?;
602
+
603
+ if reply.calls.is_empty() {
604
+ break reply.text;
605
+ }
606
+
607
+ // Execute tool calls during the planning phase.
608
+ let mut results = Vec::new();
609
+ for call in &reply.calls {
610
+ report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
611
+ let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
612
+ PreDecision::Deny(r) => Some(r),
613
+ PreDecision::Allow => None,
614
+ PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
615
+ };
616
+ if let Some(reason) = reason {
617
+ results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
618
+ continue;
619
+ }
620
+ if call.name == "save_memory" {
621
+ if let Some(note) = call.input["note"].as_str() {
622
+ config::append_memory(note);
623
+ let msg = "memory saved".to_string();
624
+ report::tool_result(&call.name, &msg, false);
625
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
626
+ continue;
627
+ }
628
+ }
629
+ let out = tools::run(&call.name, &call.input, cwd);
630
+ report::tool_result(&call.name, &out.content, out.is_error);
631
+ results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
632
+ }
633
+ msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
634
+ msgs.push(Msg::Tool(results));
635
+ };
409
636
 
410
- let mut steps: Vec<String> = reply.text.lines()
637
+ let mut steps: Vec<String> = plan_text.lines()
411
638
  .map(|l| l.trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | ')' | '-' | ' ')).trim())
412
639
  .filter(|l| !l.is_empty())
413
640
  .map(|l| l.to_string())
@@ -448,33 +675,129 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
448
675
  run_build(p, perm, "engineer", &full, cwd)
449
676
  }
450
677
 
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
- )];
678
+ // ── BRAINSTORM mode ───────────────────────────────────────────────────────────
679
+ // Brainstorm is conversational but has full tool access. The model can grep,
680
+ // read files, fetch URLs, and run commands when the conversation calls for it.
681
+ // It also has a mode-transition sensor: if it detects the user wants to build
682
+ // or plan, it suggests switching.
683
+ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -> Result<Option<ModeHint>, String> {
684
+ let prefix = context_prefix();
685
+ let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
686
+ Use tools freely to look things up, read files, grep for patterns, or run commands — \
687
+ whatever helps the conversation. \
688
+ When you think the user is ready to stop discussing and start building or planning, \
689
+ end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
690
+ Otherwise just respond naturally. No fluff.");
691
+
692
+ let defs = tools::defs(false);
693
+ let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
456
694
  let mut question = first.to_string();
695
+
457
696
  loop {
458
697
  msgs.push(Msg::User(question.clone()));
459
698
  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();
699
+
700
+ // Keep consuming tool calls until the model gives a text response.
701
+ let reply_text = loop {
702
+ tui::line("");
703
+ let mut spin = Some(tui::spinner_start("thinking…"));
704
+ let mut streamed = false;
705
+ let mut thinking_buf = String::new();
706
+ let mut renderer = if !report::is_json() { Some(tui::StreamRenderer::new()) } else { None };
707
+ let res = provider::stream(p, &msgs, &defs, &mut |c| {
708
+ if let Some(s) = spin.take() { tui::spinner_stop(s); }
709
+ if let Some(r) = &mut renderer { r.push(c); } else { report::assistant_delta(c); }
710
+ streamed = true;
711
+ }, &mut |t| {
712
+ thinking_buf.push_str(t);
713
+ while let Some(nl) = thinking_buf.find('\n') {
714
+ let tl = thinking_buf[..nl].trim().to_string();
715
+ if !tl.is_empty() {
716
+ tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&tl)));
717
+ }
718
+ thinking_buf = thinking_buf[nl + 1..].to_string();
719
+ }
720
+ });
721
+ if let Some(s) = spin.take() { tui::spinner_stop(s); }
722
+ let reply = res?;
723
+ if let Some(r) = &mut renderer { r.flush(); }
724
+ if streamed { report::assistant_end(); }
725
+
726
+ if reply.calls.is_empty() {
727
+ msgs.push(Msg::Assistant { text: reply.text.clone(), calls: vec![] });
728
+ if !report::is_json() {
729
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
730
+ }
731
+ break reply.text;
732
+ }
733
+
734
+ // Execute tool calls inline.
735
+ let mut results = Vec::new();
736
+ for call in &reply.calls {
737
+ report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
738
+ let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
739
+ PreDecision::Deny(r) => Some(r),
740
+ PreDecision::Allow => None,
741
+ PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
742
+ };
743
+ if let Some(reason) = reason {
744
+ results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
745
+ continue;
746
+ }
747
+ if call.name == "save_memory" {
748
+ if let Some(note) = call.input["note"].as_str() {
749
+ config::append_memory(note);
750
+ let msg = "memory saved".to_string();
751
+ report::tool_result(&call.name, &msg, false);
752
+ results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
753
+ continue;
754
+ }
755
+ }
756
+ let out = tools::run(&call.name, &call.input, cwd);
757
+ hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
758
+ report::tool_result(&call.name, &out.content, out.is_error);
759
+ results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
760
+ }
761
+ msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
762
+ msgs.push(Msg::Tool(results));
763
+ if !report::is_json() {
764
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
765
+ tui::poll_typeahead();
766
+ }
767
+ };
768
+
769
+ // Check for mode-transition suggestion embedded in the reply.
770
+ let hint = if reply_text.contains("[SUGGEST:BUILD]") {
771
+ Some(ModeHint::Build)
772
+ } else if reply_text.contains("[SUGGEST:PLAN]") {
773
+ Some(ModeHint::Plan)
774
+ } else {
775
+ None
776
+ };
777
+
778
+ if let Some(ref h) = hint {
779
+ tui::line("");
780
+ let suggestion = match h {
781
+ ModeHint::Build => "switch to BUILD mode and implement this?",
782
+ ModeHint::Plan => "switch to PLAN mode and break this down?",
783
+ ModeHint::CycleMode => "cycle to the next mode?",
784
+ };
785
+ tui::line(&tui::yellow(&format!(" ↪ AI suggests: {suggestion}")));
786
+ tui::line(&tui::dim(" (y to switch, anything else to keep chatting)"));
787
+ let ans = tui::ask(" ").unwrap_or_default();
788
+ if matches!(ans.trim().to_lowercase().as_str(), "y" | "yes") {
789
+ return Ok(Some(h.clone()));
790
+ }
468
791
  }
469
- tui::line("");
470
- msgs.push(Msg::Assistant { text: reply.text, calls: vec![] });
471
792
 
793
+ tui::line("");
472
794
  match tui::ask_task(&format!("{} ", tui::blue("you ›"))) {
473
- None => return Ok(()),
474
- Some(f) => {
795
+ None => return Ok(None),
796
+ Some(tui::InputEvent::CycleMode) => return Ok(Some(ModeHint::CycleMode)),
797
+ Some(tui::InputEvent::Text(f)) => {
475
798
  let t = f.trim();
476
799
  if t.is_empty() || t == "exit" || t == "done" {
477
- return Ok(());
800
+ return Ok(None);
478
801
  }
479
802
  question = t.to_string();
480
803
  }
@@ -482,12 +805,18 @@ pub fn run_brainstorm(p: &Provider, first: &str) -> Result<(), String> {
482
805
  }
483
806
  }
484
807
 
808
+ #[derive(Clone)]
809
+ pub enum ModeHint {
810
+ Build,
811
+ Plan,
812
+ CycleMode,
813
+ }
814
+
485
815
  #[cfg(test)]
486
816
  mod tests {
487
817
  use super::*;
488
818
  use serde_json::json;
489
819
 
490
- // ── permission ──────────────────────────────────────────────────────────
491
820
  #[test]
492
821
  fn permission_parsing() {
493
822
  assert!(matches!(permission("auto"), Permission::Auto));
@@ -497,18 +826,13 @@ mod tests {
497
826
  assert!(matches!(permission(""), Permission::Ask));
498
827
  }
499
828
 
500
- // ── role ────────────────────────────────────────────────────────────────
501
829
  #[test]
502
830
  fn role_selection() {
503
831
  assert!(role("researcher").system.contains("research"));
504
832
  assert!(role("engineer").system.contains("software engineer"));
505
- // Unknown roles fall back to the engineer prompt.
506
833
  assert!(role("ceo").system.contains("software engineer"));
507
834
  }
508
835
 
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
836
  #[test]
513
837
  fn gate_auto_allows_ordinary_mutation() {
514
838
  let cwd = Path::new("/proj");
@@ -519,7 +843,6 @@ mod tests {
519
843
  #[test]
520
844
  fn gate_auto_still_confirms_sensitive_path() {
521
845
  let cwd = Path::new("/proj");
522
- // Sensitive even under auto → would prompt → denied in non-terminal tests.
523
846
  let r = gate(Permission::Auto, "read_file", &json!({"path": "/proj/.env"}), cwd);
524
847
  assert!(r.is_some());
525
848
  }
@@ -546,38 +869,48 @@ mod tests {
546
869
  }
547
870
 
548
871
  #[test]
549
- fn gate_readonly_blocks_out_of_cwd_read() {
872
+ fn gate_readonly_allows_reads_everywhere() {
873
+ // Reads outside CWD are now allowed — full filesystem access.
550
874
  let cwd = Path::new("/proj/work");
551
875
  let r = gate(Permission::ReadOnly, "read_file", &json!({"path": "/etc/passwd"}), cwd);
552
- assert!(r.unwrap().contains("outside"));
876
+ assert!(r.is_none());
553
877
  }
554
878
 
555
879
  #[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);
880
+ fn gate_ask_allows_out_of_cwd_read() {
881
+ // Full filesystem read access — no longer blocked in Ask mode.
882
+ let cwd = Path::new("/proj");
883
+ let r = gate(Permission::Ask, "read_file", &json!({"path": "/home/user/docs/README.md"}), cwd);
559
884
  assert!(r.is_none());
560
885
  }
561
886
 
562
887
  #[test]
563
888
  fn gate_ask_prompts_for_mutation() {
564
889
  let cwd = Path::new("/proj");
565
- // Would prompt → denied in non-terminal test env.
566
890
  let r = gate(Permission::Ask, "write_file", &json!({"path": "a", "content": "x"}), cwd);
567
- assert!(r.is_some());
891
+ assert!(r.is_some()); // non-terminal → denied
568
892
  }
569
893
 
570
894
  #[test]
571
- fn gate_ask_allows_in_cwd_read() {
895
+ fn gate_ask_allows_default_allowed_commands() {
572
896
  let cwd = Path::new("/proj");
573
- let r = gate(Permission::Ask, "read_file", &json!({"path": "/proj/a.rs"}), cwd);
574
- assert!(r.is_none());
897
+ // git, cargo, npm are in the default allowed_commands list
898
+ for cmd in &["git status", "cargo test", "npm install", "git commit -m 'x'"] {
899
+ let r = gate(Permission::Ask, "run_command", &json!({"command": cmd}), cwd);
900
+ assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
901
+ }
902
+ }
903
+
904
+ #[test]
905
+ fn gate_ask_still_prompts_for_unknown_command() {
906
+ let cwd = Path::new("/proj");
907
+ let r = gate(Permission::Ask, "run_command", &json!({"command": "mycustombinary --flag"}), cwd);
908
+ assert!(r.is_some()); // not in allowed list → denied (non-terminal)
575
909
  }
576
910
 
577
- // ── compaction ──────────────────────────────────────────────────────────
578
911
  #[test]
579
912
  fn estimate_tokens_counts_text() {
580
- assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10); // ~4 chars/token
913
+ assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10);
581
914
  }
582
915
 
583
916
  #[test]
@@ -605,10 +938,9 @@ mod tests {
605
938
  msgs.push(Msg::User(format!("u{i}")));
606
939
  }
607
940
  let out = compact_with(msgs, |_| "SUMMARY".into());
608
- assert_eq!(out.len(), 1 + 1 + KEEP_RECENT); // system + summary + tail
941
+ assert_eq!(out.len(), 1 + 1 + KEEP_RECENT);
609
942
  assert!(matches!(&out[0], Msg::System(s) if s == "sys"));
610
943
  assert!(matches!(&out[1], Msg::User(s) if s.contains("SUMMARY")));
611
- // The recent tail is preserved verbatim.
612
944
  assert!(matches!(out.last(), Some(Msg::User(s)) if s == "u9"));
613
945
  }
614
946