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 +43 -38
- package/dist/agent.js +121 -14
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/editor.js +307 -0
- package/dist/loop.js +697 -5
- package/dist/markdown.js +152 -0
- package/dist/permissions.js +51 -3
- package/dist/prompt.js +16 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +86 -1
- package/dist/ui.js +5 -1
- package/package.json +7 -3
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
|
|
51
|
-
| `day2` |
|
|
52
|
-
| `day3` |
|
|
53
|
-
| `day4` |
|
|
54
|
-
| `day5` |
|
|
55
|
-
| `day6` |
|
|
56
|
-
| `day7` |
|
|
57
|
-
| `day8` |
|
|
58
|
-
| `day9` |
|
|
59
|
-
| `day10` | Bring your own model
|
|
60
|
-
| `day11` |
|
|
61
|
-
| `day12` |
|
|
62
|
-
| `day13` |
|
|
63
|
-
| `day14` | Hook
|
|
64
|
-
| `day15` | MCP
|
|
65
|
-
| `day16` |
|
|
66
|
-
| `day17` |
|
|
67
|
-
| `day18` |
|
|
68
|
-
| `day19` |
|
|
69
|
-
| `day20` |
|
|
70
|
-
| `day21` |
|
|
71
|
-
| `day22` | `/undo`
|
|
72
|
-
| `day23` |
|
|
73
|
-
| `day24` |
|
|
74
|
-
| `day25` | `/diff`
|
|
75
|
-
| `day26` | `@file`
|
|
76
|
-
| `day27` | `/resume`
|
|
77
|
-
| `day28` |
|
|
78
|
-
| `day29` |
|
|
79
|
-
| `day30` |
|
|
80
|
-
| `day31` |
|
|
81
|
-
| `day32` |
|
|
82
|
-
| `day33` | Skill
|
|
83
|
-
| `day34` |
|
|
84
|
-
| `day35` |
|
|
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)**——主线 +
|
|
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
|
|
248
|
+
return pendingLines.shift(); // typed-ahead (or piped) line — use it
|
|
234
249
|
if (stdinDone)
|
|
235
|
-
return
|
|
236
|
-
|
|
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
|
-
//
|
|
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 (
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
//
|
|
533
|
-
//
|
|
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" +
|
|
537
|
-
|
|
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
|
+
}
|