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 +14 -1
- package/dist/agent.js +6 -4
- package/dist/auto-debug.js +5 -2
- 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/commands.js +2 -2
- package/dist/ink/launch.js +1 -1
- package/dist/ink/setup.js +17 -3
- package/dist/loop.js +25 -2
- package/dist/memory.js +14 -4
- package/dist/session.js +20 -5
- package/dist/state.js +78 -0
- package/dist/telemetry.js +16 -9
- package/package.json +2 -2
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
|
-
|
|
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,
|
|
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
|
|
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 (${
|
|
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
|
}
|
package/dist/auto-debug.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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,
|
|
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/commands.js
CHANGED
|
@@ -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,
|
|
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 (${
|
|
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": {
|
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/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
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
fs.
|
|
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
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
fs.
|
|
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: ${
|
|
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.
|
|
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"
|