agent-from-zero 0.36.0 → 0.39.0

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.
@@ -2,7 +2,8 @@ import path from "node:path"; // used to resolve and split file paths
2
2
  import fs from "node:fs"; // resolve symlinks before auto mode grants file access
3
3
  import os from "node:os"; // `cd ~` in the recoverable-shell analyzer
4
4
  import { execFileSync } from "node:child_process"; // ask git whether a deletion target is disposable
5
- import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
5
+ import { CONFIG, GLOBAL_SETTINGS_PATH } from "./config.js"; // user-configured allow/deny rules from settings files
6
+ import { emit } from "./telemetry.js"; // a bypassed approval leaves an audit row, not silence
6
7
  // ---- The no-fly zone --------------------------------------------------------
7
8
  // Writes here are never OK, in any mode. .git can destroy history, shell rc
8
9
  // files inject code into every future terminal, .ssh/.env leak credentials.
@@ -215,6 +216,27 @@ export function gitWorkTree(target) {
215
216
  }
216
217
  return topLevels.get(dir);
217
218
  }
219
+ // Trusted ground outside any work tree: autoMode.trustedDirs from global
220
+ // settings, plus directories trusted from the approval menu this session.
221
+ // Home and / never count, for the same reason as above. Nor does the global
222
+ // settings file: it holds this very list, and an agent that could edit it
223
+ // unasked could widen its own reach.
224
+ const tooBroadToTrust = (dir) => dir === os.homedir() || dir === resolveFileTarget(os.homedir()) || dir === path.parse(dir).root;
225
+ const settingsFile = () => resolveFileTarget(GLOBAL_SETTINGS_PATH);
226
+ function trustedDir(target) {
227
+ if (target === settingsFile())
228
+ return false;
229
+ return CONFIG.autoMode.trustedDirs.some((dir) => {
230
+ let root;
231
+ try {
232
+ root = resolveFileTarget(dir);
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ return !tooBroadToTrust(root) && inside(root, target);
238
+ });
239
+ }
218
240
  // A deletion is recoverable when the target is scratch (inside a temp dir) or
219
241
  // git calls it disposable in its own repo: ignored (build output, caches) with
220
242
  // nothing tracked beneath it. Symlinks are left to review: `rm -r link/`
@@ -442,6 +464,18 @@ export function recoverableShell(command) {
442
464
  }
443
465
  return readOnly ? { readOnly, reason: "read-only command" } : { readOnly, reason: "recoverable: git can undo it, or it only removes gitignored or temp files" };
444
466
  }
467
+ // ---- The user's prod guard (permissions.ask) ----------------------------------
468
+ // Text that always needs a human: a prod context, a deployment verb, a prod
469
+ // path. Checked BEFORE auto review and BEFORE the bypass rewrite, so neither
470
+ // can approve it. Case-insensitive substring, on the whole command or the path.
471
+ function guardHit(text) {
472
+ const haystack = text.toLowerCase();
473
+ for (const rule of CONFIG.permissions.ask ?? []) {
474
+ if (rule && haystack.includes(rule.toLowerCase()))
475
+ return rule;
476
+ }
477
+ return null;
478
+ }
445
479
  // Decide what a bash command deserves: deny, ask, or allow.
