agent-from-zero 0.35.5 → 0.35.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,17 +133,47 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
133
133
 
134
134
  所有数据都存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。
135
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
+
136
150
  ### 非交互模式
137
151
 
138
152
  ```bash
139
153
  npx agent-from-zero -p "总结当前改动"
140
154
  cat error.log | npx agent-from-zero -p "分析这份日志"
141
155
  npx agent-from-zero -p "总结当前改动" --output-format json
156
+ npx agent-from-zero -p "修一下这个 bug" --output-format stream-json
142
157
  ```
143
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
+
161
+ ### 让别的程序来驱动交互会话
162
+
163
+ 编排器(比如 orca)可以把交互式的 mini-agent 放在终端里跑,用 hooks 看它的状态、替你审批:
164
+
165
+ - `MINI_AGENT_HOOKS_FILE=<文件>`:再加一层 hooks,格式和 settings.json 里的 `hooks` 一样,只对这一个进程生效,不改你的设置文件,也放宽不了权限。
166
+ - `--session-id <id>`:这个 id 的会话存在就接着聊,不存在就用它开新会话。
167
+ - 每个 hook 的输入都带 Claude Code 同名的字段:`session_id`、`transcript_path`(会话文件的绝对路径)、`cwd`、`hook_event_name`、这一轮的 `prompt_id`,工具事件另有 `tool_name`、`tool_input`。原来的字段照旧。
168
+ - `PermissionRequest`:一次工具调用要问你之前先问 hook。输出 `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` 就放行,`"behavior":"deny"` 加 `message` 或者退出码 2 就拒绝;没输出、超时、出错都算没决定,照常问你。规则要求必须由人决定的调用,hook 只能拒绝,放行还得你点。
169
+ - `Notification`:审批菜单弹出来时触发,`notification_type` 是 `permission_prompt`。
170
+ - `TurnEnd`:每一轮结束、会话存盘之后触发,被 Esc 打断或出错也会触发,带 `reason` 和 `last_assistant_message`。`Stop` 在存盘之前触发,打断时不触发,要知道"这一轮真的完了"就看 `TurnEnd`。
171
+
172
+ 输入框里 `Ctrl+U` 删除光标前的这一行,行首再按会并到上一行,连按可以清空多行草稿。
173
+
144
174
  ## 常用命令
145
175
 
146
- 在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
176
+ 在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/goal` `/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
147
177
 
148
178
  ## 开发
149
179
 
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");
@@ -51,6 +52,7 @@ const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any
51
52
  Usage:
52
53
  mini-agent interactive session (REPL)
53
54
  mini-agent -r | --resume continue the most recent session in this directory
55
+ mini-agent --session-id <id> continue that session, or start a new one with that id
54
56
  mini-agent -p "<task>" one-shot: run a single task, print the result, exit
55
57
  mini-agent exec "<task>" alias for non-interactive print mode
56
58
  cat file | mini-agent -p "<task>" append piped text to the prompt
@@ -59,7 +61,7 @@ Usage:
59
61
  --effort <level> supported effort for this model, or default
60
62
  --permission-mode <mode> default | auto | bypassPermissions (this run only)
61
63
  --dangerously-skip-permissions alias for --permission-mode bypassPermissions
62
- --output-format text|json final answer or a JSON result (print mode only)
64
+ --output-format text|json|stream-json final answer, a JSON result, or Claude-style NDJSON events (print mode only)
63
65
  mini-agent --auto review tool actions automatically (Jev or your current vendor)
64
66
  mini-agent -v | --version print the version
65
67
  mini-agent -h | --help this text
