agent-from-zero 0.5.0 → 0.28.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/dist/team.js ADDED
@@ -0,0 +1,214 @@
1
+ // ============ Agent teams (Day 38) ============
2
+ // The leap past the one-shot sub-agent (the `task` tool). A sub-agent is fire-
3
+ // and-forget: fresh context, runs to completion, returns ONE final report, gone.
4
+ // That breaks down on big work — a teammate restructuring the API can't tell the
5
+ // teammate touching auth what it changed, because they never talk; each only
6
+ // hands back a conclusion at the very end.
7
+ //
8
+ // A TEAM fixes that. A Lead spawns several PERSISTENT teammates that run
9
+ // concurrently and coordinate through ASYNCHRONOUS MAILBOXES: any agent can drop
10
+ // a message in any other agent's inbox at any time, and the recipient sees it on
11
+ // its next round. The Lead injects its inbox into its own conversation, so it
12
+ // reacts to teammate results as they arrive instead of blocking on a single
13
+ // return value.
14
+ //
15
+ // Why mailboxes live ON DISK (落盘), not in a Map: OBSERVABILITY. While the team
16
+ // runs you can `cat .mini-agent/team/<agent>.jsonl` and watch messages in flight
17
+ // — the coordination is a thing you can inspect, not hidden process state. The
18
+ // Python reference guards these files with proper-lockfile because it runs
19
+ // teammates on OS threads; we run them as async tasks on ONE event loop, where a
20
+ // single appendFileSync — or a readFileSync+rmSync pair — runs to completion
21
+ // without yielding, so two agents can never interleave mid-operation. No lock
22
+ // needed. (Cross-process is out of scope: one CLI instance per project.)
23
+ import fs from "node:fs"; // mailboxes are real files
24
+ import path from "node:path"; // path joining
25
+ // The mailbox name of the coordinator. Teammates message "lead" to report back;
26
+ // the Lead reads its own inbox under this name.
27
+ export const LEAD = "lead";
28
+ // At most this many teammates per Lead — a cost/runaway ceiling, like every
29
+ // other loop in this codebase. A handful of specialists is the whole idea; a
30
+ // swarm is a footgun.
31
+ export const MAX_TEAMMATES = 4;
32
+ const pendingRequests = new Map(); // requestId → state
33
+ let reqSeq = 0; // increments per request — seeds the id
34
+ const teammates = new Map(); // name → teammate, for this run
35
+ // ---- The mailbox files ------------------------------------------------------
36
+ // Resolved lazily (a function, not a const) so it tracks the cwd, exactly like
37
+ // session.ts. The first touch this process run wipes any stale mailboxes left by
38
+ // a crashed earlier run — a new team must start with empty inboxes.
39
+ let wiped = false;
40
+ function teamRoot() {
41
+ const root = path.resolve(".mini-agent", "team");
42
+ fs.mkdirSync(root, { recursive: true });
43
+ if (!wiped) {
44
+ for (const f of fs.readdirSync(root))
45
+ if (f.endsWith(".jsonl"))
46
+ fs.rmSync(path.join(root, f)); // fresh inboxes for this run
47
+ wiped = true;
48
+ }
49
+ return root;
50
+ }
51
+ function inboxPath(agent) {
52
+ // Keep file names filesystem-safe: agent names come from the model.
53
+ const safe = agent.replace(/[^a-zA-Z0-9_-]/g, "_");
54
+ return path.join(teamRoot(), `${safe}.jsonl`);
55
+ }
56
+ // Drop a message in the recipient's inbox: one JSON object per line (JSONL), so
57
+ // appends never have to rewrite the file and a half-written line can't corrupt
58
+ // the earlier ones. Synchronous on purpose — see the no-lock note up top.
59
+ export function sendMessage(from, to, content, type = "message") {
60
+ const msg = { from, to, content, type, ts: Date.now() };
61
+ fs.appendFileSync(inboxPath(to), JSON.stringify(msg) + "\n");
62
+ }
63
+ // Drop a PROTOCOL message in an inbox — same JSONL bus, but tagged with its
64
+ // contract role, request_id, and (for responses) the decision. This is how a
65
+ // request and its answer travel; the consume step on the other side routes by
66
+ // `kind` and correlates by `requestId`.
67
+ export function sendProtocol(from, to, kind, requestId, content, status) {
68
+ const msg = { from, to, content, type: "message", ts: Date.now(), kind, requestId, status };
69
+ fs.appendFileSync(inboxPath(to), JSON.stringify(msg) + "\n");
70
+ }
71
+ // ---- Protocol state (the pending_requests dict) -----------------------------
72
+ // Open a request: store it pending and return its fresh id. The caller then
73
+ // sends the matching *_request message carrying this id.
74
+ export function createRequest(kind, from, to, payload) {
75
+ const requestId = `req_${String(++reqSeq).padStart(6, "0")}`; // req_000001
76
+ pendingRequests.set(requestId, { requestId, kind, from, to, status: "pending", payload, createdAt: Date.now() });
77
+ return requestId;
78
+ }
79
+ export function getRequest(requestId) {
80
+ return pendingRequests.get(requestId);
81
+ }
82
+ // Correlate a response to its request — the reference's match_response. The
83
+ // guard that matters: the response's contract MUST match the request's kind, so
84
+ // a shutdown_response can never accidentally approve a plan_approval. Returns the
85
+ // updated state, or an error string the caller can hand back to the model.
86
+ export function resolveResponse(requestId, kind, approved) {
87
+ const state = pendingRequests.get(requestId);
88
+ if (!state)
89
+ return { ok: false, error: `No pending request "${requestId}".` };
90
+ if (state.kind !== kind)
91
+ return { ok: false, error: `Request "${requestId}" is a ${state.kind} request, not ${kind} — wrong response type.` };
92
+ if (state.status !== "pending")
93
+ return { ok: false, error: `Request "${requestId}" was already ${state.status}.` };
94
+ state.status = approved ? "approved" : "rejected";
95
+ return { ok: true, state };
96
+ }
97
+ // Requests still awaiting an answer for a given recipient — used by the lead's
98
+ // view and to surface "you still owe an answer" prompts.
99
+ export function openRequestsFor(agent) {
100
+ return [...pendingRequests.values()].filter((r) => r.to === agent && r.status === "pending");
101
+ }
102
+ // Read and CONSUME an inbox: parse every message, then delete the file. The
103
+ // consumptive pattern (read = remove) means a message is delivered exactly once
104
+ // and the on-disk inbox always shows only what is still pending. Unreadable
105
+ // lines are skipped, never fatal.
106
+ export function readInbox(agent) {
107
+ const p = inboxPath(agent);
108
+ if (!fs.existsSync(p))
109
+ return []; // nothing waiting
110
+ let raw = "";
111
+ try {
112
+ raw = fs.readFileSync(p, "utf8");
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ fs.rmSync(p, { force: true }); // consume: the read empties the mailbox
118
+ const out = [];
119
+ for (const line of raw.split("\n")) {
120
+ const t = line.trim();
121
+ if (!t)
122
+ continue;
123
+ try {
124
+ out.push(JSON.parse(t));
125
+ }
126
+ catch {
127
+ /* skip a corrupt line, keep the rest */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ // Peek at how many messages are waiting WITHOUT consuming them. The loop uses
133
+ // this to decide whether to keep going; reading would empty the inbox before the
134
+ // agent's own turn got to see it.
135
+ export function inboxCount(agent) {
136
+ const p = inboxPath(agent);
137
+ if (!fs.existsSync(p))
138
+ return 0;
139
+ try {
140
+ return fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).length;
141
+ }
142
+ catch {
143
+ return 0;
144
+ }
145
+ }
146
+ // ---- The registry -----------------------------------------------------------
147
+ export function teammateExists(name) {
148
+ return teammates.has(name);
149
+ }
150
+ export function teammateCount() {
151
+ return teammates.size;
152
+ }
153
+ // Record a newly spawned teammate. The caller supplies the promise for its loop.
154
+ export function registerTeammate(name, role, done) {
155
+ teammates.set(name, { name, role, status: "running", state: "active", startedAt: Date.now(), done });
156
+ }
157
+ // Mark a teammate finished. `ok=false` means its loop ended on a non-Done reason.
158
+ export function finishTeammate(name, ok) {
159
+ const t = teammates.get(name);
160
+ if (t)
161
+ t.status = ok ? "done" : "failed";
162
+ }
163
+ // How many teammates are still running — at least one running teammate means the
164
+ // team isn't disbanded yet.
165
+ export function activeTeammateCount() {
166
+ return [...teammates.values()].filter((t) => t.status === "running").length;
167
+ }
168
+ // ---- Day 39: active/idle + shutdown plumbing --------------------------------
169
+ // A running teammate flips between actively working a round and idling on its
170
+ // inbox. The lead waits while ANY teammate is actively working; once they're all
171
+ // idle (or done), the work has converged and the lead can wrap up the team.
172
+ export function setTeammateState(name, state) {
173
+ const t = teammates.get(name);
174
+ if (t && t.status === "running")
175
+ t.state = state;
176
+ }
177
+ export function anyTeammateBusy() {
178
+ return [...teammates.values()].some((t) => t.status === "running" && t.state === "active");
179
+ }
180
+ export function runningTeammates() {
181
+ return [...teammates.values()].filter((t) => t.status === "running").map((t) => t.name);
182
+ }
183
+ // The shutdown handshake: the lead opens a shutdown request against a teammate,
184
+ // stamping it with the request_id; the teammate sees the flag at its next
185
+ // stop-point, replies shutdown_response(approved) with that id, and exits.
186
+ export function markShutdown(name, requestId) {
187
+ const t = teammates.get(name);
188
+ if (t)
189
+ t.shutdownRequestId = requestId;
190
+ }
191
+ export function shutdownRequestId(name) {
192
+ return teammates.get(name)?.shutdownRequestId;
193
+ }
194
+ // A snapshot for the /team command: newest first, with live state + inbox depth.
195
+ export function listTeam() {
196
+ return [...teammates.values()]
197
+ .sort((a, b) => b.startedAt - a.startedAt)
198
+ .map((t) => ({
199
+ name: t.name,
200
+ role: t.role,
201
+ // While running, show active vs idle (Day 39) — that's the interesting bit.
202
+ status: t.status === "running" ? t.state : t.status,
203
+ elapsed: Math.round((Date.now() - t.startedAt) / 1000),
204
+ pending: inboxCount(t.name),
205
+ }));
206
+ }
207
+ // Wipe the registry AND the in-flight requests (and let the next touch clear the
208
+ // mailbox files). Called on /clear and /resume, and after the lead disbands the
209
+ // team — a new conversation starts with no team and no dangling protocols.
210
+ export function resetTeam() {
211
+ teammates.clear();
212
+ pendingRequests.clear();
213
+ wiped = false; // the next mailbox touch wipes stale files again
214
+ }
package/dist/todos.js ADDED
@@ -0,0 +1,87 @@
1
+ import chalk from "chalk"; // the one color source — match ui.ts's vocabulary
2
+ const STATUSES = ["pending", "in_progress", "completed"];
3
+ // ---- pure: validate the model's argument into a clean Todo[] ----------------
4
+ // The model's arguments are probabilistic, so be strict and return a precise
5
+ // repair message (errors are text the model can act on) rather than trusting it.
6
+ // Rule worth enforcing: AT MOST ONE in_progress — "do one thing at a time" is the
7
+ // whole discipline a plan buys you; many-in-progress is just a relabeled backlog.
8
+ export function parseTodos(raw) {
9
+ if (!Array.isArray(raw))
10
+ return { error: "`todos` must be an array of { content, status }" };
11
+ const todos = [];
12
+ for (let i = 0; i < raw.length; i++) {
13
+ const item = raw[i];
14
+ if (typeof item !== "object" || item === null)
15
+ return { error: `todo #${i + 1} must be an object with content + status` };
16
+ const content = typeof item.content === "string" ? item.content.trim() : "";
17
+ if (!content)
18
+ return { error: `todo #${i + 1} is missing non-empty "content"` };
19
+ if (!STATUSES.includes(item.status))
20
+ return { error: `todo #${i + 1} has bad status "${String(item.status)}" — use pending | in_progress | completed` };
21
+ todos.push({ content, status: item.status });
22
+ }
23
+ if (todos.filter((t) => t.status === "in_progress").length > 1)
24
+ return { error: "only one todo may be in_progress at a time — finish or re-queue the others" };
25
+ return { todos };
26
+ }
27
+ // ---- pure: render the checklist for the terminal ----------------------------
28
+ // Glyphs over words: a column of ✓ / ▶ / ○ scans at a glance. The in-progress
29
+ // item is the loud one (that's where attention belongs); done items fade.
30
+ export function renderTodos(todos) {
31
+ if (!todos.length)
32
+ return chalk.dim(" (no todos)");
33
+ return todos
34
+ .map((t) => {
35
+ if (t.status === "completed")
36
+ return chalk.green(" ✓ ") + chalk.dim.strikethrough(t.content);
37
+ if (t.status === "in_progress")
38
+ return chalk.cyan.bold(" ▶ ") + chalk.bold(t.content);
39
+ return chalk.dim(" ○ " + t.content);
40
+ })
41
+ .join("\n");
42
+ }
43
+ // ---- pure: a one-line confirmation for the MODEL (cheap, no ANSI) -----------
44
+ // The pretty list goes to the screen; the model only needs the tally + what it
45
+ // said it's doing now, so we don't burn tokens echoing its own plan back at it.
46
+ export function summarizeTodos(todos) {
47
+ const done = todos.filter((t) => t.status === "completed").length;
48
+ const doing = todos.find((t) => t.status === "in_progress");
49
+ const pending = todos.filter((t) => t.status === "pending").length;
50
+ const head = `Plan saved — ${done} done, ${doing ? 1 : 0} in progress, ${pending} pending.`;
51
+ if (doing)
52
+ return `${head} Now: ${doing.content}`;
53
+ if (todos.length && done === todos.length)
54
+ return `${head} All items complete.`;
55
+ return head;
56
+ }
57
+ // ---- session state + the nag --------------------------------------------------
58
+ // The current plan lives for the session (in process memory, like Claude Code's).
59
+ let current = [];
60
+ let staleRounds = 0; // tool-rounds since the plan was last touched
61
+ export function setTodos(todos) {
62
+ current = todos;
63
+ staleRounds = 0; // an update is a fresh signal — restart the patience clock
64
+ }
65
+ export function getTodos() {
66
+ return current;
67
+ }
68
+ export function clearTodos() {
69
+ current = [];
70
+ staleRounds = 0; // /clear and session switches wipe the plan too
71
+ }
72
+ // Call once per tool-round. If there's an unfinished plan the model has stopped
73
+ // touching, return a reminder to inject (else null). This is the "nag": without
74
+ // it the model writes a plan once and never looks back — the list goes stale and
75
+ // stops meaning anything. We re-arm after firing, so it nudges every N rounds,
76
+ // not every round (which would be noise).
77
+ export const NAG_AFTER_ROUNDS = 3;
78
+ export function todoNag() {
79
+ const unfinished = current.some((t) => t.status !== "completed");
80
+ if (!current.length || !unfinished)
81
+ return null; // no plan, or all done → nothing to nag about
82
+ staleRounds++;
83
+ if (staleRounds < NAG_AFTER_ROUNDS)
84
+ return null;
85
+ staleRounds = 0; // re-arm
86
+ return "[plan reminder] Your todo list still has unfinished items but you haven't updated it in a few steps. If you've made progress, call todo_write to mark items completed / move the next one to in_progress; if the plan changed, rewrite it. Don't lose the original goal.";
87
+ }
package/dist/tools.js CHANGED
@@ -2,6 +2,11 @@ import fs from "node:fs"; // filesystem reads and writes
2
2
  import os from "node:os"; // temp dir for spilled tool output
3
3
  import path from "node:path"; // path resolution and joining
4
4
  import { spawn } from "node:child_process"; // async process execution (does NOT block the event loop)
5
+ import { validateArgs } from "./validate.js"; // schema validation for tool arguments
6
+ import { setPlanMode } from "./permissions.js"; // plan mode (Day 20): exit_plan_mode flips it off on approval
7
+ import { recordMutation } from "./undo.js"; // capture the before-state so /undo (Day 22) can restore it
8
+ import { parseTodos, setTodos, summarizeTodos } from "./todos.js"; // the agent's own checklist (Day 36)
9
+ import { startBackground, readBackground } from "./background.js"; // long-running commands that outlive the turn (Day 37)
5
10
  // ============ Session state ============
6
11
  // The foundation of "read before edit": which files has this session actually read?
7
12
  // Files that were never read must not be edited — otherwise the model edits from
@@ -67,6 +72,7 @@ On error: follow the error message — read the file first, then retry.`, {
67
72
  return fail(`${p} already exists but you have not read it. Use read_file to check its current content before overwriting.`); // no blind overwrites
68
73
  }
69
74
  fs.mkdirSync(path.dirname(p), { recursive: true }); // create parent directories as needed
75
+ recordMutation(p); // snapshot the previous state (or "didn't exist") for /undo, before we overwrite
70
76
  fs.writeFileSync(p, args.content, "utf8"); // write the new content
71
77
  readFiles.set(p, ++touchCounter); // you wrote it, so you know its content — counts as read
72
78
  return `Wrote ${p} (${args.content.length} chars)`; // confirm with the size as a sanity check
@@ -102,6 +108,7 @@ ALWAYS use this tool to edit files. NEVER use sed/awk or shell redirection via r
102
108
  }
103
109
  // Note: content.replace(old, newStr) would expand $& $' etc. in newStr as special
104
110
  // replacement patterns — pass a function so the replacement is taken literally.
111
+ recordMutation(p); // snapshot the pre-edit content for /undo, before we change it
105
112
  fs.writeFileSync(p, content.replace(args.old_string, () => args.new_string), "utf8"); // apply the single replacement
106
113
  return `Edited ${p}: 1 replacement made.`; // confirm exactly what happened
107
114
  },
@@ -230,20 +237,133 @@ On error: failures return the error output — analyze it and try a different ap
230
237
  });
231
238
  }),
232
239
  };
240
+ // ============ run_bash_background (Day 37) ============
241
+ // run_bash blocks and dies at its 30s watchdog — useless for anything truly
242
+ // slow. This is its asynchronous twin: it spawns the command and returns a task
243
+ // id IMMEDIATELY, so the agent keeps reasoning while the work happens. The loop
244
+ // injects a <task_notification> when the command finishes (background.ts). Use
245
+ // it for installs, builds, full test runs, or servers that never exit — exactly
246
+ // the keywords that signal a slow operation. The command goes through the SAME
247
+ // permission gate as run_bash (see permissions.ts), so the danger rules are
248
+ // identical; only the blocking behavior differs.
249
+ const runBashBackground = {
250
+ 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.
251
+ 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.
252
+ 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.
253
+ 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"]),
254
+ // Fire-and-return: start the process, hand back the id. The output is captured
255
+ // by background.ts and delivered later as a notification — never inline here.
256
+ run: (args) => {
257
+ const id = startBackground(args.command);
258
+ return `Started background task ${id}: ${args.command}\nIt is running now; you will get a <task_notification> when it finishes. Keep working — use bash_output with task_id "${id}" to check on it (e.g. to confirm a server came up).`;
259
+ },
260
+ };
261
+ // ============ bash_output (Day 37) ============
262
+ // Poll a background task: its status and any output produced since the last
263
+ // poll. Essential for the case a notification can never cover — a server that
264
+ // runs forever (npm run dev) never "finishes", so the only way to see "ready on
265
+ // :3000" is to read its output. Reading advances a per-task cursor, so repeated
266
+ // polls show only what's new, not the whole log every time.
267
+ const bashOutput = {
268
+ definition: def("bash_output", `Check on a background task started with run_bash_background: returns its status and any NEW output since you last checked.
269
+ When to use: to see if a long job is done, or to read a long-running server's output (e.g. confirm "listening on :3000"). A finished task also sends you a <task_notification> automatically — you don't need to poll just to learn it ended.
270
+ Pass full=true to get the entire captured log instead of only the new lines. On an unknown task id you'll get the list of ids that exist.`, {
271
+ task_id: { type: "string", description: "The background task id, e.g. bg_1" },
272
+ full: { type: "boolean", description: "Return the entire captured output instead of only new output since the last check (default false)" },
273
+ }, ["task_id"]),
274
+ run: (args) => {
275
+ const full = args.full === true; // optional boolean
276
+ return spillIfHuge(readBackground(args.task_id, full), "background output");
277
+ },
278
+ };
279
+ // ============ exit_plan_mode ============
280
+ // Plan mode (Day 20) makes the agent research-only: the permission gate blocks
281
+ // every mutating tool until the model presents a plan the user approves. The
282
+ // model calls this when its plan is ready; the gate turns it into an "ask", so
283
+ // the user sees the plan first. run() executes ONLY after the user says yes —
284
+ // so reaching this code IS the approval, and the one thing it does is leave plan
285
+ // mode. Outside plan mode the gate makes it a harmless no-op allow.
286
+ const exitPlanMode = {
287
+ definition: def("exit_plan_mode", `Leave plan mode and start implementing. Call this ONLY when you are in plan mode and have finished investigating with read-only tools.
288
+ Pass your plan as markdown in "plan"; the user is shown it and asked to approve. Do NOT call it to ask a question, and not before you have a concrete, ordered plan.
289
+ On approval, plan mode turns off and writing/executing tools become available. If declined, you stay in plan mode — refine the plan and try again.`, { plan: { type: "string", description: "The implementation plan, as concise ordered markdown steps" } }, ["plan"]),
290
+ run: () => {
291
+ setPlanMode(false); // run() only happens after the user approved — leave plan mode so implementation can begin
292
+ return "Plan approved by the user. Plan mode is OFF — implement the plan now; writing and executing tools are available again.";
293
+ },
294
+ };
295
+ // ============ todo_write ============
296
+ // The agent's planning tool (Day 36). The model sends the WHOLE list each time
297
+ // (it replaces the previous one); we store it, show it, and hand back a one-line
298
+ // tally. The array-of-objects schema is built by hand — the `def()` helper only
299
+ // does flat string params. State + rendering + the nag live in todos.ts.
300
+ const todoWrite = {
301
+ definition: {
302
+ type: "function",
303
+ function: {
304
+ name: "todo_write",
305
+ description: `Record and update a short plan for the CURRENT task as a checklist. This adds planning, NOT new abilities — it changes nothing on disk.
306
+ When to use: a task with 3+ distinct steps, or whenever the user gives several requirements. Skip it for trivial one-step asks.
307
+ How: pass the COMPLETE list every time (it replaces the previous one). Keep AT MOST ONE item in_progress. As you work, flip items to completed and move the next to in_progress — an out-of-date list is worse than none.`,
308
+ parameters: {
309
+ type: "object",
310
+ properties: {
311
+ todos: {
312
+ type: "array",
313
+ description: "The complete todo list, in order. Replaces the previous list.",
314
+ items: {
315
+ type: "object",
316
+ properties: {
317
+ content: { type: "string", description: "The step, phrased as an action" },
318
+ status: { type: "string", enum: ["pending", "in_progress", "completed"], description: "pending | in_progress | completed (only one in_progress)" },
319
+ },
320
+ required: ["content", "status"],
321
+ },
322
+ },
323
+ },
324
+ required: ["todos"],
325
+ },
326
+ },
327
+ },
328
+ run: (args) => {
329
+ const parsed = parseTodos(args.todos); // validate the model's list
330
+ if (parsed.error)
331
+ return fail(parsed.error); // precise repair message, not a crash
332
+ setTodos(parsed.todos); // store it (resets the nag clock); the loop renders it to the screen
333
+ return summarizeTodos(parsed.todos); // concise tally back to the model — the pretty list is for the human
334
+ },
335
+ };
233
336
  // ============ Registry & dispatch ============
