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/README.md +44 -18
- package/dist/agent.js +446 -23
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/editor.js +307 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +862 -22
- package/dist/markdown.js +152 -0
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +124 -4
- package/dist/prompt.js +32 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +133 -5
- package/dist/tui.js +67 -0
- package/dist/ui.js +138 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +7 -3
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
|
-
//
|
|
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
|
-
//
|
|
243
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|