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.
- package/README.md +28 -1
- package/dist/agent.js +8 -11
- package/dist/auto-budget.js +8 -1
- package/dist/auto-context.js +1 -1
- package/dist/auto-review.js +8 -3
- package/dist/auto.js +2 -1
- package/dist/background.js +11 -6
- package/dist/config.js +6 -0
- package/dist/cron.js +7 -3
- package/dist/form.js +141 -66
- package/dist/ink/app.js +78 -35
- package/dist/loop.js +80 -17
- package/dist/menu.js +60 -22
- package/dist/permissions.js +80 -5
- package/dist/prompt.js +1 -1
- package/dist/title.js +4 -3
- package/dist/tools.js +30 -15
- package/package.json +1 -1
package/dist/permissions.js
CHANGED
|
@@ -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)
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.`, {
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
244
|
-
// slow. This is its asynchronous twin: it spawns the command
|
|
245
|
-
// id IMMEDIATELY, so the agent keeps reasoning while the work
|
|
246
|
-
// injects a <task_notification> when the command finishes
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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
|
|
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
|