agent-from-zero 0.37.0 → 0.39.1

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/dist/mcp.js CHANGED
@@ -37,6 +37,10 @@ import { emit } from "./telemetry.js"; // observability
37
37
  const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
38
38
  const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
39
39
  const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60_000; // how long the local callback waits for the browser
40
+ // A startup connect that dies on the network (a dropped TLS handshake through a
41
+ // proxy, a timeout, a blip) is retried after these pauses before the server is
42
+ // marked failed; it shows as "connecting…" meanwhile. Mutable for the tests.
43
+ export const MCP_CONNECT_RETRY = { delaysMs: [2_000, 5_000, 10_000] };
40
44
  const PROTOCOL_VERSION = "2024-11-05"; // the MCP revision we speak in the handshake
41
45
  const CLIENT_INFO = { name: "mini-agent", version: "0" }; // who we say we are in the handshake
42
46
  // Thrown when an HTTP MCP server answers 401 — the caller marks the server as
@@ -59,6 +63,15 @@ export class NeedsAuthError extends Error {
59
63
  }
60
64
  }
61
65
  }
66
+ // The request never reached the server or its answer never came back: no
67
+ // connection, a dropped TLS handshake, a timeout. Unlike an error the server
68
+ // returns, trying again later can succeed.
69
+ export class McpNetworkError extends Error {
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = "McpNetworkError";
73
+ }
74
+ }
62
75
  // ---- stdio transport: one JSON object per line, to a subprocess --------------
