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 +13 -0
- package/dist/agent.js +2 -1
- package/dist/cli.js +8 -1
- package/dist/config.js +7 -1
- package/dist/hooks.js +58 -1
- package/dist/ink/app.js +39 -2
- package/dist/ink/launch.js +1 -1
- package/dist/ink/setup.js +17 -3
- package/dist/loop.js +25 -2
- package/dist/session.js +9 -0
- package/package.json +1 -1
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,
|
|
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)
|
package/dist/ink/launch.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|