446
480
  function checkBash(command, auto = false) {
447
481
  const summary = command.trim(); // what the user will see in the prompt
@@ -458,6 +492,9 @@ function checkBash(command, auto = false) {
458
492
  for (const d of user.deny)
459
493
  if (command.toLowerCase().includes(d.toLowerCase()))
460
494
  return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
495
+ const guard = guardHit(command);
496
+ if (guard)
497
+ return { decision: "ask", requiresHuman: true, reason: `prod guard: "${guard}"`, summary };
461
498
  // node/npm/npx, git aliases and tool-wide grants can run arbitrary code.
462
499
  // Auto mode must inspect the actual command, including background commands.
463
500
  if (auto) {
@@ -539,7 +576,11 @@ export function checkPermission(toolName, argsJson, auto = false) {
539
576
  summary: base.summary,
540
577
  };
541
578
  }
542
- if (CONFIG.bypassPermissions && base.decision === "ask" && toolName !== "exit_plan_mode" && toolName !== "ask_user") {
579
+ if (CONFIG.bypassPermissions && base.decision === "ask" && !base.requiresHuman && toolName !== "exit_plan_mode" && toolName !== "ask_user") {
580
+ // Bypass may skip an ordinary approval, never one the user's own guard list
581
+ // marked for a human. Every skip is recorded: a bypass run with no audit row
582
+ // is indistinguishable from a run that never checked.
583
+ emit("agent_permission_bypassed", { tool: toolName, reason: base.reason });
543
584
  return { decision: "allow", reason: "permission approvals bypassed for this run", summary: base.summary };
544
585
  }
545
586
  return base;
@@ -575,9 +616,13 @@ function basePermission(toolName, argsJson, auto) {
575
616
  return { decision: "deny", reason, summary: args.path };
576
617
  const relative = path.relative(fs.realpathSync(process.cwd()), resolved);
577
618
  // Outside the start directory is still recoverable in another git work
578
- // tree or a temp dir (git, /undo); anywhere else a human decides.
579
- if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved)) {
580
- return { decision: "ask", requiresHuman: true, reason: "resolved write target is outside the project", summary: `${args.path} → ${resolved}` };
619
+ // tree or a temp dir (git, /undo), and a trusted directory is one the
620
+ // user vouched for; anywhere else a human decides, and may trust the
621
+ // file's directory for the session rather than approve every write.
622
+ if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved) && !trustedDir(resolved)) {
623
+ const dir = path.dirname(resolved);
624
+ return { decision: "ask", requiresHuman: true, reason: "resolved write target is outside the project", summary: `${args.path} → ${resolved}`,
625
+ ...(resolved === settingsFile() || tooBroadToTrust(dir) ? {} : { trustDir: dir }) };
581
626
  }
582
627
  }
