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/ink/app.js CHANGED
@@ -16,14 +16,18 @@ 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 { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
19
+ import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
20
+ import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
21
+ import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
20
22
  import { isPlanMode, setPlanMode } from "../permissions.js";
21
23
  import { findSkill, skillInstructions } from "../skills.js";
22
24
  import { extractMemories } from "../memory.js";
25
+ import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
23
26
  import { runHooks } from "../hooks.js";
24
27
  import { emit } from "../telemetry.js";
25
28
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
26
- import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
29
+ import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
30
+ import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
27
31
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
28
32
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
29
33
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
@@ -115,6 +119,8 @@ export function App({ session, runTurn }) {
115
119
  const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
116
120
  const history = useRef([]).current; // past prompts, for ↑/↓ recall
117
121
  const turn = useRef(null); // the in-flight turn, for Esc-interrupt
122
+ const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
123
+ const pendingTitle = useRef(undefined); // the generated title, passed into the next save
118
124
  useEffect(() => {
119
125
  const id = setInterval(() => setTick((t) => t + 1), 1000);
120
126
  return () => clearInterval(id);
@@ -162,7 +168,7 @@ export function App({ session, runTurn }) {
162
168
  .then(async (result) => {
163
169
  if (result.reason !== TerminateReason.Done)
164
170
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
165
- saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
171
+ saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
166
172
  if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
167
173
  try {
168
174
  const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
@@ -182,6 +188,29 @@ export function App({ session, runTurn }) {
182
188
  setLive(null);
183
189
  });
184
190
  };
191
+ // The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
192
+ // injectCronMessages only drains it mid-turn — so a job that fires while you're
193
+ // at the prompt would never run on its own. Here, while idle (no turn, no menu),
194
+ // we poll the queue and start a turn for any fired job: the agent does the work
195
+ // and reports the result into the conversation, autonomously — like Claude Code.
196
+ // Effect re-subscribes on busy/pending changes so the closure always sees the
197
+ // current `runConversationTurn`; it only installs the poll when truly idle.
198
+ useEffect(() => {
199
+ if (busy || pending)
200
+ return; // only fire scheduled work when the agent is idle
201
+ const id = setInterval(() => {
202
+ if (!cronItemsPending())
203
+ return;
204
+ const fired = consumeCronQueue();
205
+ if (!fired.length)
206
+ return;
207
+ const content = fired.map(cronTriggerContent).join("\n\n---\n\n");
208
+ const label = fired.length === 1 ? `⏰ cron ${fired[0].id} fired (${fired[0].cron})` : `⏰ ${fired.length} cron jobs fired`;
209
+ runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }); // a cron NOTE (not a user bar); the agent runs it + reports
210
+ }, 2000);
211
+ return () => clearInterval(id);
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [busy, pending]);
185
214
  // A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
186
215
  // mentions, then start the turn. Mirrors agent.ts's submit path.
187
216
  const submitLine = async (text) => {
@@ -200,15 +229,80 @@ export function App({ session, runTurn }) {
200
229
  note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
201
230
  if (refused.length)
202
231
  note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
232
+ // Name the session once, after the first real user message. A cheap model
233
+ // call distills the prompt into a 3-7 word title (like Claude Code's aiTitle);
234
+ // it runs fire-and-forget so it never delays the turn, and a failure just
235
+ // leaves the raw first prompt as the title. Resumed sessions that already
236
+ // have a title are left alone.
237
+ if (!titleAttempted.current) {
238
+ titleAttempted.current = true;
239
+ if (!loadSession(sessionId)?.title) {
240
+ void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, text)
241
+ .then((title) => {
242
+ if (title) {
243
+ pendingTitle.current = title;
244
+ setSessionTitle(sessionId, title);
245
+ }
246
+ })
247
+ .catch(() => { });
248
+ }
249
+ }
203
250
  runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
204
251
  };
205
252
  // Handle a /slash command. Returns true if the line was a command (handled).
253
+ // /mcp — list configured servers + status, then pick one to manage it.
254
+ const handleMcpCommand = async (arg) => {
255
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
256
+ // Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
257
+ if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
258
+ const name = parts.slice(1).join(" ");
259
+ setBusy(true);
260
+ try {
261
+ const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
262
+ const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
263
+ note(chalk.dim(msg));
264
+ }
265
+ catch (err) {
266
+ note(chalk.yellow(err.message));
267
+ }
268
+ finally {
269
+ setBusy(false);
270
+ }
271
+ return;
272
+ }
273
+ const servers = listMcpServers();
274
+ if (!servers.length) {
275
+ note(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
276
+ return;
277
+ }
278
+ const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
279
+ openSelect("MCP servers (pick one to manage):", labels, (i) => {
280
+ if (i < 0)
281
+ return note(chalk.dim("(cancelled)"));
282
+ const server = servers[i];
283
+ const actions = mcpActionsFor(server);
284
+ openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
285
+ if (j < 0)
286
+ return note(chalk.dim("(cancelled)"));
287
+ setBusy(true);
288
+ runMcpAction(server.name, actions[j].action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)))
289
+ .then((msg) => note(chalk.dim(msg)))
290
+ .catch((err) => note(chalk.yellow(err.message)))
291
+ .finally(() => setBusy(false));
292
+ });
293
+ });
294
+ };
206
295
  const handleCommand = async (line) => {
207
296
  const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
208
297
  if (info !== null) {
209
298
  note(info);
210
299
  return true;
211
300
  }
301
+ // /mcp [reconnect|auth|enable|disable <name>]
302
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
303
+ await handleMcpCommand(line.slice("/mcp".length).trim());
304
+ return true;
305
+ }
212
306
  // /model [name|save <name>]
213
307
  if (line === "/model" || line.startsWith("/model ")) {
214
308
  const arg = line.slice("/model".length).trim();
@@ -276,6 +370,8 @@ export function App({ session, runTurn }) {
276
370
  clearReasoning();
277
371
  clearToolCalls();
278
372
  setSessionId(newSessionId());
373
+ titleAttempted.current = false; // a fresh session gets its own title on its first message
374
+ pendingTitle.current = undefined;
279
375
  note(chalk.dim("(history cleared)"));
280
376
  return true;
281
377
  case "/plan":
@@ -324,6 +420,8 @@ export function App({ session, runTurn }) {
324
420
  messages.length = 0;
325
421
  messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
326
422
  setSessionId(chosen.id);
423
+ titleAttempted.current = false; // a resumed session without a title gets one on its next message
424
+ pendingTitle.current = chosen.title;
327
425
  forgetFilesExcept([]);
328
426
  clearUndo();
329
427
  clearTodos();
@@ -528,7 +626,71 @@ export function App({ session, runTurn }) {
528
626
  setCursor(cursor + add.length);
529
627
  }
530
628
  });
531
- 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.slice(0, cursor) }), _jsx(Text, { inverse: true, children: input.slice(cursor, cursor + 1) || " " }), _jsx(Text, { children: input.slice(cursor + 1) })] }), (() => {
629
+ 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: (() => {
630
+ const prompt = planMode ? "plan ❯ " : "❯ ";
631
+ const promptColor = planMode ? "magenta" : "cyan";
632
+ const cols = process.stdout.columns || 80;
633
+ const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
634
+ const promptW = displayWidth(prompt);
635
+ const firstW = Math.max(1, innerWidth - promptW);
636
+ // Build visual lines display-width-aware (CJK, emoji = 2 cols).
637
+ const lines = [""];
638
+ let row = 0;
639
+ let col = 0;
640
+ // Cursor tracking: map code-unit offset → (line, display-column).
641
+ let cursorLine = 0;
642
+ let cursorCol = 0;
643
+ let set = false;
644
+ let cu = 0; // code-unit offset, for matching `cursor`
645
+ for (const ch of input) {
646
+ if (!set && cu === cursor) {
647
+ cursorLine = row;
648
+ cursorCol = col;
649
+ set = true;
650
+ }
651
+ const cw = displayWidth(ch);
652
+ const limit = row === 0 ? firstW : innerWidth;
653
+ if (col + cw > limit) {
654
+ row++;
655
+ col = 0;
656
+ lines[row] = "";
657
+ }
658
+ lines[row] += ch;
659
+ col += cw;
660
+ cu += ch.length;
661
+ }
662
+ if (!set) {
663
+ cursorLine = row;
664
+ cursorCol = col;
665
+ } // cursor at end
666
+ // Cursor exactly at edge → wrap to start of next line for visibility
667
+ const edge = cursorLine === 0 ? firstW : innerWidth;
668
+ if (cursorCol >= edge) {
669
+ cursorLine++;
670
+ cursorCol = 0;
671
+ if (lines.length <= cursorLine)
672
+ lines.push("");
673
+ }
674
+ // Map display-column back to code-unit offset within the target line.
675
+ const cursorLineText = lines[cursorLine] ?? "";
676
+ let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
677
+ let dcol = 0;
678
+ for (const ch of cursorLineText) {
679
+ if (dcol >= cursorCol)
680
+ break;
681
+ dcol += displayWidth(ch);
682
+ cuOff += ch.length;
683
+ }
684
+ return lines.map((line, i) => {
685
+ if (i === cursorLine) {
686
+ const before = line.slice(0, cuOff);
687
+ const at = line.slice(cuOff, cuOff + 1) || " ";
688
+ const after = line.slice(cuOff + 1);
689
+ 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));
690
+ }
691
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
692
+ });
693
+ })() }), (() => {
532
694
  const agents = listSubAgents();
533
695
  if (!agents.length)
534
696
  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
@@ -23,11 +24,13 @@ export const SESSION_HELP = `commands:
23
24
  /stats event counts for this session (local telemetry — nothing leaves this machine)
24
25
  /memory show the durable facts the agent remembers about this project
25
26
  /cost tokens, cache hit rate and estimated spend this session (local)
27
+ /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
26
28
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
27
29
  /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
28
30
  /bg list background tasks this session (run_bash_background) and their status
29
31
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
30
32
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
33
+ /cron list scheduled cron jobs (schedule_cron) and their expressions
31
34
  /undo revert the most recent file write (write_file / edit_file) this session
32
35
  /diff show every file changed this session, as a diff from where it started
33
36
  /resume list recent sessions in this project and continue one of them
@@ -38,6 +41,19 @@ export const SESSION_HELP = `commands:
38
41
  keys (at the prompt):
39
42
  Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
40
43
  Ctrl+T reveal the folded tool-call trace for the last answer`;
44
+ // Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
45
+ export function mcpStatusText(info) {
46
+ switch (info.status) {
47
+ case "connected":
48
+ return chalk.green("connected");
49
+ case "needs-auth":
50
+ return chalk.yellow("needs auth");
51
+ case "failed":
52
+ return chalk.red("failed");
53
+ case "disabled":
54
+ return chalk.dim("disabled");
55
+ }
56
+ }
41
57
  // Run a non-interactive command. Returns the note text to display, or null if
42
58
  // `line` isn't one of these (the App handles the rest).
43
59
  export function runInfoCommand(line, ctx) {
@@ -77,6 +93,12 @@ export function runInfoCommand(line, ctx) {
77
93
  const summary = boardSummary();
78
94
  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
95
  }
96
+ case "/cron": {
97
+ const jobs = listJobs();
98
+ if (!jobs.length)
99
+ return chalk.dim("(no cron jobs scheduled)");
100
+ 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");
101
+ }
80
102
  case "/skills": {
81
103
  if (!ctx.skills.length)
82
104
  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