@@ -108,6 +110,7 @@ const SESSION_HELP = `commands:
108
110
  /bg list background tasks this session (run_bash_background) and their status
109
111
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
110
112
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
113
+ /goal <objective> [--check <cmd>] keep working, turn after turn, until the goal is verified done (/goal alone shows it; pause | resume | clear)
111
114
  /cron list scheduled cron jobs (schedule_cron) and their expressions
112
115
  /peers list other mini-agent sessions on this machine (the agent can message them with send_message)
113
116
  /rename <name> rename this session — the name other sessions use to reach it
@@ -152,7 +155,7 @@ async function main() {
152
155
  // MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
153
156
  // reference front-end, and an escape hatch for terminals Ink misbehaves in).
154
157
  if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
155
- await launchInk({ resume: cli.resume });
158
+ await launchInk({ resume: cli.resume, sessionId: cli.sessionId });
156
159
  return;
157
160
  }
158
161
  requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
@@ -181,6 +184,8 @@ async function main() {
181
184
  sessionId = prev.id; // keep appending to the same session file
182
185
  initialTitle = prev.title;
183
186
  console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
187
+ if (printTask === null)
188
+ restoreGoal(prev.goal); // Day 41: the goal comes back with its session (print mode doesn't run goals)
184
189
  }
185
190
  else {
186
191
  console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
@@ -216,6 +221,15 @@ async function main() {
216
221
  // The optional LLM permission judge, built once if a settings file enabled it.
217
222
  const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
218
223
  const autoMode = new AutoMode(client, { projectInstructions });
224
+ // A restored ACTIVE goal (Day 41) starts running before the first prompt —
225
+ // say so loudly. Its /goal line was a genuine human request: auto mode re-learns it.
226
+ const restoredGoal = getGoal();
227
+ if (restoredGoal?.status === "active") {
228
+ autoMode.recordRequest(`/goal ${restoredGoal.objective}`);
229
+ console.log(chalk.cyan(`(◎ resumed an ACTIVE goal — the agent continues it now; Esc pauses: ${restoredGoal.objective.slice(0, 80)})`));
230
+ }
231
+ else if (restoredGoal)
232
+ console.log(chalk.dim(`(◎ goal restored [${restoredGoal.status}] — /goal to see it, /goal resume to continue)`));
219
233
  for (const notice of autoMode.startupNotices())
220
234
  console.log(chalk.dim(notice));
221
235
  if (judge)
@@ -264,7 +278,21 @@ async function main() {
264
278
  });
265
279
  messages.push({ role: "user", content: printTask }); // the single task
266
280
  autoMode.recordRequest(printTask);
281
+ // stream-json: an init line now (MCP is connected, so statuses are real),
282
+ // then every assistant turn / tool result as it enters history.
283
+ const streaming = cli.outputFormat === "stream-json";
284
+ const startedAt = Date.now();
285
+ let numTurns = 0;
286
+ if (streaming)
287
+ printOutput.write(streamJsonInit({ sessionId, model: CONFIG.model, tools: topLevelToolNames(),
288
+ mcpServers: listMcpServers().map((s) => ({ name: s.name, status: s.status })),
289
+ permissionMode: CONFIG.bypassPermissions ? "bypassPermissions" : CONFIG.autoMode.enabled ? "auto" : "default" }));
267
290
  const result = await runLoop(messages, {
291
+ onMessage: streaming ? (m) => {
292
+ if (m.role === "assistant")
293
+ numTurns++;
294
+ printOutput.write(streamJsonMessage(sessionId, CONFIG.model, m));
295
+ } : undefined,
268
296
  client,
269
297
  model: CONFIG.model,
270
298
  quiet: true, // final result only; no spinner, reasoning or intermediate prose
@@ -289,6 +317,7 @@ async function main() {
289
317
  emit("agent_session_end"); // close the books
290
318
  printOutput.write(formatPrintResult(cli.outputFormat, {
291
319
  sessionId, model: CONFIG.model, effort: selectedEffort(CONFIG.model), result, usage: costMeter.snapshot(),
320
+ durationMs: Date.now() - startedAt, numTurns,
292
321
  }));
293
322
  if (result.reason !== TerminateReason.Done) {
294
323
  console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
@@ -569,6 +598,13 @@ async function main() {
569
598
  console.log(await statusCommand(line, costMeter));
570
599
  return true;
571
600
  }
601
+ if (line === "/goal" || line.startsWith("/goal ")) {
602
+ const { message, request } = goalCommand(line.slice(5));
603
+ if (request)
604
+ autoMode.recordRequest(request); // the /goal you typed is the authorization; automatic goal turns never are
605
+ console.log(chalk.cyan(message)); // setting or resuming: the goal turns start before the next prompt
606
+ return true;
607
+ }
572
608
  if (line.startsWith("/skills "))
573
609
  line = `/skill ${line.slice(8).trim()}`;
574
610
  if (line === "/effort" || line.startsWith("/effort ")) {
@@ -662,6 +698,7 @@ async function main() {
662
698
  return true;
663
699
  case "/clear":
664
700
  autoMode.clearRequests();
701
+ clearGoal(); // the goal belonged to the old conversation (Day 41)
665
702
  messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
666
703
  forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
667
704
  clearUndo(); // a fresh conversation should not undo the previous one's writes
@@ -805,6 +842,15 @@ async function main() {
805
842
  resetTeam(); // a resumed conversation starts with no live team
806
843
  resetBoard(); // ...and an empty task board (Day 40)
807
844
  console.log(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
+ console.log(chalk.cyan(`◎ resumed an ACTIVE goal — the agent continues it now (Esc pauses): ${goal.objective.slice(0, 80)}`));
851
+ }
852
+ else if (goal)
853
+ console.log(chalk.dim(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`));
808
854
  return true;