234
- // One place that knows every tool. Adding a tool = adding one entry here.
337
+ // The built-in tools. External tools (MCP servers, Day 15) are registered into
338
+ // the same record at startup, so they flow through the same dispatch and the
339
+ // same permission gate — there is exactly one execution path, no matter where a
340
+ // tool came from.
235
341
  export const tools = {
236
342
  read_file: readFile,
237
343
  write_file: writeFile,
238
344
  edit_file: editFile,
239
345
  search,
240
346
  run_bash: runBash,
347
+ run_bash_background: runBashBackground,
348
+ bash_output: bashOutput,
349
+ exit_plan_mode: exitPlanMode,
350
+ todo_write: todoWrite,
241
351
  };
242
- // The list of manuals sent to the model on every API call.
243
- export const toolDefinitions = Object.values(tools).map((t) => t.definition);
352
+ // Register an external tool (e.g. one discovered from an MCP server). Kept in
353
+ // the same `tools` record so dispatch, the permission gate, and the tool-list
354
+ // sent to the model all treat it identically to a built-in.
355
+ export function registerExternalTool(tool) {
356
+ tools[tool.definition.function.name] = tool;
357
+ }
358
+ // The manuals sent to the model. A function (not a const) since Day 15: MCP
359
+ // tools register after module load, so the list must be computed when asked.
360
+ export function toolDefinitions() {
361
+ return Object.values(tools).map((t) => t.definition);
362
+ }
244
363
  // Read-only tools never touch the filesystem in a conflicting way, so the loop
