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.
@@ -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.32.4",
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"