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 CHANGED
@@ -8,10 +8,11 @@ import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.
8
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
9
  import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
10
  import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
- import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
11
+ import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
12
+ import { generateSessionTitle } from "./title.js"; // concise session name, generated after the first message
12
13
  import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
13
14
  import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
14
- import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
15
+ import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
15
16
  import { Judge } from "./judge.js"; // optional LLM permission classifier
16
17
  import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
17
18
  import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
@@ -31,6 +32,7 @@ import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default fro
31
32
  import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
32
33
  import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
33
34
  import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
35
+ import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "./cron.js"; // cron scheduler (Day s14)
34
36
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
35
37
  const pkg = createRequire(import.meta.url)("../package.json");
36
38
  // ---- CLI arguments --------------------------------------------------------------
@@ -70,11 +72,13 @@ const SESSION_HELP = `commands:
70
72
  /stats event counts for this session (local telemetry — nothing leaves this machine)
71
73
  /memory show the durable facts the agent remembers about this project
72
74
  /cost tokens, cache hit rate and estimated spend this session (local)
75
+ /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
73
76
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
74
77
  /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
75
78
  /bg list background tasks this session (run_bash_background) and their status
76
79
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
77
80
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
81
+ /cron list scheduled cron jobs (schedule_cron) and their expressions
78
82
  /undo revert the most recent file write (write_file / edit_file) this session
79
83
  /diff show every file changed this session, as a diff from where it started
80
84
  /resume list recent sessions in this project and continue one of them
@@ -156,6 +160,13 @@ async function main() {
156
160
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
157
161
  process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
158
162
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
163
+ process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
164
+ // Cron scheduler (Day s14): restore durable jobs from disk, start the 1s poll.
165
+ loadDurableJobs();
166
+ const cronCount = listJobs().length;
167
+ if (cronCount)
168
+ console.log(chalk.dim(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`));
169
+ startCronScheduler();
159
170
  // The optional LLM permission judge, built once if a settings file enabled it.
160
171
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
161
172
  if (judge)
@@ -192,6 +203,8 @@ async function main() {
192
203
  let running = false; // is a task currently executing?
193
204
  let interrupted = false; // has the current task been interrupted?
194
205
  let controller = new AbortController(); // aborts the in-flight API request
206
+ let titleAttempted = false; // one-shot: name the session once, after the first real message
207
+ let pendingTitle; // the generated title, passed into the next save
195
208
  // ---- One-shot print mode -------------------------------------------------------
196
209
  // The same loop, the same permission gate (fail closed without a TTY), no REPL.
197
210
  // Designed for scripts: the exit code distinguishes success from every failure.
@@ -400,6 +413,51 @@ async function main() {
400
413
  }
401
414
  apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
402
415
  };
