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 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 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/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
- saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
297
- if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
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) => 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
+ })
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
  });
@@ -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
- 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] : []));
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.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)
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.push({
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.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." });
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.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 });
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.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 });
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
@@ -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.4",
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"