agent-from-zero 0.35.5 → 0.35.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/hooks.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process"; // hooks are external programs
2
+ import { randomUUID } from "node:crypto"; // turn ids
2
3
  import chalk from "chalk"; // status lines
3
4
  import { CONFIG } from "./config.js"; // user-configured hooks
4
5
  import { emit } from "./telemetry.js"; // count hook activity
@@ -23,6 +24,39 @@ const DEFAULT_HOOK_TIMEOUT_MS = 10_000; // most hooks get 10s, then we kill it
23
24
  // Differentiated timeouts: SessionEnd fires when the user is leaving (often via
24
25
  // Ctrl+C) — it MUST return almost instantly, so it gets a tiny budget.
25
26
  const EVENT_TIMEOUT_MS = { SessionEnd: 1_500 };
27
+ // Fields every payload carries, in Claude Code's names, so one hook script can
28
+ // serve both agents: which session, where its transcript is, the working
29
+ // directory, and which turn. An orchestrator uses them to tell sessions and
30
+ // turns apart (a hook from an interrupted turn arrives late and must be dropped).
31
+ let hookContext = {};
32
+ let currentTurnId;
33
+ export function setHookContext(context) {
34
+ hookContext = { ...context, cwd: process.cwd() };
35
+ }
36
+ // Start a new turn id. Called when a prompt is submitted (before its
37
+ // UserPromptSubmit hook) or when a turn starts without one (cron, goals, peers).
38
+ export function beginTurn() {
39
+ currentTurnId = randomUUID();
40
+ return currentTurnId;
41
+ }
42
+ // Claude Code's names for the tool fields, next to this agent's own
43
+ // (tool/args/result), so existing hooks keep working.
44
+ function claudeToolFields(payload) {
45
+ if (typeof payload.tool !== "string")
46
+ return {};
47
+ let input = payload.args;
48
+ try {
49
+ if (typeof payload.args === "string")
50
+ input = JSON.parse(payload.args);
51
+ }
52
+ catch {
53
+ /* not JSON — pass the raw string */
54
+ }
55
+ return { tool_name: payload.tool, tool_input: input, ...(typeof payload.result === "string" ? { tool_response: payload.result } : {}) };
56
+ }
57
+ function fullPayload(event, payload) {
58
+ return { event, hook_event_name: event, ...hookContext, prompt_id: currentTurnId, ...claudeToolFields(payload), ...payload };
59
+ }
26
60
  // Run one hook process, feeding it the event payload on stdin.
