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 +23 -0
- package/dist/cron.js +280 -0
- package/dist/ink/app.js +118 -6
- package/dist/ink/commands.js +8 -0
- package/dist/ink/setup.js +8 -0
- package/dist/loop.js +24 -0
- package/dist/permissions.js +11 -0
- package/dist/tools.js +57 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
//
|
|
502
|
-
// file becomes a clean absolute path;
|
|
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((
|
|
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) }) })),
|
|
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;
|
package/dist/ink/commands.js
CHANGED
|
@@ -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
|
package/dist/permissions.js
CHANGED
|
@@ -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.
|
|
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"
|