agent-from-zero 0.27.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 CHANGED
@@ -45,48 +45,53 @@ MINI_AGENT_MODEL=gpt-4.1-mini
45
45
 
46
46
  ## 进度
47
47
 
48
- | Tag | 内容 |
48
+ | Tag | What it adds |
49
49
  |---|---|
50
- | `day1` | ~50 行 raw tool-calling loop:一个工具、一个循环、两条保险 |
51
- | `day2` | 工具五件套(read/write/edit/search/bash):编辑前必须先读、old_string 唯一性、永不抛异常的 dispatch、四要素工具说明书 |
52
- | `day3` | 主循环状态机:错误分类、指数退避(带抖动)、双重重试预算、熔断器,8 种终态各配人话提示,Ctrl+C 体面退出 |
53
- | `day4` | 权限系统(allow/ask/deny):禁飞区硬拦(.git/.ssh/.env)、deny 永远赢(bypass 也压不过)、非交互 fail closed、bash 按输入定危险等级、秘密文件不进上下文 |
54
- | `day5` | 上下文管理:token 估算(字节法,宁多勿少)、80% 触发线、6 段结构化摘要(双保险禁工具)、文件恢复(最近 5 个,从磁盘重读)、主动+被动压缩、压缩熔断 |
55
- | `day6` | 多轮 REPL(行队列输入、/clear、Ctrl+C 三态语义)、system prompt 宪法(数字锚/反面禁令/防虚报)、AGENT.md 项目记忆、流式输出 + 双重看门狗(90s 断流/30s 只警告) |
56
- | `day7` | 子 agent(task 工具:独立上下文、一层分身不套娃、报告是材料不是结论、文件状态快照隔离)+ 最小 eval(10 道固定题、物证判分、8/10 及格线,`npm run eval`) |
57
- | `day8` | 封装发布:`bin` 入口 + tsc 构建,npx 一行启动(包名 `agent-from-zero`);内置 .env 加载;缺 key 人话报错 |
58
- | `day9` | 教程本身:11 章中文教程开源进 [docs/](docs/README.md) |
59
- | `day10` | Bring your own model:三个环境变量换任意 OpenAI 兼容端点,`npm run eval` 兼职模型横评 |
60
- | `day11` | 工业级 CLI 表面:settings.json 双层配置(用户权限规则,deny 永远赢)、slash 命令(/help /model /compact)、`-p` 单发模式、`--version`、测试套件进仓库(`npm test`,60 例) |
61
- | `day12` | 会话持久化(`--resume`,快照式 + 原子写)+ 本地遥测(事件 JSONL + `/stats`,绝不出本机,`MINI_AGENT_NO_TELEMETRY=1` 关闭) |
62
- | `day13` | 异步执行:run_bash 换 spawn(不冻 UI、Ctrl+C 秒杀、30s 超时 SIGKILL)、超长输出落盘、只读工具并行;`npm test` 65 例 |
63
- | `day14` | Hook 生命周期系统:PreToolUse(拦截工具)/PostToolUse(追加校验)/SessionStart(注入上下文)/Stop(测试驱动 AI),退出码协议、stderr 喂回模型;`npm test` 76 例 |
64
- | `day15` | MCP 接入:stdio JSON-RPC 客户端(握手/行缓冲/id 配对),外部 server 工具注册成内置同款(同一 dispatch + 权限闸门,默认 ask);`npm test` 84 例 |
65
- | `day16` | 可选 LLM 权限裁判:只看规则判 ask 的灰色地带、只能 ask→allow、prompt 铁死格式、解析 fail closed、自带熔断、默认关闭;`npm test` 98 例 |
66
- | `day17` | 长期记忆:跨会话的 MEMORY.md(人类可读)+ remember 工具,作为「背景」注入(区别于 AGENT.md 的「命令」),有界 30 条/8KB 且自修复;`npm test` 114 例 |
67
- | `day18` | 成本核算:从流里捕获 usage(include_usage)、抹平 DeepSeek/OpenAI 格式差异、纯函数算成本与缓存命中率、`/cost` 看花销(命中率即缓存击穿预警);`npm test` 130 例 |
68
- | `day19` | 工具调用校验:够用就好的 schema 校验(必填/类型/形状)、钉在 dispatch 唯一入口、批量返回精确修复指引、自动覆盖内置+MCP 工具(送出前拦下坏参数);`npm test` 146 例 |
69
- | `day20` | 计划模式:只读研究、想清楚再交计划由你批准;权限层一个外层过滤器(只收紧不放松、盖过预批准、保留禁飞区理由)+ `exit_plan_mode` 工具借用 ask 流程闭合审批 + `/plan` 开关;`npm test` 167 例 |
70
- | `day21` | diff 预览:写文件前把真实改动(绿 +/红 -)摊给你看再批准;十几行 LCS 行级 diff、折叠未变动行、复现工具语义无副作用推演、永不抛异常 fail 到 null;在闸门后执行前打印;`npm test` 189 例 |
71
- | `day22` | `/undo` 撤销上一次写入:写入前埋一行存「之前」快照(有界栈),`/undo` 还原内容或删掉新建文件、复用 Day 21 diff 画出撤销结果;`/clear` 同时清栈;`npm test` 204 例 |
72
- | `day23` | 子 agent 模型分级:`subAgentModel` 配置让委托跑在不同档位(便宜模型干杂活 / 强模型当顾问审查),纯函数 `subAgentModelFor` 定回落规则,首次用假 client 端到端验证「编排→委托→回归」真换了模型;`npm test` 212 例 |
73
- | `day24` | 远程 MCP(HTTP/SSE):把 Day 15 的传输从协议里剥成 Transport 接口,新增 HTTP 传输(POST→按 Content-Type 分 JSON/SSE 解析、回带 Mcp-Session-Id、AbortSignal 超时),配置 `url` 即走远程;协议层/发现/注册零改动;`npm test` 218 例 |
74
- | `day25` | `/diff` 会话改动总览:撤销账本旁加一张「会话起点基线」表(每个文件首次被碰的样子,只记一次),`/diff` 与磁盘现状逐个比、复用 Day 21 渲染净改动、净零跳过,三态 created/modified/deleted;`npm test` 227 例 |
75
- | `day26` | `@file` 引用:提问里 `@路径` 即把文件内容附进消息省一次 read_file 往返;认行首/空白后的 `@`、剥句尾标点;每个提及过 read_file 权限闸门——密钥文件(.env/*.pem)拒绝不读、被拒出声、没命中静默;`npm test` 244 例 |
76
- | `day27` | `/resume` 会话选择器:`session.ts` 加 listSessions(摘要)/loadSession(按 id)/sessionTitle(首句当标题、剥 @file 附件),REPL 列表+选号+整体切换(重建宪法、换 id、清读态与撤销栈),会话目录改惰性求值以可测;`npm test` 256 例 |
77
- | `day28` | 终端 UI:新增 `ui.ts` 收拢外观——圆角欢迎框、`❯` 提示符、`⏺`/`⎿` 活动标记(替 emoji)、走秒 spinner(搭车看门狗);讲透「颜色码污染宽度计算」要先按纯文本对齐再上色;`npm test` 277 例 |
78
- | `day29` | 方向键审批菜单:审批从「打 y/N」换成 ↑↓ 选(`menu.ts` 借 stdin 进 raw 模式、原地重绘、选完归还),带编号+底部操作提示;中间项「本会话别再问」复用 allow 规则(run_bash 只升 ask 不碰硬 deny);非 TTY 回退打字;`npm test` 289 例 |
79
- | `day30` | 多问题选择表单:给模型一个 `ask_user` 工具,需用户决策时弹表单(`form.ts` 纯状态机:扁平光标+reduce+提交校验+缺口跳转,`promptForm` 薄键盘层),收集成 question→answer 喂回;输入区改成 Claude Code 式「分隔线+❯」;`npm test` 309 例 |
80
- | `day31` | 运行时切换模型:`/model` 从只读升级成可切——`/model <name>` 直接设,裸 `/model` 拉端点 `/models` 列表用 Day 29 菜单选;切换只改 `CONFIG.model`、靠主循环每轮重读立即生效;`npm test` 314 例 |
81
- | `day32` | 让换模型看得见:流式循环多读 `reasoning_content`,把推理模型(R1)的思考灰着流出来(只渲染不进历史,绝不回传);spinner 显示当前模型作为切换生效的证明;`npm test` 324 例 |
82
- | `day33` | Skill 系统(Markdown 即插件):`SKILL.md`(frontmatter+正文)=一个插件、执行者是模型;渐进披露(列表进上下文/正文按需加载)、`skill` 工具说明书即列表、`disableModelInvocation` 分模型可调/仅用户、`/skills` `/skill <name>`;`npm test` 360 例 |
83
- | `day34` | 记忆深化:四类型(user/feedback/project/reference,feedback 最值钱)、每轮自动提取(便宜模型读对话存「用户纠正过的」,默认关、永不抛异常)、按类型分组注入、向后兼容旧格式;`npm test` 375 例 |
84
- | `day35` | Hook 深化:4→10 个时机(加 UserPromptSubmit/SessionEnd/Pre·PostCompact/Subagent*),配置改开放 record、exit 2 语义按事件由调用方解读、SessionEnd 极短超时;**`PreToolUse` 可改写工具参数**(权限三态做不到的「岔道」,严格在安全检查之后);`npm test` 381 例 |
50
+ | `day1` | ~50-line raw tool-calling loop: one tool, one loop, two safety nets (errors fed back, a round cap) |
51
+ | `day2` | The five core tools (read/write/edit/search/bash): read-before-edit, `old_string` uniqueness, a `dispatch` that never throws, the four-part tool manual |
52
+ | `day3` | Main loop as a state machine: error classification, exponential backoff (with jitter), dual retry budgets, a circuit breaker, 8 terminal states each with a plain-English note, graceful Ctrl+C exit |
53
+ | `day4` | Permission system (allow/ask/deny): hard-blocked no-fly zones (.git/.ssh/.env), deny always wins (even bypass can't override), fail-closed when non-interactive, bash risk graded by the command, secret files kept out of context |
54
+ | `day5` | Context management: token estimation (byte-based, err high), an 80% trigger line, a 6-section structured summary (tools double-disabled), file recovery (last 5, re-read from disk), proactive + reactive compaction, a compaction circuit breaker |
55
+ | `day6` | Multi-turn REPL (line-queue input, `/clear`, three-state Ctrl+C), a system-prompt constitution (number anchors / negative prohibitions / no false claims), AGENT.md project memory, streaming output + a dual watchdog (90s stall / 30s warn-only) |
56
+ | `day7` | Sub-agents (the `task` tool: isolated context, one level deep with no nesting, the report is material not the verdict, file-state snapshot isolation) + a minimal eval (10 fixed tasks, evidence-based scoring, 8/10 pass bar, `npm run eval`) |
57
+ | `day8` | Packaging & release: a `bin` entry + tsc build, one-line `npx` launch (package `agent-from-zero`); built-in .env loading; a plain-English error when the key is missing |
58
+ | `day9` | The tutorial itself: 11 Chinese chapters open-sourced into [docs/](docs/README.md) |
59
+ | `day10` | Bring your own model: three env vars swap in any OpenAI-compatible endpoint; `npm run eval` doubles as a model bake-off |
60
+ | `day11` | Industrial-grade CLI surface: two-layer settings.json (user permission rules, deny always wins), slash commands (/help /model /compact), `-p` one-shot mode, `--version`, a test suite in the repo (`npm test`, 60 cases) |
61
+ | `day12` | Session persistence (`--resume`, snapshot-style + atomic writes) + local telemetry (event JSONL + `/stats`, never leaves the machine, off via `MINI_AGENT_NO_TELEMETRY=1`) |
62
+ | `day13` | Async execution: run_bash switched to spawn (no UI freeze, instant Ctrl+C, 30s timeout then SIGKILL), oversized output spilled to disk, read-only tools run in parallel; `npm test` 65 cases |
63
+ | `day14` | Hook lifecycle system: PreToolUse (intercept a tool) / PostToolUse (append checks) / SessionStart (inject context) / Stop (test-driven AI), exit-code protocol, stderr fed back to the model; `npm test` 76 cases |
64
+ | `day15` | MCP integration: a stdio JSON-RPC client (handshake / line buffering / id pairing), external-server tools registered just like built-ins (same dispatch + permission gate, ask by default); `npm test` 84 cases |
65
+ | `day16` | Optional LLM permission judge: only for the ask gray-zone the rules left undecided, can only ask→allow, a rigid prompt format, fail-closed parsing, its own circuit breaker, off by default; `npm test` 98 cases |
66
+ | `day17` | Long-term memory: a cross-session MEMORY.md (human-readable) + a remember tool, injected as "background" (vs AGENT.md's "commands"), bounded at 30 entries / 8KB and self-healing; `npm test` 114 cases |
67
+ | `day18` | Cost accounting: capture usage from the stream (include_usage), smooth over DeepSeek/OpenAI format differences, pure-function cost + cache-hit-rate math, `/cost` to see spend (the hit rate doubles as a cache-bust warning); `npm test` 130 cases |
68
+ | `day19` | Tool-call validation: just-enough schema checks (required / type / shape), pinned at dispatch's single entry, batched precise fix-it guidance, auto-covering built-in + MCP tools (bad args caught before they're sent); `npm test` 146 cases |
69
+ | `day20` | Plan mode: read-only research, think it through, hand you a plan to approve; one outer filter in the permission layer (only tightens, never loosens, overrides pre-approvals, keeps no-fly reasons) + an `exit_plan_mode` tool that borrows the ask flow to close approval + a `/plan` toggle; `npm test` 167 cases |
70
+ | `day21` | Diff preview: before a write, show the real change (green +/red -) for you to approve; a ~dozen-line LCS line-diff, collapsed unchanged lines, side-effect-free replay of tool semantics, never throws (fails to null); printed after the gate, before execution; `npm test` 189 cases |
71
+ | `day22` | `/undo` reverts the last write: stash a "before" snapshot ahead of each write (a bounded stack), `/undo` restores the content or deletes a newly created file, reusing the Day 21 diff to draw the undo; `/clear` also clears the stack; `npm test` 204 cases |
72
+ | `day23` | Sub-agent model tiering: a `subAgentModel` config runs delegation on a different tier (cheap model for chores / strong model as a reviewer), a pure `subAgentModelFor` defines the fallback, first end-to-end proof via a fake client that "orchestrate→delegate→regress" really switched models; `npm test` 212 cases |
73
+ | `day24` | Remote MCP (HTTP/SSE): peel Day 15's transport out of the protocol into a Transport interface, add an HTTP transport (POST → parse JSON/SSE by Content-Type, carry Mcp-Session-Id, AbortSignal timeout); set `url` to go remote; zero changes to the protocol / discovery / registration; `npm test` 218 cases |
74
+ | `day25` | `/diff` session change overview: alongside the undo ledger, a "session-start baseline" table (each file as first touched, recorded once), `/diff` compares each against disk, reuses Day 21 to render net changes, skips net-zero, three states created/modified/deleted; `npm test` 227 cases |
75
+ | `day26` | `@file` mentions: an `@path` in your question attaches the file's content to the message, saving a read_file round-trip; recognizes `@` at line start / after whitespace, strips trailing punctuation; each mention passes the read_file gate — secret files (.env/*.pem) are refused, refusals voiced, misses silent; `npm test` 244 cases |
76
+ | `day27` | `/resume` session picker: `session.ts` gains listSessions (summaries) / loadSession (by id) / sessionTitle (first line as title, strips @file attachments), a REPL list + pick-by-number + wholesale switch (rebuild the constitution, swap the id, clear read-state and the undo stack), sessions dir made lazy for testability; `npm test` 256 cases |
77
+ | `day28` | Terminal UI: a new `ui.ts` owns the look — a rounded welcome box, the `❯` prompt, `⏺`/`⎿` activity markers (replacing emoji), a ticking spinner (riding the watchdog); explains why "color codes pollute width math" so you align on plain text first, then color; `npm test` 277 cases |
78
+ | `day29` | Arrow-key approval menu: approval moves from "type y/N" to ↑↓ select (`menu.ts` borrows stdin into raw mode, redraws in place, hands it back), numbered + a footer hint; the middle "don't ask again this session" reuses the allow rule (run_bash only escalates ask, never touches hard deny); non-TTY falls back to typing; `npm test` 289 cases |
79
+ | `day30` | Multi-question form: give the model an `ask_user` tool that pops a form when it needs your decision (`form.ts` pure state machine: flat cursor + reduce + submit validation + jump-to-gap, `promptForm` a thin keyboard layer), collected into question→answer fed back; the input area becomes Claude-Code-style "rule + ❯"; `npm test` 309 cases |
80
+ | `day31` | Switch models at runtime: `/model` upgrades from read-only to switchable — `/model <name>` sets it, bare `/model` pulls the endpoint's `/models` list into the Day 29 menu; the switch only changes `CONFIG.model` and takes effect immediately via the loop re-reading it each round; `npm test` 314 cases |
81
+ | `day32` | Make a model switch visible: the streaming loop also reads `reasoning_content`, streaming a reasoning model's (R1) thinking dimmed (render-only, never into history, never sent back); the spinner shows the current model as proof the switch took; `npm test` 324 cases |
82
+ | `day33` | Skill system (Markdown as a plugin): a `SKILL.md` (frontmatter + body) = one plugin, executed by the model; progressive disclosure (the listing in context / the body loaded on demand), the `skill` tool manual is the listing, `disableModelInvocation` splits model-callable vs user-only, `/skills` `/skill <name>`; `npm test` 360 cases |
83
+ | `day34` | Deeper memory: four types (user/feedback/project/reference, feedback the most valuable), auto-extraction each turn (a cheap model reads the conversation and stores "what the user corrected", off by default, never throws), grouped injection by type, backward-compatible with the old format; `npm test` 375 cases |
84
+ | `day35` | Deeper hooks: 4→10 events (adding UserPromptSubmit/SessionEnd/Pre·PostCompact/Subagent*), config becomes an open record, exit-2 meaning interpreted per-event by the caller, an extra-short SessionEnd timeout; **`PreToolUse` can rewrite a tool's arguments** (the "side road" the three-state permission can't do, strictly after the safety check); `npm test` 381 cases |
85
+ | `day36` | TodoWrite: a `todo_write` tool that lets the agent keep its own checklist (whole-list replace, at most one in_progress, ✓/▶/○ render, `/todos` to view) — planning, not new powers; plus a nag that injects a reminder when an unfinished plan goes stale for N rounds, so long tasks don't drift off the original goal; `npm test` 474 cases |
86
+ | `day37` | Background tasks: a `run_bash_background` tool spawns a slow command (install/build/test/dev server) and returns a `bg_*` id immediately instead of blocking on run_bash's 30s watchdog — the agent keeps reasoning while it runs; when it finishes the loop injects a `<task_notification>` as the next turn (once per task), a `bash_output` tool polls a job's new output (essential for servers that never "finish"), `/bg` lists them and they're SIGKILLed on exit; same permission gate as run_bash, so the danger rules are identical; `npm test` 502 cases |
87
+ | `day38` | Agent teams: past the one-shot sub-agent — a `spawn_teammate` tool starts PERSISTENT teammates that run concurrently (fire-and-forget async loops interleaving on the one event loop) and coordinate through `send_message`; mailboxes are real `.jsonl` files on disk (`.mini-agent/team/`, consumptive read = observable, no lock needed on a single event loop), the loop injects each agent's inbox as a turn and the lead WAITS on the team instead of ending early, teammates are bounded (round cap) with a focused toolset and a non-interactive permission policy (writes auto-proceed, risky bash declined, deny still wins), `/team` lists them; `npm test` 523 cases |
88
+ | `day39` | Team protocols: explicit message contracts, not vibes — every protocol is the same request→response correlated by a `request_id`, tracked as `ProtocolState` (pending→approved/rejected) with a `match_response` that rejects a mismatched reply (a shutdown_response can't approve a plan). Two contracts: **graceful shutdown** (lead `request_shutdown` → teammate finishes, confirms, exits — no orphans/half-writes) and **plan approval** (teammate `submit_plan` → lead `review_plan` before risky work runs). Teammates go from bounded auto-exit to an IDLE LOOP (wait for work, leave on the shutdown handshake; bounded backstop so an abandoned one self-exits), and the lead disbands the team gracefully when it finishes; the inbox-consume step routes protocol messages then hands the rest to the model; `npm test` 545 cases |
89
+ | `day40` | Autonomous agents: a shared TASK BOARD teammates pull from instead of being hand-fed work. `.mini-agent/tasks/<id>.json` files (on disk = observable), `Task {status, owner, blockedBy}` with `blockedBy` resolved by id OR subject; `create_task`/`list_tasks`/`claim_task`/`complete_task` tools, and `claimTask` is a synchronous read-check-write so two scanners can't both win the same task — no lock, same single-event-loop argument as the mailbox. The teammate IDLE loop now polls inbox-first (shutdown priority) then auto-claims the next ready task (`claimNextAvailable`) and resumes WORK; the lead lays out a dependency graph and teammates respect it (schema → routes → tests), `/tasks` shows the board; `npm test` 574 cases |
85
90
 
86
91
  ## 📖 配套教程(免费开源)
87
92
 
88
- **[完整教程在 docs/ 目录](docs/README.md)**——主线 + 进阶共十几章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
93
+ **[完整教程在 docs/ 目录](docs/README.md)**——主线 + 进阶共三十多章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
89
94
 
90
- 想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @(待填)。
95
+ 想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @老李是谁。
91
96
 
92
97
  如果这个项目帮你搞懂了 agent,点个 ⭐ 就是最大的支持。
package/dist/agent.js CHANGED
@@ -15,15 +15,20 @@ import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
15
15
  import { Judge } from "./judge.js"; // optional LLM permission classifier
16
16
  import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
17
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
18
19
  import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
19
20
  import path from "node:path"; // shorten paths for the /diff summary
20
21
  import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
21
22
  import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
22
23
  import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
23
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)
24
26
  import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
25
27
  import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
26
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)
27
32
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
28
33
  const pkg = createRequire(import.meta.url)("../package.json");
29
34
  // ---- CLI arguments --------------------------------------------------------------
@@ -64,6 +69,10 @@ const SESSION_HELP = `commands:
64
69
  /memory show the durable facts the agent remembers about this project
65
70
  /cost tokens, cache hit rate and estimated spend this session (local)
66
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
67
76
  /undo revert the most recent file write (write_file / edit_file) this session
68
77
  /diff show every file changed this session, as a diff from where it started
69
78
  /resume list recent sessions in this project and continue one of them
@@ -127,6 +136,7 @@ async function main() {
127
136
  // server that fails to start is skipped, never fatal.
128
137
  const disconnectMcp = await connectMcpServers();
129
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
130
140
  // The optional LLM permission judge, built once if a settings file enabled it.
131
141
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
132
142
  if (judge)
@@ -207,6 +217,7 @@ async function main() {
207
217
  // ONE readline interface for the whole session — the task prompt and the
208
218
  // permission prompts share it. Two interfaces on one stdin fight each other.
209
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()
210
221
  // Input plumbing: a line QUEUE instead of rl.question(). With piped stdin
211
222
  // all lines arrive at once — question() would catch the first and lose the
212
223
  // rest. The queue catches every line; asking pops one (or waits for one).
@@ -215,7 +226,11 @@ async function main() {
215
226
  const pendingLines = []; // lines that arrived before anyone asked
216
227
  const lineWaiters = []; // askers waiting for a line
217
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)
218
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.
219
234
  const waiter = lineWaiters.shift(); // is somebody waiting?
220
235
  if (waiter)
221
236
  waiter(line); // hand the line over directly
@@ -228,12 +243,35 @@ async function main() {
228
243
  lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
229
244
  });
230
245
  // Get the next line of user input, showing a prompt if we have to wait.
231
- const readUserLine = (prompt) => {
246
+ const readUserLine = async (prompt, footer) => {
232
247
  if (pendingLines.length)
233
- return Promise.resolve(pendingLines.shift()); // typed-ahead (or piped) line — use it
248
+ return pendingLines.shift(); // typed-ahead (or piped) line — use it
234
249
  if (stdinDone)
235
- return Promise.resolve("exit"); // stdin is gone — leave politely
236
- 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);
237
275
  rl.prompt();
238
276
  return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
239
277
  };
@@ -396,9 +434,56 @@ async function main() {
396
434
  messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
397
435
  forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
398
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)
399
440
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
400
441
  console.log(chalk.dim("(history cleared)")); // confirm the reset
401
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
+ }
402
487
  case "/stats":
403
488
  console.log(chalk.dim(statsReport())); // local counters, busiest first
404
489
  return true;
@@ -462,6 +547,9 @@ async function main() {
462
547
  sessionId = chosen.id; // keep appending to the resumed session's file
463
548
  forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
464
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)
465
553
  console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
466
554
  return true;
467
555
  }
@@ -519,28 +607,47 @@ async function main() {
519
607
  };
520
608
  // The session loop: ask → run → render → ask again. This is what makes it
521
609
  // a conversation instead of a one-shot command.
522
- // Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
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.
523
617
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
524
618
  process.stdin.on("data", (buf) => {
525
- if (buf[0] === 0x09) { // Tab
526
- if (!running)
527
- toggleLastCollapsed();
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
+ }
528
628
  }
529
629
  });
530
630
  }
531
631
  while (true) {
532
- // Before each prompt, a status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
533
- // then a separator rule, then the "❯". Only in a TTY; piped input is clean.
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;
534
638
  if (process.stdin.isTTY) {
535
639
  const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
536
- console.log("\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt));
537
- console.log(inputRule());
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
538
642
  }
539
- const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
643
+ const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
540
644
  if (!line)
541
645
  continue; // empty line — just re-prompt
542
- 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
543
649
  break; // explicit goodbye
650
+ }
544
651
  if (await handleCommand(line))
545
652
  continue; // slash commands never reach the model
546
653
  // UserPromptSubmit hook: a chance to validate/inject before the model sees
@@ -0,0 +1,160 @@
1
+ // ============ Background tasks (Day 37) ============
2
+ // The problem: run_bash blocks. A command that takes longer than its 30s
3
+ // watchdog (npm install, a full test run, a build) is SIGKILLed before it
4
+ // finishes, and even a 25s one freezes the agent — we sit idle, paying for
5
+ // tokens we aren't using, while a child process churns. The model cannot think
6
+ // and wait at the same time.
7
+ //
8
+ // The fix, mirroring Claude Code: a command can be sent to the BACKGROUND. We
9
+ // spawn it, hand the model a task id immediately, and let the loop keep going.
10
+ // When the command finishes, the loop injects a <task_notification> as the next
11
+ // user turn — so the result rejoins the conversation without ever having blocked
12
+ // it. Long-running servers (npm run dev) that never "finish" are supported too:
13
+ // they stay `running` forever and the model polls them with bash_output.
14
+ //
15
+ // Why no extra thread / lock (unlike the Python reference): Node is single-
16
+ // threaded with an event loop. `spawn` is already async — stdout/stderr arrive
17
+ // as events while the main loop runs. There is no shared-memory race to guard,
18
+ // so the whole "daemon thread + mutex" dance collapses into plain callbacks.
19
+ import { spawn } from "node:child_process";
20
+ const tasks = new Map(); // id → task, for the whole session
21
+ let seq = 0; // increments per task — seeds the id
22
+ const OUTPUT_CAP = 80_000; // chars of output retained per task (keep the tail)
23
+ const SUMMARY_TAIL = 1_500; // chars of the tail shown inline in a notification
24
+ // Append output to a task, keeping only the most recent OUTPUT_CAP chars. We
25
+ // keep the TAIL, not the head: an exit error or a "server ready on :3000" line
26
+ // lands at the end, and that is what the model needs to react to. `produced`
27
+ // counts everything ever written so the poll cursor stays correct across trims.
28
+ function append(task, chunk) {
29
+ task.produced += chunk.length;
30
+ task.output += chunk;
31
+ if (task.output.length > OUTPUT_CAP)
32
+ task.output = task.output.slice(-OUTPUT_CAP);
33
+ }
34
+ // Start a command in the background. Returns its task id immediately — the
35
+ // command keeps running after this returns. Note the deliberate omissions vs
36
+ // run_bash: NO 30s timeout (the whole point is to outlive it) and NO turn
37
+ // AbortSignal (Ctrl+C interrupts the current turn, but a background job is meant
38
+ // to survive into later turns; only killAll, on session exit, stops them).
39
+ export function startBackground(command) {
40
+ const id = `bg_${++seq}`;
41
+ const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics, same as run_bash
42
+ const task = {
43
+ id,
44
+ command,
45
+ child,
46
+ status: "running",
47
+ exitCode: null,
48
+ output: "",
49
+ produced: 0,
50
+ readAt: 0,
51
+ notified: false,
52
+ startedAt: Date.now(),
53
+ endedAt: null,
54
+ };
55
+ tasks.set(id, task);
56
+ child.stdout?.on("data", (d) => append(task, d.toString())); // stream output into the buffer as it arrives
57
+ child.stderr?.on("data", (d) => append(task, d.toString()));
58
+ child.on("error", (err) => {
59
+ // The process could not even start (e.g. command not found). Record it as a
60
+ // failure with the reason, so the notification explains what happened.
61
+ append(task, `\n[could not start: ${err.message}]`);
62
+ if (task.status === "running") {
63
+ task.status = "failed";
64
+ task.exitCode = null;
65
+ task.endedAt = Date.now();
66
+ }
67
+ });
68
+ child.on("close", (code, signal) => {
69
+ if (task.status !== "running")
70
+ return; // already terminal (killAll set `killed`, or an error fired) — don't overwrite
71
+ task.exitCode = code;
72
+ task.status = code === 0 ? "completed" : "failed";
73
+ if (signal)
74
+ task.status = "killed"; // killed by a signal, not its own exit
75
+ task.endedAt = Date.now();
76
+ });
77
+ return id;
78
+ }
79
+ // Collect notifications for every task that has FINISHED since the last call.
80
+ // Each task is reported exactly once (the `notified` flag), so calling this
81
+ // every round is safe: a finished task surfaces once, then never again. Returns
82
+ // "" when there is nothing to report. The <task_notification> shape is taken
83
+ // straight from the tutorial reference so the format is consistent across the
84
+ // course.
85
+ export function pendingNotifications() {
86
+ const done = [];
87
+ for (const task of tasks.values()) {
88
+ if (task.status === "running" || task.notified)
89
+ continue; // still going, or already reported
90
+ task.notified = true; // report it once
91
+ const secs = task.endedAt ? Math.round((task.endedAt - task.startedAt) / 1000) : 0;
92
+ const tail = task.output.slice(-SUMMARY_TAIL).trim() || "(no output)";
93
+ const more = task.output.length > SUMMARY_TAIL ? " (truncated — use bash_output for the full log)" : "";
94
+ done.push(`<task_notification>\n` +
95
+ ` <task_id>${task.id}</task_id>\n` +
96
+ ` <status>${task.status}</status>\n` +
97
+ ` <command>${task.command}</command>\n` +
98
+ ` <exit_code>${task.exitCode ?? "n/a"}</exit_code>\n` +
99
+ ` <elapsed>${secs}s</elapsed>\n` +
100
+ ` <summary>${tail}${more}</summary>\n` +
101
+ `</task_notification>`);
102
+ }
103
+ if (!done.length)
104
+ return "";
105
+ // The framing line matters: without it the model can mistake the XML for
106
+ // something it must echo. Tell it plainly that a job it started has finished.
107
+ return `A background task you started has finished. Read the notification, then continue.\n\n${done.join("\n\n")}`;
108
+ }
109
+ // Read a background task's current state for the bash_output tool. Returns only
110
+ // the output PRODUCED SINCE THE LAST POLL (like Claude Code's BashOutput), so
111
+ // the model can watch a long job without re-reading the whole log each time.
112
+ // `full: true` returns the entire retained buffer instead.
113
+ export function readBackground(id, full = false) {
114
+ const task = tasks.get(id);
115
+ if (!task) {
116
+ const known = [...tasks.keys()];
117
+ return `[error] No background task "${id}". ${known.length ? `Known tasks: ${known.join(", ")}.` : "No background tasks have been started this session."}`;
118
+ }
119
+ let chunk;
120
+ if (full) {
121
+ chunk = task.output; // the entire retained buffer
122
+ }
123
+ else {
124
+ const fresh = task.produced - task.readAt; // chars produced since last poll
125
+ // Trimming may have dropped older chars; never read past what we still hold.
126
+ chunk = task.output.slice(Math.max(0, task.output.length - fresh));
127
+ }
128
+ task.readAt = task.produced; // either way, the model has now seen up to here — next poll shows only newer output
129
+ const head = `[${task.id}] ${task.status}${task.exitCode !== null ? ` (exit ${task.exitCode})` : ""} · ${task.command}`;
130
+ // Be precise about an empty body: nothing ever vs. nothing NEW since the last
131
+ // poll (the model already read it) vs. nothing new yet on a still-running job.
132
+ const body = chunk.trim() || (task.produced === 0 ? "(no output)" : task.status === "running" ? "(no new output yet)" : "(no new output)");
133
+ return `${head}\n${body}`;
134
+ }
135
+ // A one-line-per-task snapshot for the /bg command. Newest first.
136
+ export function listBackground() {
137
+ return [...tasks.values()]
138
+ .sort((a, b) => b.startedAt - a.startedAt)
139
+ .map((t) => ({
140
+ id: t.id,
141
+ status: t.status,
142
+ command: t.command,
143
+ elapsed: Math.round(((t.endedAt ?? Date.now()) - t.startedAt) / 1000),
144
+ }));
145
+ }
146
+ // True if any task is still running — the REPL uses this to warn before exit.
147
+ export function hasRunningBackground() {
148
+ return [...tasks.values()].some((t) => t.status === "running");
149
+ }
150
+ // Kill every still-running task. Called on session exit so a forgotten dev
151
+ // server or a slow install does not outlive the agent as an orphan.
152
+ export function killAllBackground() {
153
+ for (const task of tasks.values()) {
154
+ if (task.status !== "running")
155
+ continue;
156
+ task.child.kill("SIGKILL");
157
+ task.status = "killed";
158
+ task.endedAt = Date.now();
159
+ }
160
+ }