agent-from-zero 0.32.3 → 0.33.1

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 CHANGED
@@ -31,6 +31,7 @@ import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default fro
31
31
  import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
32
32
  import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
33
33
  import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
34
+ import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "./cron.js"; // cron scheduler (Day s14)
34
35
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
35
36
  const pkg = createRequire(import.meta.url)("../package.json");
36
37
  // ---- CLI arguments --------------------------------------------------------------
@@ -75,6 +76,7 @@ const SESSION_HELP = `commands:
75
76
  /bg list background tasks this session (run_bash_background) and their status
76
77
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
77
78
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
79
+ /cron list scheduled cron jobs (schedule_cron) and their expressions
78
80
  /undo revert the most recent file write (write_file / edit_file) this session
79
81
  /diff show every file changed this session, as a diff from where it started
80
82
  /resume list recent sessions in this project and continue one of them
@@ -156,6 +158,13 @@ async function main() {
156
158
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
157
159
  process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
158
160
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
161
+ process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
162
+ // Cron scheduler (Day s14): restore durable jobs from disk, start the 1s poll.
163
+ loadDurableJobs();
164
+ const cronCount = listJobs().length;
165
+ if (cronCount)
166
+ console.log(chalk.dim(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`));
167
+ startCronScheduler();
159
168
  // The optional LLM permission judge, built once if a settings file enabled it.
160
169
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
161
170
  if (judge)
