agent-from-zero 0.37.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 CHANGED
@@ -49,7 +49,7 @@ export DEEPSEEK_API_KEY=sk-你的key
49
49
 
50
50
  ### 配置审核模型 judge.model
51
51
 
52
- 开启 auto 模式(会话里输入 `/auto`,或启动时加 `--auto`)后,每个需要审批的工具调用会先交给一个审核模型判断:安全的直接执行,有风险或拿不准的仍然问你。审核模型只有两种选择:
52
+ 开启 auto 模式(会话里输入 `/auto`,或启动时加 `--auto`)后,每个需要审批的工具调用会先交给一个审核模型判断:安全的直接执行;看不出是你要求的,先退回给 agent,让它换个做法或用表单问你(同一个任务里连续 3 次、累计 20 次还卡住,才弹给你);你要求了但做了就回不去的(比如删掉 git 恢复不了的文件),弹出来让你确认。审核模型只有两种选择:
53
53
 
54
54
  1. **主 agent 用的模型**(默认):和主 agent 共用同一个 API key 和 base URL。没配 `judge.model` 就直接用主 agent 当前的模型。
55
55
  2. **Jev**:配好 Jev 的 key 后自动优先用它(见下一节)。
@@ -85,6 +85,18 @@ export TYPESAFE_API_KEY=你的key
85
85
  }
86
86
  ```
87
87
 
88
+ ### 信任项目外的目录
89
+
90
+ auto 模式下,改当前项目、其他 git 仓库或临时目录里的文件不用审批,其他地方的文件每次都要你确认。经常要改的目录(比如全局 skills 目录)可以写进**全局** settings(`~/.config/mini-agent/settings.json`),写在项目级 settings 里不生效:
91
+
92
+ ```json
93
+ {
94
+ "autoMode": { "trustedDirs": ["~/.config/mini-agent/skills"] }
95
+ }
96
+ ```
97
+
98
+ 也可以在确认弹窗里选 "Yes, and trust …/ for this session",只对本次会话有效。家目录本身和 `/` 不能被信任,`settings.json` 本身永远要你确认。
99
+
88
100
  ### 换别的模型
89
101
 
90
102
  和 API key 放在同一个地方,设置这三个变量就行,各家的 base URL 写在 [.env.example](.env.example) 里:
@@ -121,6 +133,21 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
121
133
 
122
134
  生成预测要多调一次模型,但请求内容和刚结束的主调用相同,只在最后加了一条指令,所以基本都能命中 DeepSeek 的前缀缓存,费用很低,也会算进 `/cost`。定时任务和其他实例发来的消息触发的回合不做预测。不想要可以在 `settings.json` 里设 `"promptSuggestions": false`,或者单次运行时设环境变量 `MINI_AGENT_PROMPT_SUGGESTIONS=0`。
123
135
 
136
+ ### 结果汇报、定时任务和长任务
137
+
138
+ 每轮结束时,agent 都会给一段一眼能看懂的结果:第一行先说结论,没别的要说就到此为止(比如“没有告警,一切正常”);有问题的话,分段列出要点,每个问题写清楚是什么、在哪、有什么影响,以及需要你决定的事。
139
+
140
+ 定时任务(直接跟 agent 说“每 30 分钟查一次 prod 告警”就能建,`/cron` 查看)每次执行完也按这个格式汇报。因为执行时你多半不在旁边,执行完终端会响一声,标签页会闪几下。
141
+
142
+ 长任务:
143
+
144
+ - `run_bash` 前台命令默认 2 分钟超时,agent 预计会跑很久时可以把超时设到最多 10 分钟。
145
+ - 更久的任务,或者不需要等它跑完的任务,交给后台(`run_bash_background`)或子 agent。它们跑完时如果 agent 已经停下来了,会自动开一轮,把结果交给 agent 接着处理,不用等你下一次发消息。`/bg` 可以查看后台任务。
146
+
147
+ ### 需要你选择时
148
+
149
+ agent 需要你做决定时会弹出一个表单,和 Claude Code 的一样:每个问题一个标签页,←/→ 或 Tab 切换,↑/↓ 选择,Enter 确定。每个选项下面有说明;可以多选的问题用 `[✔]` 勾选;选项里没有合适的,选 “Type something” 直接输入;想先聊聊再决定,选 “Chat about this”。最后一页汇总所有回答,确认后提交。表单打开时也可以直接在输入框打字,按 Enter 就作为一条新消息发出去。
150
+
124
151
  ### 多开实例互相通信
125
152
 
126
153
  在几个终端里各开一个 mini-agent(比如一个在后端仓库、一个在前端仓库),它们会自动互相发现,不用配置。
package/dist/agent.js CHANGED
@@ -21,7 +21,7 @@ import { parseCli, applyCliOptions, readPrintPrompt, CliUsageError } from "./cli
21
21
  import { reservePrintOutput, formatPrintResult, streamJsonInit, streamJsonMessage } from "./print.js";
22
22
  import { FollowUpQueue } from "./follow-up.js";
23
23
  import { reviewDebugCommand } from "./auto-debug.js";
24
- import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
24
+ import { approvalMenu, isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan; the shared approval menu
25
25
  import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
26
26
  import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's plan: /todos to view, cleared with the conversation
27
27
  import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
@@ -442,7 +442,7 @@ async function main() {
442
442
  // Fail closed: in a non-interactive session nobody can say yes, so the
443
443
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
444
444
  // denies were already enforced in permissions.ts before we get here.
445
- const confirm = async (question, toolName) => {
445
+ const confirm = async (question, toolName, trustDir) => {
446
446
  console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
447
447
  if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
448
448
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
@@ -453,15 +453,12 @@ async function main() {
453
453
  return false; // fail closed
454
454
  }
455
455
  // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
456
- // option lets the user stop being asked about this tool for the session.
457
- const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
458
- 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"];
459
- const choice = await select(options);
460
- const approved = choice === 0 || (!autoMode.enabled && choice === 1);
461
- if (!autoMode.enabled && choice === 1 && toolName) {
462
- CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
463
- console.log(chalk.dim(` won't ask again for ${toolName} this session`));
464
- }
456
+ // option lets the user stop being asked about this tool (or, in auto mode,
457
+ // this outside directory) for the session.
458
+ const menu = approvalMenu(autoMode.enabled, toolName, trustDir);
459
+ const { approved, granted } = menu.choose(await select(menu.options));
460
+ if (granted)
461
+ console.log(chalk.dim(` ${granted}`));
465
462
  console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
