agent-from-zero 0.34.2 → 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 +159 -34
- 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 +384 -171
- 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 +9 -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/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,11 +1,12 @@
|
|
|
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
|
|
@@ -14,6 +15,12 @@ import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only
|
|
|
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,7 +159,8 @@ 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;
|
|
@@ -141,7 +169,7 @@ async function main() {
|
|
|
141
169
|
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
142
170
|
let initialTitle; // only a resumed session has one already
|
|
143
171
|
const sessionStartedAt = Date.now(); // for the status line's elapsed clock
|
|
144
|
-
if (
|
|
172
|
+
if (cli.resume) {
|
|
145
173
|
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
146
174
|
if (prev) {
|
|
147
175
|
messages.push(...prev.messages); // the old conversation joins the fresh constitution
|
|
@@ -161,6 +189,13 @@ async function main() {
|
|
|
161
189
|
// finish before the first model call so the tools appear in the manual. A
|
|
162
190
|
// server that fails to start is skipped, never fatal.
|
|
163
191
|
const disconnectMcp = await connectMcpServers();
|
|
192
|
+
if (cli.print)
|
|
193
|
+
printCleanup = () => {
|
|
194
|
+
stopCronScheduler();
|
|
195
|
+
killAllBackground();
|
|
196
|
+
killAllSubAgents();
|
|
197
|
+
disconnectMcp();
|
|
198
|
+
};
|
|
164
199
|
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
165
200
|
process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
|
|
166
201
|
process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
|
|
@@ -172,9 +207,12 @@ async function main() {
|
|
|
172
207
|
console.log(chalk.dim(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`));
|
|
173
208
|
startCronScheduler();
|
|
174
209
|
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
175
|
-
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));
|
|
176
214
|
if (judge)
|
|
177
|
-
console.log(chalk.dim(`(permission judge on —
|
|
215
|
+
console.log(chalk.dim(`(permission judge on — ${autoMode.backend})`));
|
|
178
216
|
// The remember tool: let the model save durable facts to long-term memory.
|
|
179
217
|
// Registered like any tool, so it flows through the same permission gate.
|
|
180
218
|
registerExternalTool(rememberTool);
|
|
@@ -220,13 +258,17 @@ async function main() {
|
|
|
220
258
|
controller.abort(); // cancel the in-flight request
|
|
221
259
|
});
|
|
222
260
|
messages.push({ role: "user", content: printTask }); // the single task
|
|
261
|
+
autoMode.recordRequest(printTask);
|
|
223
262
|
const result = await runLoop(messages, {
|
|
224
263
|
client,
|
|
225
264
|
model: CONFIG.model,
|
|
265
|
+
quiet: true, // final result only; no spinner, reasoning or intermediate prose
|
|
226
266
|
signal: controller.signal,
|
|
227
267
|
isInterrupted: () => interrupted,
|
|
228
268
|
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
229
269
|
judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
|
|
270
|
+
autoMode,
|
|
271
|
+
canPrompt: false,
|
|
230
272
|
// Print mode never prompts: unattended means nobody can say yes.
|
|
231
273
|
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
232
274
|
// were enforced in permissions.ts long before this runs.
|
|
@@ -238,14 +280,18 @@ async function main() {
|
|
|
238
280
|
},
|
|
239
281
|
});
|
|
240
282
|
saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
|
|
283
|
+
await runHooks("SessionEnd", {});
|
|
241
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
|
+
}));
|
|
242
288
|
if (result.reason !== TerminateReason.Done) {
|
|
243
289
|
console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
|
|
244
290
|
if (result.detail)
|
|
245
291
|
console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
|
|
246
292
|
process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
|
|
247
293
|
}
|
|
248
|
-
return; // the
|
|
294
|
+
return; // the outer finally closes scheduler/MCP handles so scripts can exit
|
|
249
295
|
}
|
|
250
296
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
251
297
|
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
@@ -260,6 +306,7 @@ async function main() {
|
|
|
260
306
|
// Bonus: in a terminal, lines typed while a task runs queue up as the next
|
|
261
307
|
// commands instead of vanishing.
|
|
262
308
|
const pendingLines = []; // lines that arrived before anyone asked
|
|
309
|
+
const followUps = new FollowUpQueue();
|
|
263
310
|
const lineWaiters = []; // askers waiting for a line
|
|
264
311
|
let stdinDone = false; // has stdin ended?
|
|
265
312
|
const history = []; // past prompts, for ↑/↓ recall in the editor
|
|
@@ -327,13 +374,34 @@ async function main() {
|
|
|
327
374
|
};
|
|
328
375
|
process.on("SIGINT", onSigint); // covers non-TTY runs
|
|
329
376
|
rl.on("SIGINT", onSigint); // covers TTY raw mode, where readline swallows the signal itself
|
|
330
|
-
//
|
|
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
|
+
};
|
|
331
399
|
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
332
400
|
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
333
401
|
// denies were already enforced in permissions.ts before we get here.
|
|
334
402
|
const confirm = async (question, toolName) => {
|
|
335
403
|
console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
|
|
336
|
-
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
404
|
+
if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
337
405
|
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
338
406
|
return true; // approve without asking
|
|
339
407
|
}
|
|
@@ -344,9 +412,10 @@ async function main() {
|
|
|
344
412
|
// Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
|
|
345
413
|
// option lets the user stop being asked about this tool for the session.
|
|
346
414
|
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
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) {
|
|
350
419
|
CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
|
|
351
420
|
console.log(chalk.dim(` won't ask again for ${toolName} this session`));
|
|
352
421
|
}
|
|
@@ -359,7 +428,13 @@ async function main() {
|
|
|
359
428
|
const askUser = async (questions) => {
|
|
360
429
|
if (!process.stdin.isTTY)
|
|
361
430
|
return null;
|
|
362
|
-
|
|
431
|
+
editing = true;
|
|
432
|
+
try {
|
|
433
|
+
return await promptForm(rl, questions, process.stdin, enqueuePromptFollowUp);
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
editing = false;
|
|
437
|
+
}
|
|
363
438
|
};
|
|
364
439
|
// Ask the endpoint what models it serves (the OpenAI-compatible /models API).
|
|
365
440
|
// Empty list on any failure — not every endpoint implements it, and a failed
|
|
@@ -410,7 +485,7 @@ async function main() {
|
|
|
410
485
|
return;
|
|
411
486
|
}
|
|
412
487
|
console.log(chalk.dim("switch model (this session):"));
|
|
413
|
-
const choice = await
|
|
488
|
+
const choice = await select(formatModelChoices(models, CONFIG.model));
|
|
414
489
|
if (choice < 0 || models[choice] === CONFIG.model) {
|
|
415
490
|
console.log(chalk.dim("(model unchanged)"));
|
|
416
491
|
return;
|
|
@@ -443,14 +518,14 @@ async function main() {
|
|
|
443
518
|
console.log(chalk.dim(` ${label}`));
|
|
444
519
|
if (!process.stdin.isTTY)
|
|
445
520
|
return;
|
|
446
|
-
const choice = await
|
|
521
|
+
const choice = await select(labels.map((l) => l.replace(/ · /g, " — ")));
|
|
447
522
|
if (choice < 0) {
|
|
448
523
|
console.log(chalk.dim("(cancelled)"));
|
|
449
524
|
return;
|
|
450
525
|
}
|
|
451
526
|
const server = servers[choice];
|
|
452
527
|
const actions = mcpActionsFor(server);
|
|
453
|
-
const actionChoice = await
|
|
528
|
+
const actionChoice = await select(actions.map((a) => a.label));
|
|
454
529
|
if (actionChoice < 0) {
|
|
455
530
|
console.log(chalk.dim("(cancelled)"));
|
|
456
531
|
return;
|
|
@@ -464,6 +539,41 @@ async function main() {
|
|
|
464
539
|
};
|
|
465
540
|
// Handle a /slash command. Returns true if the line was a command.
|
|
466
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
|
+
}
|
|
467
577
|
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
468
578
|
// switch case — handle it before the exact-match switch.
|
|
469
579
|
if (line === "/model" || line.startsWith("/model ")) {
|
|
@@ -484,11 +594,12 @@ async function main() {
|
|
|
484
594
|
return true;
|
|
485
595
|
}
|
|
486
596
|
messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
|
|
597
|
+
autoMode.recordRequest(`Run the "${s.name}" skill.`);
|
|
487
598
|
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
488
599
|
running = true;
|
|
489
600
|
interrupted = false;
|
|
490
601
|
controller = new AbortController();
|
|
491
|
-
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 });
|
|
492
603
|
running = false;
|
|
493
604
|
saveSession(sessionId, CONFIG.model, messages);
|
|
494
605
|
if (r.reason !== TerminateReason.Done)
|
|
@@ -517,6 +628,7 @@ async function main() {
|
|
|
517
628
|
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
518
629
|
return true;
|
|
519
630
|
case "/clear":
|
|
631
|
+
autoMode.clearRequests();
|
|
520
632
|
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
521
633
|
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
522
634
|
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
@@ -649,6 +761,7 @@ async function main() {
|
|
|
649
761
|
return true;
|
|
650
762
|
}
|
|
651
763
|
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
764
|
+
autoMode.clearRequests(); // old transcripts do not preserve trustworthy input provenance
|
|
652
765
|
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
653
766
|
titleAttempted = false; // a resumed session without a title gets one on its next message
|
|
654
767
|
pendingTitle = chosen.title;
|
|
@@ -748,7 +861,8 @@ async function main() {
|
|
|
748
861
|
console.log(""); // one blank line separates the previous answer from the prompt
|
|
749
862
|
footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
|
|
750
863
|
}
|
|
751
|
-
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
|
|
752
866
|
if (!line)
|
|
753
867
|
continue; // empty line — just re-prompt
|
|
754
868
|
if (line === "exit" || line === "quit") {
|
|
@@ -795,6 +909,7 @@ async function main() {
|
|
|
795
909
|
}
|
|
796
910
|
}
|
|
797
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
|
|
798
913
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
|
|
799
914
|
clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
|
|
800
915
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
@@ -807,8 +922,12 @@ async function main() {
|
|
|
807
922
|
isInterrupted: () => interrupted, // for stopping between steps
|
|
808
923
|
confirm, // for permission prompts
|
|
809
924
|
askUser, // the multi-question form the model can pop
|
|
925
|
+
followUps,
|
|
926
|
+
onFollowUp: (text) => console.log(sentMessage(text)),
|
|
810
927
|
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
811
928
|
judge, // optional LLM classifier for the "ask" middle ground
|
|
929
|
+
autoMode,
|
|
930
|
+
canPrompt: !!process.stdin.isTTY,
|
|
812
931
|
});
|
|
813
932
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
814
933
|
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
@@ -842,4 +961,10 @@ async function main() {
|
|
|
842
961
|
tuiCleanup(); // restore terminal
|
|
843
962
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
844
963
|
}
|
|
845
|
-
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
|
+
}
|