agent-from-zero 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +100 -3
- package/dist/cron.js +280 -0
- package/dist/ink/app.js +166 -4
- package/dist/ink/commands.js +22 -0
- package/dist/ink/setup.js +8 -0
- package/dist/loop.js +24 -0
- package/dist/mcp.js +593 -40
- package/dist/permissions.js +11 -0
- package/dist/session.js +23 -4
- package/dist/title.js +49 -0
- package/dist/tools.js +62 -0
- package/package.json +2 -2
package/dist/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
|
+
}
|