agent-from-zero 0.35.4 → 0.35.6
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 +24 -1
- package/dist/agent.js +90 -3
- package/dist/cli.js +3 -2
- package/dist/completion.js +1 -0
- package/dist/config.js +4 -0
- package/dist/goal.js +365 -0
- package/dist/ink/app.js +76 -5
- package/dist/ink/commands.js +2 -0
- package/dist/ink/setup.js +12 -0
- package/dist/loop.js +45 -9
- package/dist/permissions.js +4 -0
- package/dist/print.js +68 -0
- package/dist/session.js +3 -0
- package/dist/suggestion.js +55 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -115,6 +115,12 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
115
115
|
|
|
116
116
|
输入 `/mcp` 选一个服务器,会看到和 Claude Code 一样的详情(状态、登录、协议版本、URL、配置文件位置、能力、工具数)和操作:View tools、Re-authenticate、Clear authentication、Reconnect、Disable。也可以直接敲 `/mcp tools|auth|clear-auth|reconnect|disable|enable <名字>`。
|
|
117
117
|
|
|
118
|
+
### 下一句预测
|
|
119
|
+
|
|
120
|
+
和 Claude Code 一样,每轮对话结束后,mini-agent 会猜你下一句大概要说什么(比如 “跑一下测试”、“提交并推送”),灰色显示在空的输入框里。按 Tab(或 →)变成真正的输入,可以改,也可以直接 Enter 发送。开始打字它就消失。
|
|
121
|
+
|
|
122
|
+
生成预测要多调一次模型,但请求内容和刚结束的主调用相同,只在最后加了一条指令,所以基本都能命中 DeepSeek 的前缀缓存,费用很低,也会算进 `/cost`。定时任务和其他实例发来的消息触发的回合不做预测。不想要可以在 `settings.json` 里设 `"promptSuggestions": false`,或者单次运行时设环境变量 `MINI_AGENT_PROMPT_SUGGESTIONS=0`。
|
|
123
|
+
|
|
118
124
|
### 多开实例互相通信
|
|
119
125
|
|
|
120
126
|
在几个终端里各开一个 mini-agent(比如一个在后端仓库、一个在前端仓库),它们会自动互相发现,不用配置。
|
|
@@ -127,17 +133,34 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
127
133
|
|
|
128
134
|
所有数据都存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。
|
|
129
135
|
|
|
136
|
+
### 目标模式 /goal
|
|
137
|
+
|
|
138
|
+
普通对话里,模型觉得做完了就停。`/goal` 把"做完没有"的判断从模型手里拿走:只要目标还没验证完成,模型每次停下,mini-agent 都会自动开下一轮让它接着干。
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
/goal 让 npm test 全部通过 --check "npm test"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- 模型只能用 `update_goal` 工具结束目标:`blocked` 表示需要你介入,`complete` 表示宣布完成。宣布完成要过两关:先跑你写的 `--check` 命令,退出码必须是 0,这条命令模型改不了;再由一次独立的模型调用对照最近的工具输出核对它的说法,拿不准就算没完成。
|
|
145
|
+
- 不设费用上限,靠"有没有进展"兜底:一轮目标回合里一个工具都没调,立刻暂停;连续 3 轮工作区毫无变化,也暂停。按 Esc 同样会暂停。
|
|
146
|
+
- 你随时可以插话,你的消息永远优先,处理完目标再继续。plan 模式下不会自动续跑。
|
|
147
|
+
- `/goal` 查看状态,`/goal pause`、`/goal resume`、`/goal clear` 管理生命周期。
|
|
148
|
+
- 目标存在会话文件里,`--resume` 或 `/resume` 回来以后,没做完的目标会接着跑。
|
|
149
|
+
|
|
130
150
|
### 非交互模式
|
|
131
151
|
|
|
132
152
|
```bash
|
|
133
153
|
npx agent-from-zero -p "总结当前改动"
|
|
134
154
|
cat error.log | npx agent-from-zero -p "分析这份日志"
|
|
135
155
|
npx agent-from-zero -p "总结当前改动" --output-format json
|
|
156
|
+
npx agent-from-zero -p "修一下这个 bug" --output-format stream-json
|
|
136
157
|
```
|
|
137
158
|
|
|
159
|
+
`stream-json` 和 `claude -p --output-format stream-json` 同一格式:每行一个 JSON 事件,依次是 `system/init`、`assistant`(text / thinking / tool_use 块)、`user`(tool_result)、最后一行 `result`。为了兼容 Claude 的脚本,`--verbose` 也能传,不影响输出。
|
|
160
|
+
|
|
138
161
|
## 常用命令
|
|
139
162
|
|
|
140
|
-
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
163
|
+
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/goal` `/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
141
164
|
|
|
142
165
|
## 开发
|
|
143
166
|
|
package/dist/agent.js
CHANGED
|
@@ -5,7 +5,7 @@ import chalk from "chalk"; // terminal colors
|
|
|
5
5
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
6
6
|
import { createRequire } from "node:module"; // to read package.json for --version
|
|
7
7
|
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
8
|
-
import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
|
|
8
|
+
import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents, topLevelToolNames } from "./loop.js"; // the state machine
|
|
9
9
|
import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
10
10
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
11
11
|
import { compactHistory, compactThreshold, contextPercent, contextWindowFor } from "./context.js"; // /compact, /model info, the ctx % in the status line
|
|
@@ -18,7 +18,7 @@ import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
|
18
18
|
import { AutoMode } from "./auto.js";
|
|
19
19
|
import { effortMenu, setEffort, selectedEffort } from "./effort.js";
|
|
20
20
|
import { parseCli, applyCliOptions, readPrintPrompt, CliUsageError } from "./cli.js";
|
|
21
|
-
import { reservePrintOutput, formatPrintResult } from "./print.js";
|
|
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
24
|
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
@@ -41,6 +41,7 @@ import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default fro
|
|
|
41
41
|
import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
|
|
42
42
|
import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
|
|
43
43
|
import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
|
|
44
|
+
import { goalCommand, getGoal, restoreGoal, clearGoal, startGoalTurn, settleGoalTurn } from "./goal.js"; // /goal (Day 41): keep working until the goal is verified done
|
|
44
45
|
import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "./cron.js"; // cron scheduler (Day s14)
|
|
45
46
|
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
46
47
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
@@ -59,7 +60,7 @@ Usage:
|
|
|
59
60
|
--effort <level> supported effort for this model, or default
|
|
60
61
|
--permission-mode <mode> default | auto | bypassPermissions (this run only)
|
|
61
62
|
--dangerously-skip-permissions alias for --permission-mode bypassPermissions
|
|
62
|
-
--output-format text|json final answer
|
|
63
|
+
--output-format text|json|stream-json final answer, a JSON result, or Claude-style NDJSON events (print mode only)
|
|
63
64
|
mini-agent --auto review tool actions automatically (Jev or your current vendor)
|
|
64
65
|
mini-agent -v | --version print the version
|
|
65
66
|
mini-agent -h | --help this text
|
|
@@ -108,6 +109,7 @@ const SESSION_HELP = `commands:
|
|
|
108
109
|
/bg list background tasks this session (run_bash_background) and their status
|
|
109
110
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
110
111
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
112
|
+
/goal <objective> [--check <cmd>] keep working, turn after turn, until the goal is verified done (/goal alone shows it; pause | resume | clear)
|
|
111
113
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
112
114
|
/peers list other mini-agent sessions on this machine (the agent can message them with send_message)
|
|
113
115
|
/rename <name> rename this session — the name other sessions use to reach it
|
|
@@ -181,6 +183,8 @@ async function main() {
|
|
|
181
183
|
sessionId = prev.id; // keep appending to the same session file
|
|
182
184
|
initialTitle = prev.title;
|
|
183
185
|
console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
|
|
186
|
+
if (printTask === null)
|
|
187
|
+
restoreGoal(prev.goal); // Day 41: the goal comes back with its session (print mode doesn't run goals)
|
|
184
188
|
}
|
|
185
189
|
else {
|
|
186
190
|
console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
|
|
@@ -216,6 +220,15 @@ async function main() {
|
|
|
216
220
|
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
217
221
|
const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
218
222
|
const autoMode = new AutoMode(client, { projectInstructions });
|
|
223
|
+
// A restored ACTIVE goal (Day 41) starts running before the first prompt —
|
|
224
|
+
// say so loudly. Its /goal line was a genuine human request: auto mode re-learns it.
|
|
225
|
+
const restoredGoal = getGoal();
|
|
226
|
+
if (restoredGoal?.status === "active") {
|
|
227
|
+
autoMode.recordRequest(`/goal ${restoredGoal.objective}`);
|
|
228
|
+
console.log(chalk.cyan(`(◎ resumed an ACTIVE goal — the agent continues it now; Esc pauses: ${restoredGoal.objective.slice(0, 80)})`));
|
|
229
|
+
}
|
|
230
|
+
else if (restoredGoal)
|
|
231
|
+
console.log(chalk.dim(`(◎ goal restored [${restoredGoal.status}] — /goal to see it, /goal resume to continue)`));
|
|
219
232
|
for (const notice of autoMode.startupNotices())
|
|
220
233
|
console.log(chalk.dim(notice));
|
|
221
234
|
if (judge)
|
|
@@ -264,7 +277,21 @@ async function main() {
|
|
|
264
277
|
});
|
|
265
278
|
messages.push({ role: "user", content: printTask }); // the single task
|
|
266
279
|
autoMode.recordRequest(printTask);
|
|
280
|
+
// stream-json: an init line now (MCP is connected, so statuses are real),
|
|
281
|
+
// then every assistant turn / tool result as it enters history.
|
|
282
|
+
const streaming = cli.outputFormat === "stream-json";
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
let numTurns = 0;
|
|
285
|
+
if (streaming)
|
|
286
|
+
printOutput.write(streamJsonInit({ sessionId, model: CONFIG.model, tools: topLevelToolNames(),
|
|
287
|
+
mcpServers: listMcpServers().map((s) => ({ name: s.name, status: s.status })),
|
|
288
|
+
permissionMode: CONFIG.bypassPermissions ? "bypassPermissions" : CONFIG.autoMode.enabled ? "auto" : "default" }));
|
|
267
289
|
const result = await runLoop(messages, {
|
|
290
|
+
onMessage: streaming ? (m) => {
|
|
291
|
+
if (m.role === "assistant")
|
|
292
|
+
numTurns++;
|
|
293
|
+
printOutput.write(streamJsonMessage(sessionId, CONFIG.model, m));
|
|
294
|
+
} : undefined,
|
|
268
295
|
client,
|
|
269
296
|
model: CONFIG.model,
|
|
270
297
|
quiet: true, // final result only; no spinner, reasoning or intermediate prose
|
|
@@ -289,6 +316,7 @@ async function main() {
|
|
|
289
316
|
emit("agent_session_end"); // close the books
|
|
290
317
|
printOutput.write(formatPrintResult(cli.outputFormat, {
|
|
291
318
|
sessionId, model: CONFIG.model, effort: selectedEffort(CONFIG.model), result, usage: costMeter.snapshot(),
|
|
319
|
+
durationMs: Date.now() - startedAt, numTurns,
|
|
292
320
|
}));
|
|
293
321
|
if (result.reason !== TerminateReason.Done) {
|
|
294
322
|
console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
|
|
@@ -569,6 +597,13 @@ async function main() {
|
|
|
569
597
|
console.log(await statusCommand(line, costMeter));
|
|
570
598
|
return true;
|
|
571
599
|
}
|
|
600
|
+
if (line === "/goal" || line.startsWith("/goal ")) {
|
|
601
|
+
const { message, request } = goalCommand(line.slice(5));
|
|
602
|
+
if (request)
|
|
603
|
+
autoMode.recordRequest(request); // the /goal you typed is the authorization; automatic goal turns never are
|
|
604
|
+
console.log(chalk.cyan(message)); // setting or resuming: the goal turns start before the next prompt
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
572
607
|
if (line.startsWith("/skills "))
|
|
573
608
|
line = `/skill ${line.slice(8).trim()}`;
|
|
574
609
|
if (line === "/effort" || line.startsWith("/effort ")) {
|
|
@@ -662,6 +697,7 @@ async function main() {
|
|
|
662
697
|
return true;
|
|
663
698
|
case "/clear":
|
|
664
699
|
autoMode.clearRequests();
|
|
700
|
+
clearGoal(); // the goal belonged to the old conversation (Day 41)
|
|
665
701
|
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
666
702
|
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
667
703
|
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
@@ -805,6 +841,15 @@ async function main() {
|
|
|
805
841
|
resetTeam(); // a resumed conversation starts with no live team
|
|
806
842
|
resetBoard(); // ...and an empty task board (Day 40)
|
|
807
843
|
console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
844
|
+
// Unlike the todos and the board, the goal comes BACK with its session (Day 41).
|
|
845
|
+
restoreGoal(chosen.goal);
|
|
846
|
+
const goal = getGoal();
|
|
847
|
+
if (goal?.status === "active") {
|
|
848
|
+
autoMode.recordRequest(`/goal ${goal.objective}`);
|
|
849
|
+
console.log(chalk.cyan(`◎ resumed an ACTIVE goal — the agent continues it now (Esc pauses): ${goal.objective.slice(0, 80)}`));
|
|
850
|
+
}
|
|
851
|
+
else if (goal)
|
|
852
|
+
console.log(chalk.dim(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`));
|
|
808
853
|
return true;
|
|
809
854
|
}
|
|
810
855
|
case "/diff": {
|
|
@@ -884,7 +929,46 @@ async function main() {
|
|
|
884
929
|
}
|
|
885
930
|
});
|
|
886
931
|
}
|
|
932
|
+
// /goal (Day 41): the readline prompt blocks on your input, so it can't poll
|
|
933
|
+
// like the Ink REPL's idle processor. Instead, goal turns run HERE, before each
|
|
934
|
+
// prompt, one after another until the goal stops being active — verified
|
|
935
|
+
// complete, blocked, or paused by Esc / a failed turn / no progress. Plan mode
|
|
936
|
+
// never continues a goal: plan-only work can't move it forward.
|
|
937
|
+
const runGoalTurns = async () => {
|
|
938
|
+
for (let content = isPlanMode() ? null : startGoalTurn(); content; content = isPlanMode() ? null : startGoalTurn()) {
|
|
939
|
+
console.log(chalk.cyan(`\n◎ goal · turn ${getGoal().continuations}: ${getGoal().objective.slice(0, 70)}`));
|
|
940
|
+
messages.push({ role: "user", content }); // the goal turn (restates the objective — it survives compaction)
|
|
941
|
+
clearReasoning();
|
|
942
|
+
clearToolCalls();
|
|
943
|
+
running = true;
|
|
944
|
+
interrupted = false;
|
|
945
|
+
controller = new AbortController();
|
|
946
|
+
setSessionState("busy", CONFIG.model);
|
|
947
|
+
let reason = "error";
|
|
948
|
+
try {
|
|
949
|
+
const result = await runLoop(messages, {
|
|
950
|
+
client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, followUps,
|
|
951
|
+
onFollowUp: (text) => console.log(sentMessage(text)), subAgentModel: CONFIG.subAgentModel, judge, autoMode, canPrompt: !!process.stdin.isTTY,
|
|
952
|
+
});
|
|
953
|
+
reason = result.reason;
|
|
954
|
+
if (result.reason !== TerminateReason.Done)
|
|
955
|
+
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[result.reason]}`));
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
console.log(chalk.red(`[error] ${err.message}`));
|
|
959
|
+
}
|
|
960
|
+
finally {
|
|
961
|
+
running = false;
|
|
962
|
+
setSessionState("idle");
|
|
963
|
+
}
|
|
964
|
+
const goalNote = settleGoalTurn(reason, true);
|
|
965
|
+
if (goalNote)
|
|
966
|
+
console.log(chalk.yellow(goalNote));
|
|
967
|
+
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // the goal's progress is saved turn by turn
|
|
968
|
+
}
|
|
969
|
+
};
|
|
887
970
|
while (true) {
|
|
971
|
+
await runGoalTurns(); // an active /goal keeps going before you're asked for input
|
|
888
972
|
// The input area: a blank line for breathing room, the "❯" you type on, then
|
|
889
973
|
// a single status line (model · 📁 dir · 🌿 branch · ctx% · $ · time) pinned
|
|
890
974
|
// BELOW the input. We dropped the old top/bottom horizontal rules — two bare
|
|
@@ -985,6 +1069,9 @@ async function main() {
|
|
|
985
1069
|
});
|
|
986
1070
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
987
1071
|
setSessionState("idle"); // other sessions see this one as free again
|
|
1072
|
+
const goalNote = settleGoalTurn(result.reason, false); // Day 41: Esc on your own turn pauses an active goal too
|
|
1073
|
+
if (goalNote)
|
|
1074
|
+
console.log(chalk.yellow(goalNote));
|
|
988
1075
|
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
989
1076
|
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
990
1077
|
// that saves durable facts — especially user corrections — so memory
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ export function parseCli(args) {
|
|
|
21
21
|
help: { type: "boolean", short: "h" },
|
|
22
22
|
version: { type: "boolean", short: "v" },
|
|
23
23
|
"output-format": { type: "string" },
|
|
24
|
+
verbose: { type: "boolean" }, // accepted for Claude compatibility: its stream-json requires --verbose; ours always streams
|
|
24
25
|
} });
|
|
25
26
|
}
|
|
26
27
|
catch (error) {
|
|
@@ -35,8 +36,8 @@ export function parseCli(args) {
|
|
|
35
36
|
// Match Claude's precedence: the explicit skip flag wins over a mode flag.
|
|
36
37
|
const permissionMode = values["dangerously-skip-permissions"] ? "bypassPermissions" : requestedMode;
|
|
37
38
|
const outputFormat = values["output-format"] ?? "text";
|
|
38
|
-
if (outputFormat !== "text" && outputFormat !== "json")
|
|
39
|
-
throw new CliUsageError("--output-format must be text or json");
|
|
39
|
+
if (outputFormat !== "text" && outputFormat !== "json" && outputFormat !== "stream-json")
|
|
40
|
+
throw new CliUsageError("--output-format must be text, json, or stream-json");
|
|
40
41
|
for (const flag of ["model", "effort", "prompt"]) {
|
|
41
42
|
if (typeof values[flag] === "string" && !values[flag].trim())
|
|
42
43
|
throw new CliUsageError(`--${flag} cannot be empty`);
|
package/dist/completion.js
CHANGED
|
@@ -19,6 +19,7 @@ export const COMMANDS = [
|
|
|
19
19
|
{ value: "/bg", description: "Show background tasks" },
|
|
20
20
|
{ value: "/team", description: "Show teammates and status" },
|
|
21
21
|
{ value: "/tasks", description: "Show the shared task board" },
|
|
22
|
+
{ value: "/goal", description: "Keep working until a goal is verified done", children: true },
|
|
22
23
|
{ value: "/cron", description: "Show scheduled jobs" },
|
|
23
24
|
{ value: "/peers", description: "Talk to another mini-agent session, or find its window" },
|
|
24
25
|
{ value: "/rename", description: "Rename this session for other sessions", children: true },
|
package/dist/config.js
CHANGED
|
@@ -136,6 +136,10 @@ export const CONFIG = {
|
|
|
136
136
|
memory: {
|
|
137
137
|
autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
|
|
138
138
|
},
|
|
139
|
+
// Next-prompt suggestions (Claude Code's placeholder you accept with Tab). On
|
|
140
|
+
// by default: the call reuses the main conversation's cached prefix, so it is
|
|
141
|
+
// cheap. MINI_AGENT_PROMPT_SUGGESTIONS=0 turns it off for one run.
|
|
142
|
+
promptSuggestions: process.env.MINI_AGENT_PROMPT_SUGGESTIONS === "0" ? false : (projectSettings.promptSuggestions ?? globalSettings.promptSuggestions ?? true),
|
|
139
143
|
};
|
|
140
144
|
// Persist a single key to the global settings file, preserving all other keys.
|
|
141
145
|
// Used by /model to remember the user's last choice across sessions.
|
package/dist/goal.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import crypto from "node:crypto"; // fingerprint the working tree between goal turns
|
|
2
|
+
import fs from "node:fs"; // stat untracked files for the fingerprint
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process"; // git for the fingerprint, sh for the --check command
|
|
4
|
+
import { sessionChanges } from "./undo.js"; // files the agent's own tools changed this session
|
|
5
|
+
import { emit } from "./telemetry.js"; // observe goal lifecycle + verifier decisions
|
|
6
|
+
export const MAX_STALLED_TURNS = 3; // goal turns in a row with an unchanged working tree → pause
|
|
7
|
+
const CHECK_TIMEOUT_MS = 10 * 60_000; // a test suite may be slow — but not forever
|
|
8
|
+
const CHECK_OUTPUT_TAIL = 4_000; // chars of check output fed back (the END is where failures are)
|
|
9
|
+
const OBSERVATION_CHARS = 1_200; // per tool result shown to the verifier
|
|
10
|
+
const OBSERVATIONS_BUDGET = 12_000; // total chars of recent tool results shown to the verifier
|
|
11
|
+
const VERIFIER_MAX_TOKENS = 200; // one tag + one sentence
|
|
12
|
+
// ---- session state ------------------------------------------------------------
|
|
13
|
+
// One goal per session, in process memory like the todo list — but unlike the
|
|
14
|
+
// todo list it is written into the session file (session.ts), so `--resume`
|
|
15
|
+
// picks up a half-finished goal and keeps going.
|
|
16
|
+
let current = null;
|
|
17
|
+
let inFlight = null; // the goal turn now running
|
|
18
|
+
let toolCalls = 0; // top-level tool calls this process (loop.ts counts them) — survives compaction, unlike message indexes
|
|
19
|
+
export function getGoal() {
|
|
20
|
+
return current;
|
|
21
|
+
}
|
|
22
|
+
export function clearGoal() {
|
|
23
|
+
current = null;
|
|
24
|
+
inFlight = null;
|
|
25
|
+
}
|
|
26
|
+
// Adopt a goal loaded from a session file (or none). A malformed one is dropped.
|
|
27
|
+
export function restoreGoal(goal) {
|
|
28
|
+
inFlight = null;
|
|
29
|
+
current = goal && typeof goal.objective === "string" && goal.objective.trim() ? { ...goal, stalls: goal.stalls ?? 0, continuations: goal.continuations ?? 0 } : null;
|
|
30
|
+
}
|
|
31
|
+
function setStatus(status, note) {
|
|
32
|
+
if (!current)
|
|
33
|
+
return;
|
|
34
|
+
current.status = status;
|
|
35
|
+
current.note = note;
|
|
36
|
+
emit("agent_goal_status", { status });
|
|
37
|
+
}
|
|
38
|
+
export function countGoalToolCall() {
|
|
39
|
+
toolCalls++;
|
|
40
|
+
}
|
|
41
|
+
export function parseGoalCommand(args) {
|
|
42
|
+
const text = args.trim();
|
|
43
|
+
if (!text)
|
|
44
|
+
return { kind: "show" };
|
|
45
|
+
if (text === "pause" || text === "resume" || text === "clear")
|
|
46
|
+
return { kind: text };
|
|
47
|
+
const m = text.match(/^([\s\S]*?)\s*--check\s+([\s\S]+)$/);
|
|
48
|
+
let objective = text;
|
|
49
|
+
let check;
|
|
50
|
+
if (m) {
|
|
51
|
+
objective = m[1].trim();
|
|
52
|
+
check = m[2].trim().replace(/^(["'])([\s\S]*)\1$/, "$2").trim(); // strip one pair of matching quotes
|
|
53
|
+
if (!check)
|
|
54
|
+
return { kind: "error", message: "--check needs a command, e.g. --check \"npm test\"" };
|
|
55
|
+
}
|
|
56
|
+
else if (/(^|\s)--check\s*$/.test(text)) {
|
|
57
|
+
return { kind: "error", message: "--check needs a command, e.g. --check \"npm test\"" };
|
|
58
|
+
}
|
|
59
|
+
if (!objective)
|
|
60
|
+
return { kind: "error", message: "usage: /goal <objective> [--check <command>]" };
|
|
61
|
+
return { kind: "set", objective, check };
|
|
62
|
+
}
|
|
63
|
+
// ---- pure: render the goal for /goal -------------------------------------------
|
|
64
|
+
export function describeGoal(goal) {
|
|
65
|
+
if (!goal)
|
|
66
|
+
return "(no goal — set one with /goal <objective> [--check <command>])";
|
|
67
|
+
const lines = [
|
|
68
|
+
`goal [${goal.status}]: ${goal.objective}`,
|
|
69
|
+
goal.check ? ` check: ${goal.check}` : " check: (none — the verifier judges from evidence alone)",
|
|
70
|
+
` turns: ${goal.continuations}${goal.stalls ? ` · no progress in the last ${goal.stalls}` : ""}`,
|
|
71
|
+
];
|
|
72
|
+
if (goal.note)
|
|
73
|
+
lines.push(` note: ${goal.note}`);
|
|
74
|
+
if (goal.status === "paused" || goal.status === "blocked")
|
|
75
|
+
lines.push(" /goal resume to continue · /goal clear to drop it");
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
// ---- the /goal command (shared by both REPLs) ----------------------------------
|
|
79
|
+
// Returns the line to show, plus `request`: the human text to record as a user
|
|
80
|
+
// request for auto mode. Only the /goal line the human typed authorizes work —
|
|
81
|
+
// the automatic continuation turns never do, or each one would widen what the
|
|
82
|
+
// reviewer thinks was approved.
|
|
83
|
+
export function goalCommand(args) {
|
|
84
|
+
const cmd = parseGoalCommand(args);
|
|
85
|
+
switch (cmd.kind) {
|
|
86
|
+
case "error":
|
|
87
|
+
return { message: cmd.message };
|
|
88
|
+
case "show":
|
|
89
|
+
return { message: describeGoal(current) };
|
|
90
|
+
case "clear":
|
|
91
|
+
if (!current)
|
|
92
|
+
return { message: "(no goal to clear)" };
|
|
93
|
+
clearGoal();
|
|
94
|
+
emit("agent_goal_status", { status: "cleared" });
|
|
95
|
+
return { message: "(goal cleared)" };
|
|
96
|
+
case "pause":
|
|
97
|
+
if (current?.status !== "active")
|
|
98
|
+
return { message: `(no active goal to pause${current ? ` — it is ${current.status}` : ""})` };
|
|
99
|
+
setStatus("paused", "paused by you");
|
|
100
|
+
return { message: "(goal paused — /goal resume to continue)" };
|
|
101
|
+
case "resume":
|
|
102
|
+
if (!current)
|
|
103
|
+
return { message: "(no goal to resume)" };
|
|
104
|
+
if (current.status === "complete")
|
|
105
|
+
return { message: "(that goal is already complete — set a new one with /goal <objective>)" };
|
|
106
|
+
if (current.status === "active")
|
|
107
|
+
return { message: "(the goal is already active)" };
|
|
108
|
+
setStatus("active");
|
|
109
|
+
current.stalls = 0; // a fresh start after you stepped in
|
|
110
|
+
return { message: `(goal resumed: ${current.objective})`, request: `/goal ${current.objective}` };
|
|
111
|
+
case "set": {
|
|
112
|
+
const replaced = current && current.status !== "complete";
|
|
113
|
+
current = { objective: cmd.objective, check: cmd.check, status: "active", continuations: 0, stalls: 0, setAt: new Date().toISOString() };
|
|
114
|
+
inFlight = null;
|
|
115
|
+
emit("agent_goal_status", { status: "set", check: cmd.check ? 1 : 0 });
|
|
116
|
+
return { message: `${replaced ? "(replaced the previous goal) " : ""}goal set: ${cmd.objective}${cmd.check ? `\n check: ${cmd.check}` : ""}\n the agent keeps working until it is verified done — Esc pauses, /goal clear drops it`, request: `/goal ${args.trim()}` };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ---- turn bookkeeping -----------------------------------------------------------
|
|
121
|
+
// The REPL calls startGoalTurn when it is idle. It returns the text of the next
|
|
122
|
+
// goal turn (or null if there is nothing to do), and snapshots the working tree
|
|
123
|
+
// so settleGoalTurn can tell whether the turn moved anything.
|
|
124
|
+
export function startGoalTurn() {
|
|
125
|
+
if (current?.status !== "active" || inFlight)
|
|
126
|
+
return null;
|
|
127
|
+
inFlight = { fingerprint: workFingerprint(), toolCalls };
|
|
128
|
+
current.continuations++;
|
|
129
|
+
return goalTurnContent(current);
|
|
130
|
+
}
|
|
131
|
+
// The goal turn text. It restates the FULL objective every time: after a few
|
|
132
|
+
// compactions the original /goal message may be long gone from history, and
|
|
133
|
+
// this is what keeps the model aimed at the same target for hours.
|
|
134
|
+
export function goalTurnContent(goal) {
|
|
135
|
+
const head = goal.continuations <= 1 ? "[goal] A goal was just set. Work toward it until it is verified done." : `[goal] Continuation #${goal.continuations} — the goal is not verified done yet. Pick up where you left off.`;
|
|
136
|
+
return [
|
|
137
|
+
head,
|
|
138
|
+
`Objective: ${goal.objective}`,
|
|
139
|
+
goal.check ? `Completion check: \`${goal.check}\` must exit 0 — the harness runs it when you claim completion.` : "",
|
|
140
|
+
"Inspect, change, run and verify. Each time you stop you will be sent back here, until you call update_goal:",
|
|
141
|
+
' - update_goal({status: "complete", report}) — only after checking the objective against concrete evidence (commands you ran, their output, files you changed). An independent verifier re-checks against your recent tool results; an unsupported claim is rejected.',
|
|
142
|
+
' - update_goal({status: "blocked", report}) — when no valid path remains or you need the user: what you tried, the blocker, and the input you need.',
|
|
143
|
+
"Do not stop just to summarize progress: a goal turn that makes no tool calls pauses the goal.",
|
|
144
|
+
].filter(Boolean).join("\n");
|
|
145
|
+
}
|
|
146
|
+
// After ANY turn ends. Returns a line for the screen, or null. `goalTurn` says
|
|
147
|
+
// whether this was a goal turn (vs. the user's own message); `reason` is the
|
|
148
|
+
// loop's TerminateReason (or "error" if the turn threw); `followUp` is true when
|
|
149
|
+
// the turn was interrupted only to deliver a queued message — that is the user
|
|
150
|
+
// steering, not stopping, so it must not pause the goal.
|
|
151
|
+
export function settleGoalTurn(reason, goalTurn, followUp = false) {
|
|
152
|
+
const turn = inFlight;
|
|
153
|
+
if (goalTurn)
|
|
154
|
+
inFlight = null;
|
|
155
|
+
if (current?.status !== "active")
|
|
156
|
+
return null; // update_goal finished it mid-turn (it announced that itself)
|
|
157
|
+
if (reason === "user_interrupt") {
|
|
158
|
+
if (followUp)
|
|
159
|
+
return null;
|
|
160
|
+
setStatus("paused", "interrupted by you");
|
|
161
|
+
return "◎ goal paused (interrupted) — /goal resume to continue";
|
|
162
|
+
}
|
|
163
|
+
if (!goalTurn || !turn)
|
|
164
|
+
return null; // your own message ended normally — the goal simply continues next
|
|
165
|
+
if (reason !== "done") {
|
|
166
|
+
setStatus("paused", `a goal turn ended with ${reason}`);
|
|
167
|
+
return `◎ goal paused — the turn ended with ${reason}. /goal resume to try again`;
|
|
168
|
+
}
|
|
169
|
+
if (toolCalls === turn.toolCalls) {
|
|
170
|
+
setStatus("paused", "a goal turn made no tool calls");
|
|
171
|
+
return "◎ goal paused — the agent stopped without doing anything. Reply to steer it, then /goal resume";
|
|
172
|
+
}
|
|
173
|
+
current.stalls = workFingerprint() === turn.fingerprint ? current.stalls + 1 : 0;
|
|
174
|
+
if (current.stalls >= MAX_STALLED_TURNS) {
|
|
175
|
+
setStatus("paused", `no file changes in ${current.stalls} goal turns in a row`);
|
|
176
|
+
return `◎ goal paused — no progress in ${current.stalls} turns in a row. Reply to steer it, then /goal resume`;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
// Did anything change? The agent's own writes (undo.ts tracks their current
|
|
181
|
+
// content, so rewriting the same file counts) plus git's view, which also sees
|
|
182
|
+
// what run_bash changed (sed, codegen, npm install). `git diff` is blind to
|
|
183
|
+
// untracked files, so their size + mtime go in too. Not in a git repo → the
|
|
184
|
+
// agent's writes alone.
|
|
185
|
+
const MAX_UNTRACKED = 2_000; // stat at most this many untracked files per fingerprint
|
|
186
|
+
function workFingerprint() {
|
|
187
|
+
const h = crypto.createHash("sha1");
|
|
188
|
+
for (const c of sessionChanges())
|
|
189
|
+
h.update(c.path).update("\0").update(c.current).update("\0");
|
|
190
|
+
const git = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
191
|
+
try {
|
|
192
|
+
// Our own bookkeeping (.mini-agent/: the session file, logs, spilled output)
|
|
193
|
+
// churns every turn and is rarely gitignored — counting it as "progress"
|
|
194
|
+
// would reset the stall count every turn and disarm the fuse.
|
|
195
|
+
const lines = git(["status", "--porcelain", "-uall"]).split("\n").filter((l) => l && !/^.. "?\.mini-agent\//.test(l));
|
|
196
|
+
h.update(lines.join("\n")).update(git(["diff"]));
|
|
197
|
+
const untracked = lines.filter((l) => l.startsWith("?? ")).slice(0, MAX_UNTRACKED);
|
|
198
|
+
for (const line of untracked) {
|
|
199
|
+
try {
|
|
200
|
+
const st = fs.statSync(line.slice(3));
|
|
201
|
+
h.update(`${line}\0${st.size}\0${st.mtimeMs}\0`);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* vanished between status and stat — the next fingerprint sees it */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* not a git repo, or git missing — the agent's own writes still count */
|
|
210
|
+
}
|
|
211
|
+
return h.digest("hex");
|
|
212
|
+
}
|
|
213
|
+
// ---- update_goal: the model's only way to END a goal ----------------------------
|
|
214
|
+
export const updateGoalTool = {
|
|
215
|
+
type: "function",
|
|
216
|
+
function: {
|
|
217
|
+
name: "update_goal",
|
|
218
|
+
description: `End the active /goal, as "complete" or "blocked". While a goal is active you are sent back to work every time you stop, so this is how you finish.
|
|
219
|
+
complete: only after you checked the objective against concrete evidence — tests you ran and their output, files you changed, results you saw. The user's completion check (if any) is run, then an independent verifier compares your report with your recent tool results. If either fails you get the reason back and the goal stays active.
|
|
220
|
+
blocked: when no valid path remains or you need something only the user can give. Report what you tried, the blocker, and the exact input you need.`,
|
|
221
|
+
parameters: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
status: { type: "string", enum: ["complete", "blocked"], description: "complete = the objective is achieved and evidenced; blocked = you need the user" },
|
|
225
|
+
report: { type: "string", description: "complete: the evidence (what you ran/changed and what it showed). blocked: what you tried, the blocker, the input you need." },
|
|
226
|
+
},
|
|
227
|
+
required: ["status", "report"],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
// Run update_goal. Never throws; every outcome is text for the model.
|
|
232
|
+
export async function runUpdateGoal(rawArgs, ctx) {
|
|
233
|
+
if (current?.status !== "active")
|
|
234
|
+
return "[error] There is no active goal. update_goal is only for ending a goal the user set with /goal.";
|
|
235
|
+
let status = "", report = "";
|
|
236
|
+
try {
|
|
237
|
+
const a = JSON.parse(rawArgs);
|
|
238
|
+
status = a.status ?? "";
|
|
239
|
+
report = (a.report ?? "").trim();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* fall through */
|
|
243
|
+
}
|
|
244
|
+
if (status !== "complete" && status !== "blocked")
|
|
245
|
+
return '[error] update_goal needs status "complete" or "blocked".';
|
|
246
|
+
if (!report)
|
|
247
|
+
return "[error] update_goal needs a non-empty report — the evidence (complete) or the blocker (blocked).";
|
|
248
|
+
if (status === "blocked") {
|
|
249
|
+
setStatus("blocked", report.slice(0, 300));
|
|
250
|
+
ctx.note(`◎ goal blocked — ${report.slice(0, 160)}`);
|
|
251
|
+
return "Goal marked blocked; no more automatic turns. Stop now and tell the user what you tried, the blocker, and the input you need.";
|
|
252
|
+
}
|
|
253
|
+
// 1. The hard gate: the user's own --check command. It was typed by the human
|
|
254
|
+
// in /goal, so it does not go through the permission gate — and the model
|
|
255
|
+
// has no way to change it. Nonzero exit → not done, no verifier call.
|
|
256
|
+
let checkLine = "";
|
|
257
|
+
if (current.check) {
|
|
258
|
+
ctx.note(`◎ goal check: ${current.check}`);
|
|
259
|
+
const r = await runCheck(current.check, ctx.signal);
|
|
260
|
+
if (r.code !== 0) {
|
|
261
|
+
emit("agent_goal_claim", { accepted: 0, by: "check" });
|
|
262
|
+
ctx.note(`◎ goal check failed (${r.code === null ? "timed out / killed" : `exit ${r.code}`}) — not done`);
|
|
263
|
+
return `[goal] Not complete: the completion check \`${current.check}\` ${r.code === null ? "timed out or was killed" : `exited ${r.code}`}. The goal stays active — fix it and claim again.\nCheck output (tail):\n${r.output || "(no output)"}`;
|
|
264
|
+
}
|
|
265
|
+
checkLine = `The completion check \`${current.check}\` passed (exit 0). Output tail:\n${r.output || "(no output)"}`;
|
|
266
|
+
}
|
|
267
|
+
// 2. The independent verifier: a separate model call that sees what the tools
|
|
268
|
+
// actually returned, not just what the model says about them.
|
|
269
|
+
ctx.note("◎ goal verifier: checking the claim against the evidence…");
|
|
270
|
+
const verdict = await verifyGoal(ctx, current, report, checkLine);
|
|
271
|
+
emit("agent_goal_claim", { accepted: verdict.done ? 1 : 0, by: "verifier" });
|
|
272
|
+
if (!verdict.done) {
|
|
273
|
+
ctx.note(`◎ goal verifier: not done — ${verdict.reason.slice(0, 160)}`);
|
|
274
|
+
return `[goal] Not complete — the verifier rejected the claim: ${verdict.reason}\nThe goal stays active. Close the gap (or gather the missing evidence), then claim again.`;
|
|
275
|
+
}
|
|
276
|
+
setStatus("complete", verdict.reason.slice(0, 300));
|
|
277
|
+
ctx.note(`◎ goal complete ✓ — ${verdict.reason.slice(0, 160)}`);
|
|
278
|
+
return "Goal verified complete. Give the user a short final report: what was done and the evidence.";
|
|
279
|
+
}
|
|
280
|
+
// Run the user's check command. Output is capped to its TAIL — test runners
|
|
281
|
+
// print the failures (and the summary) last.
|
|
282
|
+
function runCheck(command, signal) {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
let out = "";
|
|
285
|
+
const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
286
|
+
const keep = (b) => {
|
|
287
|
+
out = (out + b.toString("utf8")).slice(-CHECK_OUTPUT_TAIL * 4); // bounded memory, trimmed properly below
|
|
288
|
+
};
|
|
289
|
+
child.stdout.on("data", keep);
|
|
290
|
+
child.stderr.on("data", keep);
|
|
291
|
+
const kill = () => child.kill("SIGKILL");
|
|
292
|
+
const timer = setTimeout(kill, CHECK_TIMEOUT_MS);
|
|
293
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
294
|
+
const done = (code) => {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
signal.removeEventListener("abort", kill);
|
|
297
|
+
resolve({ code, output: out.slice(-CHECK_OUTPUT_TAIL).trim() });
|
|
298
|
+
};
|
|
299
|
+
child.on("error", (e) => {
|
|
300
|
+
out += `\n${e.message}`;
|
|
301
|
+
done(null);
|
|
302
|
+
});
|
|
303
|
+
child.on("close", (code) => done(code));
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// The verifier's prompt is blunt for the same reason as the permission judge's:
|
|
307
|
+
// a rigid tag is unambiguous to parse, and "doubt → no" is the load-bearing line.
|
|
308
|
+
const VERIFIER_SYSTEM = `You verify whether a coding agent has ACHIEVED a goal. You are given the goal's objective, the agent's completion report, the result of the user's completion check (if any), and the agent's most recent tool calls with what they actually returned.
|
|
309
|
+
Judge from the tool results — they are observations. The agent's report is a claim, not evidence. Every part of the objective must be demonstrably satisfied by the observations; if something is missing, unverified, or contradicted, the goal is NOT done.
|
|
310
|
+
Err on the side of NO.
|
|
311
|
+
Respond with exactly two lines:
|
|
312
|
+
<done>yes</done> or <done>no</done>
|
|
313
|
+
one sentence: why — if no, what is missing.`;
|
|
314
|
+
// PURE: parse the verifier reply. Fail closed — only a clean yes is done.
|
|
315
|
+
export function interpretVerdict(text) {
|
|
316
|
+
const m = text.match(/<done>\s*(yes|no)\s*<\/done>/i);
|
|
317
|
+
const reason = text.replace(/<done>[\s\S]*?<\/done>/i, "").trim().replace(/\s+/g, " ") || "(no reason given)";
|
|
318
|
+
if (!m)
|
|
319
|
+
return { done: false, reason: `unparseable verifier reply: ${text.trim().slice(0, 120) || "(empty)"}` };
|
|
320
|
+
return { done: m[1].toLowerCase() === "yes", reason };
|
|
321
|
+
}
|
|
322
|
+
// PURE: the agent's most recent tool calls with their results, newest last,
|
|
323
|
+
// each capped, within a total budget — the verifier's evidence.
|
|
324
|
+
export function recentObservations(messages) {
|
|
325
|
+
const results = new Map();
|
|
326
|
+
for (const m of messages)
|
|
327
|
+
if (m.role === "tool")
|
|
328
|
+
results.set(m.tool_call_id, typeof m.content === "string" ? m.content : "");
|
|
329
|
+
const items = [];
|
|
330
|
+
let used = 0;
|
|
331
|
+
for (let i = messages.length - 1; i >= 0 && used < OBSERVATIONS_BUDGET; i--) {
|
|
332
|
+
const m = messages[i];
|
|
333
|
+
if (m.role !== "assistant" || !m.tool_calls)
|
|
334
|
+
continue;
|
|
335
|
+
for (const call of [...m.tool_calls].reverse()) {
|
|
336
|
+
if (call.type !== "function" || call.function.name === "update_goal" || !results.has(call.id))
|
|
337
|
+
continue;
|
|
338
|
+
const result = results.get(call.id);
|
|
339
|
+
const item = `$ ${call.function.name} ${call.function.arguments.slice(0, 300)}\n${result.length > OBSERVATION_CHARS ? result.slice(0, OBSERVATION_CHARS) + " …(truncated)" : result}`;
|
|
340
|
+
if (used + item.length > OBSERVATIONS_BUDGET)
|
|
341
|
+
break;
|
|
342
|
+
items.unshift(item);
|
|
343
|
+
used += item.length;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return items.length ? items.join("\n\n") : "(no tool calls in the conversation)";
|
|
347
|
+
}
|
|
348
|
+
async function verifyGoal(ctx, goal, report, checkLine) {
|
|
349
|
+
try {
|
|
350
|
+
const res = await ctx.client.chat.completions.create({
|
|
351
|
+
model: ctx.model,
|
|
352
|
+
max_tokens: VERIFIER_MAX_TOKENS,
|
|
353
|
+
temperature: 0,
|
|
354
|
+
messages: [
|
|
355
|
+
{ role: "system", content: VERIFIER_SYSTEM },
|
|
356
|
+
{ role: "user", content: [`Objective:\n${goal.objective}`, `Agent's completion report:\n${report}`, checkLine || "No completion check was configured.", `Recent tool calls and results (oldest first):\n${recentObservations(ctx.transcript)}`].join("\n\n") },
|
|
357
|
+
],
|
|
358
|
+
}, { signal: ctx.signal });
|
|
359
|
+
return interpretVerdict(res.choices[0]?.message?.content ?? "");
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
// A verifier that errors must never accidentally complete a goal.
|
|
363
|
+
return { done: false, reason: `the verifier call failed (${e.message}) — claim again` };
|
|
364
|
+
}
|
|
365
|
+
}
|
package/dist/ink/app.js
CHANGED
|
@@ -10,6 +10,8 @@ import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.j
|
|
|
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
|
+
import { generatePromptSuggestion } from "../suggestion.js"; // the predicted next message (Claude Code's prompt suggestion)
|
|
14
|
+
import { lastRequestTools } from "../loop.js"; // resend the main call's tools so the guess hits the prompt cache
|
|
13
15
|
import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
|
|
14
16
|
import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
|
|
15
17
|
import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
|
|
@@ -18,6 +20,7 @@ import { clearUndo } from "../undo.js";
|
|
|
18
20
|
import { clearTodos } from "../todos.js";
|
|
19
21
|
import { resetTeam } from "../team.js";
|
|
20
22
|
import { resetBoard } from "../board.js";
|
|
23
|
+
import { goalCommand, getGoal, restoreGoal, clearGoal, startGoalTurn, settleGoalTurn } from "../goal.js"; // /goal (Day 41): keep starting turns until the goal is verified done
|
|
21
24
|
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
|
|
22
25
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
23
26
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
@@ -178,6 +181,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
178
181
|
const [detailOffset, setDetailOffset] = useState(0);
|
|
179
182
|
const [live, setLive] = useState(null); // the streaming answer
|
|
180
183
|
const [input, setInput] = useState(""); // the current input buffer
|
|
184
|
+
const [suggestion, setSuggestion] = useState(null); // the predicted next message: a dim placeholder, Tab accepts it
|
|
185
|
+
const suggestionSeq = useRef(0); // bumped by every new turn, so a late guess for an old turn is dropped
|
|
181
186
|
const [talkTo, setTalkTo] = useState(null); // /peers → talk: the input box sends to another session instead of this agent
|
|
182
187
|
const replyTo = useRef([]); // who gets this turn's final answer (the senders of "user" peer messages)
|
|
183
188
|
const [images, setImages] = useState([]);
|
|
@@ -225,6 +230,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
225
230
|
}, rows - 10, columns);
|
|
226
231
|
// A "/" later in the text: no list, just the rest of the best match as dim ghost text (Tab accepts).
|
|
227
232
|
const ghost = completing && cursor === input.length ? inlineCommandGhost(input, { skills: typeaheadSkills }) : null;
|
|
233
|
+
// The predicted next message, shown only in an empty box while nothing else wants attention.
|
|
234
|
+
const placeholder = suggestion && !input && !busy && !pending && !talkTo && !details && subAgentFocus === null ? suggestion : null;
|
|
228
235
|
useEffect(() => {
|
|
229
236
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
230
237
|
return () => clearInterval(id);
|
|
@@ -244,7 +251,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
244
251
|
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
245
252
|
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
246
253
|
// `pending` prompt that returns a promise the loop awaits.
|
|
247
|
-
const runConversationTurn = (content, display, attachments = []) => {
|
|
254
|
+
const runConversationTurn = (content, display, attachments = [], goalTurn = false) => {
|
|
248
255
|
setDetails(null);
|
|
249
256
|
setDetailOffset(0);
|
|
250
257
|
if (display)
|
|
@@ -253,6 +260,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
253
260
|
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
254
261
|
const senders = replyTo.current; // taken now, so a failed turn can't leak them into the next one
|
|
255
262
|
replyTo.current = [];
|
|
263
|
+
// Guess the next message only after turns YOU started — not cron, not
|
|
264
|
+
// another agent's message, not your message relayed from another window.
|
|
265
|
+
const suggestAfter = CONFIG.promptSuggestions && !senders.length && (content === null || display?.kind === "user");
|
|
266
|
+
const seq = ++suggestionSeq.current;
|
|
267
|
+
setSuggestion(null);
|
|
256
268
|
setBusy(true);
|
|
257
269
|
setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
|
|
258
270
|
const controller = new AbortController();
|
|
@@ -293,8 +305,17 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
293
305
|
sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
|
|
294
306
|
if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
|
|
295
307
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
296
|
-
|
|
297
|
-
if (
|
|
308
|
+
const goalNote = settleGoalTurn(result.reason, goalTurn, result.reason === TerminateReason.UserInterrupt && followUps.size > 0); // Day 41: pause on Esc / failure / no progress
|
|
309
|
+
if (goalNote)
|
|
310
|
+
note(chalk.yellow(goalNote));
|
|
311
|
+
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction (the goal's state rides along)
|
|
312
|
+
if (suggestAfter && result.reason === TerminateReason.Done) {
|
|
313
|
+
void generatePromptSuggestion(client, CONFIG.model, messages, lastRequestTools()).then((guess) => {
|
|
314
|
+
if (guess && seq === suggestionSeq.current)
|
|
315
|
+
setSuggestion(guess); // no newer turn started meanwhile (the box shows it only while idle)
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done && !goalTurn) { // not after every automatic goal turn — that would bill an extraction per continuation
|
|
298
319
|
try {
|
|
299
320
|
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
300
321
|
if (got.length)
|
|
@@ -305,7 +326,12 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
305
326
|
}
|
|
306
327
|
}
|
|
307
328
|
})
|
|
308
|
-
.catch((e) =>
|
|
329
|
+
.catch((e) => {
|
|
330
|
+
note(chalk.red(`[error] ${e.message}`));
|
|
331
|
+
const goalNote = settleGoalTurn("error", goalTurn); // a crashed goal turn pauses the goal instead of retrying forever
|
|
332
|
+
if (goalNote)
|
|
333
|
+
note(chalk.yellow(goalNote));
|
|
334
|
+
})
|
|
309
335
|
.finally(() => {
|
|
310
336
|
turn.current = null;
|
|
311
337
|
setSessionState("idle");
|
|
@@ -343,6 +369,27 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
343
369
|
return () => clearInterval(id);
|
|
344
370
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
371
|
}, [busy, pending]);
|
|
372
|
+
// The GOAL idle processor (Day 41): while a /goal is active and the REPL is
|
|
373
|
+
// idle — no turn, no menu, no queued message — start the next goal turn. This
|
|
374
|
+
// is the whole "keep going" mechanism: the model can stop as often as it
|
|
375
|
+
// likes, but it is sent straight back until update_goal ends the goal, or
|
|
376
|
+
// settleGoalTurn pauses it (Esc, a failed turn, no progress). Plan mode never
|
|
377
|
+
// continues: plan-only work can't move a goal forward.
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (busy || pending || followUps.size || planMode)
|
|
380
|
+
return; // human input first; plan mode is read-only
|
|
381
|
+
const id = setInterval(() => {
|
|
382
|
+
if (turn.current || followUps.size)
|
|
383
|
+
return;
|
|
384
|
+
const content = startGoalTurn();
|
|
385
|
+
if (!content)
|
|
386
|
+
return;
|
|
387
|
+
const goal = getGoal();
|
|
388
|
+
runConversationTurn(content, { kind: "note", text: chalk.cyan(`◎ goal · turn ${goal.continuations}: ${goal.objective.slice(0, 70)}`) }, [], true); // a note, not a user bar — you didn't type it
|
|
389
|
+
}, 500);
|
|
390
|
+
return () => clearInterval(id);
|
|
391
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
+
}, [busy, pending, planMode]);
|
|
346
393
|
// The PEER idle processor: another mini-agent session messaged this one while
|
|
347
394
|
// it sat at the prompt. Like cron, start a turn so the agent answers on its
|
|
348
395
|
// own. Two agents answering each other could run forever (and bill for every
|
|
@@ -713,9 +760,17 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
713
760
|
runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
714
761
|
return true;
|
|
715
762
|
}
|
|
763
|
+
if (line === "/goal" || line.startsWith("/goal ")) {
|
|
764
|
+
const { message, request } = goalCommand(line.slice(5));
|
|
765
|
+
if (request)
|
|
766
|
+
autoMode.recordRequest(request); // the /goal you typed is the authorization; automatic goal turns never are
|
|
767
|
+
note(chalk.cyan(message)); // setting or resuming a goal: the idle processor starts the turn
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
716
770
|
switch (line) {
|
|
717
771
|
case "/clear":
|
|
718
772
|
autoMode.clearRequests();
|
|
773
|
+
clearGoal(); // the goal belonged to the old conversation
|
|
719
774
|
messages.length = 0; // mutate in place — same array ref the loop holds
|
|
720
775
|
messages.push({ role: "system", content: systemMessage });
|
|
721
776
|
forgetFilesExcept([]);
|
|
@@ -787,6 +842,15 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
787
842
|
resetTeam();
|
|
788
843
|
resetBoard();
|
|
789
844
|
note(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
845
|
+
// Unlike the todos and the board, the goal comes BACK with its session (Day 41).
|
|
846
|
+
restoreGoal(chosen.goal);
|
|
847
|
+
const goal = getGoal();
|
|
848
|
+
if (goal?.status === "active") {
|
|
849
|
+
autoMode.recordRequest(`/goal ${goal.objective}`);
|
|
850
|
+
note(chalk.cyan(`◎ resumed an ACTIVE goal — the agent continues it now (Esc pauses): ${goal.objective.slice(0, 80)}`));
|
|
851
|
+
}
|
|
852
|
+
else if (goal)
|
|
853
|
+
note(chalk.dim(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`));
|
|
790
854
|
});
|
|
791
855
|
return true;
|
|
792
856
|
}
|
|
@@ -952,6 +1016,13 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
952
1016
|
return;
|
|
953
1017
|
}
|
|
954
1018
|
}
|
|
1019
|
+
if ((key.tab || (key.rightArrow && !input)) && placeholder) { // Tab (or →) turns the predicted next message into real text; Enter then sends it
|
|
1020
|
+
setInput(placeholder);
|
|
1021
|
+
setCursor(placeholder.length);
|
|
1022
|
+
setHistIdx(null);
|
|
1023
|
+
setSuggestion(null);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
955
1026
|
if (key.tab && ghost) { // accept the inline suggestion as "/name "
|
|
956
1027
|
const next = input.slice(0, ghost.start) + `/${ghost.full} `;
|
|
957
1028
|
setInput(next);
|
|
@@ -1193,7 +1264,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1193
1264
|
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
1194
1265
|
const after = line.slice(cuOff + 1);
|
|
1195
1266
|
const base = lineStarts[i] ?? 0;
|
|
1196
|
-
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(before, base, "b") }), _jsx(Text, { inverse: true, color: isLit(base + cuOff) ? SKILL_COLOR : undefined, children: ghost ? ghost.suffix[0] : at }), _jsx(Text, { children: paint(after, base + cuOff + at.length, "a") }), ghost && _jsx(Text, { dimColor: true, children: ghost.suffix.slice(1) })] }, i));
|
|
1267
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(before, base, "b") }), _jsx(Text, { inverse: true, color: isLit(base + cuOff) ? SKILL_COLOR : undefined, children: ghost ? ghost.suffix[0] : placeholder && i === 0 ? [...placeholder][0] : at }), _jsx(Text, { children: paint(after, base + cuOff + at.length, "a") }), ghost && _jsx(Text, { dimColor: true, children: ghost.suffix.slice(1) }), placeholder && i === 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [[...placeholder].slice(1).join(""), _jsx(Text, { dimColor: true, children: " ⇥ tab" })] })] }, i));
|
|
1197
1268
|
}
|
|
1198
1269
|
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
|
|
1199
1270
|
});
|
package/dist/ink/commands.js
CHANGED
|
@@ -35,6 +35,7 @@ export const SESSION_HELP = `commands:
|
|
|
35
35
|
/bg list background tasks this session (run_bash_background) and their status
|
|
36
36
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
37
37
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
38
|
+
/goal <objective> [--check <cmd>] keep working, turn after turn, until the goal is verified done (/goal alone shows it; pause | resume | clear)
|
|
38
39
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
39
40
|
/peers pick another mini-agent session on this machine: talk to it from this box (Esc to come back), or make it flash its tab so you know which window it is
|
|
40
41
|
/rename <name> rename this session — the name other sessions use to reach it
|
|
@@ -46,6 +47,7 @@ export const SESSION_HELP = `commands:
|
|
|
46
47
|
exit leave (Ctrl+C does the same)
|
|
47
48
|
|
|
48
49
|
keys (during or after a turn):
|
|
50
|
+
Tab / → accept the predicted next message (the dim placeholder after a turn); Enter sends it
|
|
49
51
|
/ show command suggestions below the input (while idle)
|
|
50
52
|
↑↓ / Tab choose / complete a suggestion; Enter selects; Esc closes
|
|
51
53
|
Enter while working, queue a follow-up for the next tool boundary
|
package/dist/ink/setup.js
CHANGED
|
@@ -8,6 +8,7 @@ import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
|
|
|
8
8
|
import { gitBranch } from "../tui.js";
|
|
9
9
|
import { listPeers, registerSession, unregisterSession } from "../peers.js"; // peer sessions: other mini-agent processes on this machine
|
|
10
10
|
import { newSessionId, latestSession } from "../session.js";
|
|
11
|
+
import { getGoal, restoreGoal } from "../goal.js"; // /goal (Day 41): a resumed session keeps working toward its goal
|
|
11
12
|
import { initTelemetry, emit } from "../telemetry.js";
|
|
12
13
|
import { runHooks } from "../hooks.js";
|
|
13
14
|
import { connectMcpServers, watchMcpConfig } from "../mcp.js";
|
|
@@ -46,6 +47,7 @@ export async function buildInkSession(opts = {}) {
|
|
|
46
47
|
sessionId = prev.id; // keep appending to the same file
|
|
47
48
|
initialTitle = prev.title;
|
|
48
49
|
notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
|
|
50
|
+
restoreGoal(prev.goal);
|
|
49
51
|
}
|
|
50
52
|
else {
|
|
51
53
|
notices.push("(no previous session here — starting fresh)");
|
|
@@ -71,6 +73,16 @@ export async function buildInkSession(opts = {}) {
|
|
|
71
73
|
const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
72
74
|
const autoMode = new AutoMode(client, { projectInstructions });
|
|
73
75
|
notices.push(...autoMode.startupNotices());
|
|
76
|
+
// A restored ACTIVE goal starts running as soon as the prompt is up — no
|
|
77
|
+
// keystroke — so say so loudly. Its /goal line was a genuine human request,
|
|
78
|
+
// so auto mode re-learns it (a paused goal is re-learned on /goal resume).
|
|
79
|
+
const goal = getGoal();
|
|
80
|
+
if (goal?.status === "active") {
|
|
81
|
+
autoMode.recordRequest(`/goal ${goal.objective}`);
|
|
82
|
+
notices.push(`(◎ resumed an ACTIVE goal — the agent continues it now; Esc pauses: ${goal.objective.slice(0, 80)})`);
|
|
83
|
+
}
|
|
84
|
+
else if (goal)
|
|
85
|
+
notices.push(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`);
|
|
74
86
|
if (judge)
|
|
75
87
|
notices.push(`(permission judge on — ${autoMode.backend})`);
|
|
76
88
|
// The remember tool flows through the same permission gate as any tool.
|
package/dist/loop.js
CHANGED
|
@@ -22,6 +22,7 @@ import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan
|
|
|
22
22
|
import { currentSkills, skillBodyMessages, skillListingReminder, syncSkillTool } from "./skills.js"; // skill listing reminders + launched skill bodies
|
|
23
23
|
import { pendingNotifications } 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
|
+
import { getGoal, updateGoalTool, runUpdateGoal, countGoalToolCall } from "./goal.js"; // /goal (Day 41): the model ends a durable goal with update_goal
|
|
25
26
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
26
27
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
27
28
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -44,6 +45,26 @@ export var TerminateReason;
|
|
|
44
45
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
45
46
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
46
47
|
})(TerminateReason || (TerminateReason = {}));
|
|
48
|
+
// The tool list of the last TOP-LEVEL model call. The next-prompt suggestion
|
|
49
|
+
// (suggestion.ts) resends the same tools so its request shares the main
|
|
50
|
+
// conversation's prefix — and the provider's prompt cache with it.
|
|
51
|
+
let lastTopLevelTools = [];
|
|
52
|
+
export const lastRequestTools = () => lastTopLevelTools;
|
|
53
|
+
function rememberTools(opts, tools) {
|
|
54
|
+
if (!opts.subAgent)
|
|
55
|
+
lastTopLevelTools = tools;
|
|
56
|
+
return tools;
|
|
57
|
+
}
|
|
58
|
+
// Append a model turn or tool result to history AND tell the observer (stream-json).
|
|
59
|
+
// Only these two kinds are streamed: injected user notes are the agent's own
|
|
60
|
+
// plumbing, and compaction rewrites the array wholesale (context.ts) — tapping
|
|
61
|
+
// messages.push itself would replay the entire history mid-stream. Sub-agents
|
|
62
|
+
// inherit opts by spread, so they are filtered here: their turns are not ours.
|
|
63
|
+
function record(messages, opts, message) {
|
|
64
|
+
messages.push(message);
|
|
65
|
+
if (!opts.subAgent && !opts.teammate)
|
|
66
|
+
opts.onMessage?.(message);
|
|
67
|
+
}
|
|
47
68
|
// Resolve the output sink for a loop: the caller's sink, or stdout (the old
|
|
48
69
|
// behaviour) when none was passed. Centralised so every site reads the same way.
|
|
49
70
|
function sink(opts) {
|
|
@@ -295,8 +316,13 @@ function toolsFor(opts) {
|
|
|
295
316
|
return builtins.filter((t) => !(t.type === "function" && t.function.name === "todo_write"));
|
|
296
317
|
}
|
|
297
318
|
// The Lead can do everything: orchestration, the protocols, and authoring +
|
|
298
|
-
// watching the board (it lays out tasks; teammates pull from it).
|
|
299
|
-
|
|
319
|
+
// watching the board (it lays out tasks; teammates pull from it). update_goal
|
|
320
|
+
// (Day 41) appears only once the user has set a /goal — no goal, nothing to end.
|
|
321
|
+
return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool, ...(currentSession() ? [listPeersTool] : []), ...(getGoal() ? [updateGoalTool] : [])];
|
|
322
|
+
}
|
|
323
|
+
// The names the top-level agent would be offered right now — stream-json's init line.
|
|
324
|
+
export function topLevelToolNames() {
|
|
325
|
+
return toolsFor({}).flatMap((t) => (t.type === "function" ? [t.function.name] : []));
|
|
300
326
|
}
|
|
301
327
|
const pendingSubAgents = new Map();
|
|
302
328
|
let subAgentSeq = 0;
|
|
@@ -456,7 +482,7 @@ opts) {
|
|
|
456
482
|
// The tool manual depends on the agent kind (Lead / teammate / sub-agent)
|
|
457
483
|
// — see toolsFor(). This is also where nested spawning is prevented: a
|
|
458
484
|
// teammate's manual simply omits spawn_teammate and task.
|
|
459
|
-
{ model: opts.model, messages: effortMessages(opts.model, messages), ...effortParameters(opts.model), tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
485
|
+
{ model: opts.model, messages: effortMessages(opts.model, messages), ...effortParameters(opts.model), tools: rememberTools(opts, toolsFor(opts)), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
460
486
|
let content = ""; // accumulated answer text
|
|
461
487
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
462
488
|
// Stash thinking without appending one indicator per model round. The live
|
|
@@ -571,6 +597,8 @@ async function runOneCall(call, opts) {
|
|
|
571
597
|
const spinner = !opts.quiet && !opts.subAgent ? sink(opts).spinner(status()) : null;
|
|
572
598
|
const timer = spinner ? setInterval(() => spinner.set(status()), 1000) : undefined;
|
|
573
599
|
emit("agent_tool_call", { tool: call.name });
|
|
600
|
+
if (!opts.subAgent)
|
|
601
|
+
countGoalToolCall(); // a goal turn with zero tool calls pauses the goal (Day 41)
|
|
574
602
|
try {
|
|
575
603
|
const refusal = await authorizeCall(call, opts);
|
|
576
604
|
const content = refusal ?? await runWithHooks(call, opts);
|
|
@@ -781,6 +809,14 @@ async function execute(call, opts) {
|
|
|
781
809
|
return runClaimTask(call, opts);
|
|
782
810
|
if (call.name === "complete_task")
|
|
783
811
|
return runCompleteTask(call, opts);
|
|
812
|
+
// /goal (Day 41): loop-level because the verifier is a model call (needs the
|
|
813
|
+
// client) and reads the conversation. The goal is the user's — top-level only.
|
|
814
|
+
if (call.name === "update_goal") {
|
|
815
|
+
if (opts.subAgent || !opts.transcript)
|
|
816
|
+
return "[error] Only the top-level agent can end the goal.";
|
|
817
|
+
return runUpdateGoal(call.args, { client: opts.client, model: subAgentModelFor(opts), signal: opts.signal, transcript: opts.transcript, note: (line) => { if (!opts.quiet)
|
|
818
|
+
sink(opts).note(chalk.cyan(` ${line}`)); } });
|
|
819
|
+
}
|
|
784
820
|
if (call.name !== "task")
|
|
785
821
|
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
786
822
|
if (opts.subAgent)
|
|
@@ -1306,7 +1342,7 @@ export async function runLoop(messages, // conversation history (mutated in plac
|
|
|
1306
1342
|
opts) {
|
|
1307
1343
|
// Children inherit this snapshot. Later human turns cannot silently grant
|
|
1308
1344
|
// broader authorization to already-running background work.
|
|
1309
|
-
opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps };
|
|
1345
|
+
opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps, transcript: opts.subAgent ? undefined : messages };
|
|
1310
1346
|
const recordHumanInput = (text) => {
|
|
1311
1347
|
opts.autoMode?.recordRequest(text);
|
|
1312
1348
|
opts.autoRequests = [...(opts.autoRequests ?? []), text];
|
|
@@ -1428,13 +1464,13 @@ opts) {
|
|
|
1428
1464
|
// partial text in history (clearly marked) so the next turn makes sense.
|
|
1429
1465
|
if (opts.isInterrupted()) {
|
|
1430
1466
|
if (out.content)
|
|
1431
|
-
messages
|
|
1467
|
+
record(messages, opts, { role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
|
|
1432
1468
|
return { reason: TerminateReason.UserInterrupt };
|
|
1433
1469
|
}
|
|
1434
1470
|
attempts.consecutive = 0; // success resets the breaker — but never the total budget
|
|
1435
1471
|
// Rebuild the assistant message from the assembled stream and add it to
|
|
1436
1472
|
// history — tool results must stay paired with their calls.
|
|
1437
|
-
messages
|
|
1473
|
+
record(messages, opts, {
|
|
1438
1474
|
role: "assistant", // the model's turn
|
|
1439
1475
|
content: out.content || null, // null when the turn was tool calls only
|
|
1440
1476
|
...(out.reasoning !== undefined ? { reasoning_content: out.reasoning } : {}),
|
|
@@ -1498,7 +1534,7 @@ opts) {
|
|
|
1498
1534
|
// Resolve every outstanding tool ID BEFORE adding a user message.
|
|
1499
1535
|
// Unstarted actions came from the old intent and must be replanned.
|
|
1500
1536
|
for (const call of out.toolCalls.slice(i))
|
|
1501
|
-
messages
|
|
1537
|
+
record(messages, opts, { role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
|
|
1502
1538
|
receiveFollowUps();
|
|
1503
1539
|
break;
|
|
1504
1540
|
}
|
|
@@ -1509,12 +1545,12 @@ opts) {
|
|
|
1509
1545
|
// Run the whole read-only batch at once, preserving result order.
|
|
1510
1546
|
const results = await Promise.all(batch.map((c) => runOneCall(c, { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) })));
|
|
1511
1547
|
for (const r of results)
|
|
1512
|
-
messages
|
|
1548
|
+
record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
|
|
1513
1549
|
continue; // back to the top — the next call is non-read-only
|
|
1514
1550
|
}
|
|
1515
1551
|
// A single non-read-only call: gate, maybe ask, execute — all serial.
|
|
1516
1552
|
const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
|
|
1517
|
-
messages
|
|
1553
|
+
record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
|
|
1518
1554
|
}
|
|
1519
1555
|
// A launched skill's body arrives as its own user message, after ALL of
|
|
1520
1556
|
// this round's tool results (the tool result itself only says "Launching
|
package/dist/permissions.js
CHANGED
|
@@ -617,6 +617,10 @@ function basePermission(toolName, argsJson, auto) {
|
|
|
617
617
|
// Task board (Day 40) — bookkeeping on the internal board, not the user's
|
|
618
618
|
// project. The actual work a claimed task drives still passes this gate.
|
|
619
619
|
return { decision: "allow", reason: "task board, no project side effects", summary: toolName };
|
|
620
|
+
case "update_goal":
|
|
621
|
+
// /goal (Day 41) — ends the user's goal. Bookkeeping; the only command it
|
|
622
|
+
// runs is the user's own /goal --check, which the model cannot change.
|
|
623
|
+
return { decision: "allow", reason: "goal bookkeeping, runs only the user's own --check", summary: toolName };
|
|
620
624
|
case "ask_user":
|
|
621
625
|
// Asking the user a question has no side effects — it's the safest thing
|
|
622
626
|
// the model can do. Never gate it behind an approval prompt.
|
package/dist/print.js
CHANGED
|
@@ -1,11 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
export function formatPrintResult(format, data) {
|
|
2
3
|
if (format === "text")
|
|
3
4
|
return data.result.finalText ? `${data.result.finalText}\n` : "";
|
|
5
|
+
if (format === "stream-json")
|
|
6
|
+
return streamJsonResult(data);
|
|
4
7
|
return JSON.stringify({ type: "result", is_error: data.result.reason !== "done", reason: data.result.reason,
|
|
5
8
|
result: data.result.finalText ?? "", session_id: data.sessionId, model: data.model, effort: data.effort,
|
|
6
9
|
usage: { input_tokens: data.usage.inputUncached + data.usage.inputCached, cached_input_tokens: data.usage.inputCached,
|
|
7
10
|
output_tokens: data.usage.output, model_calls: data.usage.calls }, estimated_cost_usd: data.usage.cost }) + "\n";
|
|
8
11
|
}
|
|
12
|
+
// ---- stream-json: Claude Code's NDJSON event protocol -----------------------------
|
|
13
|
+
// `claude -p --output-format stream-json` prints one JSON object per line:
|
|
14
|
+
// system/init → assistant / user (tool_result) … → result
|
|
15
|
+
// Tools that parse Claude's stream (SDK wrappers, CI dashboards, log viewers)
|
|
16
|
+
// can read ours unchanged. Our history is OpenAI-shaped, so each message is
|
|
17
|
+
// translated at the boundary: tool_calls become tool_use blocks, role:"tool"
|
|
18
|
+
// becomes a user message carrying a tool_result, reasoning_content becomes a
|
|
19
|
+
// thinking block. Tool NAMES stay ours (run_bash, not Bash) — they are what the
|
|
20
|
+
// model actually called. Sub-agent turns are not streamed, so every event has
|
|
21
|
+
// parent_tool_use_id: null.
|
|
22
|
+
// Every line carries a fresh uuid and the session id, like Claude's.
|
|
23
|
+
const event = (sessionId, body) => JSON.stringify({ ...body, session_id: sessionId, uuid: randomUUID() }) + "\n";
|
|
24
|
+
// The first line: what this run is equipped with, before any model call.
|
|
25
|
+
export function streamJsonInit(data) {
|
|
26
|
+
return event(data.sessionId, { type: "system", subtype: "init", cwd: process.cwd(), tools: data.tools,
|
|
27
|
+
mcp_servers: data.mcpServers, model: data.model, permissionMode: data.permissionMode });
|
|
28
|
+
}
|
|
29
|
+
// Our tool results mark failure with a bracketed prefix, not a flag.
|
|
30
|
+
const TOOL_ERROR = /^\[(?:error|permission|hook|follow-up)\]/;
|
|
31
|
+
// One history message → zero or more events. Claude emits one assistant event
|
|
32
|
+
// per content block (sharing the message id), so consumers can render each
|
|
33
|
+
// block as it lands; we do the same.
|
|
34
|
+
export function streamJsonMessage(sessionId, model, message) {
|
|
35
|
+
if (message.role === "tool") {
|
|
36
|
+
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
37
|
+
return event(sessionId, { type: "user", parent_tool_use_id: null, message: { role: "user",
|
|
38
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content, is_error: TOOL_ERROR.test(content) }] } });
|
|
39
|
+
}
|
|
40
|
+
if (message.role !== "assistant")
|
|
41
|
+
return "";
|
|
42
|
+
const blocks = [];
|
|
43
|
+
const reasoning = message.reasoning_content;
|
|
44
|
+
if (reasoning)
|
|
45
|
+
blocks.push({ type: "thinking", thinking: reasoning });
|
|
46
|
+
if (typeof message.content === "string" && message.content)
|
|
47
|
+
blocks.push({ type: "text", text: message.content });
|
|
48
|
+
for (const call of message.tool_calls ?? []) {
|
|
49
|
+
if (call.type !== "function")
|
|
50
|
+
continue;
|
|
51
|
+
// The model can emit malformed arguments; the stream must never throw over it.
|
|
52
|
+
let input;
|
|
53
|
+
try {
|
|
54
|
+
input = JSON.parse(call.function.arguments || "{}");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
input = call.function.arguments;
|
|
58
|
+
}
|
|
59
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.function.name, input });
|
|
60
|
+
}
|
|
61
|
+
const id = `msg_${randomUUID()}`;
|
|
62
|
+
const stopReason = message.tool_calls?.length ? "tool_use" : "end_turn";
|
|
63
|
+
return blocks.map((block) => event(sessionId, { type: "assistant", parent_tool_use_id: null, message: {
|
|
64
|
+
id, type: "message", role: "assistant", model, content: [block], stop_reason: stopReason, stop_sequence: null
|
|
65
|
+
} })).join("");
|
|
66
|
+
}
|
|
67
|
+
// The last line. Usage follows Anthropic's split (uncached input vs cache
|
|
68
|
+
// reads), which differs from our `json` format's input_tokens (the total).
|
|
69
|
+
function streamJsonResult(data) {
|
|
70
|
+
const ok = data.result.reason === "done";
|
|
71
|
+
return event(data.sessionId, { type: "result", subtype: ok ? "success" : "error_during_execution", is_error: !ok,
|
|
72
|
+
duration_ms: data.durationMs ?? 0, num_turns: data.numTurns ?? 0, result: data.result.finalText ?? "",
|
|
73
|
+
total_cost_usd: data.usage.cost, usage: { input_tokens: data.usage.inputUncached, cache_creation_input_tokens: 0,
|
|
74
|
+
cache_read_input_tokens: data.usage.inputCached, output_tokens: data.usage.output },
|
|
75
|
+
permission_denials: [], reason: data.result.reason });
|
|
76
|
+
}
|
|
9
77
|
// stdout is a protocol in print mode, even when an MCP startup or hook helper
|
|
10
78
|
// logs directly. Reserve the original writer for the result and route all
|
|
11
79
|
// incidental output to stderr. Only the CLI entry point installs this guard.
|
package/dist/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs"; // session files live on disk
|
|
2
2
|
import path from "node:path"; // path joining
|
|
3
|
+
import { getGoal } from "./goal.js"; // the /goal rides along in the session file (Day 41)
|
|
3
4
|
// Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
|
|
4
5
|
// Project-local (not global) because a conversation is about a codebase —
|
|
5
6
|
// resuming a refactor of project A from inside project B makes no sense.
|
|
@@ -27,6 +28,7 @@ export function saveSession(id, model, messages, title) {
|
|
|
27
28
|
model, // informational — resume does not force the same model
|
|
28
29
|
title: title ?? existing?.title, // a generated title sticks across saves
|
|
29
30
|
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
31
|
+
goal: getGoal() ?? undefined, // the live goal, snapshotted with the conversation it belongs to
|
|
30
32
|
};
|
|
31
33
|
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
32
34
|
fs.writeFileSync(tmp, JSON.stringify(data)); // the new snapshot
|
|
@@ -93,6 +95,7 @@ export function sessionTitle(messages) {
|
|
|
93
95
|
const firstUser = messages.find((m) => m.role === "user");
|
|
94
96
|
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
95
97
|
text = text.split("\n\n[Referenced files")[0]; // drop the @file attachment the REPL appended (Day 26)
|
|
98
|
+
text = text.match(/^\[goal\][^\n]*\nObjective: ([^\n]*)/)?.[1] ?? text; // a session started with /goal is named after its objective (Day 41)
|
|
96
99
|
const line = text.replace(/\s+/g, " ").trim(); // flatten to one line
|
|
97
100
|
return line || "(no prompt)";
|
|
98
101
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { effortMessages, effortProfile } from "./effort.js"; // same message shape as the main loop sends
|
|
2
|
+
import { recordUsage } from "./cost.js"; // the suggestion call shows up in /cost like any other
|
|
3
|
+
// ---- Next-prompt suggestion (Claude Code's prompt suggestion) -------------------
|
|
4
|
+
// When a turn ends, guess what you'll type next and show it as a dim placeholder
|
|
5
|
+
// in the empty input box: Tab turns it into real text, Enter sends it. It's
|
|
6
|
+
// most useful at the obvious moments — "run the tests", "commit it", "yes, do
|
|
7
|
+
// the same for the other file" — so the guess must be short and concrete, or
|
|
8
|
+
// nothing at all.
|
|
9
|
+
//
|
|
10
|
+
// Cost: the request is the SAME history plus the SAME tools as the main call
|
|
11
|
+
// that just finished, with one instruction appended. That shared prefix is
|
|
12
|
+
// what the provider's prompt cache keys on (DeepSeek caches automatically),
|
|
13
|
+
// so almost all of the input is billed at the cached rate. tool_choice "none"
|
|
14
|
+
// keeps the tools in the prompt (cache) without letting the model call one.
|
|
15
|
+
const SUGGESTION_TIMEOUT_MS = 15_000;
|
|
16
|
+
const MAX_CHARS = 120;
|
|
17
|
+
const SUGGESTION_PROMPT = `[Next-prompt suggestion — this message is from the app, not the user]
|
|
18
|
+
Predict the user's NEXT message in this conversation: what they would most likely type now, given what just happened.
|
|
19
|
+
- Write it AS THE USER, addressed to the assistant (e.g. "run the tests", "commit and push", "也改一下另一个文件").
|
|
20
|
+
- Short and concrete: at most 12 words, or 30 characters for Chinese/Japanese. Same language the user writes in.
|
|
21
|
+
- Only a natural next step the user would actually want. No questions about preferences, no thanks, no pleasantries.
|
|
22
|
+
- If there is no clear next step, reply exactly: NONE
|
|
23
|
+
Reply with ONLY the message text — no quotes, no explanation.`;
|
|
24
|
+
// Keep only something that reads like a user's short next message. Anything
|
|
25
|
+
// else — an answer, a plan, the assistant's own voice — is worse than nothing.
|
|
26
|
+
export function cleanSuggestion(raw) {
|
|
27
|
+
let s = raw.trim().replace(/^["'“”「」`]+|["'“”「」`]+$/g, "").trim();
|
|
28
|
+
if (!s || /^none\.?$/i.test(s) || s.includes("\n"))
|
|
29
|
+
return null;
|
|
30
|
+
if (s.length > MAX_CHARS)
|
|
31
|
+
return null;
|
|
32
|
+
if (/^(i'll|i will|i've|i have|let me|sure|okay,? i|here's|the user)\b/i.test(s))
|
|
33
|
+
return null; // the assistant talking, not the user
|
|
34
|
+
if (/^(好的|我来|我会|我已经|用户)/.test(s))
|
|
35
|
+
return null;
|
|
36
|
+
s = s.replace(/\s+/g, " ");
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
export async function generatePromptSuggestion(client, model, messages, tools = [], signal) {
|
|
40
|
+
try {
|
|
41
|
+
const res = await client.chat.completions.create({
|
|
42
|
+
model, // the conversation's model: its cache holds this prefix
|
|
43
|
+
messages: [...effortMessages(model, messages), { role: "user", content: SUGGESTION_PROMPT }],
|
|
44
|
+
...(tools.length ? { tools, tool_choice: "none" } : {}),
|
|
45
|
+
...(effortProfile(model)?.parameter === "deepseek" ? { thinking: { type: "disabled" } } : {}), // a one-line guess needs no reasoning
|
|
46
|
+
max_tokens: 60,
|
|
47
|
+
stream: false,
|
|
48
|
+
}, { timeout: SUGGESTION_TIMEOUT_MS, signal });
|
|
49
|
+
recordUsage(res.usage);
|
|
50
|
+
return cleanSuggestion(res.choices?.[0]?.message?.content ?? "");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null; // a failed guess is simply no guess
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mini-agent": "dist/agent.js"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"auto:report": "tsx eval/auto-report.ts",
|
|
18
18
|
"build": "tsc -p tsconfig.build.json",
|
|
19
19
|
"prepare": "npm run build",
|
|
20
|
-
"test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
|
|
20
|
+
"test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/suggestion.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts && tsx tests/goal.test.ts"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20.6"
|