agent-from-zero 0.35.6 → 0.35.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,6 +158,19 @@ npx agent-from-zero -p "修一下这个 bug" --output-format stream-json
158
158
 
159
159
  `stream-json` 和 `claude -p --output-format stream-json` 同一格式:每行一个 JSON 事件,依次是 `system/init`、`assistant`(text / thinking / tool_use 块)、`user`(tool_result)、最后一行 `result`。为了兼容 Claude 的脚本,`--verbose` 也能传,不影响输出。
160
160
 
161
+ ### 让别的程序来驱动交互会话
162
+
163
+ 编排器(比如 orca)可以把交互式的 mini-agent 放在终端里跑,用 hooks 看它的状态、替你审批:
164
+
165
+ - `MINI_AGENT_HOOKS_FILE=<文件>`:再加一层 hooks,格式和 settings.json 里的 `hooks` 一样,只对这一个进程生效,不改你的设置文件,也放宽不了权限。
166
+ - `--session-id <id>`:这个 id 的会话存在就接着聊,不存在就用它开新会话。
167
+ - 每个 hook 的输入都带 Claude Code 同名的字段:`session_id`、`transcript_path`(会话文件的绝对路径)、`cwd`、`hook_event_name`、这一轮的 `prompt_id`,工具事件另有 `tool_name`、`tool_input`。原来的字段照旧。
168
+ - `PermissionRequest`:一次工具调用要问你之前先问 hook。输出 `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` 就放行,`"behavior":"deny"` 加 `message` 或者退出码 2 就拒绝;没输出、超时、出错都算没决定,照常问你。规则要求必须由人决定的调用,hook 只能拒绝,放行还得你点。
169
+ - `Notification`:审批菜单弹出来时触发,`notification_type` 是 `permission_prompt`。
170
+ - `TurnEnd`:每一轮结束、会话存盘之后触发,被 Esc 打断或出错也会触发,带 `reason` 和 `last_assistant_message`。`Stop` 在存盘之前触发,打断时不触发,要知道"这一轮真的完了"就看 `TurnEnd`。
171
+
172
+ 输入框里 `Ctrl+U` 删除光标前的这一行,行首再按会并到上一行,连按可以清空多行草稿。
173
+
161
174
  ## 常用命令
162
175
 
163
176
  在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/goal` `/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
package/dist/agent.js CHANGED
@@ -52,6 +52,7 @@ const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any
52
52
  Usage:
53
53
  mini-agent interactive session (REPL)
54
54
  mini-agent -r | --resume continue the most recent session in this directory
55
+ mini-agent --session-id <id> continue that session, or start a new one with that id
55
56
  mini-agent -p "<task>" one-shot: run a single task, print the result, exit
56
57
  mini-agent exec "<task>" alias for non-interactive print mode
57
58
  cat file | mini-agent -p "<task>" append piped text to the prompt
@@ -154,7 +155,7 @@ async function main() {
154
155
  // MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
155
156
  // reference front-end, and an escape hatch for terminals Ink misbehaves in).
156
157
  if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
157
- await launchInk({ resume: cli.resume });
158
+ await launchInk({ resume: cli.resume, sessionId: cli.sessionId });
158
159
  return;
159
160
  }
160
161
  requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { CONFIG } from "./config.js";
3
3
  import { effortMenu, setEffort } from "./effort.js";
4
+ import { isValidSessionId } from "./session.js"; // --session-id becomes a file name
4
5
  export class CliUsageError extends Error {
5
6
  }
6
7
  // -p is a mode, not a boundary after which flags turn into task text. Node's