@@ -505,6 +514,20 @@ async function main() {
505
514
  console.log(summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`));
506
515
  return true;
507
516
  }
517
+ case "/cron": {
518
+ // Scheduled cron jobs (schedule_cron): list all currently registered.
519
+ const jobs = listJobs();
520
+ if (!jobs.length) {
521
+ console.log(chalk.dim("(no cron jobs scheduled)"));
522
+ }
523
+ else {
524
+ console.log(chalk.dim(`cron jobs (${jobs.length}):`));
525
+ for (const j of jobs) {
526
+ console.log(chalk.dim(` ${j.id} "${j.cron}" → "${j.prompt}"${j.recurring ? " (recurring)" : " (one-shot)"}${j.durable ? " (durable)" : " (session-only)"}`));
527
+ }
528
+ }
529
+ return true;
530
+ }
508
531
  case "/stats":
509
532
  console.log(chalk.dim(statsReport())); // local counters, busiest first
510
533
  return true;
package/dist/cron.js ADDED
@@ -0,0 +1,280 @@
1
+ // ============ Cron Scheduler (Day s14) ============
2
+ // The problem: the agent only acts when the user says something. "Run tests every
3
+ // morning at 9" or "Check CI every 30 minutes" shouldn't need a human to push.
4
+ //
5
+ // Solution, mirroring Claude Code: an independent scheduler that checks cron
6
+ // expressions every second, a queue that holds triggered jobs, and a queue
7
+ // processor that delivers them when the agent is idle. Four layers:
8
+ //
9
+ // Scheduler — setInterval, 1s poll, writes to cron_queue
10
+ // Queue — cron_queue, the handoff from scheduler to processor
11
+ // Queue Proc. — watches cron_queue; when non-empty + agent idle, triggers a turn
12
+ // Consumer — runLoop consumes cron_queue, injects "[Scheduled] ..." messages
13
+ //
14
+ // Durable jobs are written to .mini-agent/scheduled_tasks.json and survive
15
+ // restarts (the scheduler re-loads them next time). Session-only jobs live only
16
+ // in memory — when the process exits, they are gone.
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ const SCHEDULED_TASKS_FILE = ".mini-agent/scheduled_tasks.json";
20
+ const MAX_JOBS = 50;
21
+ // In-memory registry: all jobs scheduled this session.
22
+ const scheduledJobs = new Map();
23
+ // The handoff: scheduler writes here, consumer drains.
24
+ const cronQueue = [];
25
+ // Prevent the scheduler from firing the same job twice in the same minute.
26
+ const lastFired = new Map(); // job id → "YYYY-MM-DD HH:MM"
27
+ // Lock for the cron queue and scheduled jobs map.
28
+ let cronLock = false;
29
+ function withCronLock(fn) {
30
+ // Node is single-threaded, so this lock is simpler than a real mutex.
31
+ // It's here for correctness compatibility with the Python reference.
32
+ cronLock = true;
33
+ try {
34
+ return fn();
35
+ }
36
+ finally {
37
+ cronLock = false;
38
+ }
39
+ }
40
+ // ============ Cron expression matching ============
41
+ // Standard 5-field cron: minute hour day-of-month month day-of-week.
42
+ // Day-of-month AND day-of-week both constrained → OR semantics (either matches).
43
+ function cronFieldMatches(field, value) {
44
+ if (field === "*")
45
+ return true;
46
+ // Comma-separated list: "1,3,5"
47
+ const parts = field.split(",");
48
+ for (const part of parts) {
49
+ if (part.includes("/")) {
50
+ // Step: "*/5" or "0/5"
51
+ const [base, stepStr] = part.split("/");
52
+ const step = parseInt(stepStr, 10);
53
+ if (isNaN(step) || step < 1)
54
+ continue;
55
+ if (base === "*") {
56
+ if (value % step === 0)
57
+ return true;
58
+ }
59
+ else {
60
+ const start = parseInt(base, 10);
61
+ if (!isNaN(start) && value >= start && (value - start) % step === 0)
62
+ return true;
63
+ }
64
+ }
65
+ else if (part.includes("-")) {
66
+ // Range: "1-5"
67
+ const [lo, hi] = part.split("-").map(Number);
68
+ if (!isNaN(lo) && !isNaN(hi) && value >= lo && value <= hi)
69
+ return true;
70
+ }
71
+ else {
72
+ // Exact: "5"
73
+ if (parseInt(part, 10) === value)
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ export function cronMatches(cronExpr, dt) {
80
+ const fields = cronExpr.trim().split(/\s+/);
81
+ if (fields.length !== 5)
82
+ return false;
83
+ const [minute, hour, dom, month, dow] = fields;
84
+ // Python Monday=0 → cron Sunday=0. Node: getDay() Sunday=0 Monday=1 …
85
+ // So weekday() + 1 in Python where Monday=0, but Node getDay() already
86
+ // has Sunday=0. So Node's getDay() IS cron DOW. No conversion needed.
87
+ const dowVal = dt.getDay();
88
+ const m = cronFieldMatches(minute, dt.getMinutes());
89
+ const h = cronFieldMatches(hour, dt.getHours());
90
+ const monthOk = cronFieldMatches(month, dt.getMonth() + 1); // cron months are 1-based
91
+ if (!m || !h || !monthOk)
92
+ return false;
93
+ const domOk = cronFieldMatches(dom, dt.getDate());
94
+ const dowOk = cronFieldMatches(dow, dowVal);
95
+ const domUnconstrained = dom === "*";
96
+ const dowUnconstrained = dow === "*";
97
+ if (domUnconstrained && dowUnconstrained)
98
+ return true;
99
+ if (domUnconstrained)
100
+ return dowOk;
101
+ if (dowUnconstrained)
102
+ return domOk;
103
+ return domOk || dowOk; // OR semantics
104
+ }
105
+ // ============ Validation ============
106
+ export function validateCron(cronExpr) {
107
+ const fields = cronExpr.trim().split(/\s+/);
108
+ if (fields.length !== 5)
109
+ return `Cron expression must have 5 fields (min hour dom month dow), got ${fields.length}`;
110
+ for (const field of fields) {
111
+ if (!/^[*\d,\-\/]+$/.test(field))
112
+ return `Invalid cron field: "${field}"`;
113
+ }
114
+ // Try matching against a known time to catch structural errors.
115
+ try {
116
+ cronMatches(cronExpr, new Date());
117
+ }
118
+ catch {
119
+ return `Could not parse cron expression: "${cronExpr}"`;
120
+ }
121
+ return null; // valid
122
+ }
123
+ // ============ Job management ============
124
+ let jobSeq = 0;
125
+ export function scheduleJob(cron, prompt, recurring = true, durable = true) {
126
+ const err = validateCron(cron);
127
+ if (err)
128
+ return { error: err };
129
+ if (scheduledJobs.size >= MAX_JOBS)
130
+ return { error: `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.` };
131
+ const id = `cron_${++jobSeq}`;
132
+ const job = {
133
+ id,
134
+ cron: cron.trim(),
135
+ prompt,
136
+ recurring,
137
+ durable,
138
+ createdAt: Date.now(),
139
+ };
140
+ withCronLock(() => {
141
+ scheduledJobs.set(id, job);
142
+ if (durable)
143
+ saveDurableJobs();
144
+ });
145
+ return id;
146
+ }
147
+ export function cancelJob(id) {
148
+ let found = false;
149
+ withCronLock(() => {
150
+ found = scheduledJobs.delete(id);
151
+ lastFired.delete(id);
152
+ if (found)
153
+ saveDurableJobs();
154
+ });
155
+ return found;
156
+ }
157
+ export function listJobs() {
158
+ return [...scheduledJobs.values()].sort((a, b) => a.createdAt - b.createdAt);
159
+ }
160
+ // The turn content a fired job injects: the job's own prompt, wrapped with the
161
+ // instruction to DO it now and report the result visibly, then go back to idle.
162
+ // Shared by both consumers — the in-turn one (injectCronMessages in loop.ts, for
163
+ // a job that fires mid-task) and the idle one (the REPL's idle processor, for a
164
+ // job that fires while the user is at the prompt) — so they behave identically.
165
+ export function cronTriggerContent(job) {
166
+ return [
167
+ `[Cron job "${job.id}" triggered — ${job.cron}]`,
168
+ ``,
169
+ `${job.prompt}`,
170
+ ``,
171
+ `Execute this now. When done, report the result directly to the user — make it visible and clear.`,
172
+ `If the task produces output (e.g. a shell command result, a file change, a value), show it.`,
173
+ `If there's nothing to show, confirm success with one line.`,
174
+ ``,
175
+ `After this, return to idle — do NOT start additional work unless the user asked for a chain of follow-ups.`,
176
+ ].join("\n");
177
+ }
178
+ // ============ Cron queue ============
179
+ export function hasCronQueue() {
180
+ return cronQueue.length > 0;
181
+ }
182
+ export function consumeCronQueue() {
183
+ const fired = [];
184
+ withCronLock(() => {
185
+ while (cronQueue.length > 0) {
186
+ const job = cronQueue.shift();
187
+ fired.push(job);
188
+ }
189
+ });
190
+ return fired;
191
+ }
192
+ // ============ Durable storage ============
193
+ function durableJobsPath() {
194
+ return path.resolve(SCHEDULED_TASKS_FILE);
195
+ }
196
+ function saveDurableJobs() {
197
+ const durable = [...scheduledJobs.values()].filter((j) => j.durable);
198
+ const payload = { tasks: durable };
199
+ try {
200
+ fs.mkdirSync(path.dirname(durableJobsPath()), { recursive: true });
201
+ fs.writeFileSync(durableJobsPath(), JSON.stringify(payload, null, 2), "utf8");
202
+ }
203
+ catch {
204
+ // Non-fatal: disk writes can fail; the in-memory state is still correct.
205
+ }
206
+ }
207
+ export function loadDurableJobs() {
208
+ const p = durableJobsPath();
209
+ if (!fs.existsSync(p))
210
+ return;
211
+ let data;
212
+ try {
213
+ data = JSON.parse(fs.readFileSync(p, "utf8"));
214
+ }
215
+ catch {
216
+ return; // corrupt file → ignore (the model can re-schedule)
217
+ }
218
+ if (!data.tasks || !Array.isArray(data.tasks))
219
+ return;
220
+ for (const raw of data.tasks) {
221
+ const err = validateCron(raw.cron);
222
+ if (err)
223
+ continue; // skip bad expressions so one bad job doesn't block startup
224
+ const job = {
225
+ id: raw.id,
226
+ cron: raw.cron.trim(),
227
+ prompt: raw.prompt,
228
+ recurring: raw.recurring ?? true,
229
+ durable: raw.durable ?? true,
230
+ createdAt: raw.createdAt || Date.now(),
231
+ };
232
+ scheduledJobs.set(job.id, job);
233
+ // Track the highest seq so new ids don't collide with loaded ones.
234
+ const match = job.id.match(/^cron_(\d+)$/);
235
+ if (match)
236
+ jobSeq = Math.max(jobSeq, parseInt(match[1], 10));
237
+ }
238
+ }
239
+ // ============ Scheduler loop ============
240
+ let schedulerInterval = null;
241
+ export function startCronScheduler() {
242
+ if (schedulerInterval)
243
+ return; // already running
244
+ schedulerInterval = setInterval(() => {
245
+ const now = new Date();
246
+ const minuteMarker = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
247
+ withCronLock(() => {
248
+ for (const job of [...scheduledJobs.values()]) {
249
+ try {
250
+ if (cronMatches(job.cron, now)) {
251
+ if (lastFired.get(job.id) !== minuteMarker) {
252
+ cronQueue.push(job);
253
+ lastFired.set(job.id, minuteMarker);
254
+ if (!job.recurring) {
255
+ scheduledJobs.delete(job.id);
256
+ }
257
+ if (job.durable) {
258
+ saveDurableJobs();
259
+ }
260
+ }
261
+ }
262
+ }
263
+ catch {
264
+ // A single bad job must never kill the scheduler.
265
+ }
266
+ }
267
+ });
268
+ }, 1000);
269
+ }
270
+ export function stopCronScheduler() {
271
+ if (schedulerInterval) {
272
+ clearInterval(schedulerInterval);
273
+ schedulerInterval = null;
274
+ }
275
+ }
276
+ // Called by the queue processor — returns true if the agent was idle and we
277
+ // consumed cron items (meaning: the caller should trigger a new turn).
278
+ export function cronItemsPending() {
279
+ return cronQueue.length > 0;
280
+ }
package/dist/ink/app.js CHANGED
@@ -16,10 +16,12 @@ import { resetBoard } from "../board.js";
16
16
  import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as tuiCleanup } from "../tui.js";