63
76
  class StdioTransport {
64
77
  child;
@@ -268,7 +281,11 @@ class HttpTransport {
268
281
  catch (err) {
269
282
  if (err instanceof NeedsAuthError)
270
283
  throw err; // propagate so the registry can mark needs-auth
271
- return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
284
+ // fetch says only "fetch failed"; the reason (UND_ERR_CONNECT_TIMEOUT,
285
+ // ECONNRESET, ENOTFOUND…) is on its cause, and it is what you need to know.
286
+ const cause = err.cause;
287
+ const why = cause?.code ?? cause?.message;
288
+ return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}${why ? ` (${why})` : ""}`, network: true } };
272
289
  }
273
290
  }
274
291
  notify(method, params) {
@@ -312,7 +329,7 @@ class McpClient {
312
329
  async start() {
313
330
  const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
314
331
  if (init.error)
315
- throw new Error(init.error.message); // server refused to initialize
332
+ throw init.error.network ? new McpNetworkError(init.error.message) : new Error(init.error.message); // unreachable, or the server refused to initialize
316
333
  this.protocolVersion = init.result?.protocolVersion;
317
334
  this.capabilities = Object.keys(init.result?.capabilities ?? {});
318
335
  this.transport.notify("notifications/initialized", {}); // tell the server we're ready
@@ -325,7 +342,7 @@ class McpClient {
325
342
  async listTools() {
326
343
  const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS);
327
344
  if (list.error)
328
- throw new Error(list.error.message);
345
+ throw list.error.network ? new McpNetworkError(list.error.message) : new Error(list.error.message);
329
346
  return list.result?.tools ?? [];
330
347
  }
331
348
  // Invoke one tool and flatten its content into plain text for the model.
@@ -355,9 +372,17 @@ const registry = new Map();
355
372
  // scratch path so a fake server's tokens never land in the real store).
356
373
  const OAUTH_STORE_PATH = process.env.MINI_AGENT_OAUTH_STORE || path.join(os.homedir(), ".config", "mini-agent", "mcp-oauth.json");
357
374
  const expiryFrom = (t) => (typeof t.expires_in === "number" ? Date.now() + t.expires_in * 1000 : undefined);
375
+ // The file holds bearer tokens, so only its owner may read it (0600): set on
376
+ // every write, and tightened on read for a store created before this rule or
377
+ // copied over from another machine.
358
378
  function readOAuthStore() {
359
379
  try {
360
- return JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
380
+ const store = JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
381
+ try {
382
+ fs.chmodSync(OAUTH_STORE_PATH, 0o600);
383
+ }
384
+ catch { /* not ours to fix (read-only mount) — still usable */ }
385
+ return store;
361
386
  }
362
387
  catch {
363
388
  return {};
@@ -365,7 +390,8 @@ function readOAuthStore() {
365
390
  }
366
391
  function writeOAuthStore(store) {
367
392
  fs.mkdirSync(path.dirname(OAUTH_STORE_PATH), { recursive: true });
368
- fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n");
393
+ fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
394
+ fs.chmodSync(OAUTH_STORE_PATH, 0o600); // mode applies only when the file is created
369
395
  }
370
396
  const oauthStore = readOAuthStore();
371
397
  function base64url(input) {
@@ -475,8 +501,48 @@ export async function discoverOAuth(serverUrl, challenge = {}) {
475
501
  // DCR case, where we register whatever we got); a fixed port is what a
476
502
  // pre-registered client demands. The code promise is armed on creation, BEFORE
477
503
  // the browser opens, so a fast callback can't race us.
504
+ // Logins waiting for their browser redirect, by OAuth state. The loopback
505
+ // listener is one way in; a pasted redirect address is the other, for a browser
506
+ // on another machine (SSH, a remote box) whose "localhost" is not this one.
507
+ const waitingLogins = new Map();
508
+ export const oauthLoginPending = () => waitingLogins.size > 0;
509
+ // Esc while a login waits: give up on it (the flow rejects, its listener closes).
510
+ export function cancelOAuthLogins() {
511
+ for (const login of [...waitingLogins.values()])
512
+ login.cancel();
513
+ }
514
+ // The user pasted the address the browser landed on — on its own, or inside a
515
+ // sentence ("curl this: http://localhost:37831/callback?code=…"). Hand it to the
516
+ // login waiting for its state, exactly as if the browser had reached us.
517
+ // "stale": a callback address no waiting login owns (an older attempt, a typo);
518
+ // "none": no callback address in the text at all.
519
+ export function deliverOAuthRedirect(text) {
520
+ const found = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\/callback\?[^\s"'<>]+/.exec(text);
521
+ if (!found)
522
+ return "none";
523
+ let url;
524
+ try {
525
+ url = new URL(found[0].replace(/[)\]}。,、;,;!!??]+$/, "").replace(/^(?!https?:\/\/)/, "http://")); // trailing sentence punctuation is not part of it
526
+ }
527
+ catch {
528
+ return "none";
529
+ }
530
+ const login = waitingLogins.get(url.searchParams.get("state") ?? "");
531
+ if (!login)
532
+ return "stale";
533
+ login.accept(url);
534
+ return "delivered";
535
+ }
478
536
  async function listenForCallback(port, state) {
479
537
  let settle;
538
+ // The redirect's verdict, however it arrives (the browser, or a pasted address).
539
+ const accept = (url) => {
540
+ const code = url.searchParams.get("code");
541
+ if (code)
542
+ settle?.resolve(code);
543
+ else
544
+ settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
545
+ };
480
546
  const handler = (req, res) => {
481
547
  const url = new URL(req.url ?? "/", "http://localhost");
482
548
  res.setHeader("connection", "close"); // one-shot listener: a kept-alive socket would deliver a LATER flow's callback (same fixed port) to this dead handler
@@ -486,13 +552,7 @@ async function listenForCallback(port, state) {
486
552
  res.end("<h1>Connected</h1><p>You can close this window and return to the terminal.</p>");
487
553
  // Settle only once the page has been flushed: settling tears the servers
488
554
  // down, and tearing down before the write lands would blank the browser tab.
489
- res.once("finish", () => {
490
- const code = url.searchParams.get("code");
491
- if (code)
492
- settle?.resolve(code);
493
- else
494
- settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
495
- });
555
+ res.once("finish", () => accept(url));
496
556
  };
497
557
  const v4 = createServer(handler);
498
558
  const bound = await new Promise((resolve, reject) => {
@@ -508,6 +568,7 @@ async function listenForCallback(port, state) {
508
568
  v6.listen(bound, "::1", () => resolve());
509
569
  });
510
570
  const close = () => {
571
+ waitingLogins.delete(state);
511
572
  // Release the port for real: close() alone leaves open sockets alive, and a
512
573
  // pre-registered client's next flow needs the SAME port back immediately.
513
574
  for (const s of [v4, v6]) {
@@ -525,6 +586,7 @@ async function listenForCallback(port, state) {
525
586
  reject: (e) => (clearTimeout(timer), close(), reject(e)),
526
587
  };
527
588
  });
589
+ waitingLogins.set(state, { accept, cancel: () => settle?.reject(new Error("Authorization cancelled")) });
528
590
  return { port: bound, close, code };
529
591
  }
530
592
  // Run the OAuth authorization-code flow for an HTTP server: PKCE always;
@@ -688,8 +750,10 @@ function markNeedsAuth(name, challenge) {
688
750
  }
689
751
  // Connect a single configured server and register its tools. Updates the
690
752
  // registry with the live status. Used by connectMcpServers() at startup and by
691
- // /mcp reconnect / enable / auth.
692
- async function connectOne(name, retried = false, quiet = false) {
753
+ // /mcp reconnect / enable / auth. `retryDelays`: pauses before retrying a
754
+ // network failure (background startup only — an explicit /mcp action answers
755
+ // at once, and its user can simply run it again).
756
+ async function connectOne(name, retried = false, quiet = false, retryDelays = []) {
693
757
  const entry = entryFor(name);
694
758
  const attempt = {}; // this connect's identity (see the guard after the handshake)
695
759
  entry.attempt = attempt;
@@ -735,6 +799,17 @@ async function connectOne(name, retried = false, quiet = false) {
735
799
  return connectOne(name, true, quiet);
736
800
  markNeedsAuth(name, err);
737
801
  }
802
+ else if (err instanceof McpNetworkError && retryDelays.length) {
803
+ // Unreachable this time: stay "connecting" and try again shortly, unless
804
+ // the user disabled or reconnected it meanwhile (a newer attempt).
805
+ entry.status = "pending";
806
+ entry.error = err.message;
807
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[0]).unref());
808
+ const now = registry.get(name); // re-read: /mcp may have changed it while we waited
809
+ if (now !== entry || now.attempt !== attempt || now.status === "disabled")
810
+ return;
811
+ return connectOne(name, retried, quiet, retryDelays.slice(1));
812
+ }
738
813
  else {
739
814
  entry.status = "failed";
740
815
  entry.error = err.message;
@@ -800,7 +875,7 @@ export async function connectMcpServers(options = {}) {
800
875
  for (const name of entries)
801
876
  entryFor(name).status = "pending";
802
877
  for (const name of entries)
803
- void connectOne(name, false, true); // fire and forget; connectOne never throws
878
+ void connectOne(name, false, true, MCP_CONNECT_RETRY.delaysMs); // fire and forget; connectOne never throws
804
879
  }
805
880
  else {
806
881
  for (const name of entries)
package/dist/menu.js CHANGED
@@ -2,7 +2,7 @@ import readline from "node:readline"; // raw-mode keypress events + the interfac
2
2
  import chalk from "chalk";
3
3
  import { reduceEditor, truncateToWidth } from "./editor.js";
4
4
  import { renderMenu, MENU_HINT } from "./ui.js"; // the pure drawing + the footer hint
5
- import { initFormState, reduceForm, renderForm, collectAnswers } from "./form.js"; // the multi-question form
5
+ import { initFormState, reduceForm, renderForm, collectAnswers, onTypedRow } from "./form.js"; // the multi-question form
6
6
  // Navigation owns arrows; printable text belongs to this editor. Sending a
7
7
  // follow-up cancels the pending choice, never implicitly approves it.
8
8
  function promptDraft(submit, redraw, cancel) {
@@ -10,6 +10,7 @@ function promptDraft(submit, redraw, cancel) {
10
10
  let sending = false;
11
11
  let error = "";
12
12
  return {
13
+ hasText: () => state.buffer.length > 0,
13
14
  render: () => submit ? `\n${truncateToWidth(`Follow-up ❯ ${state.buffer.slice(0, state.cursor)}${chalk.inverse(state.buffer[state.cursor] ?? " ")}${state.buffer.slice(state.cursor + 1)}`, Math.max(10, (process.stdout.columns || 80) - 1))}\n${truncateToWidth(error || "Type a follow-up · Enter sends text; empty input selects", Math.max(10, (process.stdout.columns || 80) - 1))}` : "",
14
15
  handle(str, key) {
15
16
  if (sending)
@@ -126,19 +127,27 @@ onFollowUp) {
126
127
  // The multi-question form (Day 30): the agent asks several questions at once,
127
128
  // the user answers each and submits, and we hand back organized answers. Same
128
129
  // raw-mode borrow/return dance as promptSelect; the state machine is in form.ts.
129
- // Returns the answers, or null if cancelled / not a TTY.
130
+ // Keys: ↑/↓ rows, ←/→ or Tab questions, Enter chooses, Esc cancels. On a "Type
131
+ // something" row the typed keys edit the answer; anywhere else they start a
132
+ // follow-up, which (like promptSelect) cancels the form when sent.
133
+ // Returns the answers, "chat" (talk instead of answering), or null if
134
+ // cancelled / not a TTY.
130
135
  export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
131
136
  return new Promise((resolve) => {
132
137
  if (!input.isTTY || !questions.length)
133
138
  return resolve(null); // no form without a terminal or questions
134
139
  let state = initFormState(questions);
140
+ let answer = { buffer: "", cursor: 0 }; // the "Type something" editor
135
141
  let done = false;
136
142
  const wasRaw = input.isRaw === true; // restore this on exit, not false (see promptSelect)
137
- const lineCount = renderForm(questions, state).split("\n").length + (onFollowUp ? 2 : 0);
143
+ let lineCount = 0; // tabs differ in height: redraw over whatever the last frame was
138
144
  const draw = (first) => {
145
+ const typing = onTypedRow(questions, state);
146
+ const frame = `${renderForm(questions, state, typing ? answer.buffer : undefined)}${typing ? "" : draft.render()}`;
139
147
  if (!first)
140
- process.stdout.write(`\x1b[${lineCount}A`); // up over the whole form
141
- process.stdout.write(`\x1b[J${renderForm(questions, state)}${draft.render()}\n`);
148
+ process.stdout.write(`\x1b[${lineCount}A`); // up over the previous frame
149
+ process.stdout.write(`\x1b[J${frame}\n`);
150
+ lineCount = frame.split("\n").length;
142
151
  };
143
152
  const finish = (result) => {
144
153
  if (done)
@@ -154,31 +163,60 @@ export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
154
163
  rl.resume();
155
164
  resolve(result);
156
165
  };
166
+ // Apply one action. The answer editor travels with it while on a typed row,
167
+ // and follows the cursor: loaded on arrival, cleared on departure.
168
+ const act = (action) => {
169
+ const typing = onTypedRow(questions, state);
170
+ const next = reduceForm(questions, state, action, typing ? answer.buffer : undefined);
171
+ const arriving = onTypedRow(questions, next.state) && (!typing || next.state.tab !== state.tab || next.state.cursor !== state.cursor);
172
+ state = next.state;
173
+ if (next.outcome === "submit")
174
+ return finish(collectAnswers(questions, state));
175
+ if (next.outcome === "chat")
176
+ return finish("chat");
177
+ if (next.outcome === "cancel")
178
+ return finish(null);
179
+ if (arriving) {
180
+ const text = state.typed[state.tab];
181
+ answer = { buffer: text, cursor: text.length };
182
+ }
183
+ else if (!onTypedRow(questions, state))
184
+ answer = { buffer: "", cursor: 0 };
185
+ draw(false);
186
+ };
157
187
  const draft = promptDraft(onFollowUp, () => draw(false), () => finish(null));
158
188
  const onKey = (str, key) => {
159
189
  if (!key || done)
160
190
  return;
161
- if (draft.handle(str, key))
191
+ if (key.name === "escape" || (key.ctrl && key.name === "c"))
192
+ return act("escape");
193
+ const typing = onTypedRow(questions, state);
194
+ if (key.name === "up")
195
+ return act("up");
196
+ if (key.name === "down")
197
+ return act("down");
198
+ if (key.name === "tab")
199
+ return act(key.shift ? "left" : "right");
200
+ // ←/→ switch questions, unless there is text to move the caret through.
201
+ if ((key.name === "left" || key.name === "right") && !(typing ? answer.buffer : draft.hasText()))
202
+ return act(key.name);
203
+ if (key.name === "return" || key.name === "enter") {
204
+ if (typing || !draft.handle(str, key))
205
+ act("enter"); // a drafted follow-up is sent by its own editor
162
206
  return;
163
- if (key.name === "up" || key.name === "k") {
164
- state = reduceForm(questions, state, "up").state;
165
- draw(false);
166
207
  }
167
- else if (key.name === "down" || key.name === "j" || key.name === "tab") {
168
- state = reduceForm(questions, state, "down").state;
169
- draw(false);
170
- }
171
- else if (key.name === "return" || key.name === "enter" || key.name === "space") {
172
- const next = reduceForm(questions, state, "select");
173
- state = next.state;
174
- if (next.done)
175
- finish(collectAnswers(questions, state)); // submitted with all answered
176
- else
208
+ if (typing) {
209
+ const next = reduceEditor(answer, str, key);
210
+ if (next.action === "edit") {
211
+ answer = next.state;
177
212
  draw(false);
213
+ }
214
+ return;
178
215
  }
179
- else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
180
- finish(null); // cancel
181
- }
216
+ if (draft.handle(str, key))
217
+ return;
218
+ if (key.name === "space")
219
+ act("enter"); // space toggles / chooses too
182
220
  };
183
221
  try {
184
222
  rl.pause();
@@ -2,7 +2,7 @@ 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
6
  import { emit } from "./telemetry.js"; // a bypassed approval leaves an audit row, not silence
7
7
  // ---- The no-fly zone --------------------------------------------------------
8
8
  // Writes here are never OK, in any mode. .git can destroy history, shell rc
@@ -216,6 +216,27 @@ export function gitWorkTree(target) {
216
216
  }
217
217
  return topLevels.get(dir);
218
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
+ }
219
240
  // A deletion is recoverable when the target is scratch (inside a temp dir) or
220
241
  // git calls it disposable in its own repo: ignored (build output, caches) with
221
242
  // nothing tracked beneath it. Symlinks are left to review: `rm -r link/`
@@ -595,9 +616,13 @@ function basePermission(toolName, argsJson, auto) {
595
616
  return { decision: "deny", reason, summary: args.path };
596
617
  const relative = path.relative(fs.realpathSync(process.cwd()), resolved);
597
618
  // Outside the start directory is still recoverable in another git work
598
- // tree or a temp dir (git, /undo); anywhere else a human decides.
599
- if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved)) {
600
- 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 }) };
601
626
  }
602
627
  }
603
628
  catch {
@@ -747,3 +772,30 @@ function resolveFileTarget(target) {
747
772
  }
748
773
  return path.join(fs.realpathSync(current), ...missing);
749
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.37.0",
3
+ "version": "0.39.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"