@@ -18,6 +19,7 @@ export function parseCli(args) {
18
19
  "permission-mode": { type: "string" },
19
20
  "dangerously-skip-permissions": { type: "boolean" },
20
21
  resume: { type: "boolean", short: "r" },
22
+ "session-id": { type: "string" },
21
23
  help: { type: "boolean", short: "h" },
22
24
  version: { type: "boolean", short: "v" },
23
25
  "output-format": { type: "string" },
@@ -44,12 +46,17 @@ export function parseCli(args) {
44
46
  }
45
47
  if (values.prompt !== undefined && positionals.length)
46
48
  throw new CliUsageError("Use either a positional prompt or --prompt, not both");
49
+ const sessionId = typeof values["session-id"] === "string" ? values["session-id"] : undefined;
50
+ if (sessionId !== undefined && !isValidSessionId(sessionId))
51
+ throw new CliUsageError("--session-id may only contain letters, digits, '.', '_' and '-'");
52
+ if (sessionId !== undefined && values.resume)
53
+ throw new CliUsageError("Use either --resume or --session-id, not both");
47
54
  const prompt = typeof values.prompt === "string" ? values.prompt : positionals.join(" ");
48
55
  if (!print && (prompt || values["output-format"] !== undefined))
49
56
  throw new CliUsageError("Use -p / --print or exec with a prompt or --output-format");
50
57
  return { print, prompt, outputFormat, permissionMode, model: typeof values.model === "string" ? values.model : undefined,
51
58
  effort: typeof values.effort === "string" ? values.effort : undefined, auto: values.auto === true,
52
- resume: values.resume === true, help: values.help === true, version: values.version === true };
59
+ resume: values.resume === true, sessionId, help: values.help === true, version: values.version === true };
53
60
  }
54
61
  // CLI overrides live only in this process; never save them to user settings.
55
62
  // Validate against the selected model/endpoint before any request is started.
package/dist/config.js CHANGED
@@ -40,6 +40,12 @@ function readSettings(p) {
40
40
  }
41
41
  const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
42
42
  const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
43
+ // A third, per-process layer of HOOKS only: MINI_AGENT_HOOKS_FILE points at a
44
+ // settings-shaped file ({"hooks": {...}}) whose hooks join the other two. It
45
+ // lets a program that launches the agent (an orchestrator such as orca) watch
46
+ // and steer this one process without editing the user's settings. Only hooks
47
+ // are read from it — it cannot loosen permissions.
48
+ const processHooks = process.env.MINI_AGENT_HOOKS_FILE ? readSettings(process.env.MINI_AGENT_HOOKS_FILE).hooks : undefined;
43
49
  // Re-read ONLY the mcpServers map from both layers, mid-session (the MCP hot
44
50
  // reload in mcp.ts). Unlike startup, a file that won't parse is NOT fatal here:
45
51
  // an editor saving in two steps can leave half-written JSON for a moment, and