17
17
  import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
18
18
  import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
19
+ import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
19
20
  import { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
20
21
  import { isPlanMode, setPlanMode } from "../permissions.js";
21
22
  import { findSkill, skillInstructions } from "../skills.js";
22
23
  import { extractMemories } from "../memory.js";
24
+ import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
23
25
  import { runHooks } from "../hooks.js";
24
26
  import { emit } from "../telemetry.js";
25
27
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
@@ -98,6 +100,7 @@ export function App({ session, runTurn }) {
98
100
  const [status, setStatus] = useState(null); // the live spinner line
99
101
  const [live, setLive] = useState(null); // the streaming answer
100
102
  const [input, setInput] = useState(""); // the current input buffer
103
+ const [cursor, setCursor] = useState(0); // caret position WITHIN `input` (0..input.length), for ←/→ editing
101
104
  const [busy, setBusy] = useState(false); // a turn is in flight
102
105
  const [pending, setPending] = useState(null); // a prompt/menu blocking input
103
106
  const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
@@ -181,6 +184,29 @@ export function App({ session, runTurn }) {
181
184
  setLive(null);
182
185
  });
183
186
  };
187
+ // The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
188
+ // injectCronMessages only drains it mid-turn — so a job that fires while you're
189
+ // at the prompt would never run on its own. Here, while idle (no turn, no menu),
190
+ // we poll the queue and start a turn for any fired job: the agent does the work
191
+ // and reports the result into the conversation, autonomously — like Claude Code.
192
+ // Effect re-subscribes on busy/pending changes so the closure always sees the
193
+ // current `runConversationTurn`; it only installs the poll when truly idle.
194
+ useEffect(() => {
195
+ if (busy || pending)
196
+ return; // only fire scheduled work when the agent is idle
197
+ const id = setInterval(() => {
198
+ if (!cronItemsPending())
199
+ return;
200
+ const fired = consumeCronQueue();
201
+ if (!fired.length)
202
+ return;
203
+ const content = fired.map(cronTriggerContent).join("\n\n---\n\n");
204
+ const label = fired.length === 1 ? `⏰ cron ${fired[0].id} fired (${fired[0].cron})` : `⏰ ${fired.length} cron jobs fired`;
205
+ runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }); // a cron NOTE (not a user bar); the agent runs it + reports
206
+ }, 2000);
207
+ return () => clearInterval(id);
208
+ // eslint-disable-next-line react-hooks/exhaustive-deps
209
+ }, [busy, pending]);
184
210
  // A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
