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.
@@ -0,0 +1,152 @@
1
+ import { Marked } from "marked"; // the parser
2
+ import { markedTerminal } from "marked-terminal"; // the ANSI renderer
3
+ import Table from "cli-table3"; // box-drawing table (borders + padding)
4
+ import wrapAnsi from "wrap-ansi"; // ANSI- and CJK-aware line wrapping
5
+ import chalk from "chalk"; // our one color source — keep the theme consistent with ui.ts
6
+ import { displayWidth } from "./editor.js"; // CJK-/ANSI-aware display width (shared with the line editor)
7
+ // Why a library here, not hand-rolled? Markdown→terminal is a solved, fiddly
8
+ // problem (CJK-aware table widths, nested lists, code fences, link rewriting).
9
+ // The agent's *core* is hand-written on purpose; this is pure presentation, so
10
+ // we lean on `marked` + `marked-terminal` and spend our effort on the streaming
11
+ // glue instead. The model speaks markdown; without this the screen shows raw
12
+ // `##`, `**`, and `|---|` — exactly the ugliness this module removes.
13
+ // ---- responsive tables ------------------------------------------------------
14
+ // marked-terminal's `width` does NOT size tables — a wide table overflows and the
15
+ // terminal hard-wraps it into garbage. So we render tables ourselves: shrink the
16
+ // columns to fit the window, wrap each cell (CJK-aware, no truncation), and let
17
+ // cli-table3 draw the box. This is what makes a table adapt to the window size.
18
+ // Per-column CONTENT widths that fit `maxWidth` once borders/padding are paid for.
19
+ // Start from each column's natural width, then repeatedly shave the widest until
20
+ // the row fits — so narrow columns keep their size and the roomy ones give first.
21
+ function tableColumnWidths(header, rows, maxWidth) {
22
+ const n = header.length;
23
+ const natural = header.map((h, i) => Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? ""))));
24
+ const budget = maxWidth - (n + 1) - 2 * n; // n+1 borders, 2 cols of padding per column
25
+ const widths = natural.slice();
26
+ const MIN = 4; // never shrink a column below this many content columns
27
+ while (widths.reduce((a, b) => a + b, 0) > budget) {
28
+ const widest = widths.indexOf(Math.max(...widths));
29
+ if (widths[widest] <= MIN)
30
+ break; // can't shrink further — let it overflow rather than vanish
31
+ widths[widest]--;
32
+ }
33
+ return widths;
34
+ }
35
+ // Draw one table, wrapped to fit `maxWidth`. Cells are pre-wrapped with wrap-ansi
36
+ // (which counts CJK as 2 and keeps ANSI styles intact across line breaks), then
37
+ // handed to cli-table3 with wordWrap OFF — cli-table3's own wrap mis-measures CJK.
38
+ function renderTable(header, rows, maxWidth) {
39
+ const content = tableColumnWidths(header, rows, maxWidth);
40
+ const wrapRow = (cells) => cells.map((c, i) => wrapAnsi(c ?? "", content[i], { hard: true, trim: false }));
41
+ const t = new Table({
42
+ head: wrapRow(header),
43
+ colWidths: content.map((w) => w + 2), // cli-table3 widths include the 2 padding columns
44
+ wordWrap: false, // we already wrapped — cli-table3 would mis-wrap CJK
45
+ style: { head: [], border: [] }, // no extra colour; keep the dim theme from the caller
46
+ });
47
+ for (const r of rows)
48
+ t.push(wrapRow(r));
49
+ return t.toString();
50
+ }
51
+ // The terminal theme, matched to ui.ts (cyan headers, yellow code, dim borders).
52
+ function renderer(width) {
53
+ return markedTerminal({
54
+ width, // wrap paragraphs/tables to this many columns
55
+ reflowText: true, // re-wrap long paragraphs to `width` instead of one long line
56
+ tab: 2, // list indentation
57
+ showSectionPrefix: false, // drop the literal "## " in front of headings
58
+ firstHeading: chalk.cyan.bold,
59
+ heading: chalk.cyan.bold,
60
+ code: chalk.yellow, // fenced code block
61
+ codespan: chalk.cyan, // `inline code`
62
+ blockquote: chalk.gray.italic,
63
+ strong: chalk.bold,
64
+ em: chalk.italic,
65
+ del: chalk.dim.strikethrough,
66
+ link: chalk.cyan.underline,
67
+ href: chalk.cyan.underline,
68
+ paragraph: (s) => s,
69
+ });
70
+ }
71
+ // Render a complete markdown string to ANSI. Pure (width injectable), so tests
72
+ // can assert on the output without a real terminal. A fresh Marked per call
73
+ // keeps width overridable and avoids leaking global parser state.
74
+ export function renderMarkdown(md, width = termWidth()) {
75
+ const w = Math.max(20, width);
76
+ const m = new Marked();
77
+ m.use(renderer(w));
78
+ // Override the table renderer AFTER marked-terminal so ours wins: render each
79
+ // cell's inline markdown to ANSI (parseInline), then lay the table out to fit w.
80
+ m.use({
81
+ renderer: {
82
+ table(token) {
83
+ const header = token.header.map((c) => this.parser.parseInline(c.tokens));
84
+ const rows = token.rows.map((r) => r.map((c) => this.parser.parseInline(c.tokens)));
85
+ return renderTable(header, rows, w) + "\n";
86
+ },
87
+ },
88
+ });
89
+ const out = m.parse(md, { async: false });
90
+ return out.replace(/\s+$/g, ""); // trim the trailing blank lines marked-terminal adds
91
+ }
92
+ function termWidth() {
93
+ return (process.stdout.columns || 80) - 4; // leave room for our 2-col gutter + margin
94
+ }
95
+ // ---- streaming glue ---------------------------------------------------------
96
+ // marked renders a WHOLE document; the model streams tokens. The bridge: buffer
97
+ // text and flush one markdown *block* at a time, where a block ends at a blank
98
+ // line that is NOT inside a ``` fence. So a paragraph, heading, list, or table
99
+ // renders the moment it's complete — you keep the live "it's typing" feel, but
100
+ // every block lands fully formatted (a table needs all its rows to align).
101
+ export class MarkdownStream {
102
+ write;
103
+ opts;
104
+ buf = ""; // text not yet flushed
105
+ firstLine = true; // the very first printed line gets the ⏺ prefix
106
+ constructor(write, // where rendered lines go (stdout in prod)
107
+ opts = {}) {
108
+ this.write = write;
109
+ this.opts = opts;
110
+ }
111
+ // Feed streamed tokens; flushes any blocks that are now complete.
112
+ push(chunk) {
113
+ this.buf += chunk;
114
+ for (let i = this.boundary(); i >= 0; i = this.boundary()) {
115
+ const block = this.buf.slice(0, i);
116
+ this.buf = this.buf.slice(i).replace(/^\n+/, ""); // drop the blank-line separator
117
+ if (block.trim())
118
+ this.emit(block);
119
+ }
120
+ }
121
+ // End of answer: render whatever's left as the final block.
122
+ end() {
123
+ if (this.buf.trim())
124
+ this.emit(this.buf);
125
+ this.buf = "";
126
+ }
127
+ // Index of the first blank-line boundary that sits OUTSIDE a code fence, or -1.
128
+ // Counting fence markers before the candidate tells us if we're inside one.
129
+ boundary() {
130
+ for (let from = 0;;) {
131
+ const i = this.buf.indexOf("\n\n", from);
132
+ if (i < 0)
133
+ return -1;
134
+ const fences = (this.buf.slice(0, i).match(/^[ \t]*(```|~~~)/gm) || []).length;
135
+ if (fences % 2 === 0)
136
+ return i; // even number of fences → not inside one
137
+ from = i + 2; // this blank line is inside a fence; keep looking
138
+ }
139
+ }
140
+ // Render one block and print it, with a left gutter so wrapped lines align
141
+ // under the ⏺ marker (first physical line uses firstPrefix, the rest indent).
142
+ emit(md) {
143
+ const indent = this.opts.indent ?? " ";
144
+ const rendered = renderMarkdown(md, (process.stdout.columns || 80) - indent.length - 1);
145
+ const out = rendered.split("\n").map((ln) => {
146
+ const prefix = this.firstLine ? (this.opts.firstPrefix ?? indent) : indent;
147
+ this.firstLine = false;
148
+ return prefix + ln;
149
+ });
150
+ this.write(out.join("\n") + "\n");
151
+ }
152
+ }
@@ -97,11 +97,24 @@ function planSafe(toolName, verdict) {
97
97
  case "read_file":
98
98
  case "search":
99
99
  case "task": // the sub-agent's own calls hit this same gate, still in plan mode
100
+ case "spawn_teammate": // a teammate's own calls hit this same gate (plan mode still blocks their writes)
101
+ case "send_message": // coordination only — changes nothing on disk
102
+ case "request_shutdown": // team protocol (Day 39): coordination, no side effects
103
+ case "request_plan":
104
+ case "review_plan":
105
+ case "submit_plan":
106
+ case "create_task": // task board (Day 40): touches the internal board, not the user's project
107
+ case "list_tasks":
108
+ case "claim_task":
109
+ case "complete_task":
100
110
  case "ask_user": // asking the user a question is safe in plan mode — it doesn't change anything
101
111
  case "skill": // loading a skill's instructions is read-only; what it does is gated per-call
112
+ case "todo_write": // planning is exactly what plan mode is FOR — never block it
113
+ case "bash_output": // reading a background task's output observes, never mutates
102
114
  case "exit_plan_mode": // the way OUT of plan mode must never be blocked by plan mode
103
115
  return true;
104
116
  case "run_bash":
117
+ case "run_bash_background":
105
118
  return verdict.decision === "allow"; // only the read-only commands the classifier cleared
106
119
  default:
107
120
  return false; // write_file, edit_file, MCP and unknown tools: all mutate-or-unknown
@@ -144,6 +157,29 @@ function basePermission(toolName, argsJson) {
144
157
  // Spawning a sub-agent is orchestration, not action: every tool the
145
158
  // sub-agent uses goes through this same gate individually.
146
159
  return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
160
+ case "spawn_teammate":
161
+ // Same as task: spawning is orchestration. Each teammate's own tool calls
162
+ // pass through this gate; a teammate's non-interactive policy auto-proceeds
163
+ // on writes but a hard DENY here still stands (deny always wins).
164
+ return { decision: "allow", reason: "teammate tools are gated individually", summary: "spawn_teammate" };
165
+ case "send_message":
166
+ // Dropping a message in another agent's mailbox has no filesystem effect on
167
+ // the user's project — it is pure team coordination.
168
+ return { decision: "allow", reason: "team coordination, no side effects", summary: "send_message" };
169
+ case "request_shutdown":
170
+ case "request_plan":
171
+ case "review_plan":
172
+ case "submit_plan":
173
+ // Team protocol messages (Day 39) — request/response coordination over the
174
+ // mailbox. No filesystem effect; the work they gate still passes the gate.
175
+ return { decision: "allow", reason: "team protocol, no side effects", summary: toolName };
176
+ case "create_task":
177
+ case "list_tasks":
178
+ case "claim_task":
179
+ case "complete_task":
180
+ // Task board (Day 40) — bookkeeping on the internal board, not the user's
181
+ // project. The actual work a claimed task drives still passes this gate.
182
+ return { decision: "allow", reason: "task board, no project side effects", summary: toolName };
147
183
  case "ask_user":
148
184
  // Asking the user a question has no side effects — it's the safest thing
149
185
  // the model can do. Never gate it behind an approval prompt.
@@ -152,6 +188,9 @@ function basePermission(toolName, argsJson) {
152
188
  // Loading a skill just returns instructions; whatever the skill then DOES
153
189
  // goes through this same gate, call by call. Loading itself is free.
154
190
  return { decision: "allow", reason: "loads instructions, no side effects", summary: "skill" };
191
+ case "todo_write":
192
+ // Writing the plan touches nothing but in-memory state and the screen.
193
+ return { decision: "allow", reason: "updates the plan, no side effects", summary: "todo_write" };
155
194
  case "read_file": {
156
195
  const p = args.path ?? ""; // the file the model wants to read
157
196
  if (SECRET_FILE_RE.test(path.resolve(p))) {
@@ -173,16 +212,25 @@ function basePermission(toolName, argsJson) {
173
212
  }
174
213
  return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
175
214
  }
176
- case "run_bash": {
215
+ case "run_bash":
216
+ case "run_bash_background": {
217
+ // Backgrounding changes WHEN output comes back, never WHAT runs — so a
218
+ // background command gets the exact same input-aware analysis as a
219
+ // foreground one. The danger is in the command, not the blocking.
177
220
  const verdict = checkBash(args.command ?? ""); // bash gets input-aware analysis
178
221
  // "Don't ask again for run_bash this session" (chosen in the approval menu)
179
222
  // upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
180
- // stands. Convenience never overrides the no-fly rules.
181
- if (verdict.decision === "ask" && CONFIG.permissions.allow.includes("tool:run_bash")) {
223
+ // stands. Convenience never overrides the no-fly rules. A grant for either
224
+ // shell tool covers the other: the command is what was vouched for.
225
+ if (verdict.decision === "ask" && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
182
226
  return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
183
227
  }
184
228
  return verdict;
185
229
  }
230
+ case "bash_output":
231
+ // Polling a background task only reads its captured output and status —
232
+ // it touches nothing on the user's filesystem. Always safe.
233
+ return { decision: "allow", reason: "reads background task output, no side effects", summary: args.task_id ?? "bash_output" };
186
234
  case "exit_plan_mode": {
187
235
  // Outside plan mode this tool does nothing — let it through silently. In
188
236
  // plan mode it is the approval gate: ask the human, showing the plan, and
package/dist/prompt.js CHANGED
@@ -34,6 +34,22 @@ Rules:
34
34
  - Use your tools to do the work. Verify what you claim — do not guess.
35
35
  - Your FINAL message is returned to the parent verbatim. Make it a compact, factual report: findings, exact paths, numbers, code snippets. No greetings, no questions back.
36
36
  - You cannot talk to the user. If the task cannot be completed, report precisely what is missing.`;
37
+ // The teammate constitution (Day 38, extended Day 39). Unlike a sub-agent, a
38
+ // teammate is persistent and CONNECTED: it works across many rounds, talks to
39
+ // the team through mailboxes, and coordinates through explicit PROTOCOLS rather
40
+ // than guessing. Load-bearing rules: keep the lead informed; propose-before-act
41
+ // on risky work (submit_plan); and don't exit on your own — report, then idle
42
+ // until the lead shuts you down. {name}/{role} are filled in at spawn time.
43
+ export const TEAMMATE_PROMPT = `You are {name}, a teammate on an agent team. Your role: {role}.
44
+
45
+ How a team works:
46
+ - You work alongside other teammates, coordinated by the "lead". You all run at the same time.
47
+ - There is a shared TASK BOARD. When you're idle you're automatically offered the next ready task ("You claimed task ..."). Just do it, then call complete_task — you'll be offered the next one. You can also list_tasks to see the board, claim_task a specific one, or create_task to add work you discover.
48
+ - Use send_message to coordinate: report progress or findings to "lead" as you go, or ask a specific teammate something. Messages you receive appear as "[message from X]" — read them and act.
49
+ - Before any RISKY or far-reaching change (refactoring auth, migrating a schema, deleting/rewriting many files), call submit_plan FIRST and wait for the lead's approval. Only start once it's approved; if rejected, revise and submit again.
50
+ - You CANNOT talk to the human and you CANNOT spawn teammates. Decide within your role; if you're blocked, message the lead and say precisely what you need.
51
+ - Use your tools (read_file, edit_file, write_file, search, run_bash) to do real work. Verify what you claim — do not guess.
52
+ - When your current task is done, complete_task it and report to the lead, then STOP. You'll idle and be offered more board work, or follow-up messages. Do NOT decide on your own that the whole job is over — the lead ends your run by requesting shutdown, and you'll exit cleanly then.`;
37
53
  // Project memory: an optional AGENT.md in the current directory — the
38
54
  // equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
39
55
  // within a session (cache-friendly), different across projects (by design).
package/dist/team.js ADDED
@@ -0,0 +1,214 @@
1
+ // ============ Agent teams (Day 38) ============
2
+ // The leap past the one-shot sub-agent (the `task` tool). A sub-agent is fire-
3
+ // and-forget: fresh context, runs to completion, returns ONE final report, gone.
4
+ // That breaks down on big work — a teammate restructuring the API can't tell the
5
+ // teammate touching auth what it changed, because they never talk; each only
6
+ // hands back a conclusion at the very end.
7
+ //
8
+ // A TEAM fixes that. A Lead spawns several PERSISTENT teammates that run
9
+ // concurrently and coordinate through ASYNCHRONOUS MAILBOXES: any agent can drop
10
+ // a message in any other agent's inbox at any time, and the recipient sees it on
11
+ // its next round. The Lead injects its inbox into its own conversation, so it
12
+ // reacts to teammate results as they arrive instead of blocking on a single
13
+ // return value.
14
+ //
15
+ // Why mailboxes live ON DISK (落盘), not in a Map: OBSERVABILITY. While the team
16
+ // runs you can `cat .mini-agent/team/<agent>.jsonl` and watch messages in flight
17
+ // — the coordination is a thing you can inspect, not hidden process state. The
18
+ // Python reference guards these files with proper-lockfile because it runs
19
+ // teammates on OS threads; we run them as async tasks on ONE event loop, where a
20
+ // single appendFileSync — or a readFileSync+rmSync pair — runs to completion
21
+ // without yielding, so two agents can never interleave mid-operation. No lock
22
+ // needed. (Cross-process is out of scope: one CLI instance per project.)
23
+ import fs from "node:fs"; // mailboxes are real files
24
+ import path from "node:path"; // path joining
25
+ // The mailbox name of the coordinator. Teammates message "lead" to report back;
26
+ // the Lead reads its own inbox under this name.
27
+ export const LEAD = "lead";
28
+ // At most this many teammates per Lead — a cost/runaway ceiling, like every
29
+ // other loop in this codebase. A handful of specialists is the whole idea; a
30
+ // swarm is a footgun.
31
+ export const MAX_TEAMMATES = 4;
32
+ const pendingRequests = new Map(); // requestId → state
33
+ let reqSeq = 0; // increments per request — seeds the id
34
+ const teammates = new Map(); // name → teammate, for this run
35
+ // ---- The mailbox files ------------------------------------------------------
36
+ // Resolved lazily (a function, not a const) so it tracks the cwd, exactly like
37
+ // session.ts. The first touch this process run wipes any stale mailboxes left by
38
+ // a crashed earlier run — a new team must start with empty inboxes.
39
+ let wiped = false;
40
+ function teamRoot() {
41
+ const root = path.resolve(".mini-agent", "team");
42
+ fs.mkdirSync(root, { recursive: true });
43
+ if (!wiped) {
44
+ for (const f of fs.readdirSync(root))
45
+ if (f.endsWith(".jsonl"))
46
+ fs.rmSync(path.join(root, f)); // fresh inboxes for this run
47
+ wiped = true;
48
+ }
49
+ return root;
50
+ }
51
+ function inboxPath(agent) {
52
+ // Keep file names filesystem-safe: agent names come from the model.
53
+ const safe = agent.replace(/[^a-zA-Z0-9_-]/g, "_");
54
+ return path.join(teamRoot(), `${safe}.jsonl`);
55
+ }
56
+ // Drop a message in the recipient's inbox: one JSON object per line (JSONL), so
57
+ // appends never have to rewrite the file and a half-written line can't corrupt
58
+ // the earlier ones. Synchronous on purpose — see the no-lock note up top.
59
+ export function sendMessage(from, to, content, type = "message") {
60
+ const msg = { from, to, content, type, ts: Date.now() };
61
+ fs.appendFileSync(inboxPath(to), JSON.stringify(msg) + "\n");
62
+ }
63
+ // Drop a PROTOCOL message in an inbox — same JSONL bus, but tagged with its
64
+ // contract role, request_id, and (for responses) the decision. This is how a
65
+ // request and its answer travel; the consume step on the other side routes by
66
+ // `kind` and correlates by `requestId`.
67
+ export function sendProtocol(from, to, kind, requestId, content, status) {
68
+ const msg = { from, to, content, type: "message", ts: Date.now(), kind, requestId, status };
69
+ fs.appendFileSync(inboxPath(to), JSON.stringify(msg) + "\n");
70
+ }
71
+ // ---- Protocol state (the pending_requests dict) -----------------------------
72
+ // Open a request: store it pending and return its fresh id. The caller then
73
+ // sends the matching *_request message carrying this id.
74
+ export function createRequest(kind, from, to, payload) {
75
+ const requestId = `req_${String(++reqSeq).padStart(6, "0")}`; // req_000001
76
+ pendingRequests.set(requestId, { requestId, kind, from, to, status: "pending", payload, createdAt: Date.now() });
77
+ return requestId;
78
+ }
79
+ export function getRequest(requestId) {
80
+ return pendingRequests.get(requestId);
81
+ }
82
+ // Correlate a response to its request — the reference's match_response. The
83
+ // guard that matters: the response's contract MUST match the request's kind, so
84
+ // a shutdown_response can never accidentally approve a plan_approval. Returns the
85
+ // updated state, or an error string the caller can hand back to the model.
86
+ export function resolveResponse(requestId, kind, approved) {
87
+ const state = pendingRequests.get(requestId);
88
+ if (!state)
89
+ return { ok: false, error: `No pending request "${requestId}".` };
90
+ if (state.kind !== kind)
91
+ return { ok: false, error: `Request "${requestId}" is a ${state.kind} request, not ${kind} — wrong response type.` };
92
+ if (state.status !== "pending")
93
+ return { ok: false, error: `Request "${requestId}" was already ${state.status}.` };
94
+ state.status = approved ? "approved" : "rejected";
95
+ return { ok: true, state };
96
+ }
97
+ // Requests still awaiting an answer for a given recipient — used by the lead's
98
+ // view and to surface "you still owe an answer" prompts.
99
+ export function openRequestsFor(agent) {
100
+ return [...pendingRequests.values()].filter((r) => r.to === agent && r.status === "pending");
101
+ }
102
+ // Read and CONSUME an inbox: parse every message, then delete the file. The
103
+ // consumptive pattern (read = remove) means a message is delivered exactly once
104
+ // and the on-disk inbox always shows only what is still pending. Unreadable
105
+ // lines are skipped, never fatal.
106
+ export function readInbox(agent) {
107
+ const p = inboxPath(agent);
108
+ if (!fs.existsSync(p))
109
+ return []; // nothing waiting
110
+ let raw = "";
111
+ try {
112
+ raw = fs.readFileSync(p, "utf8");
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ fs.rmSync(p, { force: true }); // consume: the read empties the mailbox
118
+ const out = [];
119
+ for (const line of raw.split("\n")) {
120
+ const t = line.trim();
121
+ if (!t)
122
+ continue;
123
+ try {
124
+ out.push(JSON.parse(t));
125
+ }
126
+ catch {
127
+ /* skip a corrupt line, keep the rest */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ // Peek at how many messages are waiting WITHOUT consuming them. The loop uses
133
+ // this to decide whether to keep going; reading would empty the inbox before the
134
+ // agent's own turn got to see it.
135
+ export function inboxCount(agent) {
136
+ const p = inboxPath(agent);
137
+ if (!fs.existsSync(p))
138
+ return 0;
139
+ try {
140
+ return fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).length;
141
+ }
142
+ catch {
143
+ return 0;
144
+ }
145
+ }
146
+ // ---- The registry -----------------------------------------------------------
147
+ export function teammateExists(name) {
148
+ return teammates.has(name);
149
+ }
150
+ export function teammateCount() {
151
+ return teammates.size;
152
+ }
153
+ // Record a newly spawned teammate. The caller supplies the promise for its loop.
154
+ export function registerTeammate(name, role, done) {
155
+ teammates.set(name, { name, role, status: "running", state: "active", startedAt: Date.now(), done });
156
+ }
157
+ // Mark a teammate finished. `ok=false` means its loop ended on a non-Done reason.
158
+ export function finishTeammate(name, ok) {
159
+ const t = teammates.get(name);
160
+ if (t)
161
+ t.status = ok ? "done" : "failed";
162
+ }
163
+ // How many teammates are still running — at least one running teammate means the
164
+ // team isn't disbanded yet.
165
+ export function activeTeammateCount() {
166
+ return [...teammates.values()].filter((t) => t.status === "running").length;
167
+ }
168
+ // ---- Day 39: active/idle + shutdown plumbing --------------------------------
169
+ // A running teammate flips between actively working a round and idling on its
170
+ // inbox. The lead waits while ANY teammate is actively working; once they're all
171
+ // idle (or done), the work has converged and the lead can wrap up the team.
172
+ export function setTeammateState(name, state) {
173
+ const t = teammates.get(name);
174
+ if (t && t.status === "running")
175
+ t.state = state;
176
+ }
177
+ export function anyTeammateBusy() {
178
+ return [...teammates.values()].some((t) => t.status === "running" && t.state === "active");
179
+ }
180
+ export function runningTeammates() {
181
+ return [...teammates.values()].filter((t) => t.status === "running").map((t) => t.name);
182
+ }
183
+ // The shutdown handshake: the lead opens a shutdown request against a teammate,
184
+ // stamping it with the request_id; the teammate sees the flag at its next
185
+ // stop-point, replies shutdown_response(approved) with that id, and exits.
186
+ export function markShutdown(name, requestId) {
187
+ const t = teammates.get(name);
188
+ if (t)
189
+ t.shutdownRequestId = requestId;
190
+ }
191
+ export function shutdownRequestId(name) {
192
+ return teammates.get(name)?.shutdownRequestId;
193
+ }
194
+ // A snapshot for the /team command: newest first, with live state + inbox depth.
195
+ export function listTeam() {
196
+ return [...teammates.values()]
197
+ .sort((a, b) => b.startedAt - a.startedAt)
198
+ .map((t) => ({
199
+ name: t.name,
200
+ role: t.role,
201
+ // While running, show active vs idle (Day 39) — that's the interesting bit.
202
+ status: t.status === "running" ? t.state : t.status,
203
+ elapsed: Math.round((Date.now() - t.startedAt) / 1000),
204
+ pending: inboxCount(t.name),
205
+ }));
206
+ }
207
+ // Wipe the registry AND the in-flight requests (and let the next touch clear the
208
+ // mailbox files). Called on /clear and /resume, and after the lead disbands the
209
+ // team — a new conversation starts with no team and no dangling protocols.
210
+ export function resetTeam() {
211
+ teammates.clear();
212
+ pendingRequests.clear();
213
+ wiped = false; // the next mailbox touch wipes stale files again
214
+ }
package/dist/todos.js ADDED
@@ -0,0 +1,87 @@
1
+ import chalk from "chalk"; // the one color source — match ui.ts's vocabulary
2
+ const STATUSES = ["pending", "in_progress", "completed"];
3
+ // ---- pure: validate the model's argument into a clean Todo[] ----------------
4
+ // The model's arguments are probabilistic, so be strict and return a precise
5
+ // repair message (errors are text the model can act on) rather than trusting it.
6
+ // Rule worth enforcing: AT MOST ONE in_progress — "do one thing at a time" is the
7
+ // whole discipline a plan buys you; many-in-progress is just a relabeled backlog.
8
+ export function parseTodos(raw) {
9
+ if (!Array.isArray(raw))
10
+ return { error: "`todos` must be an array of { content, status }" };
11
+ const todos = [];
12
+ for (let i = 0; i < raw.length; i++) {
13
+ const item = raw[i];
14
+ if (typeof item !== "object" || item === null)
15
+ return { error: `todo #${i + 1} must be an object with content + status` };
16
+ const content = typeof item.content === "string" ? item.content.trim() : "";
17
+ if (!content)
18
+ return { error: `todo #${i + 1} is missing non-empty "content"` };
19
+ if (!STATUSES.includes(item.status))
20
+ return { error: `todo #${i + 1} has bad status "${String(item.status)}" — use pending | in_progress | completed` };
21
+ todos.push({ content, status: item.status });
22
+ }
23
+ if (todos.filter((t) => t.status === "in_progress").length > 1)
24
+ return { error: "only one todo may be in_progress at a time — finish or re-queue the others" };
25
+ return { todos };
26
+ }
27
+ // ---- pure: render the checklist for the terminal ----------------------------
28
+ // Glyphs over words: a column of ✓ / ▶ / ○ scans at a glance. The in-progress
29
+ // item is the loud one (that's where attention belongs); done items fade.
30
+ export function renderTodos(todos) {
31
+ if (!todos.length)
32
+ return chalk.dim(" (no todos)");
33
+ return todos
34
+ .map((t) => {
35
+ if (t.status === "completed")
36
+ return chalk.green(" ✓ ") + chalk.dim.strikethrough(t.content);
37
+ if (t.status === "in_progress")
38
+ return chalk.cyan.bold(" ▶ ") + chalk.bold(t.content);
39
+ return chalk.dim(" ○ " + t.content);
40
+ })
41
+ .join("\n");
42
+ }
43
+ // ---- pure: a one-line confirmation for the MODEL (cheap, no ANSI) -----------
44
+ // The pretty list goes to the screen; the model only needs the tally + what it
45
+ // said it's doing now, so we don't burn tokens echoing its own plan back at it.
46
+ export function summarizeTodos(todos) {
47
+ const done = todos.filter((t) => t.status === "completed").length;
48
+ const doing = todos.find((t) => t.status === "in_progress");
49
+ const pending = todos.filter((t) => t.status === "pending").length;
50
+ const head = `Plan saved — ${done} done, ${doing ? 1 : 0} in progress, ${pending} pending.`;
51
+ if (doing)
52
+ return `${head} Now: ${doing.content}`;
53
+ if (todos.length && done === todos.length)
54
+ return `${head} All items complete.`;
55
+ return head;
56
+ }
57
+ // ---- session state + the nag --------------------------------------------------
58
+ // The current plan lives for the session (in process memory, like Claude Code's).
59
+ let current = [];
60
+ let staleRounds = 0; // tool-rounds since the plan was last touched
61
+ export function setTodos(todos) {
62
+ current = todos;
63
+ staleRounds = 0; // an update is a fresh signal — restart the patience clock
64
+ }
65
+ export function getTodos() {
66
+ return current;
67
+ }
68
+ export function clearTodos() {
69
+ current = [];
70
+ staleRounds = 0; // /clear and session switches wipe the plan too
71
+ }
72
+ // Call once per tool-round. If there's an unfinished plan the model has stopped
73
+ // touching, return a reminder to inject (else null). This is the "nag": without
74
+ // it the model writes a plan once and never looks back — the list goes stale and
75
+ // stops meaning anything. We re-arm after firing, so it nudges every N rounds,
76
+ // not every round (which would be noise).
77
+ export const NAG_AFTER_ROUNDS = 3;
78
+ export function todoNag() {
79
+ const unfinished = current.some((t) => t.status !== "completed");
80
+ if (!current.length || !unfinished)
81
+ return null; // no plan, or all done → nothing to nag about
82
+ staleRounds++;
83
+ if (staleRounds < NAG_AFTER_ROUNDS)
84
+ return null;
85
+ staleRounds = 0; // re-arm
86
+ return "[plan reminder] Your todo list still has unfinished items but you haven't updated it in a few steps. If you've made progress, call todo_write to mark items completed / move the next one to in_progress; if the plan changed, rewrite it. Don't lose the original goal.";
87
+ }