@@ -110,7 +116,7 @@ export const CONFIG = {
110
116
  // a generic union so adding a new event needs no edit here.
111
117
  hooks: (() => {
112
118
  const merged = {};
113
- for (const src of [globalSettings.hooks, projectSettings.hooks]) {
119
+ for (const src of [globalSettings.hooks, projectSettings.hooks, processHooks]) {
114
120
  for (const [event, defs] of Object.entries(src ?? {}))
115
121
  merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
116
122
  }
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
@@ -26,14 +26,14 @@ import { expandMentions } from "../mentions.js"; // @file mentions → attach fi
26
26
  import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
27
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
28
28
  import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
29
- import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
29
+ import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle, sessionPath } from "../session.js";
30
30
  import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
31
31
  import { isPlanMode, setPlanMode } from "../permissions.js";
32
32
  import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
33
33
  import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
34
34
  import { extractMemories } from "../memory.js";
35
35
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
36
- import { runHooks } from "../hooks.js";
36
+ import { runHooks, setHookContext, beginTurn } from "../hooks.js";
37
37
  import { emit } from "../telemetry.js";
38
38
  import { reviewDebugCommand } from "../auto-debug.js";
39
39
  import { readClipboard, imageLabel, imagesInInput, userContent, MAX_IMAGES } from "../images.js";
@@ -197,6 +197,8 @@ export function App({ session, runTurn, clipboard }) {
197
197
  const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
198
198
  const [formState, setFormState] = useState(null); // the ask_user form's state
199
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)
200
202
  const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
201
203
  const [autoEnabled, setAutoEnabled] = useState(autoMode.enabled);
202
204
  const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
@@ -264,6 +266,10 @@ export function App({ session, runTurn, clipboard }) {
264
266
  // another agent's message, not your message relayed from another window.
265
267
  const suggestAfter = CONFIG.promptSuggestions && !senders.length && (content === null || display?.kind === "user");
266
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
267
273
  setSuggestion(null);
268
274
  setBusy(true);
269
275
  setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
@@ -301,6 +307,7 @@ export function App({ session, runTurn, clipboard }) {
301
307
  runTurn(content, hooks)
302
308
  .then(async (result) => {
303
309
  // A turn started by the user from another window: show them the answer there.
310
+ ending = { reason: result.reason, text: result.finalText ?? "" };
304
311
  for (const to of senders)
305
312
  sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
306
313
  if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
@@ -338,6 +345,10 @@ export function App({ session, runTurn, clipboard }) {
338
345
  setBusy(false);
339
346
  setStatus(null);
340
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 });
341
352
  });
342
353
  };
343
354
  // Covers immediate interruption and a message arriving as the turn finishes.
@@ -520,7 +531,16 @@ export function App({ session, runTurn, clipboard }) {
520
531
  if (!pending && await handleCommand(draft))
521
532
  return;
522
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
+ }
523
541
  const hook = await runHooks("UserPromptSubmit", { prompt: text });
542
+ if (hook.block)
543
+ turnIdReady.current = false;
524
544
  if (hook.block)
525
545
  return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
526
546
  const injected = hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "";
@@ -1111,6 +1131,23 @@ export function App({ session, runTurn, clipboard }) {
1111
1131
  else if (key.ctrl && char === "e") {
1112
1132
  setCursor(input.length); // End — jump to the end of the line
1113
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
+ }
1114
1151
  else if (key.upArrow) {
1115
1152
  // ↑ recall an earlier prompt (newest-first), like a shell; caret to its end.
1116
1153
  if (!history.length)
@@ -9,7 +9,7 @@ import { App } from "./app.js"; // the Ink REPL
9
9
  // `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
10
10
  // once it hands interactive sessions to Ink) can start it.
11
11
  export async function launchInk(opts = {}) {
12
- const session = await buildInkSession({ resume: opts.resume });
12
+ const session = await buildInkSession(opts);
13
13
  const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
14
14
  await waitUntilExit(); // keep the process alive until the user quits
15
15
  // Then leave for real. The cron poll, the settings watcher and friends keep
package/dist/ink/setup.js CHANGED
@@ -7,10 +7,10 @@ import { contextPercent } from "../context.js";
7
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
- import { newSessionId, latestSession } from "../session.js";
10
+ import { newSessionId, latestSession, loadSession, sessionPath } from "../session.js";
11
11
  import { getGoal, restoreGoal } from "../goal.js"; // /goal (Day 41): a resumed session keeps working toward its goal
12
12
  import { initTelemetry, emit } from "../telemetry.js";
13
- import { runHooks } from "../hooks.js";
13
+ import { runHooks, setHookContext } from "../hooks.js";
14
14
  import { connectMcpServers, watchMcpConfig } from "../mcp.js";
15
15
  import { Judge } from "../judge.js";
16
16
  import { AutoMode } from "../auto.js";
@@ -40,7 +40,20 @@ export async function buildInkSession(opts = {}) {
40
40
  let sessionId = newSessionId();
41
41
  let initialTitle; // only a resumed session has one already
42
42
  const startedAt = Date.now();
43
- if (opts.resume) {
43
+ // --session-id: an id chosen by whoever started us (an orchestrator picks it
44
+ // up front so it knows the session before the first turn). Resume it if it
45
+ // exists, otherwise it names the new session.
46
+ if (opts.sessionId) {
47
+ const prev = loadSession(opts.sessionId);
48
+ sessionId = opts.sessionId;
49
+ if (prev) {
50
+ messages.push(...prev.messages);
51
+ initialTitle = prev.title;
52
+ notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
53
+ restoreGoal(prev.goal);
54
+ }
55
+ }
56
+ else if (opts.resume) {
44
57
  const prev = latestSession();
45
58
  if (prev) {
46
59
  messages.push(...prev.messages);
@@ -54,6 +67,7 @@ export async function buildInkSession(opts = {}) {
54
67
  }
55
68
  }
56
69
  initTelemetry(sessionId);
70
+ setHookContext({ session_id: sessionId, transcript_path: sessionPath(sessionId) });
57
71
  emit("agent_session_start", { mode: "repl" });
58
72
  // MCP servers connect in the BACKGROUND, like Claude Code: the prompt is
59
73
  // ready at once, each server's tools join the next model call the moment it
package/dist/loop.js CHANGED
@@ -11,7 +11,7 @@ import { currentSession, describePeers, peerMessageContent, readPeerInbox, sendP
11
11
  import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam, listTeammateViews } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
12
12
  import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
13
13
  import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
14
- import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
14
+ import { runHooks, runPermissionHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop / PermissionRequest)
15
15
  import { reviewHistory } from "./auto-context.js";
16
16
  import { AgentProgress } from "./agent-progress.js";
17
17
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
@@ -700,6 +700,29 @@ async function authorizeCall(call, opts) {
700
700
  }
701
701
  if (opts.signal.aborted || opts.isInterrupted())
702
702
  return "[permission] Interrupted before execution.";
703
+ // Before the human is asked, a PermissionRequest hook may decide (this is
704
+ // how an orchestrator approves on the human's behalf). A deny is always
705
+ // honoured. An allow is not when a rule says a human must decide: then the
706
+ // question still goes to the human. Sub-agents skip hooks, as everywhere.
707
+ if (!autoAllowed && !opts.subAgent) {
708
+ const decision = await runPermissionHooks({ tool: call.name, args: call.args, reason: v.reason, summary: v.summary });
709
+ if (opts.signal.aborted || opts.isInterrupted())
710
+ return "[permission] Interrupted before execution.";
711
+ if (decision?.behavior === "deny") {
712
+ emit("agent_tool_declined", { tool: call.name });
713
+ if (!opts.quiet)
714
+ sink(opts).note(mark.declined);
715
+ return `[permission] Action not approved: ${decision.message}. Ask the user how to proceed, or choose a safer alternative.`;
716
+ }
717
+ if (decision?.behavior === "allow" && !v.requiresHuman) {
718
+ autoAllowed = true;
719
+ if (!opts.quiet)
720
+ recordToolDetail(call.id, "Approved by a PermissionRequest hook");
721
+ }
722
+ else {
723
+ await runHooks("Notification", { notification_type: "permission_prompt", message: `approval needed: ${call.name}`, tool: call.name, args: call.args });
724
+ }
725
+ }
703
726
  // An unattended callback may contain the old blanket auto-approve policy.
704
727
  // Auto mode never uses that policy as a fallback when Jev did not approve.
705
728
  // A diff is needed when the human is deciding; automatic edits stay in
@@ -1489,7 +1512,7 @@ opts) {
1489
1512
  // runs the tests, blocks while they fail, and the agent keeps fixing.
1490
1513
  // Sub-agents are exempt — Stop hooks are the human's project policy.
1491
1514
  if (!opts.subAgent) {
1492
- const stop = await runHooks("Stop", { finalText: out.content }); // give hooks the last word
1515
+ const stop = await runHooks("Stop", { finalText: out.content, last_assistant_message: out.content, stop_hook_active: false }); // give hooks the last word
1493
1516
  if (stop.block) {
1494
1517
  emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
1495
1518
  if (!opts.quiet)
package/dist/session.js CHANGED
@@ -8,6 +8,15 @@ import { getGoal } from "./goal.js"; // the /goal rides along in the session fil
8
8
  // working directory — and so tests can chdir into a scratch dir.
9
9
  const sessionsDir = () => path.resolve(".mini-agent", "sessions");
10
10
  const KEEP_SESSIONS = 20; // how many session files survive pruning
11
+ // Where a session's file lives — absolute, so it can be handed to hooks.
12
+ export function sessionPath(id) {
13
+ return path.join(sessionsDir(), `${id}.json`);
14
+ }
15
+ // A session id becomes a file name, so one chosen from outside (--session-id)
16
+ // must not be able to name a path.
17
+ export function isValidSessionId(id) {
18
+ return /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== "..";
19
+ }
11
20
  // A fresh, filename-safe session id derived from the wall clock.
12
21
  export function newSessionId() {
13
22
  return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.35.6",
3
+ "version": "0.35.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"