agent-from-zero 0.34.1 → 0.35.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 +147 -0
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +167 -35
- package/dist/auto-budget.js +15 -0
- package/dist/auto-context.js +125 -0
- package/dist/auto-debug.js +87 -0
- package/dist/auto-providers.js +154 -0
- package/dist/auto-review.js +113 -0
- package/dist/auto.js +295 -0
- package/dist/background.js +2 -1
- package/dist/cli.js +83 -0
- package/dist/clipboard.js +88 -0
- package/dist/completion.js +65 -0
- package/dist/config.js +8 -0
- package/dist/context.js +9 -3
- package/dist/cost.js +4 -0
- package/dist/effort.js +81 -0
- package/dist/follow-up.js +22 -0
- package/dist/form.js +8 -6
- package/dist/images.js +43 -0
- package/dist/ink/activity.js +62 -0
- package/dist/ink/agents.js +24 -0
- package/dist/ink/app.js +393 -172
- package/dist/ink/chat.js +12 -1
- package/dist/ink/commands.js +16 -3
- package/dist/ink/completion.js +43 -0
- package/dist/ink/setup.js +12 -4
- package/dist/ink/sink.js +16 -6
- package/dist/ink/viewport.js +15 -0
- package/dist/loop.js +251 -106
- package/dist/mcp.js +1 -0
- package/dist/menu.js +50 -8
- package/dist/output.js +1 -1
- package/dist/pasted-text.js +32 -0
- package/dist/permissions.js +467 -12
- package/dist/print.js +16 -0
- package/dist/prompt.js +7 -4
- package/dist/status.js +80 -0
- package/dist/team.js +9 -3
- package/dist/title.js +20 -0
- package/dist/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +4 -3
package/dist/agent.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { statusCommand } from "./status.js";
|
|
2
3
|
import OpenAI from "openai"; // the API client
|
|
3
4
|
import chalk from "chalk"; // terminal colors
|
|
4
5
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
6
|
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
7
|
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
8
|
import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
|
|
8
|
-
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
|
+
import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
10
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
11
|
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
12
|
import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
12
|
-
import { generateSessionTitle } from "./title.js"; // concise session name, generated after the first message
|
|
13
|
+
import { generateSessionTitle, setTerminalTitle } from "./title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
13
14
|
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
14
15
|
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
15
16
|
import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
|
|
16
17
|
import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
18
|
+
import { AutoMode } from "./auto.js";
|
|
19
|
+
import { effortMenu, setEffort, selectedEffort } from "./effort.js";
|
|
20
|
+
import { parseCli, applyCliOptions, readPrintPrompt, CliUsageError } from "./cli.js";
|
|
21
|
+
import { reservePrintOutput, formatPrintResult } from "./print.js";
|
|
22
|
+
import { FollowUpQueue } from "./follow-up.js";
|
|
23
|
+
import { reviewDebugCommand } from "./auto-debug.js";
|
|
17
24
|
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
18
25
|
import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
|
|
19
26
|
import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's plan: /todos to view, cleared with the conversation
|
|
@@ -36,30 +43,46 @@ import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from
|
|
|
36
43
|
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
37
44
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
38
45
|
// ---- CLI arguments --------------------------------------------------------------
|
|
39
|
-
//
|
|
40
|
-
// fancier belongs in settings files, not flags.
|
|
46
|
+
// Process-local overrides share the same model/effort logic as the REPL.
|
|
41
47
|
const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
|
|
42
48
|
|
|
43
49
|
Usage:
|
|
44
50
|
mini-agent interactive session (REPL)
|
|
45
51
|
mini-agent -r | --resume continue the most recent session in this directory
|
|
46
52
|
mini-agent -p "<task>" one-shot: run a single task, print the result, exit
|
|
53
|
+
mini-agent exec "<task>" alias for non-interactive print mode
|
|
54
|
+
cat file | mini-agent -p "<task>" append piped text to the prompt
|
|
55
|
+
--prompt "<task>" explicit prompt (instead of a positional prompt)
|
|
56
|
+
--model <id> override the model for this run
|
|
57
|
+
--effort <level> supported effort for this model, or default
|
|
58
|
+
--permission-mode <mode> default | auto | bypassPermissions (this run only)
|
|
59
|
+
--dangerously-skip-permissions alias for --permission-mode bypassPermissions
|
|
60
|
+
--output-format text|json final answer or a JSON result (print mode only)
|
|
61
|
+
mini-agent --auto review tool actions automatically (Jev or your current vendor)
|
|
47
62
|
mini-agent -v | --version print the version
|
|
48
63
|
mini-agent -h | --help this text
|
|
49
64
|
|
|
50
65
|
Configuration (optional):
|
|
51
66
|
${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
|
|
52
67
|
${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
|
|
53
|
-
MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override
|
|
68
|
+
MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override settings.
|
|
69
|
+
CLI model/effort flags override this run only; they never change saved settings.
|
|
70
|
+
|
|
71
|
+
Print mode: options may precede or follow the prompt; use -- for literal flags.
|
|
72
|
+
stdout contains only the final result; diagnostics go to stderr.
|
|
73
|
+
Bypass skips approvals and AI review; hard denies, plan restrictions and hooks remain.
|
|
74
|
+
Exit codes: 0 success, 1 execution failure, 2 invalid arguments, 130 interrupted.
|
|
54
75
|
|
|
55
76
|
In a session, type /help for the in-session commands.`;
|
|
56
77
|
// What we tell the user for every way a query can end. No raw stack traces.
|
|
57
78
|
const EXIT_NOTES = {
|
|
79
|
+
[TerminateReason.ReviewLimit]: "Repeated automatic denials — stopped. Tell the agent how to proceed; prohibited actions were not executed.",
|
|
58
80
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
59
81
|
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
60
82
|
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
|
|
61
83
|
[TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
|
|
62
84
|
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
|
|
85
|
+
[TerminateReason.ImageInputRejected]: "The provider rejected this image request. Check image limits and use /model to select a vision-capable model at your current vendor, then retry (images remain in the conversation).",
|
|
63
86
|
[TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
|
|
64
87
|
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
65
88
|
};
|
|
@@ -71,9 +94,13 @@ const SESSION_HELP = `commands:
|
|
|
71
94
|
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
72
95
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
73
96
|
/memory show the durable facts the agent remembers about this project
|
|
97
|
+
/status session usage and vendor account balance/cost
|
|
74
98
|
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
75
99
|
/mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
|
|
76
100
|
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
101
|
+
/effort [level] list/select the current model’s supported reasoning effort
|
|
102
|
+
/auto toggle auto mode — risky or uncertain actions still require approval
|
|
103
|
+
/auto debug [on|off|status] inspect permission reviewer requests and replies
|
|
77
104
|
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
78
105
|
/bg list background tasks this session (run_bash_background) and their status
|
|
79
106
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
@@ -88,39 +115,39 @@ const SESSION_HELP = `commands:
|
|
|
88
115
|
|
|
89
116
|
keys (at the prompt):
|
|
90
117
|
Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
|
|
91
|
-
Ctrl+T reveal the folded tool-call trace for the last answer (
|
|
118
|
+
Ctrl+T reveal the folded tool-call trace for the last answer (recent arguments and results)
|
|
92
119
|
Tab expand/collapse the most recent tool output`;
|
|
93
120
|
// Injected when the user turns plan mode on, so the model knows the rules of the
|
|
94
121
|
// mode it now lives in (the permission gate enforces them regardless).
|
|
95
122
|
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
123
|
+
let printOutput;
|
|
124
|
+
let printCleanup;
|
|
96
125
|
async function main() {
|
|
97
126
|
// ---- Flag handling, before anything touches the network -----------------------
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
127
|
+
const cli = parseCli(process.argv.slice(2));
|
|
128
|
+
if (cli.version) {
|
|
100
129
|
console.log(pkg.version); // just the number — script-friendly
|
|
101
130
|
return;
|
|
102
131
|
}
|
|
103
|
-
if (
|
|
132
|
+
if (cli.help) {
|
|
104
133
|
console.log(USAGE); // the full help text
|
|
105
134
|
return;
|
|
106
135
|
}
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
process.exitCode = 2;
|
|
113
|
-
return;
|
|
136
|
+
applyCliOptions(cli);
|
|
137
|
+
const printTask = cli.print ? await readPrintPrompt(cli.prompt, process.stdin, !!process.stdin.isTTY) : null;
|
|
138
|
+
if (cli.print) {
|
|
139
|
+
printOutput = reservePrintOutput();
|
|
140
|
+
chalk.level = 0; // no terminal escapes in script diagnostics
|
|
114
141
|
}
|
|
115
142
|
// Interactive sessions default to the Ink REPL (a pinned input box, the
|
|
116
143
|
// conversation scrolling above it — like Claude Code). It does its OWN full
|
|
117
144
|
// setup (MCP, skills, judge, cost, hooks, session), so we hand off here before
|
|
118
145
|
// the readline path below ever runs. Print mode (-p) is a non-interactive
|
|
119
|
-
// one-shot that
|
|
146
|
+
// one-shot that writes its final result to stdout, so it stays on this path. Set
|
|
120
147
|
// MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
|
|
121
148
|
// reference front-end, and an escape hatch for terminals Ink misbehaves in).
|
|
122
149
|
if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
|
|
123
|
-
await launchInk({ resume:
|
|
150
|
+
await launchInk({ resume: cli.resume });
|
|
124
151
|
return;
|
|
125
152
|
}
|
|
126
153
|
requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
|
|
@@ -132,31 +159,43 @@ async function main() {
|
|
|
132
159
|
});
|
|
133
160
|
// The system message is built ONCE per session: stable prefix = cache hits
|
|
134
161
|
// on every request. Nothing session-specific may sneak into it later.
|
|
135
|
-
const
|
|
162
|
+
const projectInstructions = readProjectInstructions();
|
|
163
|
+
const systemMessage = buildSystemMessage(projectInstructions);
|
|
136
164
|
let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
|
|
137
165
|
// ---- Session persistence ---------------------------------------------------------
|
|
138
166
|
// Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
|
|
139
167
|
// -r / --resume picks up the most recent one. The system message is NOT
|
|
140
168
|
// restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
|
|
141
169
|
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
170
|
+
let initialTitle; // only a resumed session has one already
|
|
142
171
|
const sessionStartedAt = Date.now(); // for the status line's elapsed clock
|
|
143
|
-
if (
|
|
172
|
+
if (cli.resume) {
|
|
144
173
|
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
145
174
|
if (prev) {
|
|
146
175
|
messages.push(...prev.messages); // the old conversation joins the fresh constitution
|
|
147
176
|
sessionId = prev.id; // keep appending to the same session file
|
|
177
|
+
initialTitle = prev.title;
|
|
148
178
|
console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
|
|
149
179
|
}
|
|
150
180
|
else {
|
|
151
181
|
console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
|
|
152
182
|
}
|
|
153
183
|
}
|
|
184
|
+
if (printTask === null)
|
|
185
|
+
setTerminalTitle(initialTitle); // name the terminal tab from the start (a one-shot -p run leaves the tab alone)
|
|
154
186
|
initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
|
|
155
187
|
emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
|
|
156
188
|
// Connect to any configured MCP servers and register their tools. This must
|
|
157
189
|
// finish before the first model call so the tools appear in the manual. A
|
|
158
190
|
// server that fails to start is skipped, never fatal.
|
|
159
191
|
const disconnectMcp = await connectMcpServers();
|
|
192
|
+
if (cli.print)
|
|
193
|
+
printCleanup = () => {
|
|
194
|
+
stopCronScheduler();
|
|
195
|
+
killAllBackground();
|
|
196
|
+
killAllSubAgents();
|
|
197
|
+
disconnectMcp();
|
|
198
|
+
};
|
|
160
199
|
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
161
200
|
process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
|
|
162
201
|
process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
|
|
@@ -168,9 +207,12 @@ async function main() {
|
|
|
168
207
|
console.log(chalk.dim(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`));
|
|
169
208
|
startCronScheduler();
|
|
170
209
|
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
171
|
-
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
210
|
+
const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
211
|
+
const autoMode = new AutoMode(client, { projectInstructions });
|
|
212
|
+
for (const notice of autoMode.startupNotices())
|
|
213
|
+
console.log(chalk.dim(notice));
|
|
172
214
|
if (judge)
|
|
173
|
-
console.log(chalk.dim(`(permission judge on —
|
|
215
|
+
console.log(chalk.dim(`(permission judge on — ${autoMode.backend})`));
|
|
174
216
|
// The remember tool: let the model save durable facts to long-term memory.
|
|
175
217
|
// Registered like any tool, so it flows through the same permission gate.
|
|
176
218
|
registerExternalTool(rememberTool);
|
|
@@ -216,13 +258,17 @@ async function main() {
|
|
|
216
258
|
controller.abort(); // cancel the in-flight request
|
|
217
259
|
});
|
|
218
260
|
messages.push({ role: "user", content: printTask }); // the single task
|
|
261
|
+
autoMode.recordRequest(printTask);
|
|
219
262
|
const result = await runLoop(messages, {
|
|
220
263
|
client,
|
|
221
264
|
model: CONFIG.model,
|
|
265
|
+
quiet: true, // final result only; no spinner, reasoning or intermediate prose
|
|
222
266
|
signal: controller.signal,
|
|
223
267
|
isInterrupted: () => interrupted,
|
|
224
268
|
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
225
269
|
judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
|
|
270
|
+
autoMode,
|
|
271
|
+
canPrompt: false,
|
|
226
272
|
// Print mode never prompts: unattended means nobody can say yes.
|
|
227
273
|
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
228
274
|
// were enforced in permissions.ts long before this runs.
|
|
@@ -234,14 +280,18 @@ async function main() {
|
|
|
234
280
|
},
|
|
235
281
|
});
|
|
236
282
|
saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
|
|
283
|
+
await runHooks("SessionEnd", {});
|
|
237
284
|
emit("agent_session_end"); // close the books
|
|
285
|
+
printOutput.write(formatPrintResult(cli.outputFormat, {
|
|
286
|
+
sessionId, model: CONFIG.model, effort: selectedEffort(CONFIG.model), result, usage: costMeter.snapshot(),
|
|
287
|
+
}));
|
|
238
288
|
if (result.reason !== TerminateReason.Done) {
|
|
239
289
|
console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
|
|
240
290
|
if (result.detail)
|
|
241
291
|
console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
|
|
242
292
|
process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
|
|
243
293
|
}
|
|
244
|
-
return; // the
|
|
294
|
+
return; // the outer finally closes scheduler/MCP handles so scripts can exit
|
|
245
295
|
}
|
|
246
296
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
247
297
|
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
@@ -256,6 +306,7 @@ async function main() {
|
|
|
256
306
|
// Bonus: in a terminal, lines typed while a task runs queue up as the next
|
|
257
307
|
// commands instead of vanishing.
|
|
258
308
|
const pendingLines = []; // lines that arrived before anyone asked
|
|
309
|
+
const followUps = new FollowUpQueue();
|
|
259
310
|
const lineWaiters = []; // askers waiting for a line
|
|
260
311
|
let stdinDone = false; // has stdin ended?
|
|
261
312
|
const history = []; // past prompts, for ↑/↓ recall in the editor
|
|
@@ -323,13 +374,34 @@ async function main() {
|
|
|
323
374
|
};
|
|
324
375
|
process.on("SIGINT", onSigint); // covers non-TTY runs
|
|
325
376
|
rl.on("SIGINT", onSigint); // covers TTY raw mode, where readline swallows the signal itself
|
|
326
|
-
//
|
|
377
|
+
// Menus keep a text editor for follow-ups, sharing the session readline.
|
|
378
|
+
const enqueuePromptFollowUp = async (text) => {
|
|
379
|
+
if (!running) {
|
|
380
|
+
pendingLines.push(text);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
const hook = await runHooks("UserPromptSubmit", { prompt: text });
|
|
384
|
+
if (hook.block)
|
|
385
|
+
return false;
|
|
386
|
+
const { augmented } = expandMentions(text);
|
|
387
|
+
followUps.enqueue({ text, content: augmented + (hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "") });
|
|
388
|
+
return true;
|
|
389
|
+
};
|
|
390
|
+
const select = async (options) => {
|
|
391
|
+
editing = true;
|
|
392
|
+
try {
|
|
393
|
+
return await promptSelect(rl, options, process.stdin, enqueuePromptFollowUp);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
editing = false;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
327
399
|
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
328
400
|
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
329
401
|
// denies were already enforced in permissions.ts before we get here.
|
|
330
402
|
const confirm = async (question, toolName) => {
|
|
331
403
|
console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
|
|
332
|
-
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
404
|
+
if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
333
405
|
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
334
406
|
return true; // approve without asking
|
|
335
407
|
}
|
|
@@ -340,9 +412,10 @@ async function main() {
|
|
|
340
412
|
// Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
|
|
341
413
|
// option lets the user stop being asked about this tool for the session.
|
|
342
414
|
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
415
|
+
const options = autoMode.enabled ? ["Yes, once", "No — let me tell the agent what to do instead"] : ["Yes", allowLabel, "No — let me tell the agent what to do instead"];
|
|
416
|
+
const choice = await select(options);
|
|
417
|
+
const approved = choice === 0 || (!autoMode.enabled && choice === 1);
|
|
418
|
+
if (!autoMode.enabled && choice === 1 && toolName) {
|
|
346
419
|
CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
|
|
347
420
|
console.log(chalk.dim(` won't ask again for ${toolName} this session`));
|
|
348
421
|
}
|
|
@@ -355,7 +428,13 @@ async function main() {
|
|
|
355
428
|
const askUser = async (questions) => {
|
|
356
429
|
if (!process.stdin.isTTY)
|
|
357
430
|
return null;
|
|
358
|
-
|
|
431
|
+
editing = true;
|
|
432
|
+
try {
|
|
433
|
+
return await promptForm(rl, questions, process.stdin, enqueuePromptFollowUp);
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
editing = false;
|
|
437
|
+
}
|
|
359
438
|
};
|
|
360
439
|
// Ask the endpoint what models it serves (the OpenAI-compatible /models API).
|
|
361
440
|
// Empty list on any failure — not every endpoint implements it, and a failed
|
|
@@ -406,7 +485,7 @@ async function main() {
|
|
|
406
485
|
return;
|
|
407
486
|
}
|
|
408
487
|
console.log(chalk.dim("switch model (this session):"));
|
|
409
|
-
const choice = await
|
|
488
|
+
const choice = await select(formatModelChoices(models, CONFIG.model));
|
|
410
489
|
if (choice < 0 || models[choice] === CONFIG.model) {
|
|
411
490
|
console.log(chalk.dim("(model unchanged)"));
|
|
412
491
|
return;
|
|
@@ -439,14 +518,14 @@ async function main() {
|
|
|
439
518
|
console.log(chalk.dim(` ${label}`));
|
|
440
519
|
if (!process.stdin.isTTY)
|
|
441
520
|
return;
|
|
442
|
-
const choice = await
|
|
521
|
+
const choice = await select(labels.map((l) => l.replace(/ · /g, " — ")));
|
|
443
522
|
if (choice < 0) {
|
|
444
523
|
console.log(chalk.dim("(cancelled)"));
|
|
445
524
|
return;
|
|
446
525
|
}
|
|
447
526
|
const server = servers[choice];
|
|
448
527
|
const actions = mcpActionsFor(server);
|
|
449
|
-
const actionChoice = await
|
|
528
|
+
const actionChoice = await select(actions.map((a) => a.label));
|
|
450
529
|
if (actionChoice < 0) {
|
|
451
530
|
console.log(chalk.dim("(cancelled)"));
|
|
452
531
|
return;
|
|
@@ -460,6 +539,41 @@ async function main() {
|
|
|
460
539
|
};
|
|
461
540
|
// Handle a /slash command. Returns true if the line was a command.
|
|
462
541
|
const handleCommand = async (line) => {
|
|
542
|
+
if (line === "/status" || line.startsWith("/status ")) {
|
|
543
|
+
console.log(await statusCommand(line, costMeter));
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
if (line.startsWith("/skills "))
|
|
547
|
+
line = `/skill ${line.slice(8).trim()}`;
|
|
548
|
+
if (line === "/effort" || line.startsWith("/effort ")) {
|
|
549
|
+
const target = CONFIG.model;
|
|
550
|
+
const value = line.slice("/effort".length).trim();
|
|
551
|
+
if (value)
|
|
552
|
+
console.log(chalk.dim(setEffort(target, value)));
|
|
553
|
+
else {
|
|
554
|
+
const menu = effortMenu(target);
|
|
555
|
+
console.log(chalk.dim(menu.header));
|
|
556
|
+
if (menu.values.length) {
|
|
557
|
+
if (!process.stdin.isTTY)
|
|
558
|
+
console.log(menu.labels.join("\n"));
|
|
559
|
+
else {
|
|
560
|
+
const selected = await select(menu.labels);
|
|
561
|
+
if (selected >= 0)
|
|
562
|
+
console.log(chalk.dim(setEffort(target, menu.values[selected])));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
const debug = reviewDebugCommand(line);
|
|
569
|
+
if (debug !== null) {
|
|
570
|
+
console.log(chalk.dim(debug));
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
if (line === "/auto") {
|
|
574
|
+
console.log(chalk.dim(autoMode.toggle()));
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
463
577
|
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
464
578
|
// switch case — handle it before the exact-match switch.
|
|
465
579
|
if (line === "/model" || line.startsWith("/model ")) {
|
|
@@ -480,11 +594,12 @@ async function main() {
|
|
|
480
594
|
return true;
|
|
481
595
|
}
|
|
482
596
|
messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
|
|
597
|
+
autoMode.recordRequest(`Run the "${s.name}" skill.`);
|
|
483
598
|
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
484
599
|
running = true;
|
|
485
600
|
interrupted = false;
|
|
486
601
|
controller = new AbortController();
|
|
487
|
-
const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
|
|
602
|
+
const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, followUps, onFollowUp: (text) => console.log(sentMessage(text)), subAgentModel: CONFIG.subAgentModel, judge, autoMode, canPrompt: !!process.stdin.isTTY });
|
|
488
603
|
running = false;
|
|
489
604
|
saveSession(sessionId, CONFIG.model, messages);
|
|
490
605
|
if (r.reason !== TerminateReason.Done)
|
|
@@ -513,6 +628,7 @@ async function main() {
|
|
|
513
628
|
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
514
629
|
return true;
|
|
515
630
|
case "/clear":
|
|
631
|
+
autoMode.clearRequests();
|
|
516
632
|
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
517
633
|
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
518
634
|
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
@@ -524,6 +640,7 @@ async function main() {
|
|
|
524
640
|
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
525
641
|
titleAttempted = false; // a fresh session gets its own title on its first message
|
|
526
642
|
pendingTitle = undefined;
|
|
643
|
+
setTerminalTitle(undefined); // the tab no longer describes the old conversation
|
|
527
644
|
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
528
645
|
return true;
|
|
529
646
|
case "/todos": {
|
|
@@ -644,9 +761,11 @@ async function main() {
|
|
|
644
761
|
return true;
|
|
645
762
|
}
|
|
646
763
|
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
764
|
+
autoMode.clearRequests(); // old transcripts do not preserve trustworthy input provenance
|
|
647
765
|
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
648
766
|
titleAttempted = false; // a resumed session without a title gets one on its next message
|
|
649
767
|
pendingTitle = chosen.title;
|
|
768
|
+
setTerminalTitle(chosen.title ?? sessions[idx].title.slice(0, 40)); // the tab follows the resumed session (raw prompt if never titled)
|
|
650
769
|
forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
|
|
651
770
|
clearUndo(); // the previous session's writes are not ours to undo
|
|
652
771
|
clearTodos(); // the resumed task starts without the old session's stale plan
|
|
@@ -742,7 +861,8 @@ async function main() {
|
|
|
742
861
|
console.log(""); // one blank line separates the previous answer from the prompt
|
|
743
862
|
footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
|
|
744
863
|
}
|
|
745
|
-
const
|
|
864
|
+
const prompt = autoMode.enabled && !isPlanMode() ? chalk.yellow("auto ❯ ") : framedPrompt(isPlanMode());
|
|
865
|
+
const line = (await readUserLine(prompt, footer, sentMessage)).trim(); // next input, or "exit" on EOF
|
|
746
866
|
if (!line)
|
|
747
867
|
continue; // empty line — just re-prompt
|
|
748
868
|
if (line === "exit" || line === "quit") {
|
|
@@ -782,12 +902,14 @@ async function main() {
|
|
|
782
902
|
if (title) {
|
|
783
903
|
pendingTitle = title;
|
|
784
904
|
setSessionTitle(sessionId, title);
|
|
905
|
+
setTerminalTitle(title); // and rename the terminal tab, like Claude Code does
|
|
785
906
|
}
|
|
786
907
|
})
|
|
787
908
|
.catch(() => { });
|
|
788
909
|
}
|
|
789
910
|
}
|
|
790
911
|
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
912
|
+
autoMode.recordRequest(line); // original human text, before file/hook attachments
|
|
791
913
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
|
|
792
914
|
clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
|
|
793
915
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
@@ -800,8 +922,12 @@ async function main() {
|
|
|
800
922
|
isInterrupted: () => interrupted, // for stopping between steps
|
|
801
923
|
confirm, // for permission prompts
|
|
802
924
|
askUser, // the multi-question form the model can pop
|
|
925
|
+
followUps,
|
|
926
|
+
onFollowUp: (text) => console.log(sentMessage(text)),
|
|
803
927
|
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
804
928
|
judge, // optional LLM classifier for the "ask" middle ground
|
|
929
|
+
autoMode,
|
|
930
|
+
canPrompt: !!process.stdin.isTTY,
|
|
805
931
|
});
|
|
806
932
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
807
933
|
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
@@ -835,4 +961,10 @@ async function main() {
|
|
|
835
961
|
tuiCleanup(); // restore terminal
|
|
836
962
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
837
963
|
}
|
|
838
|
-
main()
|
|
964
|
+
main().catch((error) => {
|
|
965
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
966
|
+
process.exitCode = error instanceof CliUsageError ? 2 : 1;
|
|
967
|
+
}).finally(() => {
|
|
968
|
+
printCleanup?.();
|
|
969
|
+
printOutput?.restore();
|
|
970
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// One object per root task, shared by descendants and interrupt/resume. An
|
|
2
|
+
// exhausted budget is sticky: a late successful worker cannot reopen the gate.
|
|
3
|
+
export class AutoReviewBudget {
|
|
4
|
+
consecutive = 0;
|
|
5
|
+
total = 0;
|
|
6
|
+
stopped = false;
|
|
7
|
+
get exhausted() { return this.stopped; }
|
|
8
|
+
deny() {
|
|
9
|
+
this.total++;
|
|
10
|
+
this.consecutive++;
|
|
11
|
+
this.stopped ||= this.consecutive >= 3 || this.total >= 20;
|
|
12
|
+
}
|
|
13
|
+
executed() { if (!this.stopped)
|
|
14
|
+
this.consecutive = 0; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const HISTORY_BYTES = 8000;
|
|
3
|
+
const RESULT_BYTES = 2000;
|
|
4
|
+
function resultEvidence(result) {
|
|
5
|
+
if (!result)
|
|
6
|
+
return {};
|
|
7
|
+
if (Buffer.byteLength(result, "utf8") <= RESULT_BYTES) {
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(result);
|
|
10
|
+
if (value !== null && typeof value === "object")
|
|
11
|
+
return { resultData: value };
|
|
12
|
+
}
|
|
13
|
+
catch { /* prose is not structured evidence */ }
|
|
14
|
+
}
|
|
15
|
+
return { resultDataOmitted: true };
|
|
16
|
+
}
|
|
17
|
+
function projectInput(tool, args) {
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(args);
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
21
|
+
return value;
|
|
22
|
+
if (tool === "read_file")
|
|
23
|
+
return { path: value.path };
|
|
24
|
+
if (tool === "search")
|
|
25
|
+
return { pattern: value.pattern, path: value.path, file_glob: value.file_glob };
|
|
26
|
+
if (tool === "run_bash" || tool === "run_bash_background")
|
|
27
|
+
return { command: value.command };
|
|
28
|
+
return value; // retain edits, writes and opaque inputs without guessing relevance
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { malformedArguments: args };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// This projection deliberately cannot create user authorization. Only completed
|
|
35
|
+
// tool-call/result pairs contribute context; queued sibling actions, assistant
|
|
36
|
+
// prose and synthetic user-role notifications are excluded. Small structured
|
|
37
|
+
// results retain target/effect evidence; arbitrary prose and large results do not.
|
|
38
|
+
// User requests/project rules/current action are separately kept in full.
|
|
39
|
+
export function reviewHistory(messages, inherited) {
|
|
40
|
+
const results = new Map();
|
|
41
|
+
for (const message of messages)
|
|
42
|
+
if (message.role === "tool") {
|
|
43
|
+
results.set(message.tool_call_id, typeof message.content === "string" ? message.content : "");
|
|
44
|
+
}
|
|
45
|
+
const actions = [...(inherited?.actions ?? [])];
|
|
46
|
+
for (const message of messages) {
|
|
47
|
+
if (message.role !== "assistant")
|
|
48
|
+
continue;
|
|
49
|
+
for (const call of message.tool_calls ?? []) {
|
|
50
|
+
if (call.type !== "function" || !results.has(call.id))
|
|
51
|
+
continue;
|
|
52
|
+
const result = results.get(call.id);
|
|
53
|
+
actions.push({ callId: call.id, tool: call.function.name, input: projectInput(call.function.name, call.function.arguments),
|
|
54
|
+
outcome: /^\[permission\] Auto-denied/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
let start = actions.length;
|
|
59
|
+
while (start > 0) {
|
|
60
|
+
const size = Buffer.byteLength(JSON.stringify(actions[start - 1]), "utf8");
|
|
61
|
+
if (bytes + size > HISTORY_BYTES)
|
|
62
|
+
break; // keep a contiguous suffix, not misleading gaps
|
|
63
|
+
bytes += size;
|
|
64
|
+
start--;
|
|
65
|
+
}
|
|
66
|
+
return { actions: actions.slice(start), omittedActions: (inherited?.omittedActions ?? 0) + start };
|
|
67
|
+
}
|
|
68
|
+
// Only identity facts cross the new policy boundary. Small JSON is not trusted
|
|
69
|
+
// simply because it is small. Keep nesting for provenance, discard prose and
|
|
70
|
+
// instruction-shaped fields; these observations still cannot authorize actions.
|
|
71
|
+
export function ruleHistory(history) {
|
|
72
|
+
const project = (value, depth = 0) => {
|
|
73
|
+
if (depth > 6 || !value || typeof value !== "object")
|
|
74
|
+
return undefined;
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
const entries = value.slice(0, 30).map((v) => project(v, depth + 1)).filter((v) => v !== undefined);
|
|
77
|
+
return entries.length ? entries : undefined;
|
|
78
|
+
}
|
|
79
|
+
const facts = {};
|
|
80
|
+
for (const [key, item] of Object.entries(value)) {
|
|
81
|
+
if (/^(id|user_id|channel_id|name|display_name|real_name|email|username)$/.test(key)
|
|
82
|
+
&& typeof item === "string" && item.length <= 200 && !/[\r\n]/.test(item))
|
|
83
|
+
facts[key] = item;
|
|
84
|
+
else if (/^(results|users|members|data|profile)$/.test(key)) {
|
|
85
|
+
const nested = project(item, depth + 1);
|
|
86
|
+
if (nested !== undefined)
|
|
87
|
+
facts[key] = nested;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Object.keys(facts).length ? facts : undefined;
|
|
91
|
+
};
|
|
92
|
+
return { ...history, actions: history.actions.map(({ resultData, ...action }) => {
|
|
93
|
+
const facts = project(resultData);
|
|
94
|
+
return { ...action, ...(facts === undefined ? {} : { resultData: facts }),
|
|
95
|
+
...(resultData !== undefined && JSON.stringify(facts) !== JSON.stringify(resultData) ? { resultDataOmitted: true } : {}) };
|
|
96
|
+
}) };
|
|
97
|
+
}
|
|
98
|
+
// Read once at startup. Later tool edits to git config must not expand the set
|
|
99
|
+
// of established destinations. Do not send URL credentials/query tokens.
|
|
100
|
+
export function startupRemotes(cwd) {
|
|
101
|
+
try {
|
|
102
|
+
const output = execFileSync("git", ["config", "--get-regexp", "^remote\\..*\\.(url|pushurl)$"], { cwd, encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] });
|
|
103
|
+
return output.trim().split("\n").flatMap((line) => {
|
|
104
|
+
const entry = /^remote\.(.+)\.(url|pushurl) (.+)$/.exec(line);
|
|
105
|
+
if (!entry)
|
|
106
|
+
return [];
|
|
107
|
+
let value = entry[3];
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(value);
|
|
110
|
+
url.username = "";
|
|
111
|
+
url.password = "";
|
|
112
|
+
url.search = "";
|
|
113
|
+
url.hash = "";
|
|
114
|
+
value = url.toString();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
value = value.replace(/^[^@/]+@/, "");
|
|
118
|
+
}
|
|
119
|
+
return [{ name: entry[1], kind: entry[2] === "pushurl" ? "push" : "fetch", url: value }];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|