agent-from-zero 0.31.0 → 0.32.1
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/dist/agent.js +14 -1
- package/dist/context.js +4 -3
- package/dist/ink/app.js +518 -0
- package/dist/ink/chat.js +22 -0
- package/dist/ink/commands.js +103 -0
- package/dist/ink/launch.js +15 -0
- package/dist/ink/main.js +4 -0
- package/dist/ink/setup.js +104 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +142 -81
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +26 -5
- package/package.json +5 -1
package/dist/agent.js
CHANGED
|
@@ -4,7 +4,7 @@ import chalk from "chalk"; // terminal colors
|
|
|
4
4
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
5
|
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
6
|
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
-
import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
7
|
+
import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
|
|
8
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
9
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
10
|
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
@@ -27,6 +27,7 @@ import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped f
|
|
|
27
27
|
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
28
28
|
import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
|
|
29
29
|
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
30
|
+
import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
|
|
30
31
|
import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
|
|
31
32
|
import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
|
|
32
33
|
import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
|
|
@@ -107,6 +108,17 @@ async function main() {
|
|
|
107
108
|
process.exitCode = 2;
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
111
|
+
// Interactive sessions default to the Ink REPL (a pinned input box, the
|
|
112
|
+
// conversation scrolling above it — like Claude Code). It does its OWN full
|
|
113
|
+
// setup (MCP, skills, judge, cost, hooks, session), so we hand off here before
|
|
114
|
+
// the readline path below ever runs. Print mode (-p) is a non-interactive
|
|
115
|
+
// one-shot that streams to stdout, so it stays on the readline path. Set
|
|
116
|
+
// MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
|
|
117
|
+
// reference front-end, and an escape hatch for terminals Ink misbehaves in).
|
|
118
|
+
if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
|
|
119
|
+
await launchInk({ resume: argv.includes("-r") || argv.includes("--resume") });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
110
122
|
requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
|
|
111
123
|
// Build the API client once, for the whole session.
|
|
112
124
|
const client = new OpenAI({
|
|
@@ -143,6 +155,7 @@ async function main() {
|
|
|
143
155
|
const disconnectMcp = await connectMcpServers();
|
|
144
156
|
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
145
157
|
process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
|
|
158
|
+
process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
|
|
146
159
|
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
147
160
|
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
148
161
|
if (judge)
|
package/dist/context.js
CHANGED
|
@@ -80,9 +80,10 @@ export function recoverFileState() {
|
|
|
80
80
|
export async function compactHistory(messages, // the history, mutated in place
|
|
81
81
|
client, // the same client the loop uses
|
|
82
82
|
model, // the same model summarizes its own conversation
|
|
83
|
-
signal
|
|
83
|
+
signal, // Ctrl+C must abort compaction too
|
|
84
|
+
log = (s) => console.log(s)) {
|
|
84
85
|
const before = estimateHistoryTokens(messages); // for the log line
|
|
85
|
-
|
|
86
|
+
log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
86
87
|
const res = await client.chat.completions.create({
|
|
87
88
|
model, // same model — no need for a fancier one to summarize
|
|
88
89
|
messages: [...messages, { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
@@ -103,5 +104,5 @@ signal) {
|
|
|
103
104
|
const recoveredNote = recoverFileState(); // restore working-file contents from disk
|
|
104
105
|
if (recoveredNote)
|
|
105
106
|
messages.push({ role: "user", content: recoveredNote }); // attach as a second message
|
|
106
|
-
|
|
107
|
+
log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
|
|
107
108
|
}
|
package/dist/ink/app.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { Box, Text, Static, useInput, useApp } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
|
|
6
|
+
import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
|
|
7
|
+
import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
|
|
8
|
+
import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
|
|
9
|
+
import { TerminateReason, listSubAgents } from "../loop.js"; // how a turn can end
|
|
10
|
+
import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
|
|
11
|
+
import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
|
|
12
|
+
import { clearUndo } from "../undo.js";
|
|
13
|
+
import { clearTodos } from "../todos.js";
|
|
14
|
+
import { resetTeam } from "../team.js";
|
|
15
|
+
import { resetBoard } from "../board.js";
|
|
16
|
+
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as tuiCleanup } from "../tui.js";
|
|
17
|
+
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
18
|
+
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
19
|
+
import { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
|
|
20
|
+
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
21
|
+
import { findSkill, skillInstructions } from "../skills.js";
|
|
22
|
+
import { extractMemories } from "../memory.js";
|
|
23
|
+
import { runHooks } from "../hooks.js";
|
|
24
|
+
import { emit } from "../telemetry.js";
|
|
25
|
+
import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
|
|
26
|
+
import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
|
|
27
|
+
// Injected when plan mode turns on, so the model knows the rules it now lives in.
|
|
28
|
+
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.`;
|
|
29
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
|
|
30
|
+
// Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
|
|
31
|
+
// Ink redraws the whole dynamic region (everything below <Static>) on every
|
|
32
|
+
// token, and when that region grows TALLER than the terminal, Ink can no longer
|
|
33
|
+
// erase its previous frame — committed lines get reprinted (duplicate messages)
|
|
34
|
+
// and the cursor math drifts (sudden blank gaps). So we never let the in-flight
|
|
35
|
+
// answer render more than the rows we can spare; the FULL reply still commits to
|
|
36
|
+
// <Static> (formatted as markdown) the moment it finishes.
|
|
37
|
+
function liveTail(s) {
|
|
38
|
+
const rows = process.stdout.rows || 24;
|
|
39
|
+
const maxLines = Math.max(3, rows - 14); // reserve rows for the input box + status bar + agent bar + margins
|
|
40
|
+
const cols = process.stdout.columns || 80;
|
|
41
|
+
const maxChars = maxLines * cols; // rough bound so one very long line can't blow past either
|
|
42
|
+
let t = s.length > maxChars ? s.slice(-maxChars) : s;
|
|
43
|
+
const lines = t.split("\n");
|
|
44
|
+
const clipped = s.length > maxChars || lines.length > maxLines;
|
|
45
|
+
if (lines.length > maxLines)
|
|
46
|
+
t = lines.slice(-maxLines).join("\n");
|
|
47
|
+
return clipped ? "… " + t : t; // a leading ellipsis signals there's more above (it's all in the scrollback on commit)
|
|
48
|
+
}
|
|
49
|
+
function Spinner() {
|
|
50
|
+
const [f, setF] = useState(0);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 80);
|
|
53
|
+
return () => clearInterval(id);
|
|
54
|
+
}, []);
|
|
55
|
+
return _jsx(Text, { color: "cyan", children: SPINNER_FRAMES[f] });
|
|
56
|
+
}
|
|
57
|
+
// One committed line of conversation. `user` is a highlight bar; `answer` is a
|
|
58
|
+
// finished reply rendered as markdown; `note` is already-formatted narration.
|
|
59
|
+
function ItemView({ item }) {
|
|
60
|
+
if (item.kind === "user") {
|
|
61
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { backgroundColor: "gray", color: "whiteBright", children: `> ${item.text}` }) }));
|
|
62
|
+
}
|
|
63
|
+
if (item.kind === "answer") {
|
|
64
|
+
return (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: renderMarkdown(item.text) }) })] }));
|
|
65
|
+
}
|
|
66
|
+
return (_jsx(Box, { children: _jsx(Text, { children: item.text }) }));
|
|
67
|
+
}
|
|
68
|
+
// The bottom status bar — same content + colours as the non-Ink statusLine():
|
|
69
|
+
// [model] · 📁 dir · 🌿 branch · ctx N% · $cost · ⏱ time.
|
|
70
|
+
function StatusBar({ model, dir, branch, status }) {
|
|
71
|
+
const parts = [
|
|
72
|
+
_jsxs(Text, { color: "cyan", children: ["[", model, "]"] }, "m"),
|
|
73
|
+
_jsxs(Text, { dimColor: true, children: ["\uD83D\uDCC1 ", dir] }, "d"),
|
|
74
|
+
...(branch ? [_jsxs(Text, { color: "green", children: ["\uD83C\uDF3F ", branch] }, "b")] : []),
|
|
75
|
+
_jsxs(Text, { dimColor: true, children: ["ctx ", status.ctxPct, "%"] }, "c"),
|
|
76
|
+
_jsxs(Text, { color: "yellow", children: ["$", status.cost.toFixed(status.cost < 1 ? 4 : 2)] }, "$"),
|
|
77
|
+
_jsxs(Text, { dimColor: true, children: ["\u23F1 ", formatElapsed(status.elapsedMs)] }, "t"),
|
|
78
|
+
];
|
|
79
|
+
return (_jsx(Box, { children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
|
|
80
|
+
}
|
|
81
|
+
// One human note per non-Done ending, so the user always learns why a turn stopped.
|
|
82
|
+
const EXIT_NOTES = {
|
|
83
|
+
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here.",
|
|
84
|
+
[TerminateReason.RetryBudgetExhausted]: "Too many failed API calls — giving up. Check your network.",
|
|
85
|
+
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then retry.",
|
|
86
|
+
[TerminateReason.ContextTooLong]: "The conversation no longer fits the context window. Use /clear.",
|
|
87
|
+
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing. Use /clear to start fresh.",
|
|
88
|
+
[TerminateReason.FatalApiError]: "Unrecoverable API error — check your API key and request.",
|
|
89
|
+
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
90
|
+
};
|
|
91
|
+
export function App({ session, runTurn }) {
|
|
92
|
+
const { client, messages, systemMessage, costMeter, skills, judge, model, dir, branch, getStatus } = session;
|
|
93
|
+
// Seed the scrollback with the welcome banner + the dim startup notices.
|
|
94
|
+
const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
|
|
95
|
+
const [status, setStatus] = useState(null); // the live spinner line
|
|
96
|
+
const [live, setLive] = useState(null); // the streaming answer
|
|
97
|
+
const [input, setInput] = useState(""); // the current input buffer
|
|
98
|
+
const [busy, setBusy] = useState(false); // a turn is in flight
|
|
99
|
+
const [pending, setPending] = useState(null); // a prompt/menu blocking input
|
|
100
|
+
const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
|
|
101
|
+
const [formState, setFormState] = useState(null); // the ask_user form's state
|
|
102
|
+
const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
|
|
103
|
+
const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
|
|
104
|
+
const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
|
|
105
|
+
const [subAgentFocus, setSubAgentFocus] = useState(null); // which sub-agent is highlighted (null = none)
|
|
106
|
+
const [subAgentDetail, setSubAgentDetail] = useState(null); // the id of the sub-agent whose output is shown in detail
|
|
107
|
+
const [, setTick] = useState(0); // forces a re-render once a second so the clock / cost tick
|
|
108
|
+
const { exit } = useApp();
|
|
109
|
+
const pushItem = useRef((it) => setItems((xs) => [...xs, it])).current;
|
|
110
|
+
const note = (text) => pushItem({ kind: "note", text });
|
|
111
|
+
const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
|
|
112
|
+
const history = useRef([]).current; // past prompts, for ↑/↓ recall
|
|
113
|
+
const turn = useRef(null); // the in-flight turn, for Esc-interrupt
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
116
|
+
return () => clearInterval(id);
|
|
117
|
+
}, []);
|
|
118
|
+
// Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
|
|
119
|
+
const openSelect = (header, options, onChoose) => {
|
|
120
|
+
setMenuSel(0);
|
|
121
|
+
setPending({ kind: "select", header, options, onChoose });
|
|
122
|
+
};
|
|
123
|
+
// Run one conversation turn through the real loop. `display` is what scrolls up
|
|
124
|
+
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
125
|
+
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
126
|
+
// `pending` prompt that returns a promise the loop awaits.
|
|
127
|
+
const runConversationTurn = (content, display) => {
|
|
128
|
+
pushItem(display);
|
|
129
|
+
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
130
|
+
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
131
|
+
setBusy(true);
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const tstate = { controller, interrupted: false };
|
|
134
|
+
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
135
|
+
const hooks = {
|
|
136
|
+
output: sink,
|
|
137
|
+
confirm: (question, toolName) => new Promise((resolve) => {
|
|
138
|
+
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
139
|
+
openSelect(`⚠ approval needed — ${question}`, ["Yes", allowLabel, "No — let me tell the agent what to do instead"], (i) => {
|
|
140
|
+
const approved = i === 0 || i === 1;
|
|
141
|
+
if (approved && i === 1 && toolName)
|
|
142
|
+
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
143
|
+
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
144
|
+
resolve(approved);
|
|
145
|
+
});
|
|
146
|
+
}),
|
|
147
|
+
askUser: (questions) => new Promise((resolve) => {
|
|
148
|
+
if (!questions.length)
|
|
149
|
+
return resolve(null);
|
|
150
|
+
setFormState(initFormState(questions));
|
|
151
|
+
setPending({ kind: "form", questions, resolve });
|
|
152
|
+
}),
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
|
|
155
|
+
judge,
|
|
156
|
+
};
|
|
157
|
+
runTurn(content, hooks)
|
|
158
|
+
.then(async (result) => {
|
|
159
|
+
if (result.reason !== TerminateReason.Done)
|
|
160
|
+
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
161
|
+
saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
|
|
162
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
163
|
+
try {
|
|
164
|
+
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
165
|
+
if (got.length)
|
|
166
|
+
note(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* memory extraction must never break the session */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
.catch((e) => note(chalk.red(`[error] ${e.message}`)))
|
|
174
|
+
.finally(() => {
|
|
175
|
+
turn.current = null;
|
|
176
|
+
setBusy(false);
|
|
177
|
+
setStatus(null);
|
|
178
|
+
setLive(null);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
182
|
+
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
183
|
+
const submitLine = async (text) => {
|
|
184
|
+
if (text === "exit" || text === "quit")
|
|
185
|
+
return doExit();
|
|
186
|
+
if (await handleCommand(text))
|
|
187
|
+
return; // slash commands never reach the model
|
|
188
|
+
const hook = await runHooks("UserPromptSubmit", { prompt: text });
|
|
189
|
+
if (hook.block)
|
|
190
|
+
return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
|
|
191
|
+
const injected = hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "";
|
|
192
|
+
const { augmented, mentions } = expandMentions(text);
|
|
193
|
+
const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
|
|
194
|
+
const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
|
|
195
|
+
if (attached.length)
|
|
196
|
+
note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
197
|
+
if (refused.length)
|
|
198
|
+
note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
199
|
+
runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
|
|
200
|
+
};
|
|
201
|
+
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
202
|
+
const handleCommand = async (line) => {
|
|
203
|
+
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
204
|
+
if (info !== null) {
|
|
205
|
+
note(info);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// /model [name|save <name>]
|
|
209
|
+
if (line === "/model" || line.startsWith("/model ")) {
|
|
210
|
+
const arg = line.slice("/model".length).trim();
|
|
211
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
212
|
+
const name = save ? arg.slice(4).trim() : arg;
|
|
213
|
+
const apply = (next) => {
|
|
214
|
+
const prev = CONFIG.model;
|
|
215
|
+
CONFIG.model = next;
|
|
216
|
+
if (save) {
|
|
217
|
+
saveGlobalSetting("model", next);
|
|
218
|
+
note(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
note(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
if (name) {
|
|
225
|
+
apply(name);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
|
|
229
|
+
let models = [];
|
|
230
|
+
try {
|
|
231
|
+
const page = await client.models.list();
|
|
232
|
+
models = [...new Set((page.data ?? []).map((m) => m.id).filter(Boolean))].sort();
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
/* not every endpoint lists models */
|
|
236
|
+
}
|
|
237
|
+
if (!models.length) {
|
|
238
|
+
note(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
openSelect("switch model (this session):", formatModelChoices(models, CONFIG.model), (i) => {
|
|
242
|
+
if (i < 0 || models[i] === CONFIG.model)
|
|
243
|
+
return note(chalk.dim("(model unchanged)"));
|
|
244
|
+
apply(models[i]);
|
|
245
|
+
});
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
// /skill <name> — run a skill on the user's behalf (works for user-only skills)
|
|
249
|
+
if (line === "/skill" || line.startsWith("/skill ")) {
|
|
250
|
+
const name = line.slice("/skill".length).trim();
|
|
251
|
+
if (!name) {
|
|
252
|
+
note(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
const s = findSkill(skills, name);
|
|
256
|
+
if (!s) {
|
|
257
|
+
note(chalk.yellow(`(no skill named "${name}")`));
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
switch (line) {
|
|
264
|
+
case "/clear":
|
|
265
|
+
messages.length = 0; // mutate in place — same array ref the loop holds
|
|
266
|
+
messages.push({ role: "system", content: systemMessage });
|
|
267
|
+
forgetFilesExcept([]);
|
|
268
|
+
clearUndo();
|
|
269
|
+
clearTodos();
|
|
270
|
+
resetTeam();
|
|
271
|
+
resetBoard();
|
|
272
|
+
clearReasoning();
|
|
273
|
+
clearToolCalls();
|
|
274
|
+
setSessionId(newSessionId());
|
|
275
|
+
note(chalk.dim("(history cleared)"));
|
|
276
|
+
return true;
|
|
277
|
+
case "/plan":
|
|
278
|
+
if (isPlanMode()) {
|
|
279
|
+
setPlanMode(false);
|
|
280
|
+
setPlan(false);
|
|
281
|
+
note(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
setPlanMode(true);
|
|
285
|
+
setPlan(true);
|
|
286
|
+
messages.push({ role: "user", content: PLAN_MODE_NOTICE });
|
|
287
|
+
note(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
case "/compact": {
|
|
291
|
+
if (messages.length <= 1) {
|
|
292
|
+
note(chalk.dim("(nothing to compact yet)"));
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
setBusy(true);
|
|
296
|
+
try {
|
|
297
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
note(chalk.yellow(`compaction failed: ${err.message}`));
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
setBusy(false);
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
case "/resume": {
|
|
308
|
+
const sessions = listSessions(10);
|
|
309
|
+
if (!sessions.length) {
|
|
310
|
+
note(chalk.dim("(no saved sessions in this project yet)"));
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
const labels = sessions.map((s) => `${s.savedAt.slice(0, 16).replace("T", " ")} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`);
|
|
314
|
+
openSelect("recent sessions in this project:", labels, (i) => {
|
|
315
|
+
if (i < 0)
|
|
316
|
+
return note(chalk.dim("(cancelled)"));
|
|
317
|
+
const chosen = loadSession(sessions[i].id);
|
|
318
|
+
if (!chosen)
|
|
319
|
+
return note(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
320
|
+
messages.length = 0;
|
|
321
|
+
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
322
|
+
setSessionId(chosen.id);
|
|
323
|
+
forgetFilesExcept([]);
|
|
324
|
+
clearUndo();
|
|
325
|
+
clearTodos();
|
|
326
|
+
resetTeam();
|
|
327
|
+
resetBoard();
|
|
328
|
+
note(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
329
|
+
});
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
default:
|
|
333
|
+
if (line.startsWith("/")) {
|
|
334
|
+
note(chalk.dim(`unknown command: ${line} — try /help`));
|
|
335
|
+
return true; // consumed — don't send typos to the model
|
|
336
|
+
}
|
|
337
|
+
return false; // a normal task line
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
// Graceful exit: SessionEnd hooks (tiny budget — the user may be leaving), then unmount.
|
|
341
|
+
const doExit = async () => {
|
|
342
|
+
try {
|
|
343
|
+
await runHooks("SessionEnd", {});
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
/* never block exit on a hook */
|
|
347
|
+
}
|
|
348
|
+
emit("agent_session_end");
|
|
349
|
+
tuiCleanup();
|
|
350
|
+
exit();
|
|
351
|
+
};
|
|
352
|
+
useInput((char, key) => {
|
|
353
|
+
if (key.ctrl && char === "c") {
|
|
354
|
+
tuiCleanup();
|
|
355
|
+
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
356
|
+
}
|
|
357
|
+
// Ctrl+R / Ctrl+T: reveal the LAST answer's collapsed thinking / tool calls.
|
|
358
|
+
if (key.ctrl && char === "r")
|
|
359
|
+
return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
|
|
360
|
+
if (key.ctrl && char === "t")
|
|
361
|
+
return note(getToolCalls() ?? chalk.dim("(no tool calls recorded for the last answer)"));
|
|
362
|
+
// Esc while a sub-agent detail is open: close it, don't interrupt.
|
|
363
|
+
if (key.escape && subAgentDetail && !pending) {
|
|
364
|
+
setSubAgentDetail(null);
|
|
365
|
+
setSubAgentFocus(null);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// Esc while a turn runs (and no menu is up): interrupt it — like the first
|
|
369
|
+
// Ctrl+C of the readline REPL, without the force-quit escalation.
|
|
370
|
+
if (key.escape && busy && !pending) {
|
|
371
|
+
const t = turn.current;
|
|
372
|
+
if (t && !t.interrupted) {
|
|
373
|
+
t.interrupted = true; // the loop polls this between steps
|
|
374
|
+
t.controller.abort(); // cancel the in-flight API request
|
|
375
|
+
note(chalk.yellow("(esc — interrupting; back at the prompt)"));
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
|
|
380
|
+
if (pending?.kind === "select") {
|
|
381
|
+
const n = pending.options.length;
|
|
382
|
+
if (key.upArrow)
|
|
383
|
+
setMenuSel((i) => (i - 1 + n) % n);
|
|
384
|
+
else if (key.downArrow)
|
|
385
|
+
setMenuSel((i) => (i + 1) % n);
|
|
386
|
+
else if (key.return || key.escape) {
|
|
387
|
+
const choice = key.escape ? -1 : menuSel;
|
|
388
|
+
const onChoose = pending.onChoose;
|
|
389
|
+
setPending(null);
|
|
390
|
+
onChoose(choice);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
395
|
+
if (pending?.kind === "form" && formState) {
|
|
396
|
+
if (key.upArrow)
|
|
397
|
+
setFormState(reduceForm(pending.questions, formState, "up").state);
|
|
398
|
+
else if (key.downArrow || key.tab)
|
|
399
|
+
setFormState(reduceForm(pending.questions, formState, "down").state);
|
|
400
|
+
else if (key.return || char === " ") {
|
|
401
|
+
const next = reduceForm(pending.questions, formState, "select");
|
|
402
|
+
setFormState(next.state);
|
|
403
|
+
if (next.done) {
|
|
404
|
+
pending.resolve(collectAnswers(pending.questions, next.state));
|
|
405
|
+
setPending(null);
|
|
406
|
+
setFormState(null);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else if (key.escape) {
|
|
410
|
+
pending.resolve(null);
|
|
411
|
+
setPending(null);
|
|
412
|
+
setFormState(null);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Tab / Shift+Tab: cycle through the sub-agent list below the input box.
|
|
417
|
+
// Enter on a highlighted sub-agent toggles its detail view.
|
|
418
|
+
const agents = listSubAgents();
|
|
419
|
+
if (agents.length > 0 && !key.return && key.tab) {
|
|
420
|
+
const back = key.shift; // Shift+Tab = go backwards
|
|
421
|
+
if (subAgentDetail) {
|
|
422
|
+
// Detail open: Tab moves to next + closes detail.
|
|
423
|
+
setSubAgentDetail(null);
|
|
424
|
+
setSubAgentFocus((prev) => {
|
|
425
|
+
const n = agents.length;
|
|
426
|
+
if (back) {
|
|
427
|
+
const next = prev === null ? n - 1 : (prev - 1 + n) % n;
|
|
428
|
+
return next;
|
|
429
|
+
}
|
|
430
|
+
const next = prev === null ? 0 : (prev + 1) % n;
|
|
431
|
+
return next;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
setSubAgentDetail(null);
|
|
436
|
+
setSubAgentFocus((prev) => {
|
|
437
|
+
const n = agents.length;
|
|
438
|
+
if (back) {
|
|
439
|
+
if (prev === null)
|
|
440
|
+
return n - 1;
|
|
441
|
+
const next = (prev - 1 + n) % n;
|
|
442
|
+
return next === n - 1 ? null : next;
|
|
443
|
+
}
|
|
444
|
+
if (prev === null)
|
|
445
|
+
return 0;
|
|
446
|
+
const next = (prev + 1) % n;
|
|
447
|
+
return next === 0 ? null : next;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Enter with a sub-agent highlighted: show its detail.
|
|
453
|
+
if (agents.length > 0 && subAgentFocus !== null && key.return) {
|
|
454
|
+
setSubAgentDetail(agents[subAgentFocus].id === subAgentDetail ? null : agents[subAgentFocus].id);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (busy)
|
|
458
|
+
return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
|
|
459
|
+
if (key.return) {
|
|
460
|
+
// Dismiss the sub-agent detail/focus on a normal submit.
|
|
461
|
+
setSubAgentDetail(null);
|
|
462
|
+
setSubAgentFocus(null);
|
|
463
|
+
const text = input.trim();
|
|
464
|
+
setInput("");
|
|
465
|
+
setHistIdx(null);
|
|
466
|
+
if (!text)
|
|
467
|
+
return;
|
|
468
|
+
if (text.trim())
|
|
469
|
+
history.push(text); // remember non-empty entries for ↑/↓
|
|
470
|
+
void submitLine(text);
|
|
471
|
+
}
|
|
472
|
+
else if (key.upArrow) {
|
|
473
|
+
// ↑ recall an earlier prompt (newest-first), like a shell.
|
|
474
|
+
if (!history.length)
|
|
475
|
+
return;
|
|
476
|
+
const idx = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
477
|
+
setHistIdx(idx);
|
|
478
|
+
setInput(history[idx]);
|
|
479
|
+
}
|
|
480
|
+
else if (key.downArrow) {
|
|
481
|
+
// ↓ walk back toward the fresh line.
|
|
482
|
+
if (histIdx === null)
|
|
483
|
+
return;
|
|
484
|
+
const idx = histIdx + 1;
|
|
485
|
+
if (idx >= history.length) {
|
|
486
|
+
setHistIdx(null);
|
|
487
|
+
setInput("");
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
setHistIdx(idx);
|
|
491
|
+
setInput(history[idx]);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else if (key.backspace || key.delete) {
|
|
495
|
+
setInput((s) => s.slice(0, -1));
|
|
496
|
+
}
|
|
497
|
+
else if (char && !key.ctrl && !key.meta) {
|
|
498
|
+
// A bulk chunk (a paste / a dropped file path) is normalized: a dropped
|
|
499
|
+
// file becomes a clean absolute path; ordinary pasted prose is unchanged.
|
|
500
|
+
const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
|
|
501
|
+
setHistIdx(null);
|
|
502
|
+
setInput((s) => s + add);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), (() => {
|
|
506
|
+
const agents = listSubAgents();
|
|
507
|
+
if (!agents.length)
|
|
508
|
+
return null;
|
|
509
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "agents \u00B7 " }), agents.map((sa, i) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: i === subAgentFocus ? "cyan" : "blue", bold: i === subAgentFocus, children: sa.id }), _jsx(Text, { dimColor: true, children: sa.status === "running" ? " ●" : sa.status === "done" ? " ✓" : " ✗" }), i < agents.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502 " })] }, sa.id))), _jsx(Text, { dimColor: true, children: " \u00B7 Tab" })] }), subAgentDetail && (() => {
|
|
510
|
+
const detail = agents.find((sa) => sa.id === subAgentDetail);
|
|
511
|
+
if (!detail)
|
|
512
|
+
return null;
|
|
513
|
+
const head = `${detail.id} · ${detail.status === "running" ? "running" : detail.status === "done" ? "done" : "failed"}`;
|
|
514
|
+
const body = detail.result ?? "(still working…)";
|
|
515
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: head }), _jsx(Text, { dimColor: true, children: detail.description }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: body.length > 500 ? body.slice(0, 500) + "\n…" : body }) })] }));
|
|
516
|
+
})()] }));
|
|
517
|
+
})(), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
|
|
518
|
+
}
|
package/dist/ink/chat.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runLoop } from "../loop.js"; // the REAL agent loop — tools, permissions, retries, the lot
|
|
2
|
+
import { CONFIG } from "../config.js"; // model + sub-agent tier
|
|
3
|
+
// Drive one real turn through the loop. The loop mutates `messages` in place
|
|
4
|
+
// (appends the assistant reply + any tool messages), exactly as the readline
|
|
5
|
+
// REPL relies on — so we only push the user turn here and let the loop do the
|
|
6
|
+
// rest. All screen output flows through hooks.output (the Ink sink).
|
|
7
|
+
export function makeRunTurn(client, messages) {
|
|
8
|
+
return async (input, hooks) => {
|
|
9
|
+
messages.push({ role: "user", content: input });
|
|
10
|
+
return runLoop(messages, {
|
|
11
|
+
client,
|
|
12
|
+
model: CONFIG.model,
|
|
13
|
+
signal: hooks.signal,
|
|
14
|
+
isInterrupted: hooks.isInterrupted,
|
|
15
|
+
confirm: hooks.confirm,
|
|
16
|
+
askUser: hooks.askUser,
|
|
17
|
+
judge: hooks.judge,
|
|
18
|
+
subAgentModel: CONFIG.subAgentModel,
|
|
19
|
+
output: hooks.output,
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|