185
211
  // mentions, then start the turn. Mirrors agent.ts's submit path.
186
212
  const submitLine = async (text) => {
@@ -465,6 +491,7 @@ export function App({ session, runTurn }) {
465
491
  setSubAgentFocus(null);
466
492
  const text = input.trim();
467
493
  setInput("");
494
+ setCursor(0);
468
495
  setHistIdx(null);
469
496
  if (!text)
470
497
  return;
@@ -472,13 +499,26 @@ export function App({ session, runTurn }) {
472
499
  history.push(text); // remember non-empty entries for ↑/↓
473
500
  void submitLine(text);
474
501
  }
502
+ else if (key.leftArrow) {
503
+ setCursor((c) => Math.max(0, c - 1)); // move the caret left within the line
504
+ }
505
+ else if (key.rightArrow) {
506
+ setCursor((c) => Math.min(input.length, c + 1)); // ...and right
507
+ }
508
+ else if (key.ctrl && char === "a") {
509
+ setCursor(0); // Home — jump to the start of the line
510
+ }
511
+ else if (key.ctrl && char === "e") {
512
+ setCursor(input.length); // End — jump to the end of the line
513
+ }
475
514
  else if (key.upArrow) {
476
- // ↑ recall an earlier prompt (newest-first), like a shell.
515
+ // ↑ recall an earlier prompt (newest-first), like a shell; caret to its end.
477
516
  if (!history.length)
478
517
  return;
479
518
  const idx = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
480
519
  setHistIdx(idx);
481
520
  setInput(history[idx]);
521
+ setCursor(history[idx].length);
482
522
  }
483
523
  else if (key.downArrow) {
484
524
  // ↓ walk back toward the fresh line.
@@ -488,24 +528,96 @@ export function App({ session, runTurn }) {
488
528
  if (idx >= history.length) {
489
529
  setHistIdx(null);
490
530
  setInput("");
531
+ setCursor(0);
491
532
  }
492
533
  else {
493
534
  setHistIdx(idx);
494
535
  setInput(history[idx]);
536
+ setCursor(history[idx].length);
495
537
  }
496
538
  }
497
539
  else if (key.backspace || key.delete) {
498
- setInput((s) => s.slice(0, -1));
540
+ // Delete the char BEFORE the caret. Both Backspace and macOS DEL land here,
541
+ // so this is always a backward delete (forward-delete is rare; we skip it).
542
+ if (cursor > 0) {
543
+ setInput(input.slice(0, cursor - 1) + input.slice(cursor));
544
+ setCursor(cursor - 1);
545
+ }
499
546
  }
500
547
  else if (char && !key.ctrl && !key.meta) {
501
- // A bulk chunk (a paste / a dropped file path) is normalized: a dropped
502
- // file becomes a clean absolute path; ordinary pasted prose is unchanged.
548
+ // Insert at the caret (a bulk chunk — paste / dropped file path — is
549
+ // normalized: a dropped file becomes a clean absolute path; prose unchanged).
503
550
  const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
504
551
  setHistIdx(null);
505
- setInput((s) => s + add);
552
+ setInput(input.slice(0, cursor) + add + input.slice(cursor));
553
+ setCursor(cursor + add.length);
506
554
  }
507
555
  });
508
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), (() => {
556
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, flexDirection: "column", children: (() => {
557
+ const prompt = planMode ? "plan ❯ " : "❯ ";
558
+ const promptColor = planMode ? "magenta" : "cyan";
559
+ const cols = process.stdout.columns || 80;
560
+ const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
561
+ const promptW = displayWidth(prompt);
562
+ const firstW = Math.max(1, innerWidth - promptW);
563
+ // Build visual lines display-width-aware (CJK, emoji = 2 cols).
564
+ const lines = [""];
565
+ let row = 0;
566
+ let col = 0;
567
+ // Cursor tracking: map code-unit offset → (line, display-column).
568
+ let cursorLine = 0;
569
+ let cursorCol = 0;
570
+ let set = false;
571
+ let cu = 0; // code-unit offset, for matching `cursor`
572
+ for (const ch of input) {
573
+ if (!set && cu === cursor) {
574
+ cursorLine = row;
575
+ cursorCol = col;
576
+ set = true;
577
+ }
578
+ const cw = displayWidth(ch);
579
+ const limit = row === 0 ? firstW : innerWidth;
580
+ if (col + cw > limit) {
581
+ row++;
582
+ col = 0;
583
+ lines[row] = "";
584
+ }
585
+ lines[row] += ch;
586
+ col += cw;
587
+ cu += ch.length;
588
+ }
589
+ if (!set) {
590
+ cursorLine = row;
591
+ cursorCol = col;
592
+ } // cursor at end
593
+ // Cursor exactly at edge → wrap to start of next line for visibility
594
+ const edge = cursorLine === 0 ? firstW : innerWidth;
595
+ if (cursorCol >= edge) {
596
+ cursorLine++;
597
+ cursorCol = 0;
598
+ if (lines.length <= cursorLine)
599
+ lines.push("");
600
+ }
601
+ // Map display-column back to code-unit offset within the target line.
602
+ const cursorLineText = lines[cursorLine] ?? "";
603
+ let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
604
+ let dcol = 0;
605
+ for (const ch of cursorLineText) {
606
+ if (dcol >= cursorCol)
607
+ break;
608
+ dcol += displayWidth(ch);
609
+ cuOff += ch.length;
610
+ }
611
+ return lines.map((line, i) => {
612
+ if (i === cursorLine) {
613
+ const before = line.slice(0, cuOff);
614
+ const at = line.slice(cuOff, cuOff + 1) || " ";
615
+ const after = line.slice(cuOff + 1);
616
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
617
+ }
618
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
619
+ });
620
+ })() }), (() => {
509
621
  const agents = listSubAgents();
510
622
  if (!agents.length)
511
623
  return null;
@@ -7,6 +7,7 @@ import { getTodos, renderTodos } from "../todos.js";
7
7
  import { listBackground } from "../background.js";
8
8
  import { listTeam } from "../team.js";
9
9
  import { boardSummary } from "../board.js";
10
+ import { listJobs } from "../cron.js"; // cron scheduler (Day s14)
10
11
  import { undoLast, sessionChanges } from "../undo.js";
11
12
  import { renderDiff } from "../diff.js";
12
13
  // The non-interactive slash commands: each produces a block of text to commit as
@@ -28,6 +29,7 @@ export const SESSION_HELP = `commands:
28
29
  /bg list background tasks this session (run_bash_background) and their status
29
30
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
30
31
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
32
+ /cron list scheduled cron jobs (schedule_cron) and their expressions
31
33
  /undo revert the most recent file write (write_file / edit_file) this session
32
34
  /diff show every file changed this session, as a diff from where it started
33
35
  /resume list recent sessions in this project and continue one of them
@@ -77,6 +79,12 @@ export function runInfoCommand(line, ctx) {
77
79
  const summary = boardSummary();
78
80
  return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
79
81
  }
82
+ case "/cron": {
83
+ const jobs = listJobs();
84
+ if (!jobs.length)
85
+ return chalk.dim("(no cron jobs scheduled)");
86
+ return chalk.dim(`cron jobs (${jobs.length}):\n`) + jobs.map((j) => chalk.dim(` ${j.id} "${j.cron}" → "${j.prompt}"${j.recurring ? " (recurring)" : " (one-shot)"}${j.durable ? " (durable)" : " (session-only)"}`)).join("\n");
87
+ }
80
88
  case "/skills": {
81
89
  if (!ctx.skills.length)
82
90
  return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
package/dist/ink/setup.js CHANGED
@@ -16,6 +16,7 @@ import { rememberTool, readMemory } from "../memory.js";
16
16
  import { loadSkills, buildSkillTool } from "../skills.js";
17
17
  import { killAllBackground } from "../background.js";
18
18
  import { killAllSubAgents } from "../loop.js";
19
+ import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
19
20
  import { banner } from "../ui.js";
20
21
  // The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
21
22
  // does before the prompt loop, but front-end-agnostic: it touches none of
@@ -53,6 +54,13 @@ export async function buildInkSession(opts = {}) {
53
54
  process.on("exit", disconnectMcp);
54
55
  process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
55
56
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
57
+ process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
58
+ // Cron scheduler (Day s14): restore durable jobs, start polling.
59
+ loadDurableJobs();
60
+ const cronCount = listJobs().length;
61
+ if (cronCount)
62
+ notices.push(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`);
63
+ startCronScheduler();
56
64
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
57
65
  if (judge)
58
66
  notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
package/dist/loop.js CHANGED
@@ -15,6 +15,7 @@ import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call
15
15
  import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
16
16
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
17
17
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
18
+ import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
18
19
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
19
20
  export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
20
21
  export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
@@ -1003,6 +1004,24 @@ compaction) {
1003
1004
  // Top-level only: a sub-agent has no separate turn loop to inject into, and the
1004
1005
  // notification belongs to the agent that started the job. Returns true if it
1005
1006
  // injected something. Each finished task notifies exactly once (background.ts).
1007
+ // Cron queue consumer: drain triggered cron jobs into the messages array.
1008
+ // The scheduler thread writes to cron_queue independently; we drain it here
1009
+ // before each round. Sub-agents do not process cron — the top-level does.
1010
+ function injectCronMessages(messages, opts) {
1011
+ if (opts.subAgent)
1012
+ return false; // sub-agents don't process cron
1013
+ if (!cronItemsPending())
1014
+ return false;
1015
+ const fired = consumeCronQueue();
1016
+ let count = 0;
1017
+ for (const job of fired) {
1018
+ messages.push({ role: "user", content: cronTriggerContent(job) }); // shared with the REPL's idle processor
1019
+ count++;
1020
+ }
1021
+ if (count && !opts.quiet)
1022
+ sink(opts).note(chalk.cyan(`⏰ cron: ${count} job${count > 1 ? "s" : ""} fired`));
1023
+ return count > 0;
1024
+ }
1006
1025
  function injectBackgroundNotifications(messages, opts) {
1007
1026
  if (opts.subAgent)
1008
1027
  return false; // notifications belong to the top-level conversation
@@ -1252,6 +1271,8 @@ opts) {
1252
1271
  // model decided to stop. Don't return with an unread result on the table:
1253
1272
  // surface it and run one more round so the model can react. Each notifies
1254
1273
  // once, so this adds at most one round per finished job — never infinite.
1274
+ if (injectCronMessages(messages, opts))
1275
+ break; // cron triggered → run another round
1255
1276
  if (injectBackgroundNotifications(messages, opts))
1256
1277
  break; // exit the inner while → advance the round counter
1257
1278
  if (injectSubAgentResults(messages, opts))
@@ -1295,6 +1316,9 @@ opts) {
1295
1316
  if (!opts.quiet && out.toolCalls.length) {
1296
1317
  sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1297
1318
  }
1319
+ // Cron (Day s14): deliver any triggered scheduled jobs right after this
1320
+ // round's results so the model sees them on the next round.
1321
+ injectCronMessages(messages, opts);
1298
1322
  // Background tasks (Day 37): if a job the model started has finished, slip
1299
1323
  // its <task_notification> in here, right after this round's tool results —
1300
1324
  // the model sees the outcome on the next round without ever having blocked
@@ -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/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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.32.3",
3
+ "version": "0.33.1",
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/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"