agent-from-zero 0.35.6 → 0.36.0

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
@@ -131,7 +131,7 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
131
131
  - 实例名跟着它在做的事走:发出第一条消息后会生成会话标题(比如 “Fix login bug”),实例名随之变成 `fix-login-bug`,标签页标题就是 `✳ Fix login bug`,和 `/peers` 里看到的一致。还没有标题时用目录名。`/rename <名字>` 可以手动指定名字,指定后标签页显示 `✳ 名字 · 标题`,之后不会再跟着标题变。
132
132
  - agent 之间也能自己沟通:模型有 `list_peers` 工具,`send_message` 可以直接发给其他实例。对方空闲时会自动开一轮处理;忙的话,在它当前这一步工具调用结束后插进对话。你在 Talk 模式里打的字则会等对方这一轮结束再处理,因为回答要发回给你。另一个 agent 发来的消息不算用户的授权。为了防止两个 agent 互相回复停不下来,连续自动处理 5 轮之后会暂停,等你输入任何内容再继续。
133
133
 
134
- 所有数据都存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。
134
+ peer 实例的数据存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。会话快照和长期记忆按 cwd 分桶存在 `~/.config/mini-agent/projects/<cwd-slug>/` 下,不会在代码仓库里留下目录。
135
135
 
136
136
  ### 目标模式 /goal
137
137
 
@@ -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
@@ -34,7 +34,7 @@ import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, reveal
34
34
  import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
35
35
  import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
36
36
  import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
