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/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, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
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"; // to reset file state on /clear
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 show which model and endpoint this session is talking to
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 banner: who am I, which model and host am I on, and how to get help.
150
- console.log(chalk.bold(`mini-agent ${pkg.version}`) +
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 Promise.resolve(pendingLines.shift()); // typed-ahead (or piped) line — use it
248
+ return pendingLines.shift(); // typed-ahead (or piped) line — use it
179
249
  if (stdinDone)
180
- return Promise.resolve("exit"); // stdin is gone — leave politely
181
- rl.setPrompt(prompt); // show the prompt the redraw-safe way
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⚠️ approval needed — ${question}`)); // always show what is being asked
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
- const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
216
- return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
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 "/model":
234
- console.log(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`)); // where this session points
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
- const line = (await readUserLine(chalk.green("\n> "))).trim(); // next input, or "exit" on EOF
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
- messages.push({ role: "user", content: line }); // the new turn joins the shared history
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