27
61
  function runOne(hook, payload, timeoutMs) {
28
62
  return new Promise((resolve) => {
@@ -58,7 +92,7 @@ export async function runHooks(event, payload) {
58
92
  let rewrite; // a PreToolUse hook's replacement args
59
93
  const timeout = EVENT_TIMEOUT_MS[event] ?? DEFAULT_HOOK_TIMEOUT_MS; // SessionEnd gets a tiny budget
60
94
  for (const hook of hooks) {
61
- const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }, timeout); // run it
95
+ const { code, stdout: out, stderr: err } = await runOne(hook, fullPayload(event, payload), timeout); // run it
62
96
  emit("agent_hook_run", { event, code }); // observability for hooks themselves
63
97
  if (code === 0) {
64
98
  if (out) {
@@ -90,3 +124,26 @@ export async function runHooks(event, payload) {
90
124
  }
91
125
  return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n"), rewrite };
92
126
  }
127
+ export async function runPermissionHooks(payload) {
128
+ const hooks = (CONFIG.hooks.PermissionRequest ?? []).filter((h) => !h.match || h.match === payload.tool);
129
+ let allowed = false;
130
+ for (const hook of hooks) {
131
+ const { code, stdout, stderr } = await runOne(hook, fullPayload("PermissionRequest", payload), DEFAULT_HOOK_TIMEOUT_MS);
132
+ emit("agent_hook_run", { event: "PermissionRequest", code });
133
+ if (code === 2)
134
+ return { behavior: "deny", message: stderr || "denied by a PermissionRequest hook" };
135
+ if (code !== 0 || !stdout)
136
+ continue;
137
+ try {
138
+ const decision = JSON.parse(stdout).hookSpecificOutput?.decision;
139
+ if (decision?.behavior === "deny")
140
+ return { behavior: "deny", message: decision.message || "denied by a PermissionRequest hook" };
141
+ if (decision?.behavior === "allow")
142
+ allowed = true;
143
+ }
144
+ catch {
145
+ /* not a decision — ignore */
146
+ }
147
+ }
148
+ return allowed ? { behavior: "allow" } : null;
149
+ }
package/dist/ink/app.js CHANGED
@@ -20,19 +20,20 @@ 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
26
27
  import { currentSession, peerInboxPending, readPeerInbox, peerMessageContent, peersCommand, setSessionState, setSessionInfo, listPeers, findPeer, peerRow, sendPeerMessage, SCREEN_KINDS, MAX_PEER_TURNS } from "../peers.js"; // peer sessions: messages from other mini-agent windows start a turn while idle
27
28
  import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
28
- import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
29
+ import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle, sessionPath } from "../session.js";
29
30
  import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
30
31
  import { isPlanMode, setPlanMode } from "../permissions.js";
31
32
  import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
32
33
  import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
33
34
  import { extractMemories } from "../memory.js";
34
35
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
35
- import { runHooks } from "../hooks.js";
36
+ import { runHooks, setHookContext, beginTurn } from "../hooks.js";
36
37
  import { emit } from "../telemetry.js";
37
38
  import { reviewDebugCommand } from "../auto-debug.js";
38
39
  import { readClipboard, imageLabel, imagesInInput, userContent, MAX_IMAGES } from "../images.js";
@@ -196,6 +197,8 @@ export function App({ session, runTurn, clipboard }) {
196
197
  const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
197
198
  const [formState, setFormState] = useState(null); // the ask_user form's state
198
199
  const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
200
+ useEffect(() => setHookContext({ session_id: sessionId, transcript_path: sessionPath(sessionId) }), [sessionId]); // hooks always name the current session
201
+ const turnIdReady = useRef(false); // a submitted prompt already started this turn's id (before its UserPromptSubmit hook)
199
202
  const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
200
203
  const [autoEnabled, setAutoEnabled] = useState(autoMode.enabled);
201
204
  const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
@@ -250,7 +253,7 @@ export function App({ session, runTurn, clipboard }) {
250
253
  // as the prompt (a user highlight bar, or a note for /skill); the loop appends
251
254
  // `content` to `messages` itself. The permission menu + ask_user form raise a
252
255
  // `pending` prompt that returns a promise the loop awaits.
253
- const runConversationTurn = (content, display, attachments = []) => {
256
+ const runConversationTurn = (content, display, attachments = [], goalTurn = false) => {
254
257
  setDetails(null);
255
258
  setDetailOffset(0);
256
259
  if (display)
@@ -263,6 +266,10 @@ export function App({ session, runTurn, clipboard }) {
263
266
  // another agent's message, not your message relayed from another window.
264
267
  const suggestAfter = CONFIG.promptSuggestions && !senders.length && (content === null || display?.kind === "user");
265
268
  const seq = ++suggestionSeq.current;
269
+ if (!turnIdReady.current)
270
+ beginTurn(); // cron, goal and peer turns get their id here
271
+ turnIdReady.current = false;
272
+ let ending = { reason: "error", text: "" }; // what the TurnEnd hook reports
266
273
  setSuggestion(null);
267
274
  setBusy(true);
268
275
  setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
@@ -300,18 +307,22 @@ export function App({ session, runTurn, clipboard }) {
300
307
  runTurn(content, hooks)
301
308
  .then(async (result) => {
302
309
  // A turn started by the user from another window: show them the answer there.
310
+ ending = { reason: result.reason, text: result.finalText ?? "" };
303
311
  for (const to of senders)
304
312
  sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
305
313
  if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
306
314
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
307
- saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
315
+ const goalNote = settleGoalTurn(result.reason, goalTurn, result.reason === TerminateReason.UserInterrupt && followUps.size > 0); // Day 41: pause on Esc / failure / no progress
316
+ if (goalNote)
317
+ note(chalk.yellow(goalNote));
318
+ saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction (the goal's state rides along)
308
319
  if (suggestAfter && result.reason === TerminateReason.Done) {
309
320
  void generatePromptSuggestion(client, CONFIG.model, messages, lastRequestTools()).then((guess) => {
310
321
  if (guess && seq === suggestionSeq.current)
311
322
  setSuggestion(guess); // no newer turn started meanwhile (the box shows it only while idle)
312
323
  });
313
324
  }
314
- if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
325
+ if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done && !goalTurn) { // not after every automatic goal turn — that would bill an extraction per continuation
315
326
  try {
316
327
  const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
317
328
  if (got.length)
@@ -322,13 +333,22 @@ export function App({ session, runTurn, clipboard }) {
322
333
  }
323
334
  }
324
335
  })
325
- .catch((e) => note(chalk.red(`[error] ${e.message}`)))
336
+ .catch((e) => {
337
+ note(chalk.red(`[error] ${e.message}`));
338
+ const goalNote = settleGoalTurn("error", goalTurn); // a crashed goal turn pauses the goal instead of retrying forever
339
+ if (goalNote)
340
+ note(chalk.yellow(goalNote));
341
+ })
326
342
  .finally(() => {
327
343
  turn.current = null;
328
344
  setSessionState("idle");
329
345
  setBusy(false);
330
346
  setStatus(null);
331
347
  setLive(null);
348
+ // The one signal that fires at the end of EVERY turn, after the session
349
+ // is saved: Stop does not fire on an interrupt or an error, and fires
350
+ // before the save. An orchestrator reads this as "idle, transcript current".
351
+ void runHooks("TurnEnd", { reason: ending.reason, last_assistant_message: ending.text });
332
352
  });
333
353
  };
334
354
  // Covers immediate interruption and a message arriving as the turn finishes.
@@ -360,6 +380,27 @@ export function App({ session, runTurn, clipboard }) {
360
380
  return () => clearInterval(id);
361
381
  // eslint-disable-next-line react-hooks/exhaustive-deps
362
382
  }, [busy, pending]);
383
+ // The GOAL idle processor (Day 41): while a /goal is active and the REPL is
384
+ // idle — no turn, no menu, no queued message — start the next goal turn. This
385
+ // is the whole "keep going" mechanism: the model can stop as often as it
386
+ // likes, but it is sent straight back until update_goal ends the goal, or
387
+ // settleGoalTurn pauses it (Esc, a failed turn, no progress). Plan mode never
388
+ // continues: plan-only work can't move a goal forward.
389
+ useEffect(() => {
390
+ if (busy || pending || followUps.size || planMode)
391
+ return; // human input first; plan mode is read-only
392
+ const id = setInterval(() => {
393
+ if (turn.current || followUps.size)
394
+ return;
395
+ const content = startGoalTurn();
396
+ if (!content)
397
+ return;
398
+ const goal = getGoal();
399
+ 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
400
+ }, 500);
401
+ return () => clearInterval(id);
402
+ // eslint-disable-next-line react-hooks/exhaustive-deps
403
+ }, [busy, pending, planMode]);
363
404
  // The PEER idle processor: another mini-agent session messaged this one while
364
405
  // it sat at the prompt. Like cron, start a turn so the agent answers on its
365
406
  // own. Two agents answering each other could run forever (and bill for every
@@ -490,7 +531,16 @@ export function App({ session, runTurn, clipboard }) {
490
531
  if (!pending && await handleCommand(draft))
491
532
  return;
492
533
  const text = pastedTexts.expand(draft);
534
+ // A prompt typed while a turn runs is a follow-up that belongs to that turn;
535
+ // only a prompt that starts a turn gets a new turn id.
536
+ const startsTurn = !pending && !turn.current && !followUps.size;
537
+ if (startsTurn) {
538
+ beginTurn();
539
+ turnIdReady.current = true;
540
+ }
493
541
  const hook = await runHooks("UserPromptSubmit", { prompt: text });
542
+ if (hook.block)
543
+ turnIdReady.current = false;
494
544
  if (hook.block)
495
545
  return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
496
546
  const injected = hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "";
@@ -730,9 +780,17 @@ export function App({ session, runTurn, clipboard }) {
730
780
  runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
731
781
  return true;
732
782
  }
783
+ if (line === "/goal" || line.startsWith("/goal ")) {
784
+ const { message, request } = goalCommand(line.slice(5));
785
+ if (request)
786
+ autoMode.recordRequest(request); // the /goal you typed is the authorization; automatic goal turns never are
787
+ note(chalk.cyan(message)); // setting or resuming a goal: the idle processor starts the turn
788
+ return true;
789
+ }
733
790
  switch (line) {
734
791
  case "/clear":
735
792
  autoMode.clearRequests();
793
+ clearGoal(); // the goal belonged to the old conversation
736
794
  messages.length = 0; // mutate in place — same array ref the loop holds
737
795
  messages.push({ role: "system", content: systemMessage });
738
796
  forgetFilesExcept([]);
@@ -804,6 +862,15 @@ export function App({ session, runTurn, clipboard }) {
804
862
  resetTeam();
805
863
  resetBoard();
806
864
  note(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
865
+ // Unlike the todos and the board, the goal comes BACK with its session (Day 41).
866
+ restoreGoal(chosen.goal);
867
+ const goal = getGoal();
868
+ if (goal?.status === "active") {
869
+ autoMode.recordRequest(`/goal ${goal.objective}`);
870
+ note(chalk.cyan(`◎ resumed an ACTIVE goal — the agent continues it now (Esc pauses): ${goal.objective.slice(0, 80)}`));
871
+ }
872
+ else if (goal)
873
+ note(chalk.dim(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`));
807
874
  });
808
875
  return true;
809
876
  }
@@ -1064,6 +1131,23 @@ export function App({ session, runTurn, clipboard }) {
1064
1131
  else if (key.ctrl && char === "e") {
1065
1132
  setCursor(input.length); // End — jump to the end of the line
1066
1133
  }
1134
+ else if ((key.ctrl && char === "u") || /^\x15+$/.test(char)) {
1135
+ // ctrl+u deletes from the caret back to the start of its line, like a
1136
+ // shell; at the start of a line it joins the line to the one above, so
1137
+ // repeated presses clear a multi-line draft. Several presses can arrive
1138
+ // as one chunk when a program types them.
1139
+ let next = input;
1140
+ let at = cursor;
1141
+ for (let presses = key.ctrl ? 1 : char.length; presses > 0 && at > 0; presses--) {
1142
+ const lineStart = next.lastIndexOf("\n", at - 1) + 1;
1143
+ const from = lineStart === at ? at - 1 : lineStart;
1144
+ next = next.slice(0, from) + next.slice(at);
1145
+ at = from;
1146
+ }
1147
+ setInput(next);
1148
+ setCursor(at);
1149
+ setHistIdx(null);
1150
+ }
1067
1151
  else if (key.upArrow) {
1068
1152
  // ↑ recall an earlier prompt (newest-first), like a shell; caret to its end.
1069
1153
  if (!history.length)