37
- import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
37
+ import { rememberTool, readMemory, readMemoryTyped, extractMemories, memoryPath } from "./memory.js"; // long-term project memory + auto-extract
38
38
  import { allSkills, currentSkills, findSkill, recordSkillUsage, skillLocked, skillMode, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
39
39
  import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
40
40
  import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
@@ -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
@@ -170,7 +171,8 @@ async function main() {
170
171
  const systemMessage = buildSystemMessage(projectInstructions);
171
172
  let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
172
173
  // ---- Session persistence ---------------------------------------------------------
173
- // Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
174
+ // Every conversation is snapshotted to the project's home bucket
175
+ // (~/.config/mini-agent/projects/<slug>/sessions) after each turn;
174
176
  // -r / --resume picks up the most recent one. The system message is NOT
175
177
  // restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
176
178
  let sessionId = newSessionId(); // this session's identity (and file name)
@@ -776,7 +778,7 @@ async function main() {
776
778
  case "/memory": {
777
779
  const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
778
780
  console.log(chalk.dim(entries.length
779
- ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
781
+ ? `long-term memory (${memoryPath()}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
780
782
  : `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`));
781
783
  return true;
782
784
  }
@@ -2,10 +2,13 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { emit } from "./telemetry.js";
5
+ import { migrateLegacyState, projectStateFile } from "./state.js"; // traces follow sessions/memory into the home bucket
5
6
  // Explicit opt-in: unlike telemetry, this contains conversation and tool data.
6
7
  export function reviewDebugPath() {
7
- return process.env.MINI_AGENT_REVIEW_DEBUG === "1"
8
- ? path.resolve(".mini-agent", "review-debug.jsonl") : undefined;
8
+ if (process.env.MINI_AGENT_REVIEW_DEBUG !== "1")
9
+ return undefined;
10
+ migrateLegacyState(); // move an older ./.mini-agent/review-debug.jsonl into the bucket
11
+ return projectStateFile("review-debug.jsonl");
9
12
  }
10
13
  // Environment for commands the agent runs. Tracing is for THIS process only:
11
14
  // a child `npm test` that inherited the flag appended its mocked reviews to the
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)
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import path from "node:path";
3
3
  import { CONFIG } from "../config.js";
4
4
  import { statsReport } from "../telemetry.js";
5
- import { readMemoryTyped, MEMORY_PATH } from "../memory.js";
5
+ import { readMemoryTyped, memoryPath } from "../memory.js";
6
6
  import { getTodos, renderTodos } from "../todos.js";
7
7
  import { listBackground } from "../background.js";
8
8
  import { listTeam } from "../team.js";
@@ -87,7 +87,7 @@ export function runInfoCommand(line, ctx) {
87
87
  case "/memory": {
88
88
  const entries = readMemoryTyped();
89
89
  return chalk.dim(entries.length
90
- ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
90
+ ? `long-term memory (${memoryPath()}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
91
91
  : `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`);
92
92
  }
93
93
  case "/todos": {
@@ -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/memory.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs"; // memory lives in a file on disk
2
2
  import path from "node:path"; // path joining
3
+ import { migrateLegacyState, projectMemoryPath } from "./state.js"; // one bucket per cwd, kept out of the checkout
3
4
  // Long-term memory: facts the agent should carry ACROSS sessions, not just
4
5
  // within one conversation. Session persistence (Day 12) restores "what we were
5
6
  // talking about"; this restores "what I should always know about this project".
@@ -13,7 +14,15 @@ import path from "node:path"; // path joining
13
14
  // assistant: 90% the model gets right isn't worth saving, the 10% it got wrong
14
15
  // and was corrected on is gold). Every fact is TYPED so the model can tell "this
15
16
  // is who the user is" from "this is a correction" from "this is a project fact".
16
- export const MEMORY_PATH = path.resolve(".mini-agent", "MEMORY.md");
17
+ // The file lives in the cwd's home bucket (state.ts), not inside the checkout,
18
+ // so a repo never carries agent memory in git status. Resolved per call so a
19
+ // chdir (tests, --cd) is honoured, and so the first call can migrate an older
20
+ // build's ./.mini-agent/MEMORY.md. Still one Markdown file, human-readable and
21
+ // human-editable: not a vector database, not embeddings.
22
+ export function memoryPath() {
23
+ migrateLegacyState();
24
+ return projectMemoryPath();
25
+ }
17
26
  // The four memory types (handbook §17). feedback is the most valuable.
18
27
  export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
19
28
  // Bounds, because unbounded memory dilutes the model's attention (Ch6: too many
@@ -38,7 +47,7 @@ function parseEntry(text) {
38
47
  export function readMemoryTyped() {
39
48
  try {
40
49
  return fs
41
- .readFileSync(MEMORY_PATH, "utf8")
50
+ .readFileSync(memoryPath(), "utf8")
42
51
  .split("\n")
43
52
  .filter((l) => l.startsWith("- "))
44
53
  .map((l) => parseEntry(l.slice(2).trim()))
@@ -91,8 +100,9 @@ export function addFact(fact, type = "project") {
91
100
  entries.shift(); // still too big — drop the oldest until it fits
92
101
  body = entries.map((e) => `- [${e.type}] ${e.fact}`).join("\n");
93
102
  }
94
- fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
95
- fs.writeFileSync(MEMORY_PATH, HEADER + body + "\n", "utf8");
103
+ const file = memoryPath();
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, HEADER + body + "\n", "utf8");
96
106
  return `Remembered (${t}). (${entries.length} fact${entries.length === 1 ? "" : "s"} stored.)`;
97
107
  }
98
108
  // The tool the model calls to save something worth keeping.
package/dist/session.js CHANGED
@@ -1,13 +1,28 @@
1
1
  import fs from "node:fs"; // session files live on disk
2
2
  import path from "node:path"; // path joining
3
3
  import { getGoal } from "./goal.js"; // the /goal rides along in the session file (Day 41)
4
- // Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
5
- // Project-local (not global) because a conversation is about a codebase —
6
- // resuming a refactor of project A from inside project B makes no sense.
4
+ import { migrateLegacyState, projectSessionsDir } from "./state.js"; // one bucket per cwd, kept out of the checkout
5
+ // Sessions are per-project but not per-repo: <home>/mini-agent/projects/<slug>/sessions,
6
+ // where <slug> is the cwd flattened. Same reasoning as the old in-repo layout (a
7
+ // conversation is about a codebase, so resuming project A from inside project B
8
+ // would be wrong) without leaving an untracked directory in every checkout.
7
9
  // Resolved lazily (a function, not a const) so it always reflects the current
8
- // working directory — and so tests can chdir into a scratch dir.
9
- const sessionsDir = () => path.resolve(".mini-agent", "sessions");
10
+ // working directory, and so tests can chdir into a scratch dir. The first touch
11
+ // migrates any ./.mini-agent/sessions left by an older build.
12
+ const sessionsDir = () => {
13
+ migrateLegacyState();
14
+ return projectSessionsDir();
15
+ };
10
16
  const KEEP_SESSIONS = 20; // how many session files survive pruning
17
+ // Where a session's file lives — absolute, so it can be handed to hooks.
18
+ export function sessionPath(id) {
19
+ return path.join(sessionsDir(), `${id}.json`);
20
+ }
21
+ // A session id becomes a file name, so one chosen from outside (--session-id)
22
+ // must not be able to name a path.
23
+ export function isValidSessionId(id) {
24
+ return /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== "..";
25
+ }
11
26
  // A fresh, filename-safe session id derived from the wall clock.
12
27
  export function newSessionId() {
13
28
  return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
package/dist/state.js ADDED
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs"; // moving legacy state, creating the home bucket
2
+ import os from "node:os"; // the home directory root
3
+ import path from "node:path"; // joining and resolving paths
4
+ // Per-project state that is history rather than configuration — session
5
+ // transcripts, long-term memory, and the local event/trace logs — lives OUTSIDE
6
+ // the checkout, one bucket per working directory, mirroring how Claude Code
7
+ // keeps ~/.claude/projects/<slug>. A repo you open should not grow an untracked
8
+ // state directory just because an agent ran in it; configuration and tooling
9
+ // (settings, skills, task board, team mailboxes, cron state) stay project-local
10
+ // where they belong to the code.
11
+ //
12
+ // The bucket is keyed by the absolute cwd with separators flattened, so
13
+ // /Users/me/proj becomes -Users-me-proj, the same escaping Claude Code uses.
14
+ export const projectsRoot = () => process.env.MINI_AGENT_PROJECTS_DIR || path.join(os.homedir(), ".config", "mini-agent", "projects");
15
+ // One directory name per working directory. Windows drive colons are flattened
16
+ // too, so the name is portable and cannot escape the root. Symlinks are resolved
17
+ // first, so one project reached as /tmp/x and /private/tmp/x keeps ONE bucket
18
+ // instead of two that each hold half the history.
19
+ export function projectSlug(cwd = process.cwd()) {
20
+ let abs = path.resolve(cwd);
21
+ try {
22
+ abs = fs.realpathSync(abs);
23
+ }
24
+ catch {
25
+ /* a path that does not exist yet still gets a stable slug */
26
+ }
27
+ return abs.replace(/[\\/:]/g, "-");
28
+ }
29
+ export function projectStateDir(cwd = process.cwd()) {
30
+ return path.join(projectsRoot(), projectSlug(cwd));
31
+ }
32
+ export function projectSessionsDir(cwd = process.cwd()) {
33
+ return path.join(projectStateDir(cwd), "sessions");
34
+ }
35
+ export function projectMemoryPath(cwd = process.cwd()) {
36
+ return path.join(projectStateDir(cwd), "MEMORY.md");
37
+ }
38
+ // Any other per-project log or file that should live beside sessions and memory
39
+ // rather than inside the checkout (telemetry, review-debug traces).
40
+ export function projectStateFile(name, cwd = process.cwd()) {
41
+ return path.join(projectStateDir(cwd), name);
42
+ }
43
+ // Move a path only when the destination is absent, so a second run is a no-op
44
+ // and a newly written home file is never clobbered by an old checkout copy.
45
+ // rename works on one filesystem; cp+rm covers the home-on-another-volume case.
46
+ function moveIfAbsent(from, to) {
47
+ if (fs.existsSync(to) || !fs.existsSync(from))
48
+ return;
49
+ fs.mkdirSync(path.dirname(to), { recursive: true });
50
+ try {
51
+ fs.renameSync(from, to);
52
+ }
53
+ catch {
54
+ fs.cpSync(from, to, { recursive: true });
55
+ fs.rmSync(from, { recursive: true, force: true });
56
+ }
57
+ }
58
+ let migrated = false; // once per process; the check is idempotent anyway
59
+ // Older builds wrote sessions and memory into ./.mini-agent. Move them into the
60
+ // home bucket the first time anything touches the new location, so an upgrade
61
+ // keeps its history and the checkout ends up clean. Never throws: a failed
62
+ // migration must not break the CLI, it just leaves the old files in place.
63
+ export function migrateLegacyState(cwd = process.cwd()) {
64
+ if (migrated)
65
+ return;
66
+ migrated = true;
67
+ try {
68
+ moveIfAbsent(path.resolve(cwd, ".mini-agent", "sessions"), projectSessionsDir(cwd));
69
+ moveIfAbsent(path.resolve(cwd, ".mini-agent", "MEMORY.md"), projectMemoryPath(cwd));
70
+ // Logs are per-project too, and telemetry is usually the biggest file left
71
+ // behind by an older checkout.
72
+ moveIfAbsent(path.resolve(cwd, ".mini-agent", "telemetry.jsonl"), projectStateFile("telemetry.jsonl", cwd));
73
+ moveIfAbsent(path.resolve(cwd, ".mini-agent", "review-debug.jsonl"), projectStateFile("review-debug.jsonl", cwd));
74
+ }
75
+ catch {
76
+ /* leave the legacy files where they are */
77
+ }
78
+ }
package/dist/telemetry.js CHANGED
@@ -1,14 +1,20 @@
1
1
  import fs from "node:fs"; // the event log lives on disk
2
2
  import path from "node:path"; // path joining
3
- // LOCAL-ONLY telemetry. Events go to .mini-agent/telemetry.jsonl in the
4
- // project directory and NOWHERE else — nothing leaves the machine, ever.
5
- // It exists because "no data" means every optimization is a guess: which
6
- // errors actually happen, how often compaction fires, which tools get denied.
7
- // Opt out entirely with MINI_AGENT_NO_TELEMETRY=1.
3
+ import { migrateLegacyState, projectStateFile } from "./state.js"; // one bucket per cwd, kept out of the checkout
4
+ // LOCAL-ONLY telemetry. Events go to the project's home bucket
5
+ // (~/.config/mini-agent/projects/<cwd-slug>/telemetry.jsonl) and NOWHERE else —
6
+ // nothing leaves the machine, ever. It exists because "no data" means every
7
+ // optimization is a guess: which errors actually happen, how often compaction
8
+ // fires, which tools get denied. Opt out entirely with MINI_AGENT_NO_TELEMETRY=1.
8
9
  //
9
10
  // Naming follows the unified-prefix convention (agent_<domain>_<action>) so
10
11
  // the log stays greppable: `grep agent_api_error telemetry.jsonl | wc -l`.
11
- const TELEMETRY_FILE = path.resolve(".mini-agent", "telemetry.jsonl");
12
+ // Resolved per call so a chdir is honoured, and so the first write migrates an
13
+ // older build's ./.mini-agent/telemetry.jsonl.
14
+ const telemetryFile = () => {
15
+ migrateLegacyState();
16
+ return projectStateFile("telemetry.jsonl");
17
+ };
12
18
  let sessionId = null; // set by initTelemetry; emit() is a no-op before that
13
19
  const tallies = new Map(); // per-session event counts, for /stats
14
20
  // Arm telemetry for this session. Tests and the eval harness never call this,
@@ -25,8 +31,9 @@ export function emit(event, fields = {}) {
25
31
  if (!sessionId)
26
32
  return; // disarmed — counters only, no file
27
33
  try {
28
- fs.mkdirSync(path.dirname(TELEMETRY_FILE), { recursive: true }); // ensure the directory
29
- fs.appendFileSync(TELEMETRY_FILE, JSON.stringify({ ts: new Date().toISOString(), session: sessionId, event, ...fields }) + "\n"); // one JSON object per line
34
+ const file = telemetryFile();
35
+ fs.mkdirSync(path.dirname(file), { recursive: true }); // ensure the directory
36
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), session: sessionId, event, ...fields }) + "\n"); // one JSON object per line
30
37
  }
31
38
  catch {
32
39
  /* a full disk or a read-only checkout is not our problem to escalate */
@@ -39,5 +46,5 @@ export function statsReport() {
39
46
  const lines = [...tallies.entries()]
40
47
  .sort((a, b) => b[1] - a[1]) // busiest first
41
48
  .map(([k, n]) => ` ${String(n).padStart(5)} ${k}`); // aligned counts
42
- return ["events this session:", ...lines, `\nfull log: ${TELEMETRY_FILE} (local only — nothing leaves this machine)`].join("\n");
49
+ return ["events this session:", ...lines, `\nfull log: ${telemetryFile()} (local only — nothing leaves this machine)`].join("\n");
43
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.35.6",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"
@@ -17,7 +17,7 @@
17
17
  "auto:report": "tsx eval/auto-report.ts",
18
18
  "build": "tsc -p tsconfig.build.json",
19
19
  "prepare": "npm run build",
20
- "test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/suggestion.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts && tsx tests/goal.test.ts"
20
+ "test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/state.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/suggestion.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts && tsx tests/goal.test.ts"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20.6"