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/README.md +28 -1
- package/dist/agent.js +13 -13
- 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 +5 -0
- package/dist/cron.js +7 -3
- package/dist/form.js +141 -66
- package/dist/ink/app.js +125 -55
- package/dist/loop.js +73 -16
- package/dist/mcp.js +90 -15
- package/dist/menu.js +60 -22
- package/dist/permissions.js +56 -4
- 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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
141
|
-
process.stdout.write(`\x1b[J${
|
|
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 (
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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();
|
package/dist/permissions.js
CHANGED
|
@@ -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)
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
-
|
|
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
|