agent-from-zero 0.36.0 → 0.39.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/README.md +28 -1
- package/dist/agent.js +8 -11
- package/dist/auto-budget.js +8 -1
- package/dist/auto-context.js +1 -1
- package/dist/auto-review.js +8 -3
- package/dist/auto.js +2 -1
- package/dist/background.js +11 -6
- package/dist/config.js +6 -0
- package/dist/cron.js +7 -3
- package/dist/form.js +141 -66
- package/dist/ink/app.js +78 -35
- package/dist/loop.js +80 -17
- package/dist/menu.js +60 -22
- package/dist/permissions.js +80 -5
- package/dist/prompt.js +1 -1
- package/dist/title.js +4 -3
- package/dist/tools.js +30 -15
- package/package.json +1 -1
package/dist/ink/app.js
CHANGED
|
@@ -6,14 +6,14 @@ import { Box, Text, Static, useInput, useApp } from "ink";
|
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { COMPACTING_VERB, formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
|
|
8
8
|
import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
|
|
9
|
-
import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
|
|
9
|
+
import { initFormState, reduceForm, renderForm, collectAnswers, onTypedRow } from "../form.js"; // the ask_user form: pure state machine + renderer
|
|
10
10
|
import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
|
|
11
11
|
import { effortMenu, setEffort } from "../effort.js";
|
|
12
12
|
import { useSlashCompletion } from "./completion.js";
|
|
13
13
|
import { generatePromptSuggestion } from "../suggestion.js"; // the predicted next message (Claude Code's prompt suggestion)
|
|
14
14
|
import { lastRequestTools } from "../loop.js"; // resend the main call's tools so the guess hits the prompt cache
|
|
15
15
|
import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
|
|
16
|
-
import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
|
|
16
|
+
import { TerminateReason, listAgentViews, finishedWorkPending, takeFinishedWork } from "../loop.js"; // how a turn can end + background work that finished while idle
|
|
17
17
|
import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
|
|
18
18
|
import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
|
|
19
19
|
import { clearUndo } from "../undo.js";
|
|
@@ -28,7 +28,7 @@ import { currentSession, peerInboxPending, readPeerInbox, peerMessageContent, pe
|
|
|
28
28
|
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
29
29
|
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle, sessionPath } from "../session.js";
|
|
30
30
|
import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
31
|
-
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
31
|
+
import { approvalMenu, isPlanMode, setPlanMode } from "../permissions.js";
|
|
32
32
|
import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
|
|
33
33
|
import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
|
|
34
34
|
import { extractMemories } from "../memory.js";
|
|
@@ -253,7 +253,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
253
253
|
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
254
254
|
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
255
255
|
// `pending` prompt that returns a promise the loop awaits.
|
|
256
|
-
const runConversationTurn = (content, display, attachments = [], goalTurn = false) => {
|
|
256
|
+
const runConversationTurn = (content, display, attachments = [], goalTurn = false, afterTurn) => {
|
|
257
257
|
setDetails(null);
|
|
258
258
|
setDetailOffset(0);
|
|
259
259
|
if (display)
|
|
@@ -279,14 +279,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
279
279
|
const hooks = {
|
|
280
280
|
images: attachments,
|
|
281
281
|
output: sink,
|
|
282
|
-
confirm: (question, toolName) => new Promise((resolve) => {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!autoMode.enabled && approved && i === 1 && toolName)
|
|
288
|
-
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
289
|
-
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
282
|
+
confirm: (question, toolName, trustDir) => new Promise((resolve) => {
|
|
283
|
+
const menu = approvalMenu(autoMode.enabled, toolName, trustDir); // same choices and session grants as the readline REPL
|
|
284
|
+
openSelect(`⚠ approval needed — ${question}`, menu.options, (i) => {
|
|
285
|
+
const { approved, granted } = menu.choose(i);
|
|
286
|
+
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}${granted ? chalk.dim(` (${granted})`) : ""}`);
|
|
290
287
|
resolve(approved);
|
|
291
288
|
});
|
|
292
289
|
}),
|
|
@@ -349,6 +346,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
349
346
|
// is saved: Stop does not fire on an interrupt or an error, and fires
|
|
350
347
|
// before the save. An orchestrator reads this as "idle, transcript current".
|
|
351
348
|
void runHooks("TurnEnd", { reason: ending.reason, last_assistant_message: ending.text });
|
|
349
|
+
afterTurn?.();
|
|
352
350
|
});
|
|
353
351
|
};
|
|
354
352
|
// Covers immediate interruption and a message arriving as the turn finishes.
|
|
@@ -375,7 +373,29 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
375
373
|
return;
|
|
376
374
|
const content = fired.map(cronTriggerContent).join("\n\n---\n\n");
|
|
377
375
|
const label = fired.length === 1 ? `⏰ cron ${fired[0].id} fired (${fired[0].cron})` : `⏰ ${fired.length} cron jobs fired`;
|
|
378
|
-
|
|
376
|
+
// A cron NOTE (not a user bar); the agent runs it and reports. When the run
|
|
377
|
+
// is over, ring the bell and blink the tab: you were probably not watching.
|
|
378
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }, [], false, () => flashTerminalTitle(6000, `⏰ ${fired.map((j) => j.id).join(", ")} done`));
|
|
379
|
+
}, 2000);
|
|
380
|
+
return () => clearInterval(id);
|
|
381
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
382
|
+
}, [busy, pending]);
|
|
383
|
+
// The FINISHED-WORK idle processor: a background job (run_bash_background) or
|
|
384
|
+
// a sub-agent (task) can finish after the turn that started it ended. Its
|
|
385
|
+
// result must still reach the model, so while idle we start a turn with it and
|
|
386
|
+
// the agent carries on, without waiting for your next message. Bell and tab
|
|
387
|
+
// blink when that turn is done: long work usually finishes while you're away.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (busy || pending || followUps.size)
|
|
390
|
+
return; // human follow-ups take priority
|
|
391
|
+
const id = setInterval(() => {
|
|
392
|
+
if (turn.current || !finishedWorkPending())
|
|
393
|
+
return;
|
|
394
|
+
const content = takeFinishedWork();
|
|
395
|
+
if (!content)
|
|
396
|
+
return;
|
|
397
|
+
const ids = [...content.matchAll(/<(?:task_id|id)>([^<]+)<\//g)].map((m) => m[1]).join(", ");
|
|
398
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(`⚙ ${ids || "background work"} finished — the agent picks it up`) }, [], false, () => flashTerminalTitle(6000, `⚙ ${ids || "background work"} done`));
|
|
379
399
|
}, 2000);
|
|
380
400
|
return () => clearInterval(id);
|
|
381
401
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -1010,31 +1030,54 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1010
1030
|
}
|
|
1011
1031
|
}
|
|
1012
1032
|
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
1033
|
+
// ↑/↓ rows, ←/→ or Tab questions, Enter chooses, Esc cancels. On a "Type
|
|
1034
|
+
// something" row the input box IS the answer's editor: it travels with each
|
|
1035
|
+
// action, is loaded when the cursor arrives and cleared when it leaves.
|
|
1036
|
+
// Anywhere else, text in the input box is a follow-up, as before.
|
|
1013
1037
|
if (pending?.kind === "form" && formState) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
else if ((key.return || modifiedReturn) && !input.trim()) {
|
|
1023
|
-
const next = reduceForm(pending.questions, formState, "select");
|
|
1024
|
-
setFormState(next.state);
|
|
1025
|
-
if (next.done) {
|
|
1026
|
-
pending.resolve(collectAnswers(pending.questions, next.state));
|
|
1038
|
+
const questions = pending.questions;
|
|
1039
|
+
const typing = onTypedRow(questions, formState);
|
|
1040
|
+
const act = (action) => {
|
|
1041
|
+
const next = reduceForm(questions, formState, action, typing ? input : undefined);
|
|
1042
|
+
const close = (result) => {
|
|
1043
|
+
pending.resolve(result);
|
|
1027
1044
|
setPending(null);
|
|
1028
1045
|
setFormState(null);
|
|
1046
|
+
if (typing) {
|
|
1047
|
+
setInput("");
|
|
1048
|
+
setCursor(0);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
if (next.outcome === "submit")
|
|
1052
|
+
return close(collectAnswers(questions, next.state));
|
|
1053
|
+
if (next.outcome === "chat")
|
|
1054
|
+
return close("chat");
|
|
1055
|
+
if (next.outcome === "cancel")
|
|
1056
|
+
return close(null);
|
|
1057
|
+
setFormState(next.state);
|
|
1058
|
+
const moved = next.state.tab !== formState.tab || next.state.cursor !== formState.cursor;
|
|
1059
|
+
const stored = next.state.typed[next.state.tab] ?? "";
|
|
1060
|
+
if (onTypedRow(questions, next.state) && moved && stored) {
|
|
1061
|
+
setInput(stored);
|
|
1062
|
+
setCursor(stored.length);
|
|
1029
1063
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
return;
|
|
1037
|
-
|
|
1064
|
+
else if (typing && !onTypedRow(questions, next.state)) {
|
|
1065
|
+
setInput("");
|
|
1066
|
+
setCursor(0);
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
if (key.upArrow)
|
|
1070
|
+
return act("up");
|
|
1071
|
+
if (key.downArrow)
|
|
1072
|
+
return act("down");
|
|
1073
|
+
if (key.tab)
|
|
1074
|
+
return act(key.shift ? "left" : "right");
|
|
1075
|
+
if ((key.leftArrow || key.rightArrow) && !input)
|
|
1076
|
+
return act(key.leftArrow ? "left" : "right");
|
|
1077
|
+
if ((key.return || modifiedReturn) && (typing || !input.trim()))
|
|
1078
|
+
return act("enter");
|
|
1079
|
+
if (key.escape)
|
|
1080
|
+
return act("escape");
|
|
1038
1081
|
}
|
|
1039
1082
|
if ((key.tab || (key.rightArrow && !input)) && placeholder) { // Tab (or →) turns the predicted next message into real text; Enter then sends it
|
|
1040
1083
|
setInput(placeholder);
|
|
@@ -1207,7 +1250,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1207
1250
|
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
1208
1251
|
const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
|
|
1209
1252
|
const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
|
|
1210
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && pending.kind !== "skills" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
1253
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState, onTypedRow(pending.questions, formState) ? input : undefined) }) })), pending && pending.kind !== "skills" && pending.kind !== "form" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), pending?.kind === "form" && formState && !onTypedRow(pending.questions, formState) && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Or type a follow-up below \u00B7 Enter sends it instead of answering" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
1211
1254
|
const prompt = talkTo ? `→ ${talkTo.name} ❯ ` : "❯ ";
|
|
1212
1255
|
const promptColor = talkTo ? "magenta" : planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
1213
1256
|
const cols = columns;
|
package/dist/loop.js
CHANGED
|
@@ -20,7 +20,7 @@ import { recordToolCall, recordToolResult, recordToolDetail, recordReasoning } f
|
|
|
20
20
|
import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
|
|
21
21
|
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
22
22
|
import { currentSkills, skillBodyMessages, skillListingReminder, syncSkillTool } from "./skills.js"; // skill listing reminders + launched skill bodies
|
|
23
|
-
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
23
|
+
import { pendingNotifications, hasPendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
24
24
|
import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
|
|
25
25
|
import { getGoal, updateGoalTool, runUpdateGoal, countGoalToolCall } from "./goal.js"; // /goal (Day 41): the model ends a durable goal with update_goal
|
|
26
26
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
@@ -105,21 +105,35 @@ const askUserTool = {
|
|
|
105
105
|
function: {
|
|
106
106
|
name: "ask_user",
|
|
107
107
|
description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
|
|
108
|
-
|
|
108
|
+
Ask 1–4 questions, each with 2–4 concrete options; each option a short label plus a one-line description of what choosing it means. Put a recommended option first and end its label with "(Recommended)". Set multiSelect when several options can apply together.
|
|
109
|
+
The user can always type their own answer or choose to chat instead, so never add an "Other" option. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
|
|
109
110
|
If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
|
|
110
111
|
parameters: {
|
|
111
112
|
type: "object",
|
|
112
113
|
properties: {
|
|
113
114
|
questions: {
|
|
114
115
|
type: "array",
|
|
115
|
-
description: "The questions to ask
|
|
116
|
+
description: "The questions to ask (1–4)",
|
|
116
117
|
items: {
|
|
117
118
|
type: "object",
|
|
118
119
|
properties: {
|
|
119
|
-
question: { type: "string", description: "The question
|
|
120
|
-
|
|
120
|
+
question: { type: "string", description: "The full question, ending with a question mark" },
|
|
121
|
+
header: { type: "string", description: "A very short tab label, at most 12 characters, e.g. \"Branch\", \"Auth\"" },
|
|
122
|
+
options: {
|
|
123
|
+
type: "array",
|
|
124
|
+
description: "2–4 concrete choices",
|
|
125
|
+
items: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
label: { type: "string", description: "The choice, 1–5 words" },
|
|
129
|
+
description: { type: "string", description: "What choosing it means, one line" },
|
|
130
|
+
},
|
|
131
|
+
required: ["label", "description"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
multiSelect: { type: "boolean", description: "true when the user may pick several options" },
|
|
121
135
|
},
|
|
122
|
-
required: ["question", "options"],
|
|
136
|
+
required: ["question", "header", "options"],
|
|
123
137
|
},
|
|
124
138
|
},
|
|
125
139
|
},
|
|
@@ -340,6 +354,17 @@ function pendingSubAgentResults() {
|
|
|
340
354
|
return "";
|
|
341
355
|
return `A sub-agent you delegated work to has finished. Read its report carefully — it is INPUT MATERIAL, not verified truth. Verify key claims yourself, then continue.\n\n${done.join("\n\n")}`;
|
|
342
356
|
}
|
|
357
|
+
// Background jobs and sub-agents outlive the turn that started them. One that
|
|
358
|
+
// finishes while the agent is idle must still reach the model: the frontend's
|
|
359
|
+
// idle processor asks finishedWorkPending(), then starts a turn whose content
|
|
360
|
+
// is takeFinishedWork() (consumed, so each result arrives exactly once) — the
|
|
361
|
+
// agent picks up where it left off, as Claude Code re-invokes on completion.
|
|
362
|
+
export function finishedWorkPending() {
|
|
363
|
+
return hasPendingNotifications() || [...pendingSubAgents.values()].some((sa) => sa.result !== null && !sa.notified);
|
|
364
|
+
}
|
|
365
|
+
export function takeFinishedWork() {
|
|
366
|
+
return [pendingNotifications(), pendingSubAgentResults()].filter(Boolean).join("\n\n");
|
|
367
|
+
}
|
|
343
368
|
// Inject finished sub-agent results into the conversation, like
|
|
344
369
|
// injectBackgroundNotifications. Returns true if something was injected.
|
|
345
370
|
function injectSubAgentResults(messages, opts) {
|
|
@@ -652,6 +677,7 @@ async function authorizeCall(call, opts) {
|
|
|
652
677
|
let autoAllowed = false;
|
|
653
678
|
let reviewReason = "";
|
|
654
679
|
let reviewId = ""; // set when auto review ran; joins the human's answer to that review
|
|
680
|
+
let escalated = false; // a doubt reached the human only because the agent kept hitting it
|
|
655
681
|
// Auto mode's line is recoverability: a tool its server declares read-only
|
|
656
682
|
// or merely additive loses nothing, so it runs without review (plan mode
|
|
657
683
|
// already denied above).
|
|
@@ -681,6 +707,20 @@ async function authorizeCall(call, opts) {
|
|
|
681
707
|
recordToolDetail(call.id, review.reason);
|
|
682
708
|
return `[permission] Auto-denied (not a user refusal): ${review.reason}. Not executed. Choose a permitted approach or report the restriction. Do not retry the same prohibited effect through another tool, wrapper or worker.`;
|
|
683
709
|
}
|
|
710
|
+
// A doubt that the user asked for this goes back to the agent, not to the
|
|
711
|
+
// human — Claude Code's denial policy. The agent drops the step, takes one
|
|
712
|
+
// within the request, or asks with ask_user (a submitted answer is fresh
|
|
713
|
+
// authorization). Only an agent stuck against the reviewer (3 in a row, 20
|
|
714
|
+
// per task) brings the question to the human after all. Judge-only
|
|
715
|
+
// review outside auto mode keeps the ordinary prompt.
|
|
716
|
+
if (auto && review.unauthorized && !opts.reviewBudget?.escalated) {
|
|
717
|
+
opts.reviewBudget?.block();
|
|
718
|
+
emit("agent_tool_auto_blocked", { tool: call.name, ...(reviewId ? { reviewId } : {}) });
|
|
719
|
+
if (!opts.quiet)
|
|
720
|
+
recordToolDetail(call.id, review.reason);
|
|
721
|
+
return `[permission] Auto-blocked (not a user refusal): ${review.reason}. Not executed: the reviewer could not tie this action to what the user asked for. Drop it, take a step within the request, or, if it is truly needed, ask the user with ask_user (their answer counts as authorization). Do not retry the same effect through another tool, wrapper or worker.`;
|
|
722
|
+
}
|
|
723
|
+
escalated = auto && !!review.unauthorized;
|
|
684
724
|
autoAllowed = review.decision === "allow";
|
|
685
725
|
reviewReason = review.reason;
|
|
686
726
|
if (!opts.quiet)
|
|
@@ -732,20 +772,27 @@ async function authorizeCall(call, opts) {
|
|
|
732
772
|
if (preview)
|
|
733
773
|
sink(opts).note(preview.replace(/^/gm, agentIndent(opts)));
|
|
734
774
|
}
|
|
735
|
-
const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name));
|
|
775
|
+
const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name, v.trustDir));
|
|
736
776
|
// Every human prompt in auto mode is an interruption worth counting, with
|
|
737
777
|
// where it came from: the reviewer asked ("review", joinable by reviewId),
|
|
738
|
-
//
|
|
739
|
-
//
|
|
778
|
+
// a doubt outlived the agent's retries ("escalated"), or a rule required a
|
|
779
|
+
// human without review ("requires_human"). Approved is a likely false
|
|
780
|
+
// block, declined a correct one; "unattended" reached no one.
|
|
740
781
|
if (auto && !autoAllowed)
|
|
741
|
-
emit("agent_auto_human", { tool: call.name, source: reviewId ? "review" : v.requiresHuman ? "requires_human" : "no_review",
|
|
782
|
+
emit("agent_auto_human", { tool: call.name, source: reviewId ? (escalated ? "escalated" : "review") : v.requiresHuman ? "requires_human" : "no_review",
|
|
742
783
|
decision: ok ? "approved" : opts.canPrompt === false ? "unattended" : "declined", ...(reviewId ? { reviewId } : {}) });
|
|
743
784
|
if (!ok)
|
|
744
785
|
emit("agent_tool_declined", { tool: call.name, ...(reviewId ? { reviewId } : {}) }); // the human said no — that is signal
|
|
745
786
|
if (!ok && !opts.quiet)
|
|
746
787
|
sink(opts).note(mark.declined); // make the refusal visible
|
|
747
|
-
if (!ok)
|
|
788
|
+
if (!ok) {
|
|
789
|
+
// Say what actually happened. An unattended worker was never declined by
|
|
790
|
+
// anyone — saying so teaches the model to rephrase and retry against a
|
|
791
|
+
// refusal that never existed.
|
|
792
|
+
if (opts.canPrompt === false)
|
|
793
|
+
return `[permission] Nobody is reachable to approve this here${reviewReason ? `: ${reviewReason}` : "."} Not executed. Report it in your result and continue with what the rules allow; do not retry the same effect through another tool, wrapper or worker.`;
|
|
748
794
|
return `[permission] Action not approved${reviewReason ? `: ${reviewReason}` : ". The user declined this action"}. Ask the user how to proceed, or choose a safer alternative.`;
|
|
795
|
+
}
|
|
749
796
|
}
|
|
750
797
|
if (opts.reviewBudget?.exhausted)
|
|
751
798
|
return "[permission] Review limit reached; not executed. Wait for human direction.";
|
|
@@ -1141,18 +1188,34 @@ async function runAskUser(call, opts) {
|
|
|
1141
1188
|
return "[error] Sub-agents cannot ask the user. Decide yourself, or report back to the parent."; // only the top-level agent talks to the human
|
|
1142
1189
|
if (!opts.askUser)
|
|
1143
1190
|
return "[error] No interactive prompt is available in this session — ask in plain text instead.";
|
|
1144
|
-
let
|
|
1191
|
+
let raw = [];
|
|
1145
1192
|
try {
|
|
1146
|
-
|
|
1193
|
+
raw = JSON.parse(call.args).questions ?? [];
|
|
1147
1194
|
}
|
|
1148
1195
|
catch {
|
|
1149
1196
|
/* fall through */
|
|
1150
1197
|
}
|
|
1151
|
-
// Keep only well-formed questions (text + at least two options).
|
|
1152
|
-
|
|
1198
|
+
// Keep only well-formed questions (text + at least two options). An option may
|
|
1199
|
+
// still arrive as a bare string (the original schema): it becomes a label.
|
|
1200
|
+
const text = (v) => (typeof v === "string" ? v.trim() : "");
|
|
1201
|
+
const option = (o) => {
|
|
1202
|
+
const label = typeof o === "string" ? o.trim() : text(o?.label);
|
|
1203
|
+
const description = typeof o === "object" && o ? text(o.description) : "";
|
|
1204
|
+
return label ? { label, ...(description ? { description } : {}) } : null;
|
|
1205
|
+
};
|
|
1206
|
+
const questions = (Array.isArray(raw) ? raw : []).flatMap((q) => {
|
|
1207
|
+
const question = text(q?.question);
|
|
1208
|
+
const options = (Array.isArray(q?.options) ? q.options : []).map(option).filter((o) => o !== null);
|
|
1209
|
+
if (!question || options.length < 2)
|
|
1210
|
+
return [];
|
|
1211
|
+
const header = text(q.header).slice(0, 16);
|
|
1212
|
+
return [{ question, options, ...(header ? { header } : {}), ...(q.multiSelect === true || q.multiSelect === "true" ? { multiSelect: true } : {}) }];
|
|
1213
|
+
});
|
|
1153
1214
|
if (!questions.length)
|
|
1154
1215
|
return "[error] ask_user needs at least one question with two or more options.";
|
|
1155
1216
|
const answers = await opts.askUser(questions);
|
|
1217
|
+
if (answers === "chat")
|
|
1218
|
+
return "The user chose to talk this through instead of picking an option. Do not act on any of these questions yet: end your turn now and wait for their message.";
|
|
1156
1219
|
if (!answers)
|
|
1157
1220
|
return "The user dismissed the question form without answering. Proceed with your best judgment, or ask again if you truly need their input."; // cancelled / non-interactive
|
|
1158
1221
|
return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
|
|
@@ -1377,8 +1440,8 @@ opts) {
|
|
|
1377
1440
|
if (askUser && !opts.subAgent)
|
|
1378
1441
|
opts.askUser = async (questions) => {
|
|
1379
1442
|
const answers = await askUser(questions);
|
|
1380
|
-
if (!opts.signal.aborted && !opts.isInterrupted()) {
|
|
1381
|
-
for (const answer of answers
|
|
1443
|
+
if (Array.isArray(answers) && !opts.signal.aborted && !opts.isInterrupted()) {
|
|
1444
|
+
for (const answer of answers) {
|
|
1382
1445
|
const question = questions.find((q) => q.question === answer.question);
|
|
1383
1446
|
if (question && typeof answer.answer === "string" && answer.answer.trim()) {
|
|
1384
1447
|
recordHumanInput(`Human form reply (question is context; only the submitted answer expresses the user's choice): ${JSON.stringify({ question: question.question, answer: answer.answer })}`);
|
package/dist/menu.js
CHANGED
|
@@ -2,7 +2,7 @@ import readline from "node:readline"; // raw-mode keypress events + the interfac
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { reduceEditor, truncateToWidth } from "./editor.js";
|
|
4
4
|
import { renderMenu, MENU_HINT } from "./ui.js"; // the pure drawing + the footer hint
|
|
5
|
-
import { initFormState, reduceForm, renderForm, collectAnswers } from "./form.js"; // the multi-question form
|
|
5
|
+
import { initFormState, reduceForm, renderForm, collectAnswers, onTypedRow } from "./form.js"; // the multi-question form
|
|
6
6
|
// Navigation owns arrows; printable text belongs to this editor. Sending a
|
|
7
7
|
// follow-up cancels the pending choice, never implicitly approves it.
|
|
8
8
|
function promptDraft(submit, redraw, cancel) {
|
|
@@ -10,6 +10,7 @@ function promptDraft(submit, redraw, cancel) {
|
|
|
10
10
|
let sending = false;
|
|
11
11
|
let error = "";
|
|
12
12
|
return {
|
|
13
|
+
hasText: () => state.buffer.length > 0,
|
|
13
14
|
render: () => submit ? `\n${truncateToWidth(`Follow-up ❯ ${state.buffer.slice(0, state.cursor)}${chalk.inverse(state.buffer[state.cursor] ?? " ")}${state.buffer.slice(state.cursor + 1)}`, Math.max(10, (process.stdout.columns || 80) - 1))}\n${truncateToWidth(error || "Type a follow-up · Enter sends text; empty input selects", Math.max(10, (process.stdout.columns || 80) - 1))}` : "",
|
|
14
15
|
handle(str, key) {
|
|
15
16
|
if (sending)
|
|
@@ -126,19 +127,27 @@ onFollowUp) {
|
|
|
126
127
|
// The multi-question form (Day 30): the agent asks several questions at once,
|
|
127
128
|
// the user answers each and submits, and we hand back organized answers. Same
|
|
128
129
|
// raw-mode borrow/return dance as promptSelect; the state machine is in form.ts.
|
|
129
|
-
//
|
|
130
|
+
// Keys: ↑/↓ rows, ←/→ or Tab questions, Enter chooses, Esc cancels. On a "Type
|
|
131
|
+
// something" row the typed keys edit the answer; anywhere else they start a
|
|
132
|
+
// follow-up, which (like promptSelect) cancels the form when sent.
|
|
133
|
+
// Returns the answers, "chat" (talk instead of answering), or null if
|
|
134
|
+
// cancelled / not a TTY.
|
|
130
135
|
export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
|
|
131
136
|
return new Promise((resolve) => {
|
|
132
137
|
if (!input.isTTY || !questions.length)
|
|
133
138
|
return resolve(null); // no form without a terminal or questions
|
|
134
139
|
let state = initFormState(questions);
|
|
140
|
+
let answer = { buffer: "", cursor: 0 }; // the "Type something" editor
|
|
135
141
|
let done = false;
|
|
136
142
|
const wasRaw = input.isRaw === true; // restore this on exit, not false (see promptSelect)
|
|
137
|
-
|
|
143
|
+
let lineCount = 0; // tabs differ in height: redraw over whatever the last frame was
|
|
138
144
|
const draw = (first) => {
|
|
145
|
+
const typing = onTypedRow(questions, state);
|
|
146
|
+
const frame = `${renderForm(questions, state, typing ? answer.buffer : undefined)}${typing ? "" : draft.render()}`;
|
|
139
147
|
if (!first)
|
|
140
|
-
process.stdout.write(`\x1b[${lineCount}A`); // up over the
|
|
141
|
-
process.stdout.write(`\x1b[J${
|
|
148
|
+
process.stdout.write(`\x1b[${lineCount}A`); // up over the previous frame
|
|
149
|
+
process.stdout.write(`\x1b[J${frame}\n`);
|
|
150
|
+
lineCount = frame.split("\n").length;
|
|
142
151
|
};
|
|
143
152
|
const finish = (result) => {
|
|
144
153
|
if (done)
|
|
@@ -154,31 +163,60 @@ export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
|
|
|
154
163
|
rl.resume();
|
|
155
164
|
resolve(result);
|
|
156
165
|
};
|
|
166
|
+
// Apply one action. The answer editor travels with it while on a typed row,
|
|
167
|
+
// and follows the cursor: loaded on arrival, cleared on departure.
|
|
168
|
+
const act = (action) => {
|
|
169
|
+
const typing = onTypedRow(questions, state);
|
|
170
|
+
const next = reduceForm(questions, state, action, typing ? answer.buffer : undefined);
|
|
171
|
+
const arriving = onTypedRow(questions, next.state) && (!typing || next.state.tab !== state.tab || next.state.cursor !== state.cursor);
|
|
172
|
+
state = next.state;
|
|
173
|
+
if (next.outcome === "submit")
|
|
174
|
+
return finish(collectAnswers(questions, state));
|
|
175
|
+
if (next.outcome === "chat")
|
|
176
|
+
return finish("chat");
|
|
177
|
+
if (next.outcome === "cancel")
|
|
178
|
+
return finish(null);
|
|
179
|
+
if (arriving) {
|
|
180
|
+
const text = state.typed[state.tab];
|
|
181
|
+
answer = { buffer: text, cursor: text.length };
|
|
182
|
+
}
|
|
183
|
+
else if (!onTypedRow(questions, state))
|
|
184
|
+
answer = { buffer: "", cursor: 0 };
|
|
185
|
+
draw(false);
|
|
186
|
+
};
|
|
157
187
|
const draft = promptDraft(onFollowUp, () => draw(false), () => finish(null));
|
|
158
188
|
const onKey = (str, key) => {
|
|
159
189
|
if (!key || done)
|
|
160
190
|
return;
|
|
161
|
-
if (
|
|
191
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
192
|
+
return act("escape");
|
|
193
|
+
const typing = onTypedRow(questions, state);
|
|
194
|
+
if (key.name === "up")
|
|
195
|
+
return act("up");
|
|
196
|
+
if (key.name === "down")
|
|
197
|
+
return act("down");
|
|
198
|
+
if (key.name === "tab")
|
|
199
|
+
return act(key.shift ? "left" : "right");
|
|
200
|
+
// ←/→ switch questions, unless there is text to move the caret through.
|
|
201
|
+
if ((key.name === "left" || key.name === "right") && !(typing ? answer.buffer : draft.hasText()))
|
|
202
|
+
return act(key.name);
|
|
203
|
+
if (key.name === "return" || key.name === "enter") {
|
|
204
|
+
if (typing || !draft.handle(str, key))
|
|
205
|
+
act("enter"); // a drafted follow-up is sent by its own editor
|
|
162
206
|
return;
|
|
163
|
-
if (key.name === "up" || key.name === "k") {
|
|
164
|
-
state = reduceForm(questions, state, "up").state;
|
|
165
|
-
draw(false);
|
|
166
207
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
|
172
|
-
const next = reduceForm(questions, state, "select");
|
|
173
|
-
state = next.state;
|
|
174
|
-
if (next.done)
|
|
175
|
-
finish(collectAnswers(questions, state)); // submitted with all answered
|
|
176
|
-
else
|
|
208
|
+
if (typing) {
|
|
209
|
+
const next = reduceEditor(answer, str, key);
|
|
210
|
+
if (next.action === "edit") {
|
|
211
|
+
answer = next.state;
|
|
177
212
|
draw(false);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
178
215
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
216
|
+
if (draft.handle(str, key))
|
|
217
|
+
return;
|
|
218
|
+
if (key.name === "space")
|
|
219
|
+
act("enter"); // space toggles / chooses too
|
|
182
220
|
};
|
|
183
221
|
try {
|
|
184
222
|
rl.pause();
|