416
+ // /mcp — list configured MCP servers + status, then manage one (like Claude Code).
417
+ const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : chalk.dim("disabled");
418
+ const handleMcpCommand = async (arg) => {
419
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
420
+ if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
421
+ const name = parts.slice(1).join(" ");
422
+ const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
423
+ try {
424
+ console.log(chalk.dim(await runMcpAction(name, action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
425
+ }
426
+ catch (err) {
427
+ console.log(chalk.yellow(err.message));
428
+ }
429
+ return;
430
+ }
431
+ const servers = listMcpServers();
432
+ if (!servers.length) {
433
+ console.log(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
434
+ return;
435
+ }
436
+ console.log(chalk.dim("MCP servers:"));
437
+ const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s.status)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
438
+ for (const label of labels)
439
+ console.log(chalk.dim(` ${label}`));
440
+ if (!process.stdin.isTTY)
441
+ return;
442
+ const choice = await promptSelect(rl, labels.map((l) => l.replace(/ · /g, " — ")));
443
+ if (choice < 0) {
444
+ console.log(chalk.dim("(cancelled)"));
445
+ return;
446
+ }
447
+ const server = servers[choice];
448
+ const actions = mcpActionsFor(server);
449
+ const actionChoice = await promptSelect(rl, actions.map((a) => a.label));
450
+ if (actionChoice < 0) {
451
+ console.log(chalk.dim("(cancelled)"));
452
+ return;
453
+ }
454
+ try {
455
+ console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
456
+ }
457
+ catch (err) {
458
+ console.log(chalk.yellow(err.message));
459
+ }
460
+ };
403
461
  // Handle a /slash command. Returns true if the line was a command.
404
462
  const handleCommand = async (line) => {
405
463
  // /model takes an optional argument (/model <name>), so it can't be a plain
@@ -433,6 +491,11 @@ async function main() {
433
491
  console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
434
492
  return true;
435
493
  }
494
+ // /mcp [reconnect|auth|enable|disable <name>]
495
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
496
+ await handleMcpCommand(line.slice("/mcp".length).trim());
497
+ return true;
498
+ }
436
499
  switch (line) {
437
500
  case "/skills": {
438
501
  if (!skills.length) {
@@ -459,6 +522,8 @@ async function main() {
459
522
  clearReasoning(); // drop the collapsed thinking too
460
523
  clearToolCalls(); // ...and the folded tool-call trace
461
524
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
525
+ titleAttempted = false; // a fresh session gets its own title on its first message
526
+ pendingTitle = undefined;
462
527
  console.log(chalk.dim("(history cleared)")); // confirm the reset
463
528
  return true;
464
529
  case "/todos": {
@@ -505,6 +570,20 @@ async function main() {
505
570
  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
571
  return true;
507
572
  }
573
+ case "/cron": {
574
+ // Scheduled cron jobs (schedule_cron): list all currently registered.
575
+ const jobs = listJobs();
576
+ if (!jobs.length) {
577
+ console.log(chalk.dim("(no cron jobs scheduled)"));
578
+ }
579
+ else {
580
+ console.log(chalk.dim(`cron jobs (${jobs.length}):`));
581
+ for (const j of jobs) {
582
+ console.log(chalk.dim(` ${j.id} "${j.cron}" → "${j.prompt}"${j.recurring ? " (recurring)" : " (one-shot)"}${j.durable ? " (durable)" : " (session-only)"}`));
583
+ }
584
+ }
585
+ return true;
586
+ }
508
587
  case "/stats":
509
588
  console.log(chalk.dim(statsReport())); // local counters, busiest first
510
589
  return true;
@@ -566,6 +645,8 @@ async function main() {
566
645
  }
567
646
  messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
568
647
  sessionId = chosen.id; // keep appending to the resumed session's file
648
+ titleAttempted = false; // a resumed session without a title gets one on its next message
649
+ pendingTitle = chosen.title;
569
650
  forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
570
651
  clearUndo(); // the previous session's writes are not ours to undo
571
652
  clearTodos(); // the resumed task starts without the old session's stale plan
@@ -690,6 +771,22 @@ async function main() {
690
771
  console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
691
772
  if (refused.length)
692
773
  console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
774
+ // Name the session once, after the first real user message. Same as the Ink
775
+ // REPL: a cheap model call distills the prompt into a 3-7 word title, fired
776
+ // and forgotten so it never delays the turn; a failure leaves the raw prompt.
777
+ if (!titleAttempted) {
778
+ titleAttempted = true;
779
+ if (!loadSession(sessionId)?.title) {
780
+ void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, line)
781
+ .then((title) => {
782
+ if (title) {
783
+ pendingTitle = title;
784
+ setSessionTitle(sessionId, title);
785
+ }
786
+ })
787
+ .catch(() => { });
788
+ }
789
+ }
693
790
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
694
791
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
695
792
  clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
@@ -707,7 +804,7 @@ async function main() {
707
804
  judge, // optional LLM classifier for the "ask" middle ground
708
805
  });
709
806
  running = false; // back at the prompt — Ctrl+C means "exit" again
710
- saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
807
+ saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
711
808
  // Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
712
809
  // that saves durable facts — especially user corrections — so memory
713
810
  // captures itself instead of relying on the model to call `remember`.
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
+ }