agent-from-zero 0.27.0 → 0.29.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,21 @@ 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
- import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
23
+ import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output + collapsed reasoning (Ctrl+R)
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)
26
+ import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
24
27
  import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
25
28
  import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
26
29
  import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
30
+ import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
31
+ import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
32
+ import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
27
33
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
28
34
  const pkg = createRequire(import.meta.url)("../package.json");
29
35
  // ---- CLI arguments --------------------------------------------------------------
@@ -64,12 +70,20 @@ const SESSION_HELP = `commands:
64
70
  /memory show the durable facts the agent remembers about this project
65
71
  /cost tokens, cache hit rate and estimated spend this session (local)
66
72
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
73
+ /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
74
+ /bg list background tasks this session (run_bash_background) and their status
75
+ /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
76
+ /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
67
77
  /undo revert the most recent file write (write_file / edit_file) this session
68
78
  /diff show every file changed this session, as a diff from where it started
69
79
  /resume list recent sessions in this project and continue one of them
70
80
  /skills list the reusable skills available in this project
71
81
  /skill <name> run a skill yourself (works even for user-only skills)
72
- exit leave (Ctrl+C at the prompt does the same)`;
82
+ exit leave (Ctrl+C at the prompt does the same)
83
+
84
+ keys (at the prompt):
85
+ Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
86
+ Tab expand/collapse the most recent tool output`;
73
87
  // Injected when the user turns plan mode on, so the model knows the rules of the
74
88
  // mode it now lives in (the permission gate enforces them regardless).
75
89
  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.`;