583
628
  catch {
@@ -645,6 +690,9 @@ function basePermission(toolName, argsJson, auto) {
645
690
  const hit = noFlyHit(p); // is it in the no-fly zone?
646
691
  if (hit)
647
692
  return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
693
+ const guard = guardHit(p); // the user's prod guard, applied to the path
694
+ if (guard)
695
+ return { decision: "ask", requiresHuman: true, reason: `prod guard: "${guard}"`, summary: p };
648
696
  // The user may pre-approve write tools ("tool:edit_file") to skip the
649
697
  // prompt — note this runs AFTER the no-fly check, so it widens "ask",
650
698
  // never "deny".
@@ -724,3 +772,30 @@ function resolveFileTarget(target) {
724
772
  }
725
773
  return path.join(fs.realpathSync(current), ...missing);
726
774
  }
775
+ // ---- The approval menu ----------------------------------------------------------
776
+ // Both frontends (readline and Ink) offer the same choices and apply the same
777
+ // session grant, so the menu lives next to the rules it widens. Outside auto
778
+ // mode the grant is "don't ask again for this tool"; in auto mode it is "trust
779
+ // this directory", offered only for an outside write. The last option is always
780
+ // "No", and so is -1 (Esc). `granted` is the note to show after a grant.
781
+ export function approvalMenu(auto, toolName, trustDir) {
782
+ const grant = auto
783
+ ? (trustDir ? `Yes, and trust ${trustDir}/ for this session` : null)
784
+ : (toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session");
785
+ const options = [auto ? "Yes, once" : "Yes", ...(grant ? [grant] : []), "No — let me tell the agent what to do instead"];
786
+ const choose = (i) => {
787
+ if (i < 0 || i >= options.length - 1)
788
+ return { approved: false };
789
+ if (i === 0)
790
+ return { approved: true };
791
+ if (auto && trustDir) {
792
+ CONFIG.autoMode.trustedDirs.push(trustDir);
793
+ return { approved: true, granted: `trusting ${trustDir}/ for this session` };
794
+ }
795
+ if (!toolName)
796
+ return { approved: true };
797
+ CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
798
+ return { approved: true, granted: `won't ask again for ${toolName} this session` };
799
+ };
800
+ return { options, choose };
801
+ }
package/dist/prompt.js CHANGED
@@ -21,7 +21,7 @@ Behavior rules:
21
21
  - You support SKILLS — reusable, named procedures saved as Markdown under .mini-agent/skills/ in the project (or ~/.config/mini-agent/skills/ globally). When a request matches one of the skills listed in the \`skill\` tool, call \`skill\` with its name to load the steps, then carry them out. If there is no \`skill\` tool in this session, none are loaded in this project yet — the capability still exists: the user adds one at .mini-agent/skills/<name>/SKILL.md, runs any with "/skill <name>", and lists them with "/skills". So when asked whether you support skills, the answer is yes — describe this mechanism (and which skills, if any, are currently loaded); never claim your toolset is fixed.
22
22
  - Report results accurately. If a test fails, say it failed. Don't claim success when you're not sure. Don't be overly self-doubting either — distinguish "verified working" from "I think it works".
23
23
  - Never add comments to code you did not change. Never reformat code you did not change.
24
- - Keep final answers under 150 words unless the task genuinely requires more.
24
+ - End every turn with a final message the user can take in at a glance. First line: the outcome (done, failed, found X, nothing to report). If that says it all, stop there, e.g. "No alerts — all normal." Otherwise follow it with short sections, each a bold label over bullet points: what you did, what you found (each problem: what, where, impact), what needs the user's decision. One fact per bullet; no filler, no restating the request. Under 150 words unless the task genuinely requires more. Write in the language the user writes in.
25
25
  - Between tool calls, write at most one short sentence — usually nothing.
26
26
  - A [permission] denial is a hard boundary. Do not attempt workarounds — explain and ask the user.
27
27
  - If the task is ambiguous, ask one clarifying question instead of guessing.`;
package/dist/title.js CHANGED
@@ -41,8 +41,9 @@ export function setTerminalTitle(title) {
41
41
  }
42
42
  export const refreshTerminalTitle = () => setTerminalTitle(lastTitle);
43
43
  // "Which window is this?" — another session asked. Ring the bell and blink the
44
- // tab title for a few seconds, then put the real title back.
45
- export function flashTerminalTitle(ms = 6000) {
44
+ // tab title for a few seconds, then put the real title back. A finished cron run
45
+ // uses it too, blinking its own label ("⏰ cron_1 done") instead.
46
+ export function flashTerminalTitle(ms = 6000, label) {
46
47
  if (!process.stdout.isTTY)
47
48
  return;
48
49
  process.stdout.write("\x07");
@@ -59,7 +60,7 @@ export function flashTerminalTitle(ms = 6000) {
59
60
  process.stdout.write(terminalTitleSequence(tabTitle(lastTitle)));
60
61
  return;
61
62
  }
62
- process.stdout.write(terminalTitleSequence(on ? `👋 ${name} — HERE 👋` : tabTitle(lastTitle)));
63
+ process.stdout.write(terminalTitleSequence(on ? label ?? `👋 ${name} — HERE 👋` : tabTitle(lastTitle)));
63
64
  }, 500);
64
65
  flashing.unref();
65
66
  }
package/dist/tools.js CHANGED
@@ -23,7 +23,8 @@ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering
23
23
  const READ_LIMIT = 16000; // read_file gets a larger cap of its own
24
24
  const SEARCH_MAX_MATCHES = 50; // max matches returned by search
25
25
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
26
- const BASH_TIMEOUT_MS = 30_000; // a single command may run this long before we kill it
26
+ const BASH_TIMEOUT_MS = 120_000; // a single command may run this long before we kill it (Claude Code's 2 minutes)
27
+ const BASH_MAX_TIMEOUT_MS = 600_000; // the most a command may ask for; anything longer belongs in the background
27
28
  const BASH_OUTPUT_LIMIT = 8_000; // chars of command output kept inline; beyond this we spill to a file
28
29
  const SPILL_NOTE_SLACK = 400; // room for the "[...saved to FILE]" note so dispatch's cap never eats it
29
30
  // Errors are not exceptions — they are text that tells the model what to do next.
@@ -189,17 +190,27 @@ function spillIfHuge(out, what) {
189
190
  const head = out.slice(0, BASH_OUTPUT_LIMIT); // a generous preview
190
191
  return `${head}\n\n[...${what} truncated: ${out.length} chars total. Full output saved to ${file} — use read_file to inspect more.]`;
191
192
  }
193
+ // The watchdog for one run_bash call: the model may ask for more (or less) than
194
+ // the default, never past the maximum. Anything that isn't a positive number of
195
+ // seconds — missing, "", garbage — gets the default.
196
+ export function bashTimeoutMs(seconds) {
197
+ const s = Number(seconds);
198
+ return Number.isFinite(s) && s > 0 ? Math.min(Math.round(s * 1000), BASH_MAX_TIMEOUT_MS) : BASH_TIMEOUT_MS;
199
+ }
192
200
  const runBash = {
193
- definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
201
+ definition: def("run_bash", `Run a bash command on the user's machine and return its output. It is killed after 2 minutes unless you pass a longer timeout (up to 600 seconds) — set one when you expect a slow command (a full test suite, a build). Longer, or work you don't need to wait for: run_bash_background.
194
202
  Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
195
203
  To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
196
204
  Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
197
- On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
205
+ On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, {
206
+ command: { type: "string", description: "The bash command to run" },
207
+ timeout: { type: "number", description: "Optional: seconds before the command is killed (default 120, max 600)" },
208
+ }, ["command"]),
198
209
  // Async + spawn: the event loop stays responsive while the command runs, so
199
210
  // the spinner keeps spinning and Ctrl+C is heard. execSync would freeze
200
211
  // everything — the whole process, the UI, the signal handler — until it
201
212
  // returned. The AbortSignal lets the loop kill the child on interruption
202
- // instead of waiting out the 30s timeout.
213
+ // instead of waiting out the timeout.
203
214
  run: (args, signal) => new Promise((resolve) => {
204
215
  const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: childEnv() }); // shell:true → bash semantics
205
216
  let stdout = ""; // captured stdout
@@ -214,10 +225,12 @@ On error: failures return the error output — analyze it and try a different ap
214
225
  resolve(text); // hand the result back
215
226
  };
216
227
  // Hard timeout: SIGKILL the whole process group, never hang the agent.
228
+ const timeoutMs = bashTimeoutMs(args.timeout);
217
229
  const timer = setTimeout(() => {
218
230
  child.kill("SIGKILL");
219
- done(fail(`Command timed out after ${BASH_TIMEOUT_MS / 1000}s and was killed.${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
220
- }, BASH_TIMEOUT_MS);
231
+ const hint = timeoutMs < BASH_MAX_TIMEOUT_MS ? ` If it needs longer, rerun with a larger timeout (max ${BASH_MAX_TIMEOUT_MS / 1000}) or use run_bash_background.` : " Use run_bash_background for anything this long.";
232
+ done(fail(`Command timed out after ${timeoutMs / 1000}s and was killed.${hint}${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
233
+ }, timeoutMs);
221
234
  // Ctrl+C while a command runs: kill the child immediately.
222
235
  const onAbort = () => {
223
236
  child.kill("SIGKILL");
@@ -240,17 +253,19 @@ On error: failures return the error output — analyze it and try a different ap
240
253
  }),
241
254
  };
242
255
  // ============ run_bash_background (Day 37) ============
243
- // run_bash blocks and dies at its 30s watchdog — useless for anything truly
244
- // slow. This is its asynchronous twin: it spawns the command and returns a task
245
- // id IMMEDIATELY, so the agent keeps reasoning while the work happens. The loop
246
- // injects a <task_notification> when the command finishes (background.ts). Use
247
- // it for installs, builds, full test runs, or servers that never exit — exactly
248
- // the keywords that signal a slow operation. The command goes through the SAME
249
- // permission gate as run_bash (see permissions.ts), so the danger rules are
250
- // identical; only the blocking behavior differs.
256
+ // run_bash blocks, and dies at its watchdog (2 minutes, 10 at most) — useless
257
+ // for anything truly slow. This is its asynchronous twin: it spawns the command
258
+ // and returns a task id IMMEDIATELY, so the agent keeps reasoning while the work
259
+ // happens. The loop injects a <task_notification> when the command finishes
260
+ // (background.ts); if the agent is idle by then, the frontend wakes it with one
261
+ // (loop.ts, takeFinishedWork). Use it for installs, builds, full test runs, or
262
+ // servers that never exit — exactly the keywords that signal a slow operation.
263
+ // The command goes through the SAME permission gate as run_bash (see
264
+ // permissions.ts), so the danger rules are identical; only the blocking
265
+ // behavior differs.
251
266
  const runBashBackground = {
252
267
  definition: def("run_bash_background", `Run a slow bash command in the BACKGROUND and return immediately with a task id (e.g. bg_1) — do NOT wait for it.
253
- When to use: anything that would exceed run_bash's 30s limit or block you — installing dependencies (npm/pip install), builds (npm run build, cargo build, make), full test suites (pytest, npm test), deploys, or long-running servers (npm run dev) that never exit.
268
+ When to use: anything that could outlast run_bash's timeout (10 minutes at most) or that you don't need to wait for — installing dependencies (npm/pip install), builds (npm run build, cargo build, make), full test suites (pytest, npm test), deploys, or long-running servers (npm run dev) that never exit.
254
269
  What you get back: just the task id. The command keeps running. When it finishes you'll receive a <task_notification> with its status and output tail — react to that. For a server or a job still running, poll it with bash_output.
255
270
  Boundaries & permissions are identical to run_bash (dangerous commands are still gated or denied). For quick commands use run_bash instead — waiting on a one-off background job wastes a turn.`, { command: { type: "string", description: "The bash command to run in the background" } }, ["command"]),
256
271
  // Fire-and-return: start the process, hand back the id. The output is captured
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.36.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"