agent-from-zero 0.35.5 → 0.35.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/dist/agent.js +92 -4
- package/dist/cli.js +11 -3
- package/dist/completion.js +1 -0
- package/dist/config.js +7 -1
- package/dist/goal.js +365 -0
- package/dist/hooks.js +58 -1
- package/dist/ink/app.js +90 -6
- package/dist/ink/commands.js +1 -0
- package/dist/ink/launch.js +1 -1
- package/dist/ink/setup.js +29 -3
- package/dist/loop.js +59 -10
- package/dist/permissions.js +4 -0
- package/dist/print.js +68 -0
- package/dist/session.js +12 -0
- package/package.json +2 -2
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,
|
|
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
|
-
|
|
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) =>
|
|
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)
|