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/ink/app.js
CHANGED
|
@@ -16,14 +16,18 @@ import { resetBoard } from "../board.js";
|
|
|
16
16
|
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as tuiCleanup } from "../tui.js";
|
|
17
17
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
18
18
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
19
|
-
import {
|
|
19
|
+
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
20
|
+
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
21
|
+
import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
|
|
20
22
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
21
23
|
import { findSkill, skillInstructions } from "../skills.js";
|
|
22
24
|
import { extractMemories } from "../memory.js";
|
|
25
|
+
import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
|
|
23
26
|
import { runHooks } from "../hooks.js";
|
|
24
27
|
import { emit } from "../telemetry.js";
|
|
25
28
|
import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
|
|
26
|
-
import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
|
|
29
|
+
import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
|
|
30
|
+
import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
|
|
27
31
|
// Injected when plan mode turns on, so the model knows the rules it now lives in.
|
|
28
32
|
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
29
33
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
|
|
@@ -115,6 +119,8 @@ export function App({ session, runTurn }) {
|
|
|
115
119
|
const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
|
|
116
120
|
const history = useRef([]).current; // past prompts, for ↑/↓ recall
|
|
117
121
|
const turn = useRef(null); // the in-flight turn, for Esc-interrupt
|
|
122
|
+
const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
|
|
123
|
+
const pendingTitle = useRef(undefined); // the generated title, passed into the next save
|
|
118
124
|
useEffect(() => {
|
|
119
125
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
120
126
|
return () => clearInterval(id);
|
|
@@ -162,7 +168,7 @@ export function App({ session, runTurn }) {
|
|
|
162
168
|
.then(async (result) => {
|
|
163
169
|
if (result.reason !== TerminateReason.Done)
|
|
164
170
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
165
|
-
saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
|
|
171
|
+
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
166
172
|
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
167
173
|
try {
|
|
168
174
|
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
@@ -182,6 +188,29 @@ export function App({ session, runTurn }) {
|
|
|
182
188
|
setLive(null);
|
|
183
189
|
});
|
|
184
190
|
};
|
|
191
|
+
// The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
|
|
192
|
+
// injectCronMessages only drains it mid-turn — so a job that fires while you're
|
|
193
|
+
// at the prompt would never run on its own. Here, while idle (no turn, no menu),
|
|
194
|
+
// we poll the queue and start a turn for any fired job: the agent does the work
|
|
195
|
+
// and reports the result into the conversation, autonomously — like Claude Code.
|
|
196
|
+
// Effect re-subscribes on busy/pending changes so the closure always sees the
|
|
197
|
+
// current `runConversationTurn`; it only installs the poll when truly idle.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (busy || pending)
|
|
200
|
+
return; // only fire scheduled work when the agent is idle
|
|
201
|
+
const id = setInterval(() => {
|
|
202
|
+
if (!cronItemsPending())
|
|
203
|
+
return;
|
|
204
|
+
const fired = consumeCronQueue();
|
|
205
|
+
if (!fired.length)
|
|
206
|
+
return;
|
|
207
|
+
const content = fired.map(cronTriggerContent).join("\n\n---\n\n");
|
|
208
|
+
const label = fired.length === 1 ? `⏰ cron ${fired[0].id} fired (${fired[0].cron})` : `⏰ ${fired.length} cron jobs fired`;
|
|
209
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }); // a cron NOTE (not a user bar); the agent runs it + reports
|
|
210
|
+
}, 2000);
|
|
211
|
+
return () => clearInterval(id);
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, [busy, pending]);
|
|
185
214
|
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
186
215
|
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
187
216
|
const submitLine = async (text) => {
|
|
@@ -200,15 +229,80 @@ export function App({ session, runTurn }) {
|
|
|
200
229
|
note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
201
230
|
if (refused.length)
|
|
202
231
|
note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
232
|
+
// Name the session once, after the first real user message. A cheap model
|
|
233
|
+
// call distills the prompt into a 3-7 word title (like Claude Code's aiTitle);
|
|
234
|
+
// it runs fire-and-forget so it never delays the turn, and a failure just
|
|
235
|
+
// leaves the raw first prompt as the title. Resumed sessions that already
|
|
236
|
+
// have a title are left alone.
|
|
237
|
+
if (!titleAttempted.current) {
|
|
238
|
+
titleAttempted.current = true;
|
|
239
|
+
if (!loadSession(sessionId)?.title) {
|
|
240
|
+
void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, text)
|
|
241
|
+
.then((title) => {
|
|
242
|
+
if (title) {
|
|
243
|
+
pendingTitle.current = title;
|
|
244
|
+
setSessionTitle(sessionId, title);
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
.catch(() => { });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
203
250
|
runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
|
|
204
251
|
};
|
|
205
252
|
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
253
|
+
// /mcp — list configured servers + status, then pick one to manage it.
|
|
254
|
+
const handleMcpCommand = async (arg) => {
|
|
255
|
+
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
|
256
|
+
// Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
|
|
257
|
+
if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
|
|
258
|
+
const name = parts.slice(1).join(" ");
|
|
259
|
+
setBusy(true);
|
|
260
|
+
try {
|
|
261
|
+
const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
|
|
262
|
+
const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
|
|
263
|
+
note(chalk.dim(msg));
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
note(chalk.yellow(err.message));
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
setBusy(false);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const servers = listMcpServers();
|
|
274
|
+
if (!servers.length) {
|
|
275
|
+
note(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
|
|
279
|
+
openSelect("MCP servers (pick one to manage):", labels, (i) => {
|
|
280
|
+
if (i < 0)
|
|
281
|
+
return note(chalk.dim("(cancelled)"));
|
|
282
|
+
const server = servers[i];
|
|
283
|
+
const actions = mcpActionsFor(server);
|
|
284
|
+
openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
|
|
285
|
+
if (j < 0)
|
|
286
|
+
return note(chalk.dim("(cancelled)"));
|
|
287
|
+
setBusy(true);
|
|
288
|
+
runMcpAction(server.name, actions[j].action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)))
|
|
289
|
+
.then((msg) => note(chalk.dim(msg)))
|
|
290
|
+
.catch((err) => note(chalk.yellow(err.message)))
|
|
291
|
+
.finally(() => setBusy(false));
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
};
|
|
206
295
|
const handleCommand = async (line) => {
|
|
207
296
|
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
208
297
|
if (info !== null) {
|
|
209
298
|
note(info);
|
|
210
299
|
return true;
|
|
211
300
|
}
|
|
301
|
+
// /mcp [reconnect|auth|enable|disable <name>]
|
|
302
|
+
if (line === "/mcp" || line.startsWith("/mcp ")) {
|
|
303
|
+
await handleMcpCommand(line.slice("/mcp".length).trim());
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
212
306
|
// /model [name|save <name>]
|
|
213
307
|
if (line === "/model" || line.startsWith("/model ")) {
|
|
214
308
|
const arg = line.slice("/model".length).trim();
|
|
@@ -276,6 +370,8 @@ export function App({ session, runTurn }) {
|
|
|
276
370
|
clearReasoning();
|
|
277
371
|
clearToolCalls();
|
|
278
372
|
setSessionId(newSessionId());
|
|
373
|
+
titleAttempted.current = false; // a fresh session gets its own title on its first message
|
|
374
|
+
pendingTitle.current = undefined;
|
|
279
375
|
note(chalk.dim("(history cleared)"));
|
|
280
376
|
return true;
|
|
281
377
|
case "/plan":
|
|
@@ -324,6 +420,8 @@ export function App({ session, runTurn }) {
|
|
|
324
420
|
messages.length = 0;
|
|
325
421
|
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
326
422
|
setSessionId(chosen.id);
|
|
423
|
+
titleAttempted.current = false; // a resumed session without a title gets one on its next message
|
|
424
|
+
pendingTitle.current = chosen.title;
|
|
327
425
|
forgetFilesExcept([]);
|
|
328
426
|
clearUndo();
|
|
329
427
|
clearTodos();
|
|
@@ -528,7 +626,71 @@ export function App({ session, runTurn }) {
|
|
|
528
626
|
setCursor(cursor + add.length);
|
|
529
627
|
}
|
|
530
628
|
});
|
|
531
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })),
|
|
629
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, flexDirection: "column", children: (() => {
|
|
630
|
+
const prompt = planMode ? "plan ❯ " : "❯ ";
|
|
631
|
+
const promptColor = planMode ? "magenta" : "cyan";
|
|
632
|
+
const cols = process.stdout.columns || 80;
|
|
633
|
+
const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
|
|
634
|
+
const promptW = displayWidth(prompt);
|
|
635
|
+
const firstW = Math.max(1, innerWidth - promptW);
|
|
636
|
+
// Build visual lines display-width-aware (CJK, emoji = 2 cols).
|
|
637
|
+
const lines = [""];
|
|
638
|
+
let row = 0;
|
|
639
|
+
let col = 0;
|
|
640
|
+
// Cursor tracking: map code-unit offset → (line, display-column).
|
|
641
|
+
let cursorLine = 0;
|
|
642
|
+
let cursorCol = 0;
|
|
643
|
+
let set = false;
|
|
644
|
+
let cu = 0; // code-unit offset, for matching `cursor`
|
|
645
|
+
for (const ch of input) {
|
|
646
|
+
if (!set && cu === cursor) {
|
|
647
|
+
cursorLine = row;
|
|
648
|
+
cursorCol = col;
|
|
649
|
+
set = true;
|
|
650
|
+
}
|
|
651
|
+
const cw = displayWidth(ch);
|
|
652
|
+
const limit = row === 0 ? firstW : innerWidth;
|
|
653
|
+
if (col + cw > limit) {
|
|
654
|
+
row++;
|
|
655
|
+
col = 0;
|
|
656
|
+
lines[row] = "";
|
|
657
|
+
}
|
|
658
|
+
lines[row] += ch;
|
|
659
|
+
col += cw;
|
|
660
|
+
cu += ch.length;
|
|
661
|
+
}
|
|
662
|
+
if (!set) {
|
|
663
|
+
cursorLine = row;
|
|
664
|
+
cursorCol = col;
|
|
665
|
+
} // cursor at end
|
|
666
|
+
// Cursor exactly at edge → wrap to start of next line for visibility
|
|
667
|
+
const edge = cursorLine === 0 ? firstW : innerWidth;
|
|
668
|
+
if (cursorCol >= edge) {
|
|
669
|
+
cursorLine++;
|
|
670
|
+
cursorCol = 0;
|
|
671
|
+
if (lines.length <= cursorLine)
|
|
672
|
+
lines.push("");
|
|
673
|
+
}
|
|
674
|
+
// Map display-column back to code-unit offset within the target line.
|
|
675
|
+
const cursorLineText = lines[cursorLine] ?? "";
|
|
676
|
+
let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
|
|
677
|
+
let dcol = 0;
|
|
678
|
+
for (const ch of cursorLineText) {
|
|
679
|
+
if (dcol >= cursorCol)
|
|
680
|
+
break;
|
|
681
|
+
dcol += displayWidth(ch);
|
|
682
|
+
cuOff += ch.length;
|
|
683
|
+
}
|
|
684
|
+
return lines.map((line, i) => {
|
|
685
|
+
if (i === cursorLine) {
|
|
686
|
+
const before = line.slice(0, cuOff);
|
|
687
|
+
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
688
|
+
const after = line.slice(cuOff + 1);
|
|
689
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
|
|
690
|
+
}
|
|
691
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
|
|
692
|
+
});
|
|
693
|
+
})() }), (() => {
|
|
532
694
|
const agents = listSubAgents();
|
|
533
695
|
if (!agents.length)
|
|
534
696
|
return null;
|
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
|
|
@@ -23,11 +24,13 @@ export const SESSION_HELP = `commands:
|
|
|
23
24
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
24
25
|
/memory show the durable facts the agent remembers about this project
|
|
25
26
|
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
27
|
+
/mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
|
|
26
28
|
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
27
29
|
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
28
30
|
/bg list background tasks this session (run_bash_background) and their status
|
|
29
31
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
30
32
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
33
|
+
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
31
34
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
32
35
|
/diff show every file changed this session, as a diff from where it started
|
|
33
36
|
/resume list recent sessions in this project and continue one of them
|
|
@@ -38,6 +41,19 @@ export const SESSION_HELP = `commands:
|
|
|
38
41
|
keys (at the prompt):
|
|
39
42
|
Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
|
|
40
43
|
Ctrl+T reveal the folded tool-call trace for the last answer`;
|
|
44
|
+
// Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
|
|
45
|
+
export function mcpStatusText(info) {
|
|
46
|
+
switch (info.status) {
|
|
47
|
+
case "connected":
|
|
48
|
+
return chalk.green("connected");
|
|
49
|
+
case "needs-auth":
|
|
50
|
+
return chalk.yellow("needs auth");
|
|
51
|
+
case "failed":
|
|
52
|
+
return chalk.red("failed");
|
|
53
|
+
case "disabled":
|
|
54
|
+
return chalk.dim("disabled");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
41
57
|
// Run a non-interactive command. Returns the note text to display, or null if
|
|
42
58
|
// `line` isn't one of these (the App handles the rest).
|
|
43
59
|
export function runInfoCommand(line, ctx) {
|
|
@@ -77,6 +93,12 @@ export function runInfoCommand(line, ctx) {
|
|
|
77
93
|
const summary = boardSummary();
|
|
78
94
|
return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
|
|
79
95
|
}
|
|
96
|
+
case "/cron": {
|
|
97
|
+
const jobs = listJobs();
|
|
98
|
+
if (!jobs.length)
|
|
99
|
+
return chalk.dim("(no cron jobs scheduled)");
|
|
100
|
+
return chalk.dim(`cron jobs (${jobs.length}):\n`) + jobs.map((j) => chalk.dim(` ${j.id} "${j.cron}" → "${j.prompt}"${j.recurring ? " (recurring)" : " (one-shot)"}${j.durable ? " (durable)" : " (session-only)"}`)).join("\n");
|
|
101
|
+
}
|
|
80
102
|
case "/skills": {
|
|
81
103
|
if (!ctx.skills.length)
|
|
82
104
|
return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
|
package/dist/ink/setup.js
CHANGED
|
@@ -16,6 +16,7 @@ import { rememberTool, readMemory } from "../memory.js";
|
|
|
16
16
|
import { loadSkills, buildSkillTool } from "../skills.js";
|
|
17
17
|
import { killAllBackground } from "../background.js";
|
|
18
18
|
import { killAllSubAgents } from "../loop.js";
|
|
19
|
+
import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
|
|
19
20
|
import { banner } from "../ui.js";
|
|
20
21
|
// The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
|
|
21
22
|
// does before the prompt loop, but front-end-agnostic: it touches none of
|
|
@@ -53,6 +54,13 @@ export async function buildInkSession(opts = {}) {
|
|
|
53
54
|
process.on("exit", disconnectMcp);
|
|
54
55
|
process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
|
|
55
56
|
process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
|
|
57
|
+
process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
|
|
58
|
+
// Cron scheduler (Day s14): restore durable jobs, start polling.
|
|
59
|
+
loadDurableJobs();
|
|
60
|
+
const cronCount = listJobs().length;
|
|
61
|
+
if (cronCount)
|
|
62
|
+
notices.push(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`);
|
|
63
|
+
startCronScheduler();
|
|
56
64
|
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
57
65
|
if (judge)
|
|
58
66
|
notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
|
package/dist/loop.js
CHANGED
|
@@ -15,6 +15,7 @@ import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call
|
|
|
15
15
|
import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
|
|
16
16
|
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
17
17
|
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
18
|
+
import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
|
|
18
19
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
19
20
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
20
21
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -1003,6 +1004,24 @@ compaction) {
|
|
|
1003
1004
|
// Top-level only: a sub-agent has no separate turn loop to inject into, and the
|
|
1004
1005
|
// notification belongs to the agent that started the job. Returns true if it
|
|
1005
1006
|
// injected something. Each finished task notifies exactly once (background.ts).
|
|
1007
|
+
// Cron queue consumer: drain triggered cron jobs into the messages array.
|
|
1008
|
+
// The scheduler thread writes to cron_queue independently; we drain it here
|
|
1009
|
+
// before each round. Sub-agents do not process cron — the top-level does.
|
|
1010
|
+
function injectCronMessages(messages, opts) {
|
|
1011
|
+
if (opts.subAgent)
|
|
1012
|
+
return false; // sub-agents don't process cron
|
|
1013
|
+
if (!cronItemsPending())
|
|
1014
|
+
return false;
|
|
1015
|
+
const fired = consumeCronQueue();
|
|
1016
|
+
let count = 0;
|
|
1017
|
+
for (const job of fired) {
|
|
1018
|
+
messages.push({ role: "user", content: cronTriggerContent(job) }); // shared with the REPL's idle processor
|
|
1019
|
+
count++;
|
|
1020
|
+
}
|
|
1021
|
+
if (count && !opts.quiet)
|
|
1022
|
+
sink(opts).note(chalk.cyan(`⏰ cron: ${count} job${count > 1 ? "s" : ""} fired`));
|
|
1023
|
+
return count > 0;
|
|
1024
|
+
}
|
|
1006
1025
|
function injectBackgroundNotifications(messages, opts) {
|
|
1007
1026
|
if (opts.subAgent)
|
|
1008
1027
|
return false; // notifications belong to the top-level conversation
|
|
@@ -1252,6 +1271,8 @@ opts) {
|
|
|
1252
1271
|
// model decided to stop. Don't return with an unread result on the table:
|
|
1253
1272
|
// surface it and run one more round so the model can react. Each notifies
|
|
1254
1273
|
// once, so this adds at most one round per finished job — never infinite.
|
|
1274
|
+
if (injectCronMessages(messages, opts))
|
|
1275
|
+
break; // cron triggered → run another round
|
|
1255
1276
|
if (injectBackgroundNotifications(messages, opts))
|
|
1256
1277
|
break; // exit the inner while → advance the round counter
|
|
1257
1278
|
if (injectSubAgentResults(messages, opts))
|
|
@@ -1295,6 +1316,9 @@ opts) {
|
|
|
1295
1316
|
if (!opts.quiet && out.toolCalls.length) {
|
|
1296
1317
|
sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
|
|
1297
1318
|
}
|
|
1319
|
+
// Cron (Day s14): deliver any triggered scheduled jobs right after this
|
|
1320
|
+
// round's results so the model sees them on the next round.
|
|
1321
|
+
injectCronMessages(messages, opts);
|
|
1298
1322
|
// Background tasks (Day 37): if a job the model started has finished, slip
|
|
1299
1323
|
// its <task_notification> in here, right after this round's tool results —
|
|
1300
1324
|
// the model sees the outcome on the next round without ever having blocked
|