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 +28 -1
- package/dist/agent.js +8 -11
- package/dist/auto-budget.js +8 -1
- package/dist/auto-context.js +1 -1
- package/dist/auto-review.js +8 -3
- package/dist/auto.js +2 -1
- package/dist/background.js +11 -6
- package/dist/config.js +5 -0
- package/dist/cron.js +7 -3
- package/dist/form.js +141 -66
- package/dist/ink/app.js +78 -35
- package/dist/loop.js +73 -16
- package/dist/menu.js +60 -22
- package/dist/permissions.js +56 -4
- package/dist/prompt.js +1 -1
- package/dist/title.js +4 -3
- package/dist/tools.js +30 -15
- package/package.json +1 -1
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
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
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
|
};
|
package/dist/auto-budget.js
CHANGED
|
@@ -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; }
|
package/dist/auto-context.js
CHANGED
|
@@ -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;
|
package/dist/auto-review.js
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/dist/background.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ============ Background tasks (Day 37) ============
|
|
2
|
-
// The problem: run_bash blocks. A command that takes longer than its
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// tokens we aren't using, while a child process churns.
|
|
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
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
function
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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.
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
93
|
+
return questions.map((q, i) => ({ question: q.question, answer: answerText(q, state, i) || "(unanswered)" }));
|
|
51
94
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!autoMode.enabled && approved && i === 1 && toolName)
|
|
288
|
-
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
289
|
-
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
282
|
+
confirm: (question, toolName, trustDir) => new Promise((resolve) => {
|
|
283
|
+
const menu = approvalMenu(autoMode.enabled, toolName, trustDir); // same choices and session grants as the readline REPL
|
|
284
|
+
openSelect(`⚠ approval needed — ${question}`, menu.options, (i) => {
|
|
285
|
+
const { approved, granted } = menu.choose(i);
|
|
286
|
+
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}${granted ? chalk.dim(` (${granted})`) : ""}`);
|
|
290
287
|
resolve(approved);
|
|
291
288
|
});
|
|
292
289
|
}),
|
|
@@ -349,6 +346,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
349
346
|
// is saved: Stop does not fire on an interrupt or an error, and fires
|
|
350
347
|
// before the save. An orchestrator reads this as "idle, transcript current".
|
|
351
348
|
void runHooks("TurnEnd", { reason: ending.reason, last_assistant_message: ending.text });
|
|
349
|
+
afterTurn?.();
|
|
352
350
|
});
|
|
353
351
|
};
|
|
354
352
|
// Covers immediate interruption and a message arriving as the turn finishes.
|
|
@@ -375,7 +373,29 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
375
373
|
return;
|
|
376
374
|
const content = fired.map(cronTriggerContent).join("\n\n---\n\n");
|
|
377
375
|
const label = fired.length === 1 ? `⏰ cron ${fired[0].id} fired (${fired[0].cron})` : `⏰ ${fired.length} cron jobs fired`;
|
|
378
|
-
|
|
376
|
+
// A cron NOTE (not a user bar); the agent runs it and reports. When the run
|
|
377
|
+
// is over, ring the bell and blink the tab: you were probably not watching.
|
|
378
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(label) }, [], false, () => flashTerminalTitle(6000, `⏰ ${fired.map((j) => j.id).join(", ")} done`));
|
|
379
|
+
}, 2000);
|
|
380
|
+
return () => clearInterval(id);
|
|
381
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
382
|
+
}, [busy, pending]);
|
|
383
|
+
// The FINISHED-WORK idle processor: a background job (run_bash_background) or
|
|
384
|
+
// a sub-agent (task) can finish after the turn that started it ended. Its
|
|
385
|
+
// result must still reach the model, so while idle we start a turn with it and
|
|
386
|
+
// the agent carries on, without waiting for your next message. Bell and tab
|
|
387
|
+
// blink when that turn is done: long work usually finishes while you're away.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (busy || pending || followUps.size)
|
|
390
|
+
return; // human follow-ups take priority
|
|
391
|
+
const id = setInterval(() => {
|
|
392
|
+
if (turn.current || !finishedWorkPending())
|
|
393
|
+
return;
|
|
394
|
+
const content = takeFinishedWork();
|
|
395
|
+
if (!content)
|
|
396
|
+
return;
|
|
397
|
+
const ids = [...content.matchAll(/<(?:task_id|id)>([^<]+)<\//g)].map((m) => m[1]).join(", ");
|
|
398
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(`⚙ ${ids || "background work"} finished — the agent picks it up`) }, [], false, () => flashTerminalTitle(6000, `⚙ ${ids || "background work"} done`));
|
|
379
399
|
}, 2000);
|
|
380
400
|
return () => clearInterval(id);
|
|
381
401
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -1010,31 +1030,54 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1010
1030
|
}
|
|
1011
1031
|
}
|
|
1012
1032
|
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
1033
|
+
// ↑/↓ rows, ←/→ or Tab questions, Enter chooses, Esc cancels. On a "Type
|
|
1034
|
+
// something" row the input box IS the answer's editor: it travels with each
|
|
1035
|
+
// action, is loaded when the cursor arrives and cleared when it leaves.
|
|
1036
|
+
// Anywhere else, text in the input box is a follow-up, as before.
|
|
1013
1037
|
if (pending?.kind === "form" && formState) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
else if ((key.return || modifiedReturn) && !input.trim()) {
|
|
1023
|
-
const next = reduceForm(pending.questions, formState, "select");
|
|
1024
|
-
setFormState(next.state);
|
|
1025
|
-
if (next.done) {
|
|
1026
|
-
pending.resolve(collectAnswers(pending.questions, next.state));
|
|
1038
|
+
const questions = pending.questions;
|
|
1039
|
+
const typing = onTypedRow(questions, formState);
|
|
1040
|
+
const act = (action) => {
|
|
1041
|
+
const next = reduceForm(questions, formState, action, typing ? input : undefined);
|
|
1042
|
+
const close = (result) => {
|
|
1043
|
+
pending.resolve(result);
|
|
1027
1044
|
setPending(null);
|
|
1028
1045
|
setFormState(null);
|
|
1046
|
+
if (typing) {
|
|
1047
|
+
setInput("");
|
|
1048
|
+
setCursor(0);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
if (next.outcome === "submit")
|
|
1052
|
+
return close(collectAnswers(questions, next.state));
|
|
1053
|
+
if (next.outcome === "chat")
|
|
1054
|
+
return close("chat");
|
|
1055
|
+
if (next.outcome === "cancel")
|
|
1056
|
+
return close(null);
|
|
1057
|
+
setFormState(next.state);
|
|
1058
|
+
const moved = next.state.tab !== formState.tab || next.state.cursor !== formState.cursor;
|
|
1059
|
+
const stored = next.state.typed[next.state.tab] ?? "";
|
|
1060
|
+
if (onTypedRow(questions, next.state) && moved && stored) {
|
|
1061
|
+
setInput(stored);
|
|
1062
|
+
setCursor(stored.length);
|
|
1029
1063
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
return;
|
|
1037
|
-
|
|
1064
|
+
else if (typing && !onTypedRow(questions, next.state)) {
|
|
1065
|
+
setInput("");
|
|
1066
|
+
setCursor(0);
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
if (key.upArrow)
|
|
1070
|
+
return act("up");
|
|
1071
|
+
if (key.downArrow)
|
|
1072
|
+
return act("down");
|
|
1073
|
+
if (key.tab)
|
|
1074
|
+
return act(key.shift ? "left" : "right");
|
|
1075
|
+
if ((key.leftArrow || key.rightArrow) && !input)
|
|
1076
|
+
return act(key.leftArrow ? "left" : "right");
|
|
1077
|
+
if ((key.return || modifiedReturn) && (typing || !input.trim()))
|
|
1078
|
+
return act("enter");
|
|
1079
|
+
if (key.escape)
|
|
1080
|
+
return act("escape");
|
|
1038
1081
|
}
|
|
1039
1082
|
if ((key.tab || (key.rightArrow && !input)) && placeholder) { // Tab (or →) turns the predicted next message into real text; Enter then sends it
|
|
1040
1083
|
setInput(placeholder);
|
|
@@ -1207,7 +1250,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1207
1250
|
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
1208
1251
|
const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
|
|
1209
1252
|
const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
|
|
1210
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && pending.kind !== "skills" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
1253
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState, onTypedRow(pending.questions, formState) ? input : undefined) }) })), pending && pending.kind !== "skills" && pending.kind !== "form" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), pending?.kind === "form" && formState && !onTypedRow(pending.questions, formState) && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Or type a follow-up below \u00B7 Enter sends it instead of answering" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
1211
1254
|
const prompt = talkTo ? `→ ${talkTo.name} ❯ ` : "❯ ";
|
|
1212
1255
|
const promptColor = talkTo ? "magenta" : planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
1213
1256
|
const cols = columns;
|
package/dist/loop.js
CHANGED
|
@@ -20,7 +20,7 @@ import { recordToolCall, recordToolResult, recordToolDetail, recordReasoning } f
|
|
|
20
20
|
import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
|
|
21
21
|
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
22
22
|
import { currentSkills, skillBodyMessages, skillListingReminder, syncSkillTool } from "./skills.js"; // skill listing reminders + launched skill bodies
|
|
23
|
-
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
23
|
+
import { pendingNotifications, hasPendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
24
24
|
import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
|
|
25
25
|
import { getGoal, updateGoalTool, runUpdateGoal, countGoalToolCall } from "./goal.js"; // /goal (Day 41): the model ends a durable goal with update_goal
|
|
26
26
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
@@ -105,21 +105,35 @@ const askUserTool = {
|
|
|
105
105
|
function: {
|
|
106
106
|
name: "ask_user",
|
|
107
107
|
description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
|
|
108
|
-
|
|
108
|
+
Ask 1–4 questions, each with 2–4 concrete options; each option a short label plus a one-line description of what choosing it means. Put a recommended option first and end its label with "(Recommended)". Set multiSelect when several options can apply together.
|
|
109
|
+
The user can always type their own answer or choose to chat instead, so never add an "Other" option. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
|
|
109
110
|
If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
|
|
110
111
|
parameters: {
|
|
111
112
|
type: "object",
|
|
112
113
|
properties: {
|
|
113
114
|
questions: {
|
|
114
115
|
type: "array",
|
|
115
|
-
description: "The questions to ask
|
|
116
|
+
description: "The questions to ask (1–4)",
|
|
116
117
|
items: {
|
|
117
118
|
type: "object",
|
|
118
119
|
properties: {
|
|
119
|
-
question: { type: "string", description: "The question
|
|
120
|
-
|
|
120
|
+
question: { type: "string", description: "The full question, ending with a question mark" },
|
|
121
|
+
header: { type: "string", description: "A very short tab label, at most 12 characters, e.g. \"Branch\", \"Auth\"" },
|
|
122
|
+
options: {
|
|
123
|
+
type: "array",
|
|
124
|
+
description: "2–4 concrete choices",
|
|
125
|
+
items: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
label: { type: "string", description: "The choice, 1–5 words" },
|
|
129
|
+
description: { type: "string", description: "What choosing it means, one line" },
|
|
130
|
+
},
|
|
131
|
+
required: ["label", "description"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
multiSelect: { type: "boolean", description: "true when the user may pick several options" },
|
|
121
135
|
},
|
|
122
|
-
required: ["question", "options"],
|
|
136
|
+
required: ["question", "header", "options"],
|
|
123
137
|
},
|
|
124
138
|
},
|
|
125
139
|
},
|
|
@@ -340,6 +354,17 @@ function pendingSubAgentResults() {
|
|
|
340
354
|
return "";
|
|
341
355
|
return `A sub-agent you delegated work to has finished. Read its report carefully — it is INPUT MATERIAL, not verified truth. Verify key claims yourself, then continue.\n\n${done.join("\n\n")}`;
|
|
342
356
|
}
|
|
357
|
+
// Background jobs and sub-agents outlive the turn that started them. One that
|
|
358
|
+
// finishes while the agent is idle must still reach the model: the frontend's
|
|
359
|
+
// idle processor asks finishedWorkPending(), then starts a turn whose content
|
|
360
|
+
// is takeFinishedWork() (consumed, so each result arrives exactly once) — the
|
|
361
|
+
// agent picks up where it left off, as Claude Code re-invokes on completion.
|
|
362
|
+
export function finishedWorkPending() {
|
|
363
|
+
return hasPendingNotifications() || [...pendingSubAgents.values()].some((sa) => sa.result !== null && !sa.notified);
|
|
364
|
+
}
|
|
365
|
+
export function takeFinishedWork() {
|
|
366
|
+
return [pendingNotifications(), pendingSubAgentResults()].filter(Boolean).join("\n\n");
|
|
367
|
+
}
|
|
343
368
|
// Inject finished sub-agent results into the conversation, like
|
|
344
369
|
// injectBackgroundNotifications. Returns true if something was injected.
|
|
345
370
|
function injectSubAgentResults(messages, opts) {
|
|
@@ -652,6 +677,7 @@ async function authorizeCall(call, opts) {
|
|
|
652
677
|
let autoAllowed = false;
|
|
653
678
|
let reviewReason = "";
|
|
654
679
|
let reviewId = ""; // set when auto review ran; joins the human's answer to that review
|
|
680
|
+
let escalated = false; // a doubt reached the human only because the agent kept hitting it
|
|
655
681
|
// Auto mode's line is recoverability: a tool its server declares read-only
|
|
656
682
|
// or merely additive loses nothing, so it runs without review (plan mode
|
|
657
683
|
// already denied above).
|
|
@@ -681,6 +707,20 @@ async function authorizeCall(call, opts) {
|
|
|
681
707
|
recordToolDetail(call.id, review.reason);
|
|
682
708
|
return `[permission] Auto-denied (not a user refusal): ${review.reason}. Not executed. Choose a permitted approach or report the restriction. Do not retry the same prohibited effect through another tool, wrapper or worker.`;
|
|
683
709
|
}
|
|
710
|
+
// A doubt that the user asked for this goes back to the agent, not to the
|
|
711
|
+
// human — Claude Code's denial policy. The agent drops the step, takes one
|
|
712
|
+
// within the request, or asks with ask_user (a submitted answer is fresh
|
|
713
|
+
// authorization). Only an agent stuck against the reviewer (3 in a row, 20
|
|
714
|
+
// per task) brings the question to the human after all. Judge-only
|
|
715
|
+
// review outside auto mode keeps the ordinary prompt.
|
|
716
|
+
if (auto && review.unauthorized && !opts.reviewBudget?.escalated) {
|
|
717
|
+
opts.reviewBudget?.block();
|
|
718
|
+
emit("agent_tool_auto_blocked", { tool: call.name, ...(reviewId ? { reviewId } : {}) });
|
|
719
|
+
if (!opts.quiet)
|
|
720
|
+
recordToolDetail(call.id, review.reason);
|
|
721
|
+
return `[permission] Auto-blocked (not a user refusal): ${review.reason}. Not executed: the reviewer could not tie this action to what the user asked for. Drop it, take a step within the request, or, if it is truly needed, ask the user with ask_user (their answer counts as authorization). Do not retry the same effect through another tool, wrapper or worker.`;
|
|
722
|
+
}
|
|
723
|
+
escalated = auto && !!review.unauthorized;
|
|
684
724
|
autoAllowed = review.decision === "allow";
|
|
685
725
|
reviewReason = review.reason;
|
|
686
726
|
if (!opts.quiet)
|
|
@@ -732,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
|
-
//
|
|
739
|
-
//
|
|
778
|
+
// a doubt outlived the agent's retries ("escalated"), or a rule required a
|
|
779
|
+
// human without review ("requires_human"). Approved is a likely false
|
|
780
|
+
// block, declined a correct one; "unattended" reached no one.
|
|
740
781
|
if (auto && !autoAllowed)
|
|
741
|
-
emit("agent_auto_human", { tool: call.name, source: reviewId ? "review" : v.requiresHuman ? "requires_human" : "no_review",
|
|
782
|
+
emit("agent_auto_human", { tool: call.name, source: reviewId ? (escalated ? "escalated" : "review") : v.requiresHuman ? "requires_human" : "no_review",
|
|
742
783
|
decision: ok ? "approved" : opts.canPrompt === false ? "unattended" : "declined", ...(reviewId ? { reviewId } : {}) });
|
|
743
784
|
if (!ok)
|
|
744
785
|
emit("agent_tool_declined", { tool: call.name, ...(reviewId ? { reviewId } : {}) }); // the human said no — that is signal
|
|
@@ -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
|
|
1191
|
+
let raw = [];
|
|
1151
1192
|
try {
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
+
// Keys: ↑/↓ rows, ←/→ or Tab questions, Enter chooses, Esc cancels. On a "Type
|
|
131
|
+
// something" row the typed keys edit the answer; anywhere else they start a
|
|
132
|
+
// follow-up, which (like promptSelect) cancels the form when sent.
|
|
133
|
+
// Returns the answers, "chat" (talk instead of answering), or null if
|
|
134
|
+
// cancelled / not a TTY.
|
|
130
135
|
export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
|
|
131
136
|
return new Promise((resolve) => {
|
|
132
137
|
if (!input.isTTY || !questions.length)
|
|
133
138
|
return resolve(null); // no form without a terminal or questions
|
|
134
139
|
let state = initFormState(questions);
|
|
140
|
+
let answer = { buffer: "", cursor: 0 }; // the "Type something" editor
|
|
135
141
|
let done = false;
|
|
136
142
|
const wasRaw = input.isRaw === true; // restore this on exit, not false (see promptSelect)
|
|
137
|
-
|
|
143
|
+
let lineCount = 0; // tabs differ in height: redraw over whatever the last frame was
|
|
138
144
|
const draw = (first) => {
|
|
145
|
+
const typing = onTypedRow(questions, state);
|
|
146
|
+
const frame = `${renderForm(questions, state, typing ? answer.buffer : undefined)}${typing ? "" : draft.render()}`;
|
|
139
147
|
if (!first)
|
|
140
|
-
process.stdout.write(`\x1b[${lineCount}A`); // up over the
|
|
141
|
-
process.stdout.write(`\x1b[J${
|
|
148
|
+
process.stdout.write(`\x1b[${lineCount}A`); // up over the previous frame
|
|
149
|
+
process.stdout.write(`\x1b[J${frame}\n`);
|
|
150
|
+
lineCount = frame.split("\n").length;
|
|
142
151
|
};
|
|
143
152
|
const finish = (result) => {
|
|
144
153
|
if (done)
|
|
@@ -154,31 +163,60 @@ export function promptForm(rl, questions, input = process.stdin, onFollowUp) {
|
|
|
154
163
|
rl.resume();
|
|
155
164
|
resolve(result);
|
|
156
165
|
};
|
|
166
|
+
// Apply one action. The answer editor travels with it while on a typed row,
|
|
167
|
+
// and follows the cursor: loaded on arrival, cleared on departure.
|
|
168
|
+
const act = (action) => {
|
|
169
|
+
const typing = onTypedRow(questions, state);
|
|
170
|
+
const next = reduceForm(questions, state, action, typing ? answer.buffer : undefined);
|
|
171
|
+
const arriving = onTypedRow(questions, next.state) && (!typing || next.state.tab !== state.tab || next.state.cursor !== state.cursor);
|
|
172
|
+
state = next.state;
|
|
173
|
+
if (next.outcome === "submit")
|
|
174
|
+
return finish(collectAnswers(questions, state));
|
|
175
|
+
if (next.outcome === "chat")
|
|
176
|
+
return finish("chat");
|
|
177
|
+
if (next.outcome === "cancel")
|
|
178
|
+
return finish(null);
|
|
179
|
+
if (arriving) {
|
|
180
|
+
const text = state.typed[state.tab];
|
|
181
|
+
answer = { buffer: text, cursor: text.length };
|
|
182
|
+
}
|
|
183
|
+
else if (!onTypedRow(questions, state))
|
|
184
|
+
answer = { buffer: "", cursor: 0 };
|
|
185
|
+
draw(false);
|
|
186
|
+
};
|
|
157
187
|
const draft = promptDraft(onFollowUp, () => draw(false), () => finish(null));
|
|
158
188
|
const onKey = (str, key) => {
|
|
159
189
|
if (!key || done)
|
|
160
190
|
return;
|
|
161
|
-
if (
|
|
191
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
192
|
+
return act("escape");
|
|
193
|
+
const typing = onTypedRow(questions, state);
|
|
194
|
+
if (key.name === "up")
|
|
195
|
+
return act("up");
|
|
196
|
+
if (key.name === "down")
|
|
197
|
+
return act("down");
|
|
198
|
+
if (key.name === "tab")
|
|
199
|
+
return act(key.shift ? "left" : "right");
|
|
200
|
+
// ←/→ switch questions, unless there is text to move the caret through.
|
|
201
|
+
if ((key.name === "left" || key.name === "right") && !(typing ? answer.buffer : draft.hasText()))
|
|
202
|
+
return act(key.name);
|
|
203
|
+
if (key.name === "return" || key.name === "enter") {
|
|
204
|
+
if (typing || !draft.handle(str, key))
|
|
205
|
+
act("enter"); // a drafted follow-up is sent by its own editor
|
|
162
206
|
return;
|
|
163
|
-
if (key.name === "up" || key.name === "k") {
|
|
164
|
-
state = reduceForm(questions, state, "up").state;
|
|
165
|
-
draw(false);
|
|
166
207
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
|
172
|
-
const next = reduceForm(questions, state, "select");
|
|
173
|
-
state = next.state;
|
|
174
|
-
if (next.done)
|
|
175
|
-
finish(collectAnswers(questions, state)); // submitted with all answered
|
|
176
|
-
else
|
|
208
|
+
if (typing) {
|
|
209
|
+
const next = reduceEditor(answer, str, key);
|
|
210
|
+
if (next.action === "edit") {
|
|
211
|
+
answer = next.state;
|
|
177
212
|
draw(false);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
178
215
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
216
|
+
if (draft.handle(str, key))
|
|
217
|
+
return;
|
|
218
|
+
if (key.name === "space")
|
|
219
|
+
act("enter"); // space toggles / chooses too
|
|
182
220
|
};
|
|
183
221
|
try {
|
|
184
222
|
rl.pause();
|
package/dist/permissions.js
CHANGED
|
@@ -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)
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.`, {
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
244
|
-
// slow. This is its asynchronous twin: it spawns the command
|
|
245
|
-
// id IMMEDIATELY, so the agent keeps reasoning while the work
|
|
246
|
-
// injects a <task_notification> when the command finishes
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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
|
|
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
|