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/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 allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
284
- const options = autoMode.enabled ? ["Yes, once", "No — let me tell the agent what to do instead"] : ["Yes", allowLabel, "No — let me tell the agent what to do instead"];
285
- openSelect(`⚠ approval needed — ${question}`, options, (i) => {
286
- const approved = i === 0 || (!autoMode.enabled && i === 1);
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
- runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }); // a cron NOTE (not a user bar); the agent runs it + reports
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
- if (key.upArrow) {
1015
- setFormState(reduceForm(pending.questions, formState, "up").state);
1016
- return;
1017
- }
1018
- else if (key.downArrow || key.tab) {
1019
- setFormState(reduceForm(pending.questions, formState, "down").state);
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
- return;
1031
- }
1032
- else if (key.escape) {
1033
- pending.resolve(null);
1034
- setPending(null);
1035
- setFormState(null);
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
- Provide each question with 2–4 concrete options. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
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, each with a list of options",
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 text" },
120
- options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
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
- // or a rule required a human without review ("requires_human"). Approved is
739
- // a likely false block, declined a correct one; "unattended" reached no one.
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 questions = [];
1191
+ let raw = [];
1145
1192
  try {
1146
- questions = JSON.parse(call.args).questions ?? [];
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
- questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
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
- // Returns the answers, or null if cancelled / not a TTY.
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
- const lineCount = renderForm(questions, state).split("\n").length + (onFollowUp ? 2 : 0);
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 whole form
141
- process.stdout.write(`\x1b[J${renderForm(questions, state)}${draft.render()}\n`);
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 (draft.handle(str, key))
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
- else if (key.name === "down" || key.name === "j" || key.name === "tab") {
168
- state = reduceForm(questions, state, "down").state;
169
- draw(false);
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
- else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
180
- finish(null); // cancel
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();