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/agent.js
CHANGED
|
@@ -3,14 +3,32 @@ import OpenAI from "openai"; // the API client
|
|
|
3
3
|
import chalk from "chalk"; // terminal colors
|
|
4
4
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
5
|
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
|
-
import { CONFIG, requireApiKey, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
-
import { runLoop, TerminateReason,
|
|
6
|
+
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
+
import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
8
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
|
-
import { forgetFilesExcept } from "./tools.js"; //
|
|
10
|
-
import { compactHistory, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
-
import { newSessionId, saveSession, latestSession } from "./session.js"; // conversation persistence (project-local)
|
|
9
|
+
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
|
+
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
+
import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
12
12
|
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
13
13
|
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
14
|
+
import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
|
|
15
|
+
import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
16
|
+
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
17
|
+
import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
|
|
18
|
+
import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's plan: /todos to view, cleared with the conversation
|
|
19
|
+
import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
|
|
20
|
+
import path from "node:path"; // shorten paths for the /diff summary
|
|
21
|
+
import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
|
|
22
|
+
import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
|
|
23
|
+
import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
|
|
24
|
+
import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
|
|
25
|
+
import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
|
|
26
|
+
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
27
|
+
import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
|
|
28
|
+
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
29
|
+
import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
|
|
30
|
+
import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
|
|
31
|
+
import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
|
|
14
32
|
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
15
33
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
16
34
|
// ---- CLI arguments --------------------------------------------------------------
|
|
@@ -33,7 +51,6 @@ Configuration (optional):
|
|
|
33
51
|
In a session, type /help for the in-session commands.`;
|
|
34
52
|
// What we tell the user for every way a query can end. No raw stack traces.
|
|
35
53
|
const EXIT_NOTES = {
|
|
36
|
-
[TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
|
|
37
54
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
38
55
|
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
39
56
|
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
|
|
@@ -47,9 +64,24 @@ const SESSION_HELP = `commands:
|
|
|
47
64
|
/help this text
|
|
48
65
|
/clear wipe the conversation (and the file read-state) — fresh start
|
|
49
66
|
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
50
|
-
/model
|
|
67
|
+
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
51
68
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
69
|
+
/memory show the durable facts the agent remembers about this project
|
|
70
|
+
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
71
|
+
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
72
|
+
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
73
|
+
/bg list background tasks this session (run_bash_background) and their status
|
|
74
|
+
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
75
|
+
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
76
|
+
/undo revert the most recent file write (write_file / edit_file) this session
|
|
77
|
+
/diff show every file changed this session, as a diff from where it started
|
|
78
|
+
/resume list recent sessions in this project and continue one of them
|
|
79
|
+
/skills list the reusable skills available in this project
|
|
80
|
+
/skill <name> run a skill yourself (works even for user-only skills)
|
|
52
81
|
exit leave (Ctrl+C at the prompt does the same)`;
|
|
82
|
+
// Injected when the user turns plan mode on, so the model knows the rules of the
|
|
83
|
+
// mode it now lives in (the permission gate enforces them regardless).
|
|
84
|
+
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
53
85
|
async function main() {
|
|
54
86
|
// ---- Flag handling, before anything touches the network -----------------------
|
|
55
87
|
const argv = process.argv.slice(2); // everything after "mini-agent"
|
|
@@ -85,6 +117,7 @@ async function main() {
|
|
|
85
117
|
// -r / --resume picks up the most recent one. The system message is NOT
|
|
86
118
|
// restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
|
|
87
119
|
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
120
|
+
const sessionStartedAt = Date.now(); // for the status line's elapsed clock
|
|
88
121
|
if (argv.includes("-r") || argv.includes("--resume")) {
|
|
89
122
|
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
90
123
|
if (prev) {
|
|
@@ -98,6 +131,37 @@ async function main() {
|
|
|
98
131
|
}
|
|
99
132
|
initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
|
|
100
133
|
emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
|
|
134
|
+
// Connect to any configured MCP servers and register their tools. This must
|
|
135
|
+
// finish before the first model call so the tools appear in the manual. A
|
|
136
|
+
// server that fails to start is skipped, never fatal.
|
|
137
|
+
const disconnectMcp = await connectMcpServers();
|
|
138
|
+
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
139
|
+
process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
|
|
140
|
+
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
141
|
+
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
142
|
+
if (judge)
|
|
143
|
+
console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
|
|
144
|
+
// The remember tool: let the model save durable facts to long-term memory.
|
|
145
|
+
// Registered like any tool, so it flows through the same permission gate.
|
|
146
|
+
registerExternalTool(rememberTool);
|
|
147
|
+
const memCount = readMemory().length;
|
|
148
|
+
if (memCount)
|
|
149
|
+
console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
|
|
150
|
+
// Skills: reusable Markdown procedures. The model-invocable ones are exposed
|
|
151
|
+
// through a single `skill` tool whose description lists them (progressive
|
|
152
|
+
// disclosure); user-only skills are reachable via /skill <name>.
|
|
153
|
+
const skills = loadSkills();
|
|
154
|
+
if (skills.some((s) => !s.disableModelInvocation))
|
|
155
|
+
registerExternalTool(buildSkillTool(skills));
|
|
156
|
+
if (skills.length)
|
|
157
|
+
console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
|
|
158
|
+
// Token/cost meter for this session. Prices come from settings, falling back
|
|
159
|
+
// to the defaults; the loop records usage into it from every stream.
|
|
160
|
+
const costMeter = initCostMeter({
|
|
161
|
+
inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
|
|
162
|
+
cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
|
|
163
|
+
outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
|
|
164
|
+
});
|
|
101
165
|
// SessionStart hooks run once, here. Their stdout is injected as context the
|
|
102
166
|
// model sees on its first turn — e.g. inject the current git branch, an
|
|
103
167
|
// on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
|
|
@@ -125,6 +189,8 @@ async function main() {
|
|
|
125
189
|
model: CONFIG.model,
|
|
126
190
|
signal: controller.signal,
|
|
127
191
|
isInterrupted: () => interrupted,
|
|
192
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
193
|
+
judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
|
|
128
194
|
// Print mode never prompts: unattended means nobody can say yes.
|
|
129
195
|
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
130
196
|
// were enforced in permissions.ts long before this runs.
|
|
@@ -146,12 +212,12 @@ async function main() {
|
|
|
146
212
|
return; // the answer itself already streamed to stdout
|
|
147
213
|
}
|
|
148
214
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
149
|
-
// The
|
|
150
|
-
console.log(
|
|
151
|
-
chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | /help for commands · Ctrl+C interrupts a running task`));
|
|
215
|
+
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
216
|
+
console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
|
|
152
217
|
// ONE readline interface for the whole session — the task prompt and the
|
|
153
218
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
154
219
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
220
|
+
rl.setPrompt(""); // a TTY edits via our editor (not rl), so blank readline's default "> " — it would otherwise flash on resume()
|
|
155
221
|
// Input plumbing: a line QUEUE instead of rl.question(). With piped stdin
|
|
156
222
|
// all lines arrive at once — question() would catch the first and lose the
|
|
157
223
|
// rest. The queue catches every line; asking pops one (or waits for one).
|
|
@@ -160,7 +226,11 @@ async function main() {
|
|
|
160
226
|
const pendingLines = []; // lines that arrived before anyone asked
|
|
161
227
|
const lineWaiters = []; // askers waiting for a line
|
|
162
228
|
let stdinDone = false; // has stdin ended?
|
|
229
|
+
const history = []; // past prompts, for ↑/↓ recall in the editor
|
|
230
|
+
let editing = false; // true while our line editor owns stdin (so the running-key handler stands down)
|
|
163
231
|
rl.on("line", (line) => {
|
|
232
|
+
// Only fires for NON-TTY (piped) input — a TTY goes through editLine, which
|
|
233
|
+
// doesn't emit readline 'line' events. Piped input has no footer to clear.
|
|
164
234
|
const waiter = lineWaiters.shift(); // is somebody waiting?
|
|
165
235
|
if (waiter)
|
|
166
236
|
waiter(line); // hand the line over directly
|
|
@@ -173,12 +243,35 @@ async function main() {
|
|
|
173
243
|
lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
|
|
174
244
|
});
|
|
175
245
|
// Get the next line of user input, showing a prompt if we have to wait.
|
|
176
|
-
const readUserLine = (prompt) => {
|
|
246
|
+
const readUserLine = async (prompt, footer) => {
|
|
177
247
|
if (pendingLines.length)
|
|
178
|
-
return
|
|
248
|
+
return pendingLines.shift(); // typed-ahead (or piped) line — use it
|
|
179
249
|
if (stdinDone)
|
|
180
|
-
return
|
|
181
|
-
|
|
250
|
+
return "exit"; // stdin is gone — leave politely
|
|
251
|
+
if (process.stdin.isTTY) {
|
|
252
|
+
// Our editor owns the whole input block (prompt + wrapped input + footer),
|
|
253
|
+
// so the status bar stays pinned below however the line wraps.
|
|
254
|
+
editing = true;
|
|
255
|
+
try {
|
|
256
|
+
const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed() });
|
|
257
|
+
if (res.type === "line") {
|
|
258
|
+
if (res.value.trim())
|
|
259
|
+
history.push(res.value); // remember non-empty entries for ↑/↓
|
|
260
|
+
return res.value;
|
|
261
|
+
}
|
|
262
|
+
if (res.type === "cancel") {
|
|
263
|
+
// Ctrl+C at the prompt: exit the session (standard REPL behavior).
|
|
264
|
+
console.log();
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
return "exit"; // eof (Ctrl+D)
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
editing = false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Non-TTY (piped): the readline line queue, no prompt frame, no footer.
|
|
274
|
+
rl.setPrompt(prompt);
|
|
182
275
|
rl.prompt();
|
|
183
276
|
return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
|
|
184
277
|
};
|
|
@@ -202,8 +295,8 @@ async function main() {
|
|
|
202
295
|
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
203
296
|
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
204
297
|
// denies were already enforced in permissions.ts before we get here.
|
|
205
|
-
const confirm = async (question) => {
|
|
206
|
-
console.log(chalk.yellow(`\n
|
|
298
|
+
const confirm = async (question, toolName) => {
|
|
299
|
+
console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
|
|
207
300
|
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
208
301
|
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
209
302
|
return true; // approve without asking
|
|
@@ -212,26 +305,284 @@ async function main() {
|
|
|
212
305
|
console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
|
|
213
306
|
return false; // fail closed
|
|
214
307
|
}
|
|
215
|
-
|
|
216
|
-
|
|
308
|
+
// Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
|
|
309
|
+
// option lets the user stop being asked about this tool for the session.
|
|
310
|
+
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
311
|
+
const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
|
|
312
|
+
const approved = choice === 0 || choice === 1;
|
|
313
|
+
if (choice === 1 && toolName) {
|
|
314
|
+
CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
|
|
315
|
+
console.log(chalk.dim(` won't ask again for ${toolName} this session`));
|
|
316
|
+
}
|
|
317
|
+
console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
|
|
318
|
+
return approved;
|
|
319
|
+
};
|
|
320
|
+
// The model's handle on the user: present a multi-question form and return the
|
|
321
|
+
// selections. Null in a non-interactive session — the model is told to ask in
|
|
322
|
+
// plain text instead.
|
|
323
|
+
const askUser = async (questions) => {
|
|
324
|
+
if (!process.stdin.isTTY)
|
|
325
|
+
return null;
|
|
326
|
+
return promptForm(rl, questions);
|
|
327
|
+
};
|
|
328
|
+
// Ask the endpoint what models it serves (the OpenAI-compatible /models API).
|
|
329
|
+
// Empty list on any failure — not every endpoint implements it, and a failed
|
|
330
|
+
// listing should never break the command.
|
|
331
|
+
const listModels = async () => {
|
|
332
|
+
try {
|
|
333
|
+
const page = await client.models.list();
|
|
334
|
+
const ids = (page.data ?? []).map((m) => m.id).filter(Boolean);
|
|
335
|
+
return [...new Set(ids)].sort();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
// /model — show the current model, or switch. `/model <name>` sets it directly;
|
|
342
|
+
// bare `/model` lists the endpoint's models and lets you pick one (Day 29 menu).
|
|
343
|
+
// The switch takes effect next turn: the loop reads CONFIG.model each turn.
|
|
344
|
+
const handleModelCommand = async (arg) => {
|
|
345
|
+
// Switching is SESSION-ONLY by default — a quick "/model <name>" to try a
|
|
346
|
+
// model must never permanently change your default. "/model save <name>"
|
|
347
|
+
// (or save after picking) writes it to the global settings file.
|
|
348
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
349
|
+
const name = save ? arg.slice(4).trim() : arg;
|
|
350
|
+
const apply = (next) => {
|
|
351
|
+
const prev = CONFIG.model;
|
|
352
|
+
CONFIG.model = next; // any string — the API errors on an unknown one, and the error layer reports it
|
|
353
|
+
if (save) {
|
|
354
|
+
saveGlobalSetting("model", next); // persist as the new default
|
|
355
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
if (name) {
|
|
362
|
+
apply(name);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// No name: show the current model and a picker.
|
|
366
|
+
console.log(chalk.dim(`model: ${CONFIG.model}\n` +
|
|
367
|
+
(CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
|
|
368
|
+
`endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
|
|
369
|
+
if (!process.stdin.isTTY)
|
|
370
|
+
return; // no picker without a TTY — switch with: /model <name>
|
|
371
|
+
const models = await listModels();
|
|
372
|
+
if (!models.length) {
|
|
373
|
+
console.log(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
console.log(chalk.dim("switch model (this session):"));
|
|
377
|
+
const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
|
|
378
|
+
if (choice < 0 || models[choice] === CONFIG.model) {
|
|
379
|
+
console.log(chalk.dim("(model unchanged)"));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
|
|
217
383
|
};
|
|
218
384
|
// Handle a /slash command. Returns true if the line was a command.
|
|
219
385
|
const handleCommand = async (line) => {
|
|
386
|
+
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
387
|
+
// switch case — handle it before the exact-match switch.
|
|
388
|
+
if (line === "/model" || line.startsWith("/model ")) {
|
|
389
|
+
await handleModelCommand(line.slice("/model".length).trim());
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
// /skill <name> runs a skill on the user's behalf — inject its instructions
|
|
393
|
+
// as a turn so the model follows them. Works for user-only skills too.
|
|
394
|
+
if (line === "/skill" || line.startsWith("/skill ")) {
|
|
395
|
+
const name = line.slice("/skill".length).trim();
|
|
396
|
+
if (!name) {
|
|
397
|
+
console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
const s = findSkill(skills, name);
|
|
401
|
+
if (!s) {
|
|
402
|
+
console.log(chalk.yellow(`(no skill named "${name}")`));
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
|
|
406
|
+
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
407
|
+
running = true;
|
|
408
|
+
interrupted = false;
|
|
409
|
+
controller = new AbortController();
|
|
410
|
+
const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
|
|
411
|
+
running = false;
|
|
412
|
+
saveSession(sessionId, CONFIG.model, messages);
|
|
413
|
+
if (r.reason !== TerminateReason.Done)
|
|
414
|
+
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
220
417
|
switch (line) {
|
|
418
|
+
case "/skills": {
|
|
419
|
+
if (!skills.length) {
|
|
420
|
+
console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
console.log(chalk.dim(`skills in this project:`));
|
|
424
|
+
for (const s of skills) {
|
|
425
|
+
const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
|
|
426
|
+
console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
221
430
|
case "/help":
|
|
222
431
|
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
223
432
|
return true;
|
|
224
433
|
case "/clear":
|
|
225
434
|
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
226
435
|
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
436
|
+
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
437
|
+
clearTodos(); // the plan belonged to the old task — drop it
|
|
438
|
+
resetTeam(); // the team belonged to the old task — forget it (mailboxes re-wipe on next use)
|
|
439
|
+
resetBoard(); // and the shared task board (Day 40)
|
|
227
440
|
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
228
441
|
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
229
442
|
return true;
|
|
443
|
+
case "/todos": {
|
|
444
|
+
const todos = getTodos(); // the agent's current checklist for this task
|
|
445
|
+
console.log(todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)"));
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
case "/bg": {
|
|
449
|
+
// The background jobs (run_bash_background) started this session — what's
|
|
450
|
+
// still running, what finished, and with what exit status.
|
|
451
|
+
const bg = listBackground();
|
|
452
|
+
if (!bg.length) {
|
|
453
|
+
console.log(chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)"));
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
|
|
457
|
+
console.log(chalk.dim(`background tasks this session:`));
|
|
458
|
+
for (const t of bg) {
|
|
459
|
+
console.log(chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`));
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
case "/team": {
|
|
464
|
+
// The agent team (spawn_teammate): who's running, who's done, and how
|
|
465
|
+
// many messages still sit unread in each one's on-disk inbox.
|
|
466
|
+
const team = listTeam();
|
|
467
|
+
if (!team.length) {
|
|
468
|
+
console.log(chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)"));
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
// Day 39: a running teammate reports as active (working) or idle (waiting).
|
|
472
|
+
const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
|
|
473
|
+
console.log(chalk.dim(`agent team this session:`));
|
|
474
|
+
for (const t of team) {
|
|
475
|
+
const inbox = t.pending ? chalk.yellow(` · ${t.pending} unread`) : "";
|
|
476
|
+
console.log(chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${inbox}`));
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
case "/tasks": {
|
|
481
|
+
// The shared task board (create_task/claim_task/complete_task): what's
|
|
482
|
+
// pending, claimed, and done, and who owns what.
|
|
483
|
+
const summary = boardSummary();
|
|
484
|
+
console.log(summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`));
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
230
487
|
case "/stats":
|
|
231
488
|
console.log(chalk.dim(statsReport())); // local counters, busiest first
|
|
232
489
|
return true;
|
|
233
|
-
case "/
|
|
234
|
-
|
|
490
|
+
case "/memory": {
|
|
491
|
+
const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
|
|
492
|
+
console.log(chalk.dim(entries.length
|
|
493
|
+
? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
|
|
494
|
+
: `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`));
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
case "/cost":
|
|
498
|
+
console.log(chalk.dim(costMeter.report())); // tokens, cache hit rate, estimated spend (local)
|
|
499
|
+
return true;
|
|
500
|
+
case "/undo": {
|
|
501
|
+
// Revert the most recent write. This is a USER action on the filesystem,
|
|
502
|
+
// not a turn for the model — the conversation is untouched. If the model
|
|
503
|
+
// later edits a file it thinks it changed, edit_file's read-before-edit +
|
|
504
|
+
// exact-match guards will catch the staleness and ask it to re-read.
|
|
505
|
+
const undone = undoLast();
|
|
506
|
+
if (!undone) {
|
|
507
|
+
console.log(chalk.dim("(nothing to undo — no file writes recorded this session)"));
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
console.log(chalk.dim(`↩ ${undone.summary}`));
|
|
511
|
+
// Show the diff of what was put back (from the current state to the
|
|
512
|
+
// restored one), reusing the Day 21 renderer.
|
|
513
|
+
console.log(renderDiff(undone.after ?? "", undone.before ?? ""));
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
case "/resume": {
|
|
517
|
+
// List recent sessions in this project and switch to the chosen one.
|
|
518
|
+
// Swapping mid-conversation is fine: we rebuild from the constitution +
|
|
519
|
+
// the saved messages, and reset the per-conversation state (read-state,
|
|
520
|
+
// undo history) so the resumed session behaves like a fresh start of it.
|
|
521
|
+
const sessions = listSessions(10);
|
|
522
|
+
if (!sessions.length) {
|
|
523
|
+
console.log(chalk.dim("(no saved sessions in this project yet)"));
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
console.log(chalk.dim("recent sessions in this project:"));
|
|
527
|
+
sessions.forEach((s, i) => {
|
|
528
|
+
const when = s.savedAt.slice(0, 16).replace("T", " "); // 2026-06-15 10:30
|
|
529
|
+
console.log(chalk.dim(` ${i + 1}. ${when} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`));
|
|
530
|
+
});
|
|
531
|
+
const answer = (await readUserLine(" resume which? [number, or Enter to cancel] ")).trim();
|
|
532
|
+
if (!answer) {
|
|
533
|
+
console.log(chalk.dim("(cancelled)"));
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
const idx = Number(answer) - 1;
|
|
537
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= sessions.length) {
|
|
538
|
+
console.log(chalk.dim("(not a valid choice — cancelled)"));
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
const chosen = loadSession(sessions[idx].id);
|
|
542
|
+
if (!chosen) {
|
|
543
|
+
console.log(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
547
|
+
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
548
|
+
forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
|
|
549
|
+
clearUndo(); // the previous session's writes are not ours to undo
|
|
550
|
+
clearTodos(); // the resumed task starts without the old session's stale plan
|
|
551
|
+
resetTeam(); // a resumed conversation starts with no live team
|
|
552
|
+
resetBoard(); // ...and an empty task board (Day 40)
|
|
553
|
+
console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
case "/diff": {
|
|
557
|
+
// A read-only review of the net effect of this session on the
|
|
558
|
+
// filesystem — every file whose content differs from where it started,
|
|
559
|
+
// shown as a diff from baseline → now. Reuses the Day 21 renderer.
|
|
560
|
+
const changes = sessionChanges();
|
|
561
|
+
if (!changes.length) {
|
|
562
|
+
console.log(chalk.dim("(no file changes this session)"));
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
|
|
566
|
+
console.log(chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`));
|
|
567
|
+
for (const c of changes) {
|
|
568
|
+
const short = path.relative(process.cwd(), c.path) || c.path;
|
|
569
|
+
console.log(`\n${verb[c.status]} ${short}`);
|
|
570
|
+
console.log(renderDiff(c.baseline, c.current));
|
|
571
|
+
}
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
case "/plan":
|
|
575
|
+
// A toggle. Turning it on injects the rules of the mode as a user turn so
|
|
576
|
+
// the model adopts them; the gate (permissions.ts) enforces them either way.
|
|
577
|
+
if (isPlanMode()) {
|
|
578
|
+
setPlanMode(false);
|
|
579
|
+
console.log(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
setPlanMode(true);
|
|
583
|
+
messages.push({ role: "user", content: PLAN_MODE_NOTICE });
|
|
584
|
+
console.log(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
|
|
585
|
+
}
|
|
235
586
|
return true;
|
|
236
587
|
case "/compact": {
|
|
237
588
|
if (messages.length <= 1) {
|
|
@@ -256,15 +607,69 @@ async function main() {
|
|
|
256
607
|
};
|
|
257
608
|
// The session loop: ask → run → render → ask again. This is what makes it
|
|
258
609
|
// a conversation instead of a one-shot command.
|
|
610
|
+
// Raw-stdin key handling that applies while a task is RUNNING. At the prompt
|
|
611
|
+
// the line editor owns stdin (and handles its own keys, incl. Tab → collapse),
|
|
612
|
+
// so we stand down whenever `editing` is true to avoid double-processing.
|
|
613
|
+
// Esc → interrupt the current operation and return to the prompt (like the
|
|
614
|
+
// first Ctrl+C, but without the "press again to force-quit" escalation).
|
|
615
|
+
// A LONE Esc is one byte (0x1b); arrow keys etc. are multi-byte escape
|
|
616
|
+
// sequences starting with 0x1b — the length check tells them apart.
|
|
617
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
618
|
+
process.stdin.on("data", (buf) => {
|
|
619
|
+
if (editing)
|
|
620
|
+
return; // the editor is in charge of these keys
|
|
621
|
+
if (buf.length === 1 && buf[0] === 0x1b) {
|
|
622
|
+
// Esc: stop the current operation, keep the session alive and typeable.
|
|
623
|
+
if (running && !interrupted) {
|
|
624
|
+
interrupted = true; // the loop polls this between steps
|
|
625
|
+
controller.abort(); // cancel the in-flight API request
|
|
626
|
+
console.log(chalk.yellow("\n(esc — interrupted; back at the prompt)"));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
259
631
|
while (true) {
|
|
260
|
-
|
|
632
|
+
// The input area, Claude-Code style: a top rule, the "❯" you type on, then a
|
|
633
|
+
// bottom rule + status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
|
|
634
|
+
// pinned BELOW the input. readUserLine draws the footer under the prompt and
|
|
635
|
+
// keeps the cursor on the ❯ line, so the status reads like a real bottom bar.
|
|
636
|
+
// Only in a TTY; piped input stays clean.
|
|
637
|
+
let footer;
|
|
638
|
+
if (process.stdin.isTTY) {
|
|
639
|
+
const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
|
|
640
|
+
console.log("\n" + inputRule()); // top rule above the prompt
|
|
641
|
+
footer = inputRule() + "\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // bottom rule + status, pinned below
|
|
642
|
+
}
|
|
643
|
+
const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
|
|
261
644
|
if (!line)
|
|
262
645
|
continue; // empty line — just re-prompt
|
|
263
|
-
if (line === "exit" || line === "quit")
|
|
646
|
+
if (line === "exit" || line === "quit") {
|
|
647
|
+
if (hasRunningBackground())
|
|
648
|
+
console.log(chalk.yellow("(stopping background tasks still running — see /bg)")); // Day 37: they're about to be SIGKILLed on exit
|
|
264
649
|
break; // explicit goodbye
|
|
650
|
+
}
|
|
265
651
|
if (await handleCommand(line))
|
|
266
652
|
continue; // slash commands never reach the model
|
|
267
|
-
|
|
653
|
+
// UserPromptSubmit hook: a chance to validate/inject before the model sees
|
|
654
|
+
// the prompt. exit 2 DROPS the prompt entirely (e.g. block a forbidden ask);
|
|
655
|
+
// stdout becomes extra context for this turn (e.g. inject the current ticket).
|
|
656
|
+
const submit = await runHooks("UserPromptSubmit", { prompt: line });
|
|
657
|
+
if (submit.block) {
|
|
658
|
+
console.log(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${submit.feedback.slice(0, 150)})`));
|
|
659
|
+
continue; // the prompt is erased — never reaches the model
|
|
660
|
+
}
|
|
661
|
+
const injected = submit.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${submit.stdout}` : "";
|
|
662
|
+
// @file mentions: if the line references files (@src/loop.ts), attach their
|
|
663
|
+
// content to the message so the model sees them directly. Secret files are
|
|
664
|
+
// refused here, exactly as read_file would refuse them.
|
|
665
|
+
const { augmented, mentions } = expandMentions(line);
|
|
666
|
+
const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
|
|
667
|
+
const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
|
|
668
|
+
if (attached.length)
|
|
669
|
+
console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
670
|
+
if (refused.length)
|
|
671
|
+
console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
672
|
+
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
268
673
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
269
674
|
interrupted = false; // fresh interrupt state for this task
|
|
270
675
|
controller = new AbortController(); // fresh abort signal for this task
|
|
@@ -274,9 +679,25 @@ async function main() {
|
|
|
274
679
|
signal: controller.signal, // for aborting requests
|
|
275
680
|
isInterrupted: () => interrupted, // for stopping between steps
|
|
276
681
|
confirm, // for permission prompts
|
|
682
|
+
askUser, // the multi-question form the model can pop
|
|
683
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
684
|
+
judge, // optional LLM classifier for the "ask" middle ground
|
|
277
685
|
});
|
|
278
686
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
279
687
|
saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
|
|
688
|
+
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
689
|
+
// that saves durable facts — especially user corrections — so memory
|
|
690
|
+
// captures itself instead of relying on the model to call `remember`.
|
|
691
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
692
|
+
try {
|
|
693
|
+
const got = await extractMemories(client, CONFIG.subAgentModel || CONFIG.model, messages);
|
|
694
|
+
if (got.length)
|
|
695
|
+
console.log(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
/* memory extraction must never break the session */
|
|
699
|
+
}
|
|
700
|
+
}
|
|
280
701
|
// Done already streamed its answer to the screen; everything else gets a
|
|
281
702
|
// one-line human explanation. The session continues either way — except a
|
|
282
703
|
// fatal API error, which no amount of chatting will fix.
|
|
@@ -288,8 +709,10 @@ async function main() {
|
|
|
288
709
|
break; // a bad key won't fix itself mid-session
|
|
289
710
|
}
|
|
290
711
|
}
|
|
712
|
+
await runHooks("SessionEnd", {}); // cleanup hooks (tiny 1.5s budget — the user may be leaving via Ctrl+C)
|
|
291
713
|
emit("agent_session_end"); // close the books
|
|
292
714
|
rl.close(); // release stdin
|
|
715
|
+
tuiCleanup(); // restore terminal
|
|
293
716
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
294
717
|
}
|
|
295
718
|
main(); // kick everything off
|