245
364
  // is free to run a batch of them concurrently. Anything that writes or executes
246
- // runs alone, in order. (See loop.ts for the batching.)
365
+ // (including any external/MCP tool, which we cannot reason about) runs alone, in
366
+ // order. (See loop.ts for the batching.)
247
367
  const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
248
368
  export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
249
369
  // Single entry point: every failure becomes text fed back to the model.
@@ -261,11 +381,19 @@ export async function dispatch(name, argsJson, signal) {
261
381
  catch {
262
382
  return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
263
383
  }
384
+ // Validate against the tool's declared schema BEFORE running it. The model's
385
+ // arguments are probabilistic — a missing required field or a wrong type gets
386
+ // a precise repair message here instead of a cryptic crash inside the tool
387
+ // (or, for MCP tools, a wasted round-trip to the server).
388
+ const problems = validateArgs(tool.definition.function.parameters, args);
389
+ if (problems.length) {
390
+ return fail(`Invalid arguments for ${name}: ${problems.join("; ")}. Fix and call again.`);
391
+ }
264
392
  // Each tool gets a result cap sized to its job. run_bash already manages its
265
393
  // own output (preview + spill-to-disk via spillIfHuge); its cap must leave
266
394
  // room for the spill note, or dispatch would chop off the very pointer the
267
395
  // model needs to read the rest.
268
- const cap = name === "read_file" ? READ_LIMIT + 100 : name === "run_bash" ? BASH_OUTPUT_LIMIT + SPILL_NOTE_SLACK : TOOL_RESULT_LIMIT;
396
+ const cap = name === "read_file" ? READ_LIMIT + 100 : name === "run_bash" || name === "bash_output" ? BASH_OUTPUT_LIMIT + SPILL_NOTE_SLACK : TOOL_RESULT_LIMIT;
269
397
  try {
270
398
  const result = await tool.run(args, signal); // await covers both sync and async tools
271
399
  return result.slice(0, cap); // cap the result size
package/dist/tui.js ADDED
@@ -0,0 +1,67 @@
1
+ // Terminal UI helpers that need state or the filesystem: the git branch for the
2
+ // status line, and collapsible tool output. The pure formatting (status line,
3
+ // spinner) lives in ui.ts; this is the side-effecting half.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import chalk from "chalk";
7
+ // ---- git branch for the status line -----------------------------------------
8
+ // Walk up from the cwd to the nearest .git and read HEAD. A branch name, a short
9
+ // detached SHA, or null if this isn't a repo. Never throws.
10
+ export function gitBranch() {
11
+ let dir = process.cwd();
12
+ for (let i = 0; i < 40; i++) {
13
+ try {
14
+ const gitPath = path.join(dir, ".git");
15
+ const stat = fs.statSync(gitPath);
16
+ let headFile = path.join(gitPath, "HEAD");
17
+ if (stat.isFile()) {
18
+ // A worktree: .git is a file "gitdir: <path>" pointing elsewhere.
19
+ const gitdir = fs.readFileSync(gitPath, "utf8").replace(/^gitdir:\s*/, "").trim();
20
+ headFile = path.join(path.resolve(dir, gitdir), "HEAD");
21
+ }
22
+ const head = fs.readFileSync(headFile, "utf8").trim();
23
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
24
+ return m ? m[1] : head.slice(0, 7); // branch name, or a short detached SHA
25
+ }
26
+ catch {
27
+ /* not a repo here — keep walking up */
28
+ }
29
+ const parent = path.dirname(dir);
30
+ if (parent === dir)
31
+ break; // reached the filesystem root
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+ const collapsed = [];
37
+ const MAX_COLLAPSED = 50; // bound the memory — old blocks fall off
38
+ // Print the one-line summary now; stash the full text for Tab to reveal later.
39
+ export function printToolSummary(summary, full) {
40
+ process.stdout.write(summary + "\n");
41
+ collapsed.push({ full, expanded: false });
42
+ if (collapsed.length > MAX_COLLAPSED)
43
+ collapsed.shift();
44
+ }
45
+ // Tab handler: reveal (or re-hide) the full text of the most recent tool block.
46
+ // Returns false if there's nothing to toggle.
47
+ export function toggleLastCollapsed() {
48
+ const block = collapsed[collapsed.length - 1];
49
+ if (!block)
50
+ return false;
51
+ block.expanded = !block.expanded;
52
+ if (block.expanded) {
53
+ process.stdout.write(chalk.dim(block.full.replace(/^/gm, " ⎿ ")) + "\n");
54
+ process.stdout.write(chalk.dim(" (Tab to collapse)\n"));
55
+ }
56
+ else {
57
+ process.stdout.write(chalk.dim(" (collapsed)\n"));
58
+ }
59
+ return true;
60
+ }
61
+ // ---- cleanup -----------------------------------------------------------------
62
+ // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
63
+ // of the screen), but keep the hook so callers don't have to change if that
64
+ // changes again.
65
+ export function cleanup() {
66
+ process.stdout.write("\n");
67
+ }