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/dist/board.js ADDED
@@ -0,0 +1,179 @@
1
+ // ============ The shared task board (Day 40) ============
2
+ // Day 38/39 gave us a team, but the LEAD still had to hand every teammate its
3
+ // work by message. That doesn't scale: with twenty tasks the lead becomes a
4
+ // dispatcher, and a teammate that finishes early sits idle while the lead is
5
+ // busy. s17's fix: a shared TASK BOARD that agents pull from THEMSELVES. The
6
+ // lead (or a teammate) drops tasks on the board; any idle teammate scans it,
7
+ // CLAIMS a ready one, does it, marks it complete, and scans again — autonomous
8
+ // work distribution with no dispatcher in the middle.
9
+ //
10
+ // Like the mailboxes, the board is ON DISK (落盘) for observability: each task is
11
+ // a .mini-agent/tasks/<id>.json file you can read while the team runs. And like
12
+ // the mailboxes, it needs NO lock despite "concurrent" claimers: claimTask is a
13
+ // synchronous read-check-write, and on one event loop a synchronous function
14
+ // runs to completion without yielding — so two teammates can never both win the
15
+ // same task. (The Python reference uses proper-lockfile because its teammates are
16
+ // OS threads; production CC adds fs.watch + atomic claim-with-busy-check.)
17
+ //
18
+ // Not to be confused with todo_write (Day 36): that is one agent's PRIVATE
19
+ // checklist; this is the team's SHARED, claimable work queue.
20
+ import fs from "node:fs"; // tasks are real files
21
+ import path from "node:path"; // path joining
22
+ let seq = 0; // increments per task — seeds the id
23
+ let wiped = false; // wipe stale board files once per process run (like team.ts)
24
+ // Resolved lazily so it tracks the cwd, exactly like the session + team dirs.
25
+ function boardRoot() {
26
+ const root = path.resolve(".mini-agent", "tasks");
27
+ fs.mkdirSync(root, { recursive: true });
28
+ if (!wiped) {
29
+ for (const f of fs.readdirSync(root))
30
+ if (f.endsWith(".json"))
31
+ fs.rmSync(path.join(root, f)); // fresh board for this run
32
+ wiped = true;
33
+ }
34
+ return root;
35
+ }
36
+ const taskPath = (id) => path.join(boardRoot(), `${id}.json`);
37
+ function saveTask(task) {
38
+ fs.writeFileSync(taskPath(task.id), JSON.stringify(task, null, 2)); // pretty, so `cat` is readable
39
+ }
40
+ export function getTask(id) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(taskPath(id), "utf8"));
43
+ }
44
+ catch {
45
+ return undefined; // missing or corrupt
46
+ }
47
+ }
48
+ // Every task on the board, oldest first (creation order = the natural reading
49
+ // order for a dependency graph).
50
+ export function listTasks() {
51
+ const root = boardRoot();
52
+ return fs
53
+ .readdirSync(root)
54
+ .filter((f) => f.endsWith(".json"))
55
+ .map((f) => {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(path.join(root, f), "utf8"));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ })
63
+ .filter((t) => t !== null)
64
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
65
+ }
66
+ // Create a task. `blockedBy` may reference ids that don't exist yet — canStart
67
+ // treats an unknown dependency as "not satisfied", so order of creation is free.
68
+ export function createTask(subject, description = "", blockedBy = []) {
69
+ const task = { id: `task_${++seq}`, subject, description, status: "pending", owner: null, blockedBy, createdAt: Date.now() };
70
+ saveTask(task);
71
+ return task;
72
+ }
73
+ // Resolve a blockedBy entry to a task. Models naturally reference a dependency
74
+ // by its SUBJECT ("alpha") rather than its id ("task_1"), so we accept either —
75
+ // id first, then a unique subject match. Returns undefined if it matches nothing
76
+ // (or an ambiguous subject), which canStart then treats as still-blocking.
77
+ export function resolveDep(dep) {
78
+ const byId = getTask(dep);
79
+ if (byId)
80
+ return byId;
81
+ const bySubject = listTasks().filter((t) => t.subject === dep);
82
+ return bySubject.length === 1 ? bySubject[0] : undefined; // ignore an ambiguous subject
83
+ }
84
+ // Can this task be started? Only if every blockedBy dependency resolves to a
85
+ // COMPLETED task. A missing/ambiguous dependency counts as blocking (fail closed).
86
+ export function canStart(id) {
87
+ const task = getTask(id);
88
+ if (!task)
89
+ return false;
90
+ return task.blockedBy.every((dep) => resolveDep(dep)?.status === "completed");
91
+ }
92
+ // Claim a task for `owner`. The critical section: read, check it's still
93
+ // pending+unowned+ready, then write owner+in_progress — all synchronous, so it
94
+ // is atomic against any other agent on this event loop. Returns ok=false (with a
95
+ // reason) if someone else got there first or a dependency isn't done.
96
+ export function claimTask(id, owner) {
97
+ const task = getTask(id);
98
+ if (!task)
99
+ return { ok: false, error: `No task "${id}".` };
100
+ if (task.status !== "pending")
101
+ return { ok: false, error: `Task ${id} is ${task.status}${task.owner ? ` (owner ${task.owner})` : ""}, not claimable.` };
102
+ if (task.owner)
103
+ return { ok: false, error: `Task ${id} is already owned by ${task.owner}.` };
104
+ if (!canStart(id))
105
+ return { ok: false, error: `Task ${id} is blocked by unfinished tasks: ${task.blockedBy.filter((d) => resolveDep(d)?.status !== "completed").join(", ")}.` };
106
+ task.owner = owner;
107
+ task.status = "in_progress";
108
+ saveTask(task);
109
+ return { ok: true, task };
110
+ }
111
+ // Complete a task and report which downstream tasks it just unblocked (so the
112
+ // claimer can mention them, and the board moves forward).
113
+ export function completeTask(id, by) {
114
+ const task = getTask(id);
115
+ if (!task)
116
+ return { ok: false, error: `No task "${id}".` };
117
+ if (task.status !== "in_progress")
118
+ return { ok: false, error: `Task ${id} is ${task.status}, not in progress.` };
119
+ if (by && task.owner && task.owner !== by)
120
+ return { ok: false, error: `Task ${id} is owned by ${task.owner}, not ${by}.` };
121
+ task.status = "completed";
122
+ saveTask(task);
123
+ // Tasks that listed this one as a dependency (by id or subject) and are now
124
+ // ready just opened up.
125
+ const unblocked = listTasks()
126
+ .filter((t) => t.status === "pending" && !t.owner && t.blockedBy.some((d) => resolveDep(d)?.id === id) && canStart(t.id))
127
+ .map((t) => t.id);
128
+ return { ok: true, unblocked };
129
+ }
130
+ // Scan for the next task this agent can take and CLAIM it, atomically. Returns
131
+ // the claimed task, or null if nothing is available right now. This is the heart
132
+ // of autonomy: an idle teammate calls this and either gets work or keeps waiting.
133
+ // Trying each candidate (rather than just the first) closes the race where two
134
+ // agents scanned the same "available" task — the loser simply tries the next.
135
+ export function claimNextAvailable(owner) {
136
+ for (const t of listTasks()) {
137
+ if (t.status !== "pending" || t.owner || !canStart(t.id))
138
+ continue;
139
+ const res = claimTask(t.id, owner);
140
+ if (res.ok)
141
+ return res.task;
142
+ }
143
+ return null;
144
+ }
145
+ // Is there pending-and-ready work nobody has claimed? Used to tell "the board is
146
+ // genuinely drained" from "a teammate is briefly between tasks".
147
+ export function hasClaimableWork() {
148
+ return listTasks().some((t) => t.status === "pending" && !t.owner && canStart(t.id));
149
+ }
150
+ // Any task not yet completed — the board still has open work (claimed or not).
151
+ export function hasOpenTasks() {
152
+ return listTasks().some((t) => t.status !== "completed");
153
+ }
154
+ // A compact, human-readable board snapshot — for the list_tasks tool result and
155
+ // the /tasks command. One line per task: status, id, owner, deps.
156
+ export function boardSummary() {
157
+ const tasks = listTasks();
158
+ if (!tasks.length)
159
+ return "(the task board is empty)";
160
+ const icon = { pending: "○", in_progress: "◐", completed: "✓" };
161
+ return tasks
162
+ .map((t) => {
163
+ const owner = t.owner ? ` @${t.owner}` : "";
164
+ const deps = t.blockedBy.length ? ` (blockedBy: ${t.blockedBy.join(", ")})` : "";
165
+ return `${icon[t.status]} ${t.id} [${t.status}${owner}] ${t.subject}${deps}`;
166
+ })
167
+ .join("\n");
168
+ }
169
+ // Wipe the board: delete every task file and reset the counter. Called on
170
+ // /clear, /resume, and when the lead disbands the team — each task starts fresh.
171
+ export function resetBoard() {
172
+ const root = path.resolve(".mini-agent", "tasks");
173
+ if (fs.existsSync(root))
174
+ for (const f of fs.readdirSync(root))
175
+ if (f.endsWith(".json"))
176
+ fs.rmSync(path.join(root, f));
177
+ seq = 0;
178
+ wiped = true; // we just cleared it; don't re-wipe on the next touch this run
179
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,307 @@
1
+ import readline from "node:readline"; // raw-mode keypress decoding + the interface we pause
2
+ // ---- pure editing logic -----------------------------------------------------
3
+ // Map a keypress onto a new state + an action. Emacs-style bindings, the ones a
4
+ // terminal user expects. Everything printable (letters, digits, punctuation,
5
+ // pasted text, CJK) falls through to insertion at the cursor.
6
+ export function reduceEditor(s, str, key) {
7
+ const { buffer, cursor } = s;
8
+ const name = key?.name;
9
+ if (key?.ctrl) {
10
+ switch (name) {
11
+ case "c":
12
+ return { state: s, action: "cancel" }; // Ctrl+C → caller decides (at the prompt: quit)
13
+ case "d":
14
+ return buffer.length === 0 ? { state: s, action: "eof" } : forwardDelete(s); // Ctrl+D empty → EOF, else delete
15
+ case "a":
16
+ return { state: { buffer, cursor: 0 }, action: "edit" }; // start of line
17
+ case "e":
18
+ return { state: { buffer, cursor: buffer.length }, action: "edit" }; // end of line
19
+ case "u":
20
+ return { state: { buffer: buffer.slice(cursor), cursor: 0 }, action: "edit" }; // kill to start
21
+ case "k":
22
+ return { state: { buffer: buffer.slice(0, cursor), cursor }, action: "edit" }; // kill to end
23
+ case "w":
24
+ return killWordBack(s); // kill the word before the cursor
25
+ case "b":
26
+ return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" }; // back one char
27
+ case "f":
28
+ return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" }; // forward one char
29
+ default:
30
+ return { state: s, action: "none" };
31
+ }
32
+ }
33
+ switch (name) {
34
+ case "return":
35
+ case "enter":
36
+ return { state: s, action: "submit" };
37
+ case "backspace":
38
+ return cursor > 0
39
+ ? { state: { buffer: buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor: cursor - 1 }, action: "edit" }
40
+ : { state: s, action: "none" };
41
+ case "delete":
42
+ return forwardDelete(s);
43
+ case "left":
44
+ return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" };
45
+ case "right":
46
+ return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" };
47
+ case "home":
48
+ return { state: { buffer, cursor: 0 }, action: "edit" };
49
+ case "end":
50
+ return { state: { buffer, cursor: buffer.length }, action: "edit" };
51
+ case "up":
52
+ return { state: s, action: "histPrev" };
53
+ case "down":
54
+ return { state: s, action: "histNext" };
55
+ case "escape":
56
+ return { state: { buffer: "", cursor: 0 }, action: "edit" }; // Esc clears the line
57
+ case "tab":
58
+ return { state: s, action: "tab" };
59
+ default:
60
+ // Printable insertion. A paste arrives as a multi-char string; insert it whole.
61
+ if (str && !key?.meta && isPrintable(str)) {
62
+ return { state: { buffer: buffer.slice(0, cursor) + str + buffer.slice(cursor), cursor: cursor + str.length }, action: "edit" };
63
+ }
64
+ return { state: s, action: "none" };
65
+ }
66
+ }
67
+ function forwardDelete(s) {
68
+ const { buffer, cursor } = s;
69
+ return cursor < buffer.length ? { state: { buffer: buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor }, action: "edit" } : { state: s, action: "none" };
70
+ }
71
+ function killWordBack(s) {
72
+ const { buffer, cursor } = s;
73
+ let i = cursor;
74
+ while (i > 0 && /\s/.test(buffer[i - 1]))
75
+ i--; // skip trailing spaces
76
+ while (i > 0 && !/\s/.test(buffer[i - 1]))
77
+ i--; // then the word
78
+ return { state: { buffer: buffer.slice(0, i) + buffer.slice(cursor), cursor: i }, action: "edit" };
79
+ }
80
+ function isPrintable(str) {
81
+ return [...str].every((c) => c >= " " && c !== "\x7f"); // no control chars (incl. lone newlines/DEL)
82
+ }
83
+ // ---- width + wrapping math --------------------------------------------------
84
+ const ANSI = /\x1b\[[0-9;]*m/g; // SGR color codes — they take zero display columns
85
+ // Display width of a string, ANSI-aware and CJK-aware (full-width glyphs are 2
86
+ // columns; String.length would miscount them and break wrapping/cursor math).
87
+ export function displayWidth(s) {
88
+ let w = 0;
89
+ for (const ch of s.replace(ANSI, ""))
90
+ w += charWidth(ch.codePointAt(0));
91
+ return w;
92
+ }
93
+ function charWidth(cp) {
94
+ if (cp >= 0x1100 &&
95
+ (cp <= 0x115f || // Hangul Jamo
96
+ cp === 0x2329 ||
97
+ cp === 0x232a ||
98
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK … Yi
99
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
100
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
101
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
102
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
103
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
104
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
105
+ (cp >= 0x20000 && cp <= 0x3fffd)) // CJK extension B+
106
+ )
107
+ return 2;
108
+ return 1;
109
+ }
110
+ // Clip a (possibly ANSI-styled) string to a display width: keep escape codes
111
+ // (zero width) intact, never split a wide glyph. This stops a long status footer
112
+ // from wrapping — a wrapped footer row occupies TWO physical rows, which would
113
+ // desync the editor's row math and drop the caret a line too low (and then every
114
+ // repaint clears from the wrong row, stacking a fresh prompt each keystroke).
115
+ export function truncateToWidth(s, width) {
116
+ let w = 0;
117
+ let out = "";
118
+ let i = 0;
119
+ while (i < s.length) {
120
+ if (s[i] === "\x1b") {
121
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); // an SGR colour code — copy it, it has no width
122
+ if (m) {
123
+ out += m[0];
124
+ i += m[0].length;
125
+ continue;
126
+ }
127
+ }
128
+ const cp = s.codePointAt(i);
129
+ const ch = String.fromCodePoint(cp);
130
+ const cw = charWidth(cp);
131
+ if (w + cw > width)
132
+ return out + "\x1b[0m"; // would overflow → stop, closing any open colour
133
+ out += ch;
134
+ w += cw;
135
+ i += ch.length;
136
+ }
137
+ return out;
138
+ }
139
+ // Wrap the input across the terminal width and find where the cursor lands.
140
+ // Row 0 begins after the prompt (promptW columns); continuation rows start at 0.
141
+ // Returns the buffer text per row (the driver prepends the prompt to row 0) plus
142
+ // the cursor's (row, col) — so a wrapped line keeps the caret exactly right.
143
+ export function layout(buffer, cursor, promptW, cols) {
144
+ const width = Math.max(2, cols);
145
+ const rows = [""];
146
+ let row = 0;
147
+ let col = promptW;
148
+ let cursorRow = 0;
149
+ let cursorCol = promptW;
150
+ let set = false;
151
+ let cu = 0; // code-unit offset, to match `cursor`
152
+ for (const ch of buffer) {
153
+ if (!set && cu === cursor) {
154
+ cursorRow = row;
155
+ cursorCol = col;
156
+ set = true;
157
+ }
158
+ const w = charWidth(ch.codePointAt(0));
159
+ if (col + w > width) {
160
+ row++;
161
+ col = 0;
162
+ rows[row] = "";
163
+ }
164
+ rows[row] += ch;
165
+ col += w;
166
+ cu += ch.length;
167
+ }
168
+ if (!set) {
169
+ cursorRow = row;
170
+ cursorCol = col; // cursor at the very end
171
+ }
172
+ if (cursorCol >= width) {
173
+ // caret sat exactly at the right edge — it belongs at the start of the next row
174
+ cursorRow++;
175
+ cursorCol = 0;
176
+ if (rows.length <= cursorRow)
177
+ rows.push("");
178
+ }
179
+ return { rows, cursorRow, cursorCol };
180
+ }
181
+ // Read one line with the full editor + pinned footer. Resolves with the line, or
182
+ // cancel (Ctrl+C) / eof (Ctrl+D on an empty line) for the caller to interpret.
183
+ export function editLine(rl, opts) {
184
+ return new Promise((resolve) => {
185
+ const input = opts.input ?? process.stdin;
186
+ const out = process.stdout;
187
+ if (!input.isTTY)
188
+ return resolve({ type: "eof" }); // caller guards, but be safe
189
+ const promptW = displayWidth(opts.prompt);
190
+ const footerRows = opts.footer ? opts.footer.split("\n") : [];
191
+ const history = opts.history ?? [];
192
+ let hi = history.length; // history cursor; == length means "the live draft"
193
+ let draft = "";
194
+ let state = { buffer: opts.initial ?? "", cursor: (opts.initial ?? "").length };
195
+ let done = false;
196
+ let first = true;
197
+ let prevCursorRow = 0; // where the caret sits within the block, to climb back next repaint
198
+ const wasRaw = input.isRaw === true; // restore THIS on exit (forcing false would close the live readline)
199
+ const cols = () => out.columns || 80;
200
+ // The session's readline keeps its OWN 'keypress' handler on stdin. Once
201
+ // readline has been activated (after the first resume), that handler fires on
202
+ // every key alongside ours and fights us for the cursor — the line stacks
203
+ // instead of redrawing in place. Detach the other keypress listeners while we
204
+ // own the input, and restore them exactly on exit (menu/confirm still need them).
205
+ let otherKeypress = [];
206
+ // Repaint the whole block in place: climb to its top, clear downward, reprint
207
+ // input rows + footer, then drop the caret onto its computed (row, col).
208
+ const draw = () => {
209
+ const { rows, cursorRow, cursorCol } = layout(state.buffer, state.cursor, promptW, cols());
210
+ if (!first) {
211
+ if (prevCursorRow > 0)
212
+ out.write(`\x1b[${prevCursorRow}A`); // up to the block's top row
213
+ out.write("\r");
214
+ }
215
+ out.write("\x1b[0J"); // clear from the top of the block to end of screen
216
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
217
+ // Truncate each footer row so it can't wrap — otherwise its extra physical
218
+ // row throws off the cursor math below (caret lands low → prompts stack).
219
+ const fr = footerRows.map((f) => truncateToWidth(f, Math.max(1, cols() - 1)));
220
+ const all = [...inputRows, ...fr];
221
+ out.write(all.join("\r\n"));
222
+ const lastRow = all.length - 1; // every row is now exactly one physical line
223
+ if (lastRow > cursorRow)
224
+ out.write(`\x1b[${lastRow - cursorRow}A`); // up from the last footer row
225
+ out.write("\r");
226
+ if (cursorCol > 0)
227
+ out.write(`\x1b[${cursorCol}C`); // right to the caret column
228
+ prevCursorRow = cursorRow;
229
+ first = false;
230
+ };
231
+ const finish = (result) => {
232
+ if (done)
233
+ return;
234
+ done = true;
235
+ input.off("keypress", onKey);
236
+ for (const l of otherKeypress)
237
+ input.on("keypress", l); // give readline its keypress handler back
238
+ // Leave the typed command on screen, drop the footer, land on a fresh line.
239
+ const { rows } = layout(state.buffer, state.cursor, promptW, cols());
240
+ if (prevCursorRow > 0)
241
+ out.write(`\x1b[${prevCursorRow}A`);
242
+ out.write("\r\x1b[0J");
243
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
244
+ out.write(inputRows.join("\r\n") + "\r\n");
245
+ try {
246
+ input.setRawMode(wasRaw); // hand stdin back exactly as we found it
247
+ }
248
+ catch {
249
+ /* not all streams support it */
250
+ }
251
+ rl.resume();
252
+ resolve(result);
253
+ };
254
+ const onKey = (str, key) => {
255
+ if (done)
256
+ return;
257
+ const next = reduceEditor(state, str, key || {});
258
+ state = next.state;
259
+ switch (next.action) {
260
+ case "submit":
261
+ return finish({ type: "line", value: state.buffer });
262
+ case "cancel":
263
+ return finish({ type: "cancel" });
264
+ case "eof":
265
+ return finish({ type: "eof" });
266
+ case "histPrev":
267
+ if (hi > 0) {
268
+ if (hi === history.length)
269
+ draft = state.buffer; // stash the live draft on the way up
270
+ hi--;
271
+ state = { buffer: history[hi], cursor: history[hi].length };
272
+ }
273
+ return draw();
274
+ case "histNext":
275
+ if (hi < history.length) {
276
+ hi++;
277
+ const b = hi === history.length ? draft : history[hi];
278
+ state = { buffer: b, cursor: b.length };
279
+ }
280
+ return draw();
281
+ case "tab":
282
+ opts.onTab?.(); // may print above us (e.g. expand a collapsed block) and scroll
283
+ first = true; // so the next paint lands fresh at the cursor, not a stale climb-up
284
+ return draw();
285
+ case "edit":
286
+ return draw();
287
+ case "none":
288
+ return;
289
+ }
290
+ };
291
+ try {
292
+ rl.pause(); // stop the session reader from turning our keystrokes into lines
293
+ if (input === process.stdin)
294
+ readline.emitKeypressEvents(input); // decode raw bytes into named keys
295
+ otherKeypress = input.listeners("keypress"); // readline's handler(s)…
296
+ for (const l of otherKeypress)
297
+ input.off("keypress", l); // …detached so only we drive the cursor
298
+ input.setRawMode(true);
299
+ input.resume(); // rl.pause() also paused the stream — resume it or no keys arrive
300
+ input.on("keypress", onKey);
301
+ draw();
302
+ }
303
+ catch {
304
+ finish({ type: "eof" }); // setup failed (non-TTY etc.) → behave like EOF
305
+ }
306
+ });
307
+ }