agent-from-zero 0.32.4 → 0.34.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/agent.js +100 -3
- package/dist/cron.js +280 -0
- package/dist/ink/app.js +166 -4
- package/dist/ink/commands.js +22 -0
- package/dist/ink/setup.js +8 -0
- package/dist/loop.js +24 -0
- package/dist/mcp.js +593 -40
- package/dist/permissions.js +11 -0
- package/dist/session.js +23 -4
- package/dist/title.js +49 -0
- package/dist/tools.js +62 -0
- package/package.json +2 -2
package/dist/permissions.js
CHANGED
|
@@ -111,6 +111,7 @@ function planSafe(toolName, verdict) {
|
|
|
111
111
|
case "skill": // loading a skill's instructions is read-only; what it does is gated per-call
|
|
112
112
|
case "todo_write": // planning is exactly what plan mode is FOR — never block it
|
|
113
113
|
case "bash_output": // reading a background task's output observes, never mutates
|
|
114
|
+
case "list_crons": // listing cron jobs is read-only
|
|
114
115
|
case "exit_plan_mode": // the way OUT of plan mode must never be blocked by plan mode
|
|
115
116
|
return true;
|
|
116
117
|
case "run_bash":
|
|
@@ -231,6 +232,16 @@ function basePermission(toolName, argsJson) {
|
|
|
231
232
|
// Polling a background task only reads its captured output and status —
|
|
232
233
|
// it touches nothing on the user's filesystem. Always safe.
|
|
233
234
|
return { decision: "allow", reason: "reads background task output, no side effects", summary: args.task_id ?? "bash_output" };
|
|
235
|
+
case "schedule_cron":
|
|
236
|
+
// Scheduling a cron job writes to .mini-agent/scheduled_tasks.json and
|
|
237
|
+
// drives future work — worth a confirmation prompt.
|
|
238
|
+
return { decision: "ask", reason: "schedules a recurring task", summary: `${args.cron ?? ""} → ${args.prompt ?? ""}` };
|
|
239
|
+
case "list_crons":
|
|
240
|
+
// Listing cron jobs reads in-memory state only — no side effects.
|
|
241
|
+
return { decision: "allow", reason: "read-only, no side effects", summary: "list_crons" };
|
|
242
|
+
case "cancel_cron":
|
|
243
|
+
// Cancelling a cron job modifies internal state and may write to disk.
|
|
244
|
+
return { decision: "ask", reason: "cancels a scheduled task", summary: args.id ?? "cancel_cron" };
|
|
234
245
|
case "exit_plan_mode": {
|
|
235
246
|
// Outside plan mode this tool does nothing — let it through silently. In
|
|
236
247
|
// plan mode it is the approval gate: ask the human, showing the plan, and
|
package/dist/session.js
CHANGED
|
@@ -15,16 +15,17 @@ export function newSessionId() {
|
|
|
15
15
|
// history is not append-only — compaction rewrites it — so a snapshot is the
|
|
16
16
|
// only representation that is always correct. Crash mid-write leaves the
|
|
17
17
|
// previous snapshot intact.
|
|
18
|
-
export function saveSession(id, model, messages) {
|
|
18
|
+
export function saveSession(id, model, messages, title) {
|
|
19
19
|
const dir = sessionsDir();
|
|
20
20
|
fs.mkdirSync(dir, { recursive: true }); // first save creates the directory
|
|
21
21
|
const file = path.join(dir, `${id}.json`); // the real destination
|
|
22
|
-
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
|
|
22
|
+
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt + title
|
|
23
23
|
const data = {
|
|
24
24
|
id,
|
|
25
25
|
startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
|
|
26
26
|
savedAt: new Date().toISOString(), // always refreshed
|
|
27
27
|
model, // informational — resume does not force the same model
|
|
28
|
+
title: title ?? existing?.title, // a generated title sticks across saves
|
|
28
29
|
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
29
30
|
};
|
|
30
31
|
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
@@ -32,6 +33,23 @@ export function saveSession(id, model, messages) {
|
|
|
32
33
|
fs.renameSync(tmp, file); // atomic swap
|
|
33
34
|
prune(); // keep the directory bounded
|
|
34
35
|
}
|
|
36
|
+
// Overwrite just the title of an existing session. Used when the title model
|
|
37
|
+
// finishes AFTER the first save — the next saveSession would otherwise drop it.
|
|
38
|
+
export function setSessionTitle(id, title) {
|
|
39
|
+
const file = path.join(sessionsDir(), `${id}.json`);
|
|
40
|
+
let s;
|
|
41
|
+
try {
|
|
42
|
+
s = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return; // the session hasn't been saved yet — the title will land on the next save
|
|
46
|
+
}
|
|
47
|
+
s.title = title;
|
|
48
|
+
s.savedAt = new Date().toISOString(); // a title is a change; keep the list ordering honest
|
|
49
|
+
const tmp = `${file}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(s)); // atomic, same as saveSession
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
}
|
|
35
53
|
// Session files on disk, newest (most recently saved) first. Shared by every
|
|
36
54
|
// reader below. Skips tmp files and strangers.
|
|
37
55
|
function sessionFilesNewestFirst() {
|
|
@@ -69,7 +87,8 @@ export function loadSession(id) {
|
|
|
69
87
|
// A one-line, human-readable title for a session: the first thing the user
|
|
70
88
|
// actually typed, with any @file attachment block stripped off and the text
|
|
71
89
|
// flattened to a single trimmed line. This is what makes a session list
|
|
72
|
-
// pickable instead of a wall of timestamps.
|
|
90
|
+
// pickable instead of a wall of timestamps. It's the FALLBACK when no concise
|
|
91
|
+
// generated title has been stored (see saveSession title + setSessionTitle).
|
|
73
92
|
export function sessionTitle(messages) {
|
|
74
93
|
const firstUser = messages.find((m) => m.role === "user");
|
|
75
94
|
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
@@ -86,7 +105,7 @@ export function listSessions(limit = 10) {
|
|
|
86
105
|
break;
|
|
87
106
|
try {
|
|
88
107
|
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir(), f), "utf8"));
|
|
89
|
-
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: sessionTitle(s.messages) });
|
|
108
|
+
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: s.title ?? sessionTitle(s.messages) });
|
|
90
109
|
}
|
|
91
110
|
catch {
|
|
92
111
|
/* skip a corrupt file */
|
package/dist/title.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// One cheap model call that turns the user's first prompt into a short,
|
|
2
|
+
// pickable session name — the same idea Claude Code uses (a Haiku-tier call
|
|
3
|
+
// producing a 3-7 word sentence-case title, stored as the session's aiTitle).
|
|
4
|
+
// It runs once, fire-and-forget, after the first real user message, so it never
|
|
5
|
+
// blocks the turn and a failure silently falls back to the raw first prompt.
|
|
6
|
+
const TITLE_SYSTEM = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of the user's request. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.
|
|
7
|
+
Return ONLY the title — no quotes, no leading/trailing punctuation, no explanation.
|
|
8
|
+
|
|
9
|
+
Good examples:
|
|
10
|
+
Fix login button on mobile
|
|
11
|
+
Add OAuth authentication
|
|
12
|
+
Debug failing CI tests
|
|
13
|
+
Refactor API client error handling
|
|
14
|
+
|
|
15
|
+
Bad (too vague): Code changes
|
|
16
|
+
Bad (too long): Investigate and fix the issue where the login button does not respond on mobile devices
|
|
17
|
+
Bad (wrong case): Fix Login Button On Mobile`;
|
|
18
|
+
// Strip the model's inevitable chatter: surrounding quotes, trailing periods,
|
|
19
|
+
// markdown fences, and any multi-line prose. Pure + lenient on purpose.
|
|
20
|
+
export function cleanSessionTitle(text) {
|
|
21
|
+
let t = text.replace(/^```[^\n]*\n?|\n?```$/g, ""); // drop a markdown fence if present
|
|
22
|
+
t = t.split("\n")[0] ?? ""; // first line only
|
|
23
|
+
t = t.replace(/^[\s"'“”‘’]+|[\s"'“”‘’.。]+$/g, "").trim(); // strip quotes + trailing punctuation
|
|
24
|
+
return t.replace(/\s+/g, " ").trim();
|
|
25
|
+
}
|
|
26
|
+
// Generate a session title from the first user message. Returns null on any
|
|
27
|
+
// failure (bad model response, network error) — callers fall back to the prompt.
|
|
28
|
+
export async function generateSessionTitle(client, model, firstPrompt) {
|
|
29
|
+
const trimmed = firstPrompt.replace(/\s+/g, " ").trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
return null;
|
|
32
|
+
let text = "";
|
|
33
|
+
try {
|
|
34
|
+
const res = await client.chat.completions.create({
|
|
35
|
+
model,
|
|
36
|
+
messages: [
|
|
37
|
+
{ role: "system", content: TITLE_SYSTEM },
|
|
38
|
+
{ role: "user", content: trimmed },
|
|
39
|
+
],
|
|
40
|
+
stream: false,
|
|
41
|
+
});
|
|
42
|
+
text = res.choices?.[0]?.message?.content ?? "";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null; // a failed title must never surface or break the session
|
|
46
|
+
}
|
|
47
|
+
const title = cleanSessionTitle(text);
|
|
48
|
+
return title || null;
|
|
49
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { setPlanMode } from "./permissions.js"; // plan mode (Day 20): exit_plan
|
|
|
7
7
|
import { recordMutation } from "./undo.js"; // capture the before-state so /undo (Day 22) can restore it
|
|
8
8
|
import { parseTodos, setTodos, summarizeTodos } from "./todos.js"; // the agent's own checklist (Day 36)
|
|
9
9
|
import { startBackground, readBackground } from "./background.js"; // long-running commands that outlive the turn (Day 37)
|
|
10
|
+
import { scheduleJob, cancelJob, listJobs } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
|
|
10
11
|
// ============ Session state ============
|
|
11
12
|
// The foundation of "read before edit": which files has this session actually read?
|
|
12
13
|
// Files that were never read must not be edited — otherwise the model edits from
|
|
@@ -264,6 +265,59 @@ Boundaries & permissions are identical to run_bash (dangerous commands are still
|
|
|
264
265
|
// runs forever (npm run dev) never "finishes", so the only way to see "ready on
|
|
265
266
|
// :3000" is to read its output. Reading advances a per-task cursor, so repeated
|
|
266
267
|
// polls show only what's new, not the whole log every time.
|
|
268
|
+
// ============ schedule_cron (Day s14) ============
|
|
269
|
+
const scheduleCron = {
|
|
270
|
+
definition: def("schedule_cron", `Schedule a recurring or one-shot task using a 5-field cron expression. When it fires, YOU are woken with the prompt below, you do the task, and you report the result to the user — even while they're idle. This is the RIGHT tool for "every morning at 9 run the tests", "every 5 minutes check CI", "every minute print X" — anything that should run on a schedule AND report back. Prefer it over run_bash_background for such requests: a background job's output is captured, not shown, so the user won't see it.
|
|
271
|
+
Boundaries: max 50 jobs; the scheduler checks every second and delivers the prompt when the cron matches.
|
|
272
|
+
GRANULARITY: cron's finest interval is ONE MINUTE. Sub-minute schedules ("every 10 seconds") are NOT possible — tell the user that, and offer the nearest option (every minute) or, if they truly need higher frequency to watch live, suggest they run a foreground loop in their own shell.
|
|
273
|
+
Cron format: minute hour day-of-month month day-of-week. Supports *, */N, N, N-M, N,M,….
|
|
274
|
+
Examples: "* * * * *" = every minute; "0 9 * * *" = every day at 9:00; "*/5 * * * *" = every 5 minutes; "0 9 * * 1-5" = weekdays at 9:00.
|
|
275
|
+
|
|
276
|
+
IMPORTANT — the "prompt" parameter is what you (the agent) will receive when the job fires.
|
|
277
|
+
Put the user's FULL INTENT here — a complete self-contained instruction that tells you what to do.
|
|
278
|
+
Do NOT just repeat the cron expression — describe the actual task in natural language, as if the user
|
|
279
|
+
was saying it to you directly. Include what to execute, what to check, and how to report back.
|
|
280
|
+
|
|
281
|
+
Examples of good prompts:
|
|
282
|
+
"Print 'ftfw' to the terminal by running echo ftfw, then report the output."
|
|
283
|
+
"Run 'npm test' and report how many tests passed/failed."
|
|
284
|
+
"Check if the dev server on :3000 is reachable via curl, report UP or DOWN."
|
|
285
|
+
"Check disk usage with df -h, report the / partition usage percentage."`, {
|
|
286
|
+
cron: { type: "string", description: '5-field cron expression: "0 9 * * *" = daily at 9am, "*/5 * * * *" = every 5 min' },
|
|
287
|
+
prompt: { type: "string", description: "The FULL task instruction you will receive when this fires — a self-contained description of what to do and how to report results" },
|
|
288
|
+
recurring: { type: "string", description: '"true" (default) = repeat, "false" = one-shot' },
|
|
289
|
+
durable: { type: "string", description: '"true" (default) = persist to disk, "false" = session-only' },
|
|
290
|
+
}, ["cron", "prompt"]),
|
|
291
|
+
run: (args) => {
|
|
292
|
+
const recurring = args.recurring !== "false";
|
|
293
|
+
const durable = args.durable !== "false";
|
|
294
|
+
const result = scheduleJob(args.cron, args.prompt, recurring, durable);
|
|
295
|
+
if (typeof result === "string") {
|
|
296
|
+
return `Scheduled cron job ${result}: "${args.cron}" → "${args.prompt}"${recurring ? " (recurring)" : " (one-shot)"}${durable ? " (durable)" : " (session-only)"}`;
|
|
297
|
+
}
|
|
298
|
+
return fail(result.error);
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
// ============ list_crons (Day s14) ============
|
|
302
|
+
const listCrons = {
|
|
303
|
+
definition: def("list_crons", "List all currently scheduled cron jobs (both durable and session-only). Returns id, cron expression, prompt, and flags.", {}, []),
|
|
304
|
+
run: () => {
|
|
305
|
+
const jobs = listJobs();
|
|
306
|
+
if (!jobs.length)
|
|
307
|
+
return "No cron jobs scheduled.";
|
|
308
|
+
return jobs
|
|
309
|
+
.map((j) => `${j.id}: "${j.cron}" → "${j.prompt}"${j.recurring ? " (recurring)" : " (one-shot)"}${j.durable ? " (durable)" : " (session-only)"}`)
|
|
310
|
+
.join("\n");
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
// ============ cancel_cron (Day s14) ============
|
|
314
|
+
const cancelCron = {
|
|
315
|
+
definition: def("cancel_cron", "Cancel a scheduled cron job by its id (from list_crons). Returns whether the job was found and removed.", { id: { type: "string", description: "The cron job id to cancel, e.g. 'cron_1'" } }, ["id"]),
|
|
316
|
+
run: (args) => {
|
|
317
|
+
const ok = cancelJob(args.id);
|
|
318
|
+
return ok ? `Cancelled cron job ${args.id}.` : fail(`Cron job not found: ${args.id}. Use list_crons to see scheduled jobs.`);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
267
321
|
const bashOutput = {
|
|
268
322
|
definition: def("bash_output", `Check on a background task started with run_bash_background: returns its status and any NEW output since you last checked.
|
|
269
323
|
When to use: to see if a long job is done, or to read a long-running server's output (e.g. confirm "listening on :3000"). A finished task also sends you a <task_notification> automatically — you don't need to poll just to learn it ended.
|
|
@@ -346,6 +400,9 @@ export const tools = {
|
|
|
346
400
|
run_bash: runBash,
|
|
347
401
|
run_bash_background: runBashBackground,
|
|
348
402
|
bash_output: bashOutput,
|
|
403
|
+
schedule_cron: scheduleCron,
|
|
404
|
+
list_crons: listCrons,
|
|
405
|
+
cancel_cron: cancelCron,
|
|
349
406
|
exit_plan_mode: exitPlanMode,
|
|
350
407
|
todo_write: todoWrite,
|
|
351
408
|
};
|
|
@@ -355,6 +412,11 @@ export const tools = {
|
|
|
355
412
|
export function registerExternalTool(tool) {
|
|
356
413
|
tools[tool.definition.function.name] = tool;
|
|
357
414
|
}
|
|
415
|
+
// Remove an external tool by name (used when an MCP server is disconnected or
|
|
416
|
+
// reconnected — its stale tools must never stay registered under the same name).
|
|
417
|
+
export function unregisterExternalTool(name) {
|
|
418
|
+
delete tools[name];
|
|
419
|
+
}
|
|
358
420
|
// The manuals sent to the model. A function (not a const) since Day 15: MCP
|
|
359
421
|
// tools register after module load, so the list must be computed when asked.
|
|
360
422
|
export function toolDefinitions() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"eval": "tsx --env-file=.env eval/run.ts",
|
|
18
18
|
"build": "tsc -p tsconfig.build.json",
|
|
19
19
|
"prepare": "npm run build",
|
|
20
|
-
"test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts"
|
|
20
|
+
"test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20.6"
|