809
855
  }
810
856
  case "/diff": {
@@ -884,7 +930,46 @@ async function main() {
884
930
  }
885
931
  });
886
932
  }
933
+ // /goal (Day 41): the readline prompt blocks on your input, so it can't poll
934
+ // like the Ink REPL's idle processor. Instead, goal turns run HERE, before each
935
+ // prompt, one after another until the goal stops being active — verified
936
+ // complete, blocked, or paused by Esc / a failed turn / no progress. Plan mode
937
+ // never continues a goal: plan-only work can't move it forward.
938
+ const runGoalTurns = async () => {
939
+ for (let content = isPlanMode() ? null : startGoalTurn(); content; content = isPlanMode() ? null : startGoalTurn()) {
940
+ console.log(chalk.cyan(`\n◎ goal · turn ${getGoal().continuations}: ${getGoal().objective.slice(0, 70)}`));
941
+ messages.push({ role: "user", content }); // the goal turn (restates the objective — it survives compaction)
942
+ clearReasoning();
943
+ clearToolCalls();
944
+ running = true;
945
+ interrupted = false;
946
+ controller = new AbortController();
947
+ setSessionState("busy", CONFIG.model);
948
+ let reason = "error";
949
+ try {
950
+ const result = await runLoop(messages, {
951
+ client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, followUps,
952
+ onFollowUp: (text) => console.log(sentMessage(text)), subAgentModel: CONFIG.subAgentModel, judge, autoMode, canPrompt: !!process.stdin.isTTY,
953
+ });
954
+ reason = result.reason;
955
+ if (result.reason !== TerminateReason.Done)
956
+ console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[result.reason]}`));
957
+ }
958
+ catch (err) {
959
+ console.log(chalk.red(`[error] ${err.message}`));
960
+ }
961
+ finally {
962
+ running = false;
963
+ setSessionState("idle");
964
+ }
965
+ const goalNote = settleGoalTurn(reason, true);
966
+ if (goalNote)
967
+ console.log(chalk.yellow(goalNote));
968
+ saveSession(sessionId, CONFIG.model, messages, pendingTitle); // the goal's progress is saved turn by turn
969
+ }
970
+ };
887
971
  while (true) {
972
+ await runGoalTurns(); // an active /goal keeps going before you're asked for input
888
973
  // The input area: a blank line for breathing room, the "❯" you type on, then
889
974
  // a single status line (model · 📁 dir · 🌿 branch · ctx% · $ · time) pinned
890
975
  // BELOW the input. We dropped the old top/bottom horizontal rules — two bare
@@ -985,6 +1070,9 @@ async function main() {
985
1070
  });
986
1071
  running = false; // back at the prompt — Ctrl+C means "exit" again
987
1072
  setSessionState("idle"); // other sessions see this one as free again
1073
+ const goalNote = settleGoalTurn(result.reason, false); // Day 41: Esc on your own turn pauses an active goal too
1074
+ if (goalNote)
1075
+ console.log(chalk.yellow(goalNote));
988
1076
  saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
989
1077
  // Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
990
1078
  // that saves durable facts — especially user corrections — so memory
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { CONFIG } from "./config.js";
3
3
  import { effortMenu, setEffort } from "./effort.js";
4
+ import { isValidSessionId } from "./session.js"; // --session-id becomes a file name
4
5
  export class CliUsageError extends Error {
5
6
  }
6
7
  // -p is a mode, not a boundary after which flags turn into task text. Node's
@@ -18,9 +19,11 @@ export function parseCli(args) {
18
19
  "permission-mode": { type: "string" },
19
20
  "dangerously-skip-permissions": { type: "boolean" },
20
21
  resume: { type: "boolean", short: "r" },
22
+ "session-id": { type: "string" },
21
23
  help: { type: "boolean", short: "h" },
22
24
  version: { type: "boolean", short: "v" },
23
25
  "output-format": { type: "string" },
26
+ verbose: { type: "boolean" }, // accepted for Claude compatibility: its stream-json requires --verbose; ours always streams
24
27
  } });
25
28
  }
26
29
  catch (error) {
@@ -35,20 +38,25 @@ export function parseCli(args) {
35
38
  // Match Claude's precedence: the explicit skip flag wins over a mode flag.
36
39
  const permissionMode = values["dangerously-skip-permissions"] ? "bypassPermissions" : requestedMode;
37
40
  const outputFormat = values["output-format"] ?? "text";
38
- if (outputFormat !== "text" && outputFormat !== "json")
39
- throw new CliUsageError("--output-format must be text or json");
41
+ if (outputFormat !== "text" && outputFormat !== "json" && outputFormat !== "stream-json")
42
+ throw new CliUsageError("--output-format must be text, json, or stream-json");
40
43
  for (const flag of ["model", "effort", "prompt"]) {
41
44
  if (typeof values[flag] === "string" && !values[flag].trim())
42
45
  throw new CliUsageError(`--${flag} cannot be empty`);
43
46
  }
44
47
  if (values.prompt !== undefined && positionals.length)
45
48
  throw new CliUsageError("Use either a positional prompt or --prompt, not both");
49
+ const sessionId = typeof values["session-id"] === "string" ? values["session-id"] : undefined;
50
+ if (sessionId !== undefined && !isValidSessionId(sessionId))
51
+ throw new CliUsageError("--session-id may only contain letters, digits, '.', '_' and '-'");
52
+ if (sessionId !== undefined && values.resume)
53
+ throw new CliUsageError("Use either --resume or --session-id, not both");
46
54
  const prompt = typeof values.prompt === "string" ? values.prompt : positionals.join(" ");
47
55
  if (!print && (prompt || values["output-format"] !== undefined))
48
56
  throw new CliUsageError("Use -p / --print or exec with a prompt or --output-format");
49
57
  return { print, prompt, outputFormat, permissionMode, model: typeof values.model === "string" ? values.model : undefined,
50
58
  effort: typeof values.effort === "string" ? values.effort : undefined, auto: values.auto === true,
51
- resume: values.resume === true, help: values.help === true, version: values.version === true };
59
+ resume: values.resume === true, sessionId, help: values.help === true, version: values.version === true };
52
60
  }
53
61
  // CLI overrides live only in this process; never save them to user settings.
54
62
  // Validate against the selected model/endpoint before any request is started.
@@ -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
@@ -40,6 +40,12 @@ function readSettings(p) {
40
40
  }
41
41
  const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
42
42
  const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
43
+ // A third, per-process layer of HOOKS only: MINI_AGENT_HOOKS_FILE points at a
44
+ // settings-shaped file ({"hooks": {...}}) whose hooks join the other two. It
45
+ // lets a program that launches the agent (an orchestrator such as orca) watch
46
+ // and steer this one process without editing the user's settings. Only hooks
47
+ // are read from it — it cannot loosen permissions.
48
+ const processHooks = process.env.MINI_AGENT_HOOKS_FILE ? readSettings(process.env.MINI_AGENT_HOOKS_FILE).hooks : undefined;
43
49
  // Re-read ONLY the mcpServers map from both layers, mid-session (the MCP hot
44
50
  // reload in mcp.ts). Unlike startup, a file that won't parse is NOT fatal here:
45
51
  // an editor saving in two steps can leave half-written JSON for a moment, and
@@ -110,7 +116,7 @@ export const CONFIG = {
110
116
  // a generic union so adding a new event needs no edit here.
111
117
  hooks: (() => {
112
118
  const merged = {};
113
- for (const src of [globalSettings.hooks, projectSettings.hooks]) {
119
+ for (const src of [globalSettings.hooks, projectSettings.hooks, processHooks]) {
114
120
  for (const [event, defs] of Object.entries(src ?? {}))
115
121
  merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
116
122
  }