@@ -127,6 +141,7 @@ async function main() {
127
141
  // server that fails to start is skipped, never fatal.
128
142
  const disconnectMcp = await connectMcpServers();
129
143
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
144
+ process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
130
145
  // The optional LLM permission judge, built once if a settings file enabled it.
131
146
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
132
147
  if (judge)
@@ -207,6 +222,7 @@ async function main() {
207
222
  // ONE readline interface for the whole session — the task prompt and the
208
223
  // permission prompts share it. Two interfaces on one stdin fight each other.
209
224
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
225
+ rl.setPrompt(""); // a TTY edits via our editor (not rl), so blank readline's default "> " — it would otherwise flash on resume()
210
226
  // Input plumbing: a line QUEUE instead of rl.question(). With piped stdin
211
227
  // all lines arrive at once — question() would catch the first and lose the
212
228
  // rest. The queue catches every line; asking pops one (or waits for one).
@@ -215,7 +231,11 @@ async function main() {
215
231
  const pendingLines = []; // lines that arrived before anyone asked
216
232
  const lineWaiters = []; // askers waiting for a line
217
233
  let stdinDone = false; // has stdin ended?
234
+ const history = []; // past prompts, for ↑/↓ recall in the editor
235
+ let editing = false; // true while our line editor owns stdin (so the running-key handler stands down)
218
236
  rl.on("line", (line) => {
237
+ // Only fires for NON-TTY (piped) input — a TTY goes through editLine, which
238
+ // doesn't emit readline 'line' events. Piped input has no footer to clear.
219
239
  const waiter = lineWaiters.shift(); // is somebody waiting?
220
240
  if (waiter)
221
241
  waiter(line); // hand the line over directly
@@ -228,12 +248,35 @@ async function main() {
228
248
  lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
229
249
  });
230
250
  // Get the next line of user input, showing a prompt if we have to wait.
231
- const readUserLine = (prompt) => {
251
+ const readUserLine = async (prompt, footer) => {
232
252
  if (pendingLines.length)
233
- return Promise.resolve(pendingLines.shift()); // typed-ahead (or piped) line — use it
253
+ return pendingLines.shift(); // typed-ahead (or piped) line — use it
234
254
  if (stdinDone)
235
- return Promise.resolve("exit"); // stdin is gone — leave politely
236
- rl.setPrompt(prompt); // show the prompt the redraw-safe way
255
+ return "exit"; // stdin is gone — leave politely
256
+ if (process.stdin.isTTY) {
257
+ // Our editor owns the whole input block (prompt + wrapped input + footer),
258
+ // so the status bar stays pinned below however the line wraps.
259
+ editing = true;
260
+ try {
261
+ const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), transformPaste: normalizeDroppedPaths });
262
+ if (res.type === "line") {
263
+ if (res.value.trim())
264
+ history.push(res.value); // remember non-empty entries for ↑/↓
265
+ return res.value;
266
+ }
267
+ if (res.type === "cancel") {
268
+ // Ctrl+C at the prompt: exit the session (standard REPL behavior).
269
+ console.log();
270
+ process.exit(0);
271
+ }
272
+ return "exit"; // eof (Ctrl+D)
273
+ }
274
+ finally {
275
+ editing = false;
276
+ }
277
+ }
278
+ // Non-TTY (piped): the readline line queue, no prompt frame, no footer.
279
+ rl.setPrompt(prompt);
237
280
  rl.prompt();
238
281
  return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
239
282
  };
@@ -396,9 +439,57 @@ async function main() {
396
439
  messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
397
440
  forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
398
441
  clearUndo(); // a fresh conversation should not undo the previous one's writes
442
+ clearTodos(); // the plan belonged to the old task — drop it
443
+ resetTeam(); // the team belonged to the old task — forget it (mailboxes re-wipe on next use)
444
+ resetBoard(); // and the shared task board (Day 40)
445
+ clearReasoning(); // drop the collapsed thinking too
399
446
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
400
447
  console.log(chalk.dim("(history cleared)")); // confirm the reset
401
448
  return true;
449
+ case "/todos": {
450
+ const todos = getTodos(); // the agent's current checklist for this task
451
+ console.log(todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)"));
452
+ return true;
453
+ }
454
+ case "/bg": {
455
+ // The background jobs (run_bash_background) started this session — what's
456
+ // still running, what finished, and with what exit status.
457
+ const bg = listBackground();
458
+ if (!bg.length) {
459
+ console.log(chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)"));
460
+ return true;
461
+ }
462
+ const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
463
+ console.log(chalk.dim(`background tasks this session:`));
464
+ for (const t of bg) {
465
+ console.log(chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`));
466
+ }
467
+ return true;
468
+ }
469
+ case "/team": {
470
+ // The agent team (spawn_teammate): who's running, who's done, and how
471
+ // many messages still sit unread in each one's on-disk inbox.
472
+ const team = listTeam();
473
+ if (!team.length) {
474
+ console.log(chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)"));
475
+ return true;
476
+ }
477
+ // Day 39: a running teammate reports as active (working) or idle (waiting).
478
+ const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
479
+ console.log(chalk.dim(`agent team this session:`));
480
+ for (const t of team) {
481
+ const inbox = t.pending ? chalk.yellow(` · ${t.pending} unread`) : "";
482
+ console.log(chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${inbox}`));
483
+ }
484
+ return true;
485
+ }
486
+ case "/tasks": {
487
+ // The shared task board (create_task/claim_task/complete_task): what's
488
+ // pending, claimed, and done, and who owns what.
489
+ const summary = boardSummary();
490
+ 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}`));
491
+ return true;
492
+ }
402
493
  case "/stats":
403
494
  console.log(chalk.dim(statsReport())); // local counters, busiest first
404
495
  return true;
@@ -462,6 +553,9 @@ async function main() {
462
553
  sessionId = chosen.id; // keep appending to the resumed session's file
463
554
  forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
464
555
  clearUndo(); // the previous session's writes are not ours to undo
556
+ clearTodos(); // the resumed task starts without the old session's stale plan
557
+ resetTeam(); // a resumed conversation starts with no live team
558
+ resetBoard(); // ...and an empty task board (Day 40)
465
559
  console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
466
560
  return true;
467
561
  }
@@ -519,28 +613,47 @@ async function main() {
519
613
  };
520
614
  // The session loop: ask → run → render → ask again. This is what makes it
521
615
  // a conversation instead of a one-shot command.
522
- // Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
616
+ // Raw-stdin key handling that applies while a task is RUNNING. At the prompt
617
+ // the line editor owns stdin (and handles its own keys, incl. Tab → collapse),
618
+ // so we stand down whenever `editing` is true to avoid double-processing.
619
+ // Esc → interrupt the current operation and return to the prompt (like the
620
+ // first Ctrl+C, but without the "press again to force-quit" escalation).
621
+ // A LONE Esc is one byte (0x1b); arrow keys etc. are multi-byte escape
622
+ // sequences starting with 0x1b — the length check tells them apart.
523
623
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
524
624
  process.stdin.on("data", (buf) => {
525
- if (buf[0] === 0x09) { // Tab
526
- if (!running)
527
- toggleLastCollapsed();
625
+ if (editing)
626
+ return; // the editor is in charge of these keys
627
+ if (buf.length === 1 && buf[0] === 0x1b) {
628
+ // Esc: stop the current operation, keep the session alive and typeable.
629
+ if (running && !interrupted) {
630
+ interrupted = true; // the loop polls this between steps
631
+ controller.abort(); // cancel the in-flight API request
632
+ console.log(chalk.yellow("\n(esc — interrupted; back at the prompt)"));
633
+ }
528
634
  }
529
635
  });
530
636
  }
531
637
  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.
638
+ // The input area, Claude-Code style: a top rule, the "❯" you type on, then a
639
+ // bottom rule + status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
640
+ // pinned BELOW the input. readUserLine draws the footer under the prompt and
641
+ // keeps the cursor on the ❯ line, so the status reads like a real bottom bar.
642
+ // Only in a TTY; piped input stays clean.
643
+ let footer;
534
644
  if (process.stdin.isTTY) {
535
645
  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());
646
+ console.log("\n" + inputRule()); // top rule above the prompt
647
+ footer = inputRule() + "\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // bottom rule + status, pinned below
538
648
  }
539
- const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
649
+ const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
540
650
  if (!line)
541
651
  continue; // empty line — just re-prompt
542
- if (line === "exit" || line === "quit")
652
+ if (line === "exit" || line === "quit") {
653
+ if (hasRunningBackground())
654
+ console.log(chalk.yellow("(stopping background tasks still running — see /bg)")); // Day 37: they're about to be SIGKILLed on exit
543
655
  break; // explicit goodbye
656
+ }
544
657
  if (await handleCommand(line))
545
658
  continue; // slash commands never reach the model
546
659
  // UserPromptSubmit hook: a chance to validate/inject before the model sees
@@ -563,6 +676,7 @@ async function main() {
563
676
  if (refused.length)
564
677
  console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
565
678
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
679
+ clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
566
680
  running = true; // Ctrl+C now means "interrupt the task"
567
681
  interrupted = false; // fresh interrupt state for this task
568
682
  controller = new AbortController(); // fresh abort signal for this task
@@ -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
+ }