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/dist/markdown.js
ADDED
|
@@ -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
|
+
}
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|