466
463
  return approved;
467
464
  };
@@ -1,14 +1,21 @@
1
1
  // One object per root task, shared by descendants and interrupt/resume. An
2
2
  // exhausted budget is sticky: a late successful worker cannot reopen the gate.
3
+ // Claude Code's denial limits: 3 in a row or 20 per task. Rules-policy denials
4
+ // stop the task there; score-policy doubts returned to the agent instead turn
5
+ // back into human prompts (escalated) until an action executes again.
3
6
  export class AutoReviewBudget {
4
7
  consecutive = 0;
5
8
  total = 0;
6
9
  stopped = false;
7
10
  get exhausted() { return this.stopped; }
11
+ get escalated() { return this.consecutive >= 3 || this.total >= 20; }
8
12
  deny() {
13
+ this.block();
14
+ this.stopped ||= this.escalated;
15
+ }
16
+ block() {
9
17
  this.total++;
10
18
  this.consecutive++;
11
- this.stopped ||= this.consecutive >= 3 || this.total >= 20;
12
19
  }
13
20
  executed() { if (!this.stopped)
14
21
  this.consecutive = 0; }
@@ -51,7 +51,7 @@ export function reviewHistory(messages, inherited) {
51
51
  continue;
52
52
  const result = results.get(call.id);
53
53
  actions.push({ callId: call.id, tool: call.function.name, input: projectInput(call.function.name, call.function.arguments),
54
- outcome: /^\[permission\] Auto-denied/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
54
+ outcome: /^\[permission\] Auto-(?:denied|blocked)/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
55
55
  }
56
56
  }
57
57
  let bytes = 0;
@@ -50,14 +50,19 @@ export function needsAuthorizationReview(assessment) {
50
50
  }
51
51
  // The ONLY semantic allow/ask decision. Boolean answers are categorical and keep
52
52
  // that representation in the UI. An invalid provider result can never approve.
53
+ // Which dimension failed decides who hears the ask: a doubt about authorization
54
+ // goes back to the agent (it can drop the step or ask the user), while risk with
55
+ // clear authorization is irreversible work the user requested, so the human
56
+ // confirms it.
53
57
  export function decideReview(assessment) {
54
58
  const { authorized, risky } = assessment;
55
59
  if (!valid(authorized) || !valid(risky))
56
60
  return { decision: "ask", reason: "Invalid review assessment" };
57
- const allow = (typeof authorized === "boolean" ? authorized : authorized >= MIN_AUTHORIZATION)
58
- && (typeof risky === "boolean" ? !risky : risky <= MAX_RISK);
61
+ const requested = typeof authorized === "boolean" ? authorized : authorized >= MIN_AUTHORIZATION;
62
+ const allow = requested && (typeof risky === "boolean" ? !risky : risky <= MAX_RISK);
59
63
  const format = (value) => typeof value === "boolean" ? (value ? "yes" : "no") : value.toFixed(3);
60
- return { decision: allow ? "allow" : "ask", reason: `authorization=${format(authorized)}, risk=${format(risky)}${allow ? " — auto-approved" : " — human review required"}` };
64
+ const outcome = allow ? " — auto-approved" : requested ? " — human review required" : " — not clearly requested";
65
+ return { decision: allow ? "allow" : "ask", ...(requested ? {} : { unauthorized: true }), reason: `authorization=${format(authorized)}, risk=${format(risky)}${outcome}` };
61
66
  }
62
67
  // The score policy above remains a deliberate rollout fallback. Rules v1 has
63
68
  // no authorization/risk cutoffs: providers report facts, this resolver owns the
package/dist/auto.js CHANGED
@@ -107,7 +107,8 @@ export class AutoMode {
107
107
  status() {
108
108
  if (CONFIG.bypassPermissions)
109
109
  return "(bypassPermissions — approvals and AI review skipped for this run; hard denies, plan restrictions and hooks still apply)";
110
- return `(auto mode ${this.enabled ? "ON" : "OFF"} — ${this.backend}; ${this.enabled ? "uncertain or risky actions require approval" : "use /auto to enable"})`;
110
+ const enabled = this.policy === "rules" ? "uncertain or risky actions require approval" : "irreversible actions require approval; unrequested ones go back to the agent first";
111
+ return `(auto mode ${this.enabled ? "ON" : "OFF"} — ${this.backend}; ${this.enabled ? enabled : "use /auto to enable"})`;
111
112
  }
112
113
  async classify(tool, argsJson, userRequests, signal, options = {}) {
113
114
  if (this.policy === "rules")
@@ -1,9 +1,9 @@
1
1
  // ============ Background tasks (Day 37) ============
2
- // The problem: run_bash blocks. A command that takes longer than its 30s
3
- // watchdog (npm install, a full test run, a build) is SIGKILLed before it
4
- // finishes, and even a 25s one freezes the agent — we sit idle, paying for
5
- // tokens we aren't using, while a child process churns. The model cannot think
6
- // and wait at the same time.
2
+ // The problem: run_bash blocks. A command that takes longer than its watchdog
3
+ // (2 minutes by default, 10 at most) is SIGKILLed before it finishes, and even
4
+ // a shorter slow one (npm install, a full test run, a build) freezes the agent:
5
+ // we sit idle, paying for tokens we aren't using, while a child process churns.
6
+ // The model cannot think and wait at the same time.
7
7
  //
8
8
  // The fix, mirroring Claude Code: a command can be sent to the BACKGROUND. We
9
9
  // spawn it, hand the model a task id immediately, and let the loop keep going.
@@ -34,7 +34,7 @@ function append(task, chunk) {
34
34
  }
35
35
  // Start a command in the background. Returns its task id immediately — the
36
36
  // command keeps running after this returns. Note the deliberate omissions vs
37
- // run_bash: NO 30s timeout (the whole point is to outlive it) and NO turn
37
+ // run_bash: NO timeout (the whole point is to outlive it) and NO turn
38
38
  // AbortSignal (Ctrl+C interrupts the current turn, but a background job is meant
39
39
  // to survive into later turns; only killAll, on session exit, stops them).
40
40
  export function startBackground(command) {
@@ -107,6 +107,11 @@ export function pendingNotifications() {
107
107
  // something it must echo. Tell it plainly that a job it started has finished.
108
108
  return `A background task you started has finished. Read the notification, then continue.\n\n${done.join("\n\n")}`;
109
109
  }
110
+ // Has a task finished whose notification has not been delivered yet? Lets an
111
+ // idle frontend wake the agent without consuming the notification itself.
112
+ export function hasPendingNotifications() {
113
+ return [...tasks.values()].some((task) => task.status !== "running" && !task.notified);
114
+ }
110
115
  // Read a background task's current state for the bash_output tool. Returns only
111
116
  // the output PRODUCED SINCE THE LAST POLL (like Claude Code's BashOutput), so
112
117
  // the model can watch a long job without re-reading the whole log each time.
package/dist/config.js CHANGED
@@ -134,6 +134,11 @@ export const CONFIG = {
134
134
  policy: (process.env.MINI_AGENT_AUTO_POLICY ?? projectSettings.autoMode?.policy ?? globalSettings.autoMode?.policy ?? "scores") === "rules" ? "rules" : "scores",
135
135
  enabled: process.env.MINI_AGENT_AUTO_MODE !== undefined ? process.env.MINI_AGENT_AUTO_MODE === "1" : (projectSettings.autoMode?.enabled ?? globalSettings.autoMode?.enabled ?? false),
136
136
  model: projectSettings.autoMode?.model || globalSettings.autoMode?.model || "jev-1.13.0",
137
+ // GLOBAL settings only: a cloned repo's settings must never extend auto
138
+ // mode's write reach into your home. "Trust this directory" in the approval
139
+ // menu appends here for the rest of the session.
140
+ trustedDirs: (globalSettings.autoMode?.trustedDirs ?? []).filter((dir) => typeof dir === "string" && dir.trim())
141
+ .map((dir) => path.resolve(dir.replace(/^~(?=$|\/)/, os.homedir()))),
137
142
  },
138
143
  // Token prices for the /cost estimate (defaults applied in cost.ts). Project
139
144
  // overrides global; either may set just the fields it cares about.
package/dist/cron.js CHANGED
@@ -162,15 +162,19 @@ export function listJobs() {
162
162
  // Shared by both consumers — the in-turn one (injectCronMessages in loop.ts, for
163
163
  // a job that fires mid-task) and the idle one (the REPL's idle processor, for a
164
164
  // job that fires while the user is at the prompt) — so they behave identically.
165
+ // The report is the run's summary: the user was not watching, so a quiet run is
166
+ // one line and anything wrong is laid out point by point (a prod-alert monitor:
167
+ // "No alerts — all normal.", or each alert with what, where and impact).
165
168
  export function cronTriggerContent(job) {
166
169
  return [
167
170
  `[Cron job "${job.id}" triggered — ${job.cron}]`,
168
171
  ``,
169
172
  `${job.prompt}`,
170
173
  ``,
171
- `Execute this now. When done, report the result directly to the user — make it visible and clear.`,
172
- `If the task produces output (e.g. a shell command result, a file change, a value), show it.`,
173
- `If there's nothing to show, confirm success with one line.`,
174
+ `Execute this now, then report to the user, who was not watching. Write the report in the language of the task above:`,
175
+ `- Nothing needs attention: ONE line that says so, e.g. "没有告警,一切正常。" / "No alerts — all normal."`,
176
+ `- Otherwise: first line the outcome, then bullet points under short bold labels — each problem with what, where and impact, then anything the user must decide or do.`,
177
+ `- Quote exact values (counts, names, error lines), never a raw log dump.`,
174
178
  ``,
175
179
  `After this, return to idle — do NOT start additional work unless the user asked for a chain of follow-ups.`,
176
180
  ].join("\n");
package/dist/form.js CHANGED
@@ -1,80 +1,155 @@
1
1
  import chalk from "chalk"; // rendering only
2
- // The cursor walks a flat list of options. Multiple questions add a submit row;
3
- // with one question, explicitly choosing an option submits it immediately.
4
- export function formRowCount(questions) {
5
- return questions.reduce((n, q) => n + q.options.length, 0) + (questions.length > 1 ? 1 : 0);
2
+ export function formRows(questions, tab) {
3
+ const q = questions[tab];
4
+ if (!q)
5
+ return [{ kind: "submit" }, { kind: "cancel" }];
6
+ return [
7
+ ...q.options.map((_, index) => ({ kind: "option", index })),
8
+ { kind: "typed" },
9
+ ...(q.multiSelect ? [{ kind: "next" }] : []),
10
+ { kind: "chat" },
11
+ ];
6
12
  }
7
- // Map a flat cursor to either a question/option, or the submit row.
8
- export function rowAt(questions, cursor) {
9
- let i = cursor;
10
- for (let q = 0; q < questions.length; q++) {
11
- if (i < questions[q].options.length)
12
- return { q, opt: i };
13
- i -= questions[q].options.length;
14
- }
15
- return { submit: true };
13
+ export function initFormState(questions) {
14
+ return { tab: 0, cursor: 0, picked: questions.map(() => []), typed: questions.map(() => "") };
16
15
  }
17
- // The flat cursor position of a question's first option — used to jump the
18
- // cursor to the first unanswered question when the user tries to submit early.
19
- function cursorForQuestion(questions, q) {
20
- let c = 0;
21
- for (let i = 0; i < q; i++)
22
- c += questions[i].options.length;
23
- return c;
16
+ // Is the cursor on a "Type something" row? The drivers route typed keys into
17
+ // their line editor there (and load / clear it as the cursor arrives / leaves).
18
+ export function onTypedRow(questions, state) {
19
+ return formRows(questions, state.tab)[state.cursor]?.kind === "typed";
24
20
  }
25
- export function initFormState(questions) {
26
- return { cursor: 0, selections: questions.map(() => -1) }; // nothing chosen yet
21
+ const answered = (state, q) => state.picked[q].length > 0;
22
+ // Land on a tab with the cursor on its first chosen option, or at the top.
23
+ function goToTab(state, tab) {
24
+ return { ...state, tab, cursor: Math.max(0, state.picked[tab]?.[0] ?? 0) };
27
25
  }
28
- // The one transition function. Returns the next state, and `done: true` only
29
- // when the user submits with every question answered.
30
- export function reduceForm(questions, state, action) {
31
- const rows = formRowCount(questions);
32
- if (action === "up")
33
- return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
34
- if (action === "down")
35
- return { state: { ...state, cursor: Math.min(rows - 1, state.cursor + 1) } };
36
- // select (Enter / Space)
37
- const row = rowAt(questions, state.cursor);
38
- if ("submit" in row) {
39
- const firstUnanswered = state.selections.findIndex((s) => s < 0);
40
- if (firstUnanswered < 0)
41
- return { state, done: true }; // all answered → submit for real
42
- return { state: { ...state, cursor: cursorForQuestion(questions, firstUnanswered) } }; // nudge to the gap
26
+ // The one transition function. `text` is what the driver's editor holds while
27
+ // the cursor is on a "Type something" row; it is stored as that question's
28
+ // typed answer before the action runs, so leaving the row keeps it. Returns the
29
+ // next state, and an `outcome` when the form is finished.
30
+ export function reduceForm(questions, state, action, text) {
31
+ const single = questions.length === 1; // no tabs, no review: picking submits
32
+ let s = { ...state, picked: state.picked.map((p) => p.slice()), typed: state.typed.slice() };
33
+ const q = questions[s.tab];
34
+ const typedIndex = q?.options.length ?? -1;
35
+ if (q && text !== undefined && onTypedRow(questions, s)) {
36
+ s.typed[s.tab] = text.trim();
37
+ // A multi-select typed answer counts as checked exactly while it has text.
38
+ if (q.multiSelect)
39
+ s.picked[s.tab] = s.picked[s.tab].filter((i) => i !== typedIndex).concat(s.typed[s.tab] ? [typedIndex] : []);
40
+ }
41
+ const rows = formRows(questions, s.tab);
42
+ const advance = () => single ? (answered(s, 0) ? { state: s, outcome: "submit" } : { state: s }) : { state: goToTab(s, s.tab + 1) };
43
+ switch (action) {
44
+ case "up":
45
+ return { state: { ...s, cursor: Math.max(0, s.cursor - 1) } };
46
+ case "down":
47
+ return { state: { ...s, cursor: Math.min(rows.length - 1, s.cursor + 1) } };
48
+ case "left":
49
+ case "right":
50
+ if (single)
51
+ return { state: s };
52
+ return { state: goToTab(s, Math.min(questions.length, Math.max(0, s.tab + (action === "left" ? -1 : 1)))) };
53
+ case "escape":
54
+ return { state: s, outcome: "cancel" };
43
55
  }
44
- const selections = state.selections.slice();
45
- selections[row.q] = row.opt; // set this question's answer (single-select)
46
- return { state: { ...state, selections }, ...(questions.length === 1 ? { done: true } : {}) };
56
+ // enter
57
+ const row = rows[s.cursor];
58
+ switch (row?.kind) {
59
+ case "option":
60
+ if (q.multiSelect) {
61
+ const on = s.picked[s.tab].includes(row.index);
62
+ s.picked[s.tab] = on ? s.picked[s.tab].filter((i) => i !== row.index) : [...s.picked[s.tab], row.index].sort((a, b) => a - b);
63
+ return { state: s };
64
+ }
65
+ s.picked[s.tab] = [row.index];
66
+ return advance();
67
+ case "typed":
68
+ if (!s.typed[s.tab])
69
+ return { state: s }; // nothing typed yet
70
+ if (q.multiSelect)
71
+ return { state: { ...s, cursor: s.cursor + 1 } }; // checked by having text; move on to Next/Submit
72
+ s.picked[s.tab] = [typedIndex];
73
+ return advance();
74
+ case "next":
75
+ return advance();
76
+ case "chat":
77
+ return { state: s, outcome: "chat" };
78
+ case "submit": {
79
+ const gap = questions.findIndex((_, i) => !answered(s, i));
80
+ return gap < 0 ? { state: s, outcome: "submit" } : { state: goToTab(s, gap) }; // jump to what is still open
81
+ }
82
+ case "cancel":
83
+ return { state: s, outcome: "cancel" };
84
+ }
85
+ return { state: s };
86
+ }
87
+ // One question's answer: chosen labels in option order, the typed answer last.
88
+ function answerText(q, state, i) {
89
+ return state.picked[i].map((p) => (p === q.options.length ? state.typed[i] : q.options[p]?.label ?? "")).filter(Boolean).join(", ");
47
90
  }
48
91
  // Turn a completed form into question→answer pairs for the model.
49
92
  export function collectAnswers(questions, state) {
50
- return questions.map((q, i) => ({ question: q.question, answer: q.options[state.selections[i]] ?? "(unanswered)" }));
93
+ return questions.map((q, i) => ({ question: q.question, answer: answerText(q, state, i) || "(unanswered)" }));
51
94
  }
52
- const FORM_HINT = chalk.dim("↑↓ to move · Enter to choose / submit · Esc to cancel");
53
- // Render the whole form: each question with radio-style options, the cursor on
54
- // one row, then a submit row. Line count is constant for a given question set
55
- // (selections don't add/remove lines), which is what lets the driver redraw in
56
- // place by moving the cursor up a fixed number of lines.
57
- export function renderForm(questions, state) {
58
- const lines = ["The agent needs your input:", ""];
59
- let flat = 0;
60
- questions.forEach((q, qi) => {
61
- lines.push(chalk.bold(`${qi + 1}. ${q.question}`));
62
- q.options.forEach((opt, oi) => {
63
- const onCursor = state.cursor === flat;
64
- const chosen = state.selections[qi] === oi;
65
- const text = `${chosen ? "●" : "○"} ${opt}`; // filled radio = chosen
66
- lines.push(onCursor ? chalk.cyan.bold(`❯ ${text}`) : chosen ? chalk.cyan(` ${text}`) : chalk.dim(` ${text}`));
67
- flat++;
68
- });
69
- lines.push("");
70
- });
71
- const onSubmit = state.cursor === flat; // the submit row is last
72
- const allAnswered = state.selections.every((s) => s >= 0);
73
- const submitText = allAnswered ? "▶ Submit answers" : "▶ Submit answers (answer every question first)";
95
+ // The footer: what the keys do right now.
96
+ export function formHint(questions, state) {
97
+ if (onTypedRow(questions, state))
98
+ return "Type your answer · Enter to use it · ↑/↓ to move · Esc to cancel";
99
+ return `Enter to select · ↑/↓ to move${questions.length > 1 ? " · ←/→ or Tab to switch questions" : ""} · Esc to cancel`;
100
+ }
101
+ // Render the current tab. `live` is the driver's editor text, shown on the
102
+ // "Type something" row while the cursor is on it.
103
+ export function renderForm(questions, state, live) {
104
+ const lines = [];
74
105
  if (questions.length > 1) {
75
- lines.push(onSubmit ? chalk.green.bold(`❯ ${submitText}`) : chalk.dim(` ${submitText}`));
76
- lines.push("");
106
+ const tabs = questions.map((q, i) => {
107
+ const label = ` ${answered(state, i) ? "☒" : "☐"} ${q.header || `Q${i + 1}`} `;
108
+ return i === state.tab ? chalk.inverse(label) : label;
109
+ });
110
+ const submit = state.tab === questions.length ? chalk.inverse(" ✔ Submit ") : " ✔ Submit ";
111
+ lines.push(`${chalk.dim("←")}${[...tabs, submit].join("")}${chalk.dim("→")}`, "");
112
+ }
113
+ const rows = formRows(questions, state.tab);
114
+ const mark = (i, text) => (i === state.cursor ? chalk.cyan(`❯ ${text}`) : ` ${text}`);
115
+ const q = questions[state.tab];
116
+ if (!q) {
117
+ // The review tab: every question with its answer, then Submit / Cancel.
118
+ lines.push(chalk.bold("Review your answers"), "");
119
+ questions.forEach((qq, i) => {
120
+ const answer = answerText(qq, state, i);
121
+ lines.push(` ● ${qq.question}`, answer ? chalk.green(` → ${answer}`) : chalk.yellow(" → (not answered)"));
122
+ });
123
+ lines.push("", chalk.dim("Ready to submit your answers?"), "");
124
+ rows.forEach((row, i) => lines.push(mark(i, `${i + 1}. ${row.kind === "submit" ? "Submit answers" : "Cancel"}`)));
125
+ }
126
+ else {
127
+ lines.push(chalk.bold(q.question), "");
128
+ const typedIndex = q.options.length;
129
+ rows.forEach((row, i) => {
130
+ const box = (on) => (q.multiSelect ? (on ? chalk.green("[✔] ") : "[ ] ") : "");
131
+ if (row.kind === "option") {
132
+ const option = q.options[row.index];
133
+ const on = state.picked[state.tab].includes(row.index);
134
+ const label = !q.multiSelect && on ? chalk.green(`${option.label} ✔`) : option.label;
135
+ lines.push(mark(i, `${row.index + 1}. ${box(on)}${label}`));
136
+ if (option.description)
137
+ lines.push(chalk.dim(` ${q.multiSelect ? " " : ""}${option.description}`));
138
+ }
139
+ else if (row.kind === "typed") {
140
+ const text = i === state.cursor && live !== undefined ? live : state.typed[state.tab];
141
+ const on = state.picked[state.tab].includes(typedIndex);
142
+ const shown = text ? (!q.multiSelect && on ? chalk.green(`${text} ✔`) : text) : chalk.dim("Type something");
143
+ lines.push(mark(i, `${typedIndex + 1}. ${box(on)}${shown}`));
144
+ }
145
+ else if (row.kind === "next") {
146
+ lines.push(mark(i, ` ${questions.length > 1 && state.tab < questions.length - 1 ? "Next" : "Submit"}`));
147
+ }
148
+ else if (row.kind === "chat") {
149
+ lines.push(chalk.dim(" ─────"), mark(i, `${typedIndex + 2}. Chat about this`));
150
+ }
151
+ });
77
152
  }
78
- lines.push(FORM_HINT);
153
+ lines.push("", chalk.dim(formHint(questions, state)));
79
154
  return lines.join("\n");
80
155
  }
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,13 +772,14 @@ 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
@@ -1147,18 +1188,34 @@ async function runAskUser(call, opts) {
1147
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
1148
1189
  if (!opts.askUser)
1149
1190
  return "[error] No interactive prompt is available in this session — ask in plain text instead.";
1150
- let questions = [];
1191
+ let raw = [];
1151
1192
  try {
1152
- questions = JSON.parse(call.args).questions ?? [];
1193
+ raw = JSON.parse(call.args).questions ?? [];
1153
1194
  }
1154
1195
  catch {
1155
1196
  /* fall through */
1156
1197
  }
1157
- // Keep only well-formed questions (text + at least two options).
1158
- 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
+ });
1159
1214
  if (!questions.length)
1160
1215
  return "[error] ask_user needs at least one question with two or more options.";
1161
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.";
1162
1219
  if (!answers)
1163
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
1164
1221
  return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
@@ -1383,8 +1440,8 @@ opts) {
1383
1440
  if (askUser && !opts.subAgent)
1384
1441
  opts.askUser = async (questions) => {
1385
1442
  const answers = await askUser(questions);
1386
- if (!opts.signal.aborted && !opts.isInterrupted()) {
1387
- for (const answer of answers ?? []) {
1443
+ if (Array.isArray(answers) && !opts.signal.aborted && !opts.isInterrupted()) {
1444
+ for (const answer of answers) {
1388
1445
  const question = questions.find((q) => q.question === answer.question);
1389
1446
  if (question && typeof answer.answer === "string" && answer.answer.trim()) {
1390
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();
@@ -2,7 +2,7 @@ import path from "node:path"; // used to resolve and split file paths
2
2
  import fs from "node:fs"; // resolve symlinks before auto mode grants file access
3
3
  import os from "node:os"; // `cd ~` in the recoverable-shell analyzer
4
4
  import { execFileSync } from "node:child_process"; // ask git whether a deletion target is disposable
5
- import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
5
+ import { CONFIG, GLOBAL_SETTINGS_PATH } from "./config.js"; // user-configured allow/deny rules from settings files
6
6
  import { emit } from "./telemetry.js"; // a bypassed approval leaves an audit row, not silence
7
7
  // ---- The no-fly zone --------------------------------------------------------
8
8
  // Writes here are never OK, in any mode. .git can destroy history, shell rc
@@ -216,6 +216,27 @@ export function gitWorkTree(target) {
216
216
  }
217
217
  return topLevels.get(dir);
218
218
  }
219
+ // Trusted ground outside any work tree: autoMode.trustedDirs from global
220
+ // settings, plus directories trusted from the approval menu this session.
221
+ // Home and / never count, for the same reason as above. Nor does the global
222
+ // settings file: it holds this very list, and an agent that could edit it
223
+ // unasked could widen its own reach.
224
+ const tooBroadToTrust = (dir) => dir === os.homedir() || dir === resolveFileTarget(os.homedir()) || dir === path.parse(dir).root;
225
+ const settingsFile = () => resolveFileTarget(GLOBAL_SETTINGS_PATH);
226
+ function trustedDir(target) {
227
+ if (target === settingsFile())
228
+ return false;
229
+ return CONFIG.autoMode.trustedDirs.some((dir) => {
230
+ let root;
231
+ try {
232
+ root = resolveFileTarget(dir);
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ return !tooBroadToTrust(root) && inside(root, target);
238
+ });
239
+ }
219
240
  // A deletion is recoverable when the target is scratch (inside a temp dir) or
220
241
  // git calls it disposable in its own repo: ignored (build output, caches) with
221
242
  // nothing tracked beneath it. Symlinks are left to review: `rm -r link/`
@@ -595,9 +616,13 @@ function basePermission(toolName, argsJson, auto) {
595
616
  return { decision: "deny", reason, summary: args.path };
596
617
  const relative = path.relative(fs.realpathSync(process.cwd()), resolved);
597
618
  // Outside the start directory is still recoverable in another git work
598
- // tree or a temp dir (git, /undo); anywhere else a human decides.
599
- if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved)) {
600
- return { decision: "ask", requiresHuman: true, reason: "resolved write target is outside the project", summary: `${args.path} → ${resolved}` };
619
+ // tree or a temp dir (git, /undo), and a trusted directory is one the
620
+ // user vouched for; anywhere else a human decides, and may trust the
621
+ // file's directory for the session rather than approve every write.
622
+ if (toolName !== "read_file" && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) && !inTempDir(resolved) && !gitWorkTree(resolved) && !trustedDir(resolved)) {
623
+ const dir = path.dirname(resolved);
624
+ return { decision: "ask", requiresHuman: true, reason: "resolved write target is outside the project", summary: `${args.path} → ${resolved}`,
625
+ ...(resolved === settingsFile() || tooBroadToTrust(dir) ? {} : { trustDir: dir }) };
601
626
  }
602
627
  }
603
628
  catch {
@@ -747,3 +772,30 @@ function resolveFileTarget(target) {
747
772
  }
748
773
  return path.join(fs.realpathSync(current), ...missing);
749
774
  }
775
+ // ---- The approval menu ----------------------------------------------------------
776
+ // Both frontends (readline and Ink) offer the same choices and apply the same
777
+ // session grant, so the menu lives next to the rules it widens. Outside auto
778
+ // mode the grant is "don't ask again for this tool"; in auto mode it is "trust
779
+ // this directory", offered only for an outside write. The last option is always
780
+ // "No", and so is -1 (Esc). `granted` is the note to show after a grant.
781
+ export function approvalMenu(auto, toolName, trustDir) {
782
+ const grant = auto
783
+ ? (trustDir ? `Yes, and trust ${trustDir}/ for this session` : null)
784
+ : (toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session");
785
+ const options = [auto ? "Yes, once" : "Yes", ...(grant ? [grant] : []), "No — let me tell the agent what to do instead"];
786
+ const choose = (i) => {
787
+ if (i < 0 || i >= options.length - 1)
788
+ return { approved: false };
789
+ if (i === 0)
790
+ return { approved: true };
791
+ if (auto && trustDir) {
792
+ CONFIG.autoMode.trustedDirs.push(trustDir);
793
+ return { approved: true, granted: `trusting ${trustDir}/ for this session` };
794
+ }
795
+ if (!toolName)
796
+ return { approved: true };
797
+ CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
798
+ return { approved: true, granted: `won't ask again for ${toolName} this session` };
799
+ };
800
+ return { options, choose };
801
+ }
package/dist/prompt.js CHANGED
@@ -21,7 +21,7 @@ Behavior rules:
21
21
  - You support SKILLS — reusable, named procedures saved as Markdown under .mini-agent/skills/ in the project (or ~/.config/mini-agent/skills/ globally). When a request matches one of the skills listed in the \`skill\` tool, call \`skill\` with its name to load the steps, then carry them out. If there is no \`skill\` tool in this session, none are loaded in this project yet — the capability still exists: the user adds one at .mini-agent/skills/<name>/SKILL.md, runs any with "/skill <name>", and lists them with "/skills". So when asked whether you support skills, the answer is yes — describe this mechanism (and which skills, if any, are currently loaded); never claim your toolset is fixed.
22
22
  - Report results accurately. If a test fails, say it failed. Don't claim success when you're not sure. Don't be overly self-doubting either — distinguish "verified working" from "I think it works".
23
23
  - Never add comments to code you did not change. Never reformat code you did not change.
24
- - Keep final answers under 150 words unless the task genuinely requires more.
24
+ - End every turn with a final message the user can take in at a glance. First line: the outcome (done, failed, found X, nothing to report). If that says it all, stop there, e.g. "No alerts — all normal." Otherwise follow it with short sections, each a bold label over bullet points: what you did, what you found (each problem: what, where, impact), what needs the user's decision. One fact per bullet; no filler, no restating the request. Under 150 words unless the task genuinely requires more. Write in the language the user writes in.
25
25
  - Between tool calls, write at most one short sentence — usually nothing.
26
26
  - A [permission] denial is a hard boundary. Do not attempt workarounds — explain and ask the user.
27
27
  - If the task is ambiguous, ask one clarifying question instead of guessing.`;
package/dist/title.js CHANGED
@@ -41,8 +41,9 @@ export function setTerminalTitle(title) {
41
41
  }
42
42
  export const refreshTerminalTitle = () => setTerminalTitle(lastTitle);
43
43
  // "Which window is this?" — another session asked. Ring the bell and blink the
44
- // tab title for a few seconds, then put the real title back.
45
- export function flashTerminalTitle(ms = 6000) {
44
+ // tab title for a few seconds, then put the real title back. A finished cron run
45
+ // uses it too, blinking its own label ("⏰ cron_1 done") instead.
46
+ export function flashTerminalTitle(ms = 6000, label) {
46
47
  if (!process.stdout.isTTY)
47
48
  return;
48
49
  process.stdout.write("\x07");
@@ -59,7 +60,7 @@ export function flashTerminalTitle(ms = 6000) {
59
60
  process.stdout.write(terminalTitleSequence(tabTitle(lastTitle)));
60
61
  return;
61
62
  }
62
- process.stdout.write(terminalTitleSequence(on ? `👋 ${name} — HERE 👋` : tabTitle(lastTitle)));
63
+ process.stdout.write(terminalTitleSequence(on ? label ?? `👋 ${name} — HERE 👋` : tabTitle(lastTitle)));
63
64
  }, 500);
64
65
  flashing.unref();
65
66
  }
package/dist/tools.js CHANGED
@@ -23,7 +23,8 @@ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering
23
23
  const READ_LIMIT = 16000; // read_file gets a larger cap of its own
24
24
  const SEARCH_MAX_MATCHES = 50; // max matches returned by search
25
25
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
26
- const BASH_TIMEOUT_MS = 30_000; // a single command may run this long before we kill it
26
+ const BASH_TIMEOUT_MS = 120_000; // a single command may run this long before we kill it (Claude Code's 2 minutes)
27
+ const BASH_MAX_TIMEOUT_MS = 600_000; // the most a command may ask for; anything longer belongs in the background
27
28
  const BASH_OUTPUT_LIMIT = 8_000; // chars of command output kept inline; beyond this we spill to a file
28
29
  const SPILL_NOTE_SLACK = 400; // room for the "[...saved to FILE]" note so dispatch's cap never eats it
29
30
  // Errors are not exceptions — they are text that tells the model what to do next.
@@ -189,17 +190,27 @@ function spillIfHuge(out, what) {
189
190
  const head = out.slice(0, BASH_OUTPUT_LIMIT); // a generous preview
190
191
  return `${head}\n\n[...${what} truncated: ${out.length} chars total. Full output saved to ${file} — use read_file to inspect more.]`;
191
192
  }
193
+ // The watchdog for one run_bash call: the model may ask for more (or less) than
194
+ // the default, never past the maximum. Anything that isn't a positive number of
195
+ // seconds — missing, "", garbage — gets the default.
196
+ export function bashTimeoutMs(seconds) {
197
+ const s = Number(seconds);
198
+ return Number.isFinite(s) && s > 0 ? Math.min(Math.round(s * 1000), BASH_MAX_TIMEOUT_MS) : BASH_TIMEOUT_MS;
199
+ }
192
200
  const runBash = {
193
- definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
201
+ definition: def("run_bash", `Run a bash command on the user's machine and return its output. It is killed after 2 minutes unless you pass a longer timeout (up to 600 seconds) — set one when you expect a slow command (a full test suite, a build). Longer, or work you don't need to wait for: run_bash_background.
194
202
  Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
195
203
  To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
196
204
  Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
197
- On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
205
+ On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, {
206
+ command: { type: "string", description: "The bash command to run" },
207
+ timeout: { type: "number", description: "Optional: seconds before the command is killed (default 120, max 600)" },
208
+ }, ["command"]),
198
209
  // Async + spawn: the event loop stays responsive while the command runs, so
199
210
  // the spinner keeps spinning and Ctrl+C is heard. execSync would freeze
200
211
  // everything — the whole process, the UI, the signal handler — until it
201
212
  // returned. The AbortSignal lets the loop kill the child on interruption
202
- // instead of waiting out the 30s timeout.
213
+ // instead of waiting out the timeout.
203
214
  run: (args, signal) => new Promise((resolve) => {
204
215
  const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: childEnv() }); // shell:true → bash semantics
205
216
  let stdout = ""; // captured stdout
@@ -214,10 +225,12 @@ On error: failures return the error output — analyze it and try a different ap
214
225
  resolve(text); // hand the result back
215
226
  };
216
227
  // Hard timeout: SIGKILL the whole process group, never hang the agent.
228
+ const timeoutMs = bashTimeoutMs(args.timeout);
217
229
  const timer = setTimeout(() => {
218
230
  child.kill("SIGKILL");
219
- done(fail(`Command timed out after ${BASH_TIMEOUT_MS / 1000}s and was killed.${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
220
- }, BASH_TIMEOUT_MS);
231
+ const hint = timeoutMs < BASH_MAX_TIMEOUT_MS ? ` If it needs longer, rerun with a larger timeout (max ${BASH_MAX_TIMEOUT_MS / 1000}) or use run_bash_background.` : " Use run_bash_background for anything this long.";
232
+ done(fail(`Command timed out after ${timeoutMs / 1000}s and was killed.${hint}${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
233
+ }, timeoutMs);
221
234
  // Ctrl+C while a command runs: kill the child immediately.
222
235
  const onAbort = () => {
223
236
  child.kill("SIGKILL");
@@ -240,17 +253,19 @@ On error: failures return the error output — analyze it and try a different ap
240
253
  }),
241
254
  };
242
255
  // ============ run_bash_background (Day 37) ============
243
- // run_bash blocks and dies at its 30s watchdog — useless for anything truly
244
- // slow. This is its asynchronous twin: it spawns the command and returns a task
245
- // id IMMEDIATELY, so the agent keeps reasoning while the work happens. The loop
246
- // injects a <task_notification> when the command finishes (background.ts). Use
247
- // it for installs, builds, full test runs, or servers that never exit — exactly
248
- // the keywords that signal a slow operation. The command goes through the SAME
249
- // permission gate as run_bash (see permissions.ts), so the danger rules are
250
- // identical; only the blocking behavior differs.
256
+ // run_bash blocks, and dies at its watchdog (2 minutes, 10 at most) — useless
257
+ // for anything truly slow. This is its asynchronous twin: it spawns the command
258
+ // and returns a task id IMMEDIATELY, so the agent keeps reasoning while the work
259
+ // happens. The loop injects a <task_notification> when the command finishes
260
+ // (background.ts); if the agent is idle by then, the frontend wakes it with one
261
+ // (loop.ts, takeFinishedWork). Use it for installs, builds, full test runs, or
262
+ // servers that never exit — exactly the keywords that signal a slow operation.
263
+ // The command goes through the SAME permission gate as run_bash (see
264
+ // permissions.ts), so the danger rules are identical; only the blocking
265
+ // behavior differs.
251
266
  const runBashBackground = {
252
267
  definition: def("run_bash_background", `Run a slow bash command in the BACKGROUND and return immediately with a task id (e.g. bg_1) — do NOT wait for it.
253
- When to use: anything that would exceed run_bash's 30s limit or block you — installing dependencies (npm/pip install), builds (npm run build, cargo build, make), full test suites (pytest, npm test), deploys, or long-running servers (npm run dev) that never exit.
268
+ When to use: anything that could outlast run_bash's timeout (10 minutes at most) or that you don't need to wait for — installing dependencies (npm/pip install), builds (npm run build, cargo build, make), full test suites (pytest, npm test), deploys, or long-running servers (npm run dev) that never exit.
254
269
  What you get back: just the task id. The command keeps running. When it finishes you'll receive a <task_notification> with its status and output tail — react to that. For a server or a job still running, poll it with bash_output.
255
270
  Boundaries & permissions are identical to run_bash (dangerous commands are still gated or denied). For quick commands use run_bash instead — waiting on a one-off background job wastes a turn.`, { command: { type: "string", description: "The bash command to run in the background" } }, ["command"]),
256
271
  // Fire-and-return: start the process, hand back the id. The output is captured
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"