agent-from-zero 0.35.5 → 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 CHANGED
@@ -133,17 +133,34 @@ 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
+
144
161
  ## 常用命令
145
162
 
146
- 在会话里输入 `/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`。
147
164
 
148
165
  ## 开发
149
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 or a JSON result (print mode only)
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`);
@@ -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/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
@@ -20,6 +20,7 @@ import { clearUndo } from "../undo.js";
20
20
  import { clearTodos } from "../todos.js";
21
21
  import { resetTeam } from "../team.js";
22
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
23
24
  import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
24
25
  import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
25
26
  import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
@@ -250,7 +251,7 @@ export function App({ session, runTurn, clipboard }) {
250
251
  // as the prompt (a user highlight bar, or a note for /skill); the loop appends
251
252
  // `content` to `messages` itself. The permission menu + ask_user form raise a
252
253
  // `pending` prompt that returns a promise the loop awaits.
253
- const runConversationTurn = (content, display, attachments = []) => {
254
+ const runConversationTurn = (content, display, attachments = [], goalTurn = false) => {
254
255
  setDetails(null);
255
256
  setDetailOffset(0);
256
257
  if (display)
@@ -304,14 +305,17 @@ export function App({ session, runTurn, clipboard }) {
304
305
  sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
305
306
  if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
306
307
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
307
- saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
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)
308
312
  if (suggestAfter && result.reason === TerminateReason.Done) {
309
313
  void generatePromptSuggestion(client, CONFIG.model, messages, lastRequestTools()).then((guess) => {
310
314
  if (guess && seq === suggestionSeq.current)
311
315
  setSuggestion(guess); // no newer turn started meanwhile (the box shows it only while idle)
312
316
  });
313
317
  }
314
- if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
318
+ if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done && !goalTurn) { // not after every automatic goal turn — that would bill an extraction per continuation
315
319
  try {
316
320
  const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
317
321
  if (got.length)
@@ -322,7 +326,12 @@ export function App({ session, runTurn, clipboard }) {
322
326
  }
323
327
  }
324
328
  })
325
- .catch((e) => note(chalk.red(`[error] ${e.message}`)))
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
+ })
326
335
  .finally(() => {
327
336
  turn.current = null;
328
337
  setSessionState("idle");
@@ -360,6 +369,27 @@ export function App({ session, runTurn, clipboard }) {
360
369
  return () => clearInterval(id);
361
370
  // eslint-disable-next-line react-hooks/exhaustive-deps
362
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]);
363
393
  // The PEER idle processor: another mini-agent session messaged this one while
364
394
  // it sat at the prompt. Like cron, start a turn so the agent answers on its
365
395
  // own. Two agents answering each other could run forever (and bill for every
@@ -730,9 +760,17 @@ export function App({ session, runTurn, clipboard }) {
730
760
  runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
731
761
  return true;
732
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
+ }
733
770
  switch (line) {
734
771
  case "/clear":
735
772
  autoMode.clearRequests();
773
+ clearGoal(); // the goal belonged to the old conversation
736
774
  messages.length = 0; // mutate in place — same array ref the loop holds
737
775
  messages.push({ role: "system", content: systemMessage });
738
776
  forgetFilesExcept([]);
@@ -804,6 +842,15 @@ export function App({ session, runTurn, clipboard }) {
804
842
  resetTeam();
805
843
  resetBoard();
806
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)`));
807
854
  });
808
855
  return true;
809
856
  }
@@ -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
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
@@ -54,6 +55,16 @@ function rememberTools(opts, tools) {
54
55
  lastTopLevelTools = tools;
55
56
  return tools;
56
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
+ }
57
68
  // Resolve the output sink for a loop: the caller's sink, or stdout (the old
58
69
  // behaviour) when none was passed. Centralised so every site reads the same way.
59
70
  function sink(opts) {
@@ -305,8 +316,13 @@ function toolsFor(opts) {
305
316
  return builtins.filter((t) => !(t.type === "function" && t.function.name === "todo_write"));
306
317
  }
307
318
  // The Lead can do everything: orchestration, the protocols, and authoring +
308
- // watching the board (it lays out tasks; teammates pull from it).
309
- return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool, ...(currentSession() ? [listPeersTool] : [])];
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] : []));
310
326
  }
311
327
  const pendingSubAgents = new Map();
312
328
  let subAgentSeq = 0;
@@ -581,6 +597,8 @@ async function runOneCall(call, opts) {
581
597
  const spinner = !opts.quiet && !opts.subAgent ? sink(opts).spinner(status()) : null;
582
598
  const timer = spinner ? setInterval(() => spinner.set(status()), 1000) : undefined;
583
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)
584
602
  try {
585
603
  const refusal = await authorizeCall(call, opts);
586
604
  const content = refusal ?? await runWithHooks(call, opts);
@@ -791,6 +809,14 @@ async function execute(call, opts) {
791
809
  return runClaimTask(call, opts);
792
810
  if (call.name === "complete_task")
793
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
+ }
794
820
  if (call.name !== "task")
795
821
  return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
796
822
  if (opts.subAgent)
@@ -1316,7 +1342,7 @@ export async function runLoop(messages, // conversation history (mutated in plac
1316
1342
  opts) {
1317
1343
  // Children inherit this snapshot. Later human turns cannot silently grant
1318
1344
  // broader authorization to already-running background work.
1319
- 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 };
1320
1346
  const recordHumanInput = (text) => {
1321
1347
  opts.autoMode?.recordRequest(text);
1322
1348
  opts.autoRequests = [...(opts.autoRequests ?? []), text];
@@ -1438,13 +1464,13 @@ opts) {
1438
1464
  // partial text in history (clearly marked) so the next turn makes sense.
1439
1465
  if (opts.isInterrupted()) {
1440
1466
  if (out.content)
1441
- messages.push({ role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
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)
1442
1468
  return { reason: TerminateReason.UserInterrupt };
1443
1469
  }
1444
1470
  attempts.consecutive = 0; // success resets the breaker — but never the total budget
1445
1471
  // Rebuild the assistant message from the assembled stream and add it to
1446
1472
  // history — tool results must stay paired with their calls.
1447
- messages.push({
1473
+ record(messages, opts, {
1448
1474
  role: "assistant", // the model's turn
1449
1475
  content: out.content || null, // null when the turn was tool calls only
1450
1476
  ...(out.reasoning !== undefined ? { reasoning_content: out.reasoning } : {}),
@@ -1508,7 +1534,7 @@ opts) {
1508
1534
  // Resolve every outstanding tool ID BEFORE adding a user message.
1509
1535
  // Unstarted actions came from the old intent and must be replanned.
1510
1536
  for (const call of out.toolCalls.slice(i))
1511
- messages.push({ role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
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." });
1512
1538
  receiveFollowUps();
1513
1539
  break;
1514
1540
  }
@@ -1519,12 +1545,12 @@ opts) {
1519
1545
  // Run the whole read-only batch at once, preserving result order.
1520
1546
  const results = await Promise.all(batch.map((c) => runOneCall(c, { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) })));
1521
1547
  for (const r of results)
1522
- messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1548
+ record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
1523
1549
  continue; // back to the top — the next call is non-read-only
1524
1550
  }
1525
1551
  // A single non-read-only call: gate, maybe ask, execute — all serial.
1526
1552
  const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
1527
- messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1553
+ record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
1528
1554
  }
1529
1555
  // A launched skill's body arrives as its own user message, after ALL of
1530
1556
  // this round's tool results (the tool result itself only says "Launching
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.35.5",
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/suggestion.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"