agent-from-zero 0.31.0 → 0.32.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/dist/agent.js +12 -0
- package/dist/context.js +4 -3
- package/dist/ink/app.js +435 -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 +102 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +50 -52
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +12 -4
- package/package.json +5 -1
package/dist/agent.js
CHANGED
|
@@ -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({
|
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,435 @@
|
|
|
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 } 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
|
+
function Spinner() {
|
|
31
|
+
const [f, setF] = useState(0);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 80);
|
|
34
|
+
return () => clearInterval(id);
|
|
35
|
+
}, []);
|
|
36
|
+
return _jsx(Text, { color: "cyan", children: SPINNER_FRAMES[f] });
|
|
37
|
+
}
|
|
38
|
+
// One committed line of conversation. `user` is a highlight bar; `answer` is a
|
|
39
|
+
// finished reply rendered as markdown; `note` is already-formatted narration.
|
|
40
|
+
function ItemView({ item }) {
|
|
41
|
+
if (item.kind === "user") {
|
|
42
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { backgroundColor: "gray", color: "whiteBright", children: `> ${item.text}` }) }));
|
|
43
|
+
}
|
|
44
|
+
if (item.kind === "answer") {
|
|
45
|
+
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) }) })] }));
|
|
46
|
+
}
|
|
47
|
+
return (_jsx(Box, { children: _jsx(Text, { children: item.text }) }));
|
|
48
|
+
}
|
|
49
|
+
// The bottom status bar — same content + colours as the non-Ink statusLine():
|
|
50
|
+
// [model] · 📁 dir · 🌿 branch · ctx N% · $cost · ⏱ time.
|
|
51
|
+
function StatusBar({ model, dir, branch, status }) {
|
|
52
|
+
const parts = [
|
|
53
|
+
_jsxs(Text, { color: "cyan", children: ["[", model, "]"] }, "m"),
|
|
54
|
+
_jsxs(Text, { dimColor: true, children: ["\uD83D\uDCC1 ", dir] }, "d"),
|
|
55
|
+
...(branch ? [_jsxs(Text, { color: "green", children: ["\uD83C\uDF3F ", branch] }, "b")] : []),
|
|
56
|
+
_jsxs(Text, { dimColor: true, children: ["ctx ", status.ctxPct, "%"] }, "c"),
|
|
57
|
+
_jsxs(Text, { color: "yellow", children: ["$", status.cost.toFixed(status.cost < 1 ? 4 : 2)] }, "$"),
|
|
58
|
+
_jsxs(Text, { dimColor: true, children: ["\u23F1 ", formatElapsed(status.elapsedMs)] }, "t"),
|
|
59
|
+
];
|
|
60
|
+
return (_jsx(Box, { children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
|
|
61
|
+
}
|
|
62
|
+
// One human note per non-Done ending, so the user always learns why a turn stopped.
|
|
63
|
+
const EXIT_NOTES = {
|
|
64
|
+
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here.",
|
|
65
|
+
[TerminateReason.RetryBudgetExhausted]: "Too many failed API calls — giving up. Check your network.",
|
|
66
|
+
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then retry.",
|
|
67
|
+
[TerminateReason.ContextTooLong]: "The conversation no longer fits the context window. Use /clear.",
|
|
68
|
+
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing. Use /clear to start fresh.",
|
|
69
|
+
[TerminateReason.FatalApiError]: "Unrecoverable API error — check your API key and request.",
|
|
70
|
+
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
71
|
+
};
|
|
72
|
+
export function App({ session, runTurn }) {
|
|
73
|
+
const { client, messages, systemMessage, costMeter, skills, judge, model, dir, branch, getStatus } = session;
|
|
74
|
+
// Seed the scrollback with the welcome banner + the dim startup notices.
|
|
75
|
+
const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
|
|
76
|
+
const [status, setStatus] = useState(null); // the live spinner line
|
|
77
|
+
const [live, setLive] = useState(null); // the streaming answer
|
|
78
|
+
const [input, setInput] = useState(""); // the current input buffer
|
|
79
|
+
const [busy, setBusy] = useState(false); // a turn is in flight
|
|
80
|
+
const [pending, setPending] = useState(null); // a prompt/menu blocking input
|
|
81
|
+
const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
|
|
82
|
+
const [formState, setFormState] = useState(null); // the ask_user form's state
|
|
83
|
+
const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
|
|
84
|
+
const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
|
|
85
|
+
const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
|
|
86
|
+
const [, setTick] = useState(0); // forces a re-render once a second so the clock / cost tick
|
|
87
|
+
const { exit } = useApp();
|
|
88
|
+
const pushItem = useRef((it) => setItems((xs) => [...xs, it])).current;
|
|
89
|
+
const note = (text) => pushItem({ kind: "note", text });
|
|
90
|
+
const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
|
|
91
|
+
const history = useRef([]).current; // past prompts, for ↑/↓ recall
|
|
92
|
+
const turn = useRef(null); // the in-flight turn, for Esc-interrupt
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
95
|
+
return () => clearInterval(id);
|
|
96
|
+
}, []);
|
|
97
|
+
// Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
|
|
98
|
+
const openSelect = (header, options, onChoose) => {
|
|
99
|
+
setMenuSel(0);
|
|
100
|
+
setPending({ kind: "select", header, options, onChoose });
|
|
101
|
+
};
|
|
102
|
+
// Run one conversation turn through the real loop. `display` is what scrolls up
|
|
103
|
+
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
104
|
+
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
105
|
+
// `pending` prompt that returns a promise the loop awaits.
|
|
106
|
+
const runConversationTurn = (content, display) => {
|
|
107
|
+
pushItem(display);
|
|
108
|
+
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
109
|
+
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
110
|
+
setBusy(true);
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const tstate = { controller, interrupted: false };
|
|
113
|
+
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
114
|
+
const hooks = {
|
|
115
|
+
output: sink,
|
|
116
|
+
confirm: (question, toolName) => new Promise((resolve) => {
|
|
117
|
+
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
118
|
+
openSelect(`⚠ approval needed — ${question}`, ["Yes", allowLabel, "No — let me tell the agent what to do instead"], (i) => {
|
|
119
|
+
const approved = i === 0 || i === 1;
|
|
120
|
+
if (approved && i === 1 && toolName)
|
|
121
|
+
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
122
|
+
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
123
|
+
resolve(approved);
|
|
124
|
+
});
|
|
125
|
+
}),
|
|
126
|
+
askUser: (questions) => new Promise((resolve) => {
|
|
127
|
+
if (!questions.length)
|
|
128
|
+
return resolve(null);
|
|
129
|
+
setFormState(initFormState(questions));
|
|
130
|
+
setPending({ kind: "form", questions, resolve });
|
|
131
|
+
}),
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
|
|
134
|
+
judge,
|
|
135
|
+
};
|
|
136
|
+
runTurn(content, hooks)
|
|
137
|
+
.then(async (result) => {
|
|
138
|
+
if (result.reason !== TerminateReason.Done)
|
|
139
|
+
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
140
|
+
saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
|
|
141
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
142
|
+
try {
|
|
143
|
+
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
144
|
+
if (got.length)
|
|
145
|
+
note(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* memory extraction must never break the session */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
.catch((e) => note(chalk.red(`[error] ${e.message}`)))
|
|
153
|
+
.finally(() => {
|
|
154
|
+
turn.current = null;
|
|
155
|
+
setBusy(false);
|
|
156
|
+
setStatus(null);
|
|
157
|
+
setLive(null);
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
161
|
+
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
162
|
+
const submitLine = async (text) => {
|
|
163
|
+
if (text === "exit" || text === "quit")
|
|
164
|
+
return doExit();
|
|
165
|
+
if (await handleCommand(text))
|
|
166
|
+
return; // slash commands never reach the model
|
|
167
|
+
const hook = await runHooks("UserPromptSubmit", { prompt: text });
|
|
168
|
+
if (hook.block)
|
|
169
|
+
return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
|
|
170
|
+
const injected = hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "";
|
|
171
|
+
const { augmented, mentions } = expandMentions(text);
|
|
172
|
+
const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
|
|
173
|
+
const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
|
|
174
|
+
if (attached.length)
|
|
175
|
+
note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
176
|
+
if (refused.length)
|
|
177
|
+
note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
178
|
+
runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
|
|
179
|
+
};
|
|
180
|
+
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
181
|
+
const handleCommand = async (line) => {
|
|
182
|
+
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
183
|
+
if (info !== null) {
|
|
184
|
+
note(info);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// /model [name|save <name>]
|
|
188
|
+
if (line === "/model" || line.startsWith("/model ")) {
|
|
189
|
+
const arg = line.slice("/model".length).trim();
|
|
190
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
191
|
+
const name = save ? arg.slice(4).trim() : arg;
|
|
192
|
+
const apply = (next) => {
|
|
193
|
+
const prev = CONFIG.model;
|
|
194
|
+
CONFIG.model = next;
|
|
195
|
+
if (save) {
|
|
196
|
+
saveGlobalSetting("model", next);
|
|
197
|
+
note(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
note(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
if (name) {
|
|
204
|
+
apply(name);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
|
|
208
|
+
let models = [];
|
|
209
|
+
try {
|
|
210
|
+
const page = await client.models.list();
|
|
211
|
+
models = [...new Set((page.data ?? []).map((m) => m.id).filter(Boolean))].sort();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
/* not every endpoint lists models */
|
|
215
|
+
}
|
|
216
|
+
if (!models.length) {
|
|
217
|
+
note(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
openSelect("switch model (this session):", formatModelChoices(models, CONFIG.model), (i) => {
|
|
221
|
+
if (i < 0 || models[i] === CONFIG.model)
|
|
222
|
+
return note(chalk.dim("(model unchanged)"));
|
|
223
|
+
apply(models[i]);
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
// /skill <name> — run a skill on the user's behalf (works for user-only skills)
|
|
228
|
+
if (line === "/skill" || line.startsWith("/skill ")) {
|
|
229
|
+
const name = line.slice("/skill".length).trim();
|
|
230
|
+
if (!name) {
|
|
231
|
+
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)"));
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const s = findSkill(skills, name);
|
|
235
|
+
if (!s) {
|
|
236
|
+
note(chalk.yellow(`(no skill named "${name}")`));
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
switch (line) {
|
|
243
|
+
case "/clear":
|
|
244
|
+
messages.length = 0; // mutate in place — same array ref the loop holds
|
|
245
|
+
messages.push({ role: "system", content: systemMessage });
|
|
246
|
+
forgetFilesExcept([]);
|
|
247
|
+
clearUndo();
|
|
248
|
+
clearTodos();
|
|
249
|
+
resetTeam();
|
|
250
|
+
resetBoard();
|
|
251
|
+
clearReasoning();
|
|
252
|
+
clearToolCalls();
|
|
253
|
+
setSessionId(newSessionId());
|
|
254
|
+
note(chalk.dim("(history cleared)"));
|
|
255
|
+
return true;
|
|
256
|
+
case "/plan":
|
|
257
|
+
if (isPlanMode()) {
|
|
258
|
+
setPlanMode(false);
|
|
259
|
+
setPlan(false);
|
|
260
|
+
note(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
setPlanMode(true);
|
|
264
|
+
setPlan(true);
|
|
265
|
+
messages.push({ role: "user", content: PLAN_MODE_NOTICE });
|
|
266
|
+
note(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
case "/compact": {
|
|
270
|
+
if (messages.length <= 1) {
|
|
271
|
+
note(chalk.dim("(nothing to compact yet)"));
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
setBusy(true);
|
|
275
|
+
try {
|
|
276
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
note(chalk.yellow(`compaction failed: ${err.message}`));
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
setBusy(false);
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
case "/resume": {
|
|
287
|
+
const sessions = listSessions(10);
|
|
288
|
+
if (!sessions.length) {
|
|
289
|
+
note(chalk.dim("(no saved sessions in this project yet)"));
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
const labels = sessions.map((s) => `${s.savedAt.slice(0, 16).replace("T", " ")} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`);
|
|
293
|
+
openSelect("recent sessions in this project:", labels, (i) => {
|
|
294
|
+
if (i < 0)
|
|
295
|
+
return note(chalk.dim("(cancelled)"));
|
|
296
|
+
const chosen = loadSession(sessions[i].id);
|
|
297
|
+
if (!chosen)
|
|
298
|
+
return note(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
299
|
+
messages.length = 0;
|
|
300
|
+
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
301
|
+
setSessionId(chosen.id);
|
|
302
|
+
forgetFilesExcept([]);
|
|
303
|
+
clearUndo();
|
|
304
|
+
clearTodos();
|
|
305
|
+
resetTeam();
|
|
306
|
+
resetBoard();
|
|
307
|
+
note(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
default:
|
|
312
|
+
if (line.startsWith("/")) {
|
|
313
|
+
note(chalk.dim(`unknown command: ${line} — try /help`));
|
|
314
|
+
return true; // consumed — don't send typos to the model
|
|
315
|
+
}
|
|
316
|
+
return false; // a normal task line
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
// Graceful exit: SessionEnd hooks (tiny budget — the user may be leaving), then unmount.
|
|
320
|
+
const doExit = async () => {
|
|
321
|
+
try {
|
|
322
|
+
await runHooks("SessionEnd", {});
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
/* never block exit on a hook */
|
|
326
|
+
}
|
|
327
|
+
emit("agent_session_end");
|
|
328
|
+
tuiCleanup();
|
|
329
|
+
exit();
|
|
330
|
+
};
|
|
331
|
+
useInput((char, key) => {
|
|
332
|
+
if (key.ctrl && char === "c") {
|
|
333
|
+
tuiCleanup();
|
|
334
|
+
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
335
|
+
}
|
|
336
|
+
// Ctrl+R / Ctrl+T: reveal the LAST answer's collapsed thinking / tool calls.
|
|
337
|
+
if (key.ctrl && char === "r")
|
|
338
|
+
return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
|
|
339
|
+
if (key.ctrl && char === "t")
|
|
340
|
+
return note(getToolCalls() ?? chalk.dim("(no tool calls recorded for the last answer)"));
|
|
341
|
+
// Esc while a turn runs (and no menu is up): interrupt it — like the first
|
|
342
|
+
// Ctrl+C of the readline REPL, without the force-quit escalation.
|
|
343
|
+
if (key.escape && busy && !pending) {
|
|
344
|
+
const t = turn.current;
|
|
345
|
+
if (t && !t.interrupted) {
|
|
346
|
+
t.interrupted = true; // the loop polls this between steps
|
|
347
|
+
t.controller.abort(); // cancel the in-flight API request
|
|
348
|
+
note(chalk.yellow("(esc — interrupting; back at the prompt)"));
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
|
|
353
|
+
if (pending?.kind === "select") {
|
|
354
|
+
const n = pending.options.length;
|
|
355
|
+
if (key.upArrow)
|
|
356
|
+
setMenuSel((i) => (i - 1 + n) % n);
|
|
357
|
+
else if (key.downArrow)
|
|
358
|
+
setMenuSel((i) => (i + 1) % n);
|
|
359
|
+
else if (key.return || key.escape) {
|
|
360
|
+
const choice = key.escape ? -1 : menuSel;
|
|
361
|
+
const onChoose = pending.onChoose;
|
|
362
|
+
setPending(null);
|
|
363
|
+
onChoose(choice);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
368
|
+
if (pending?.kind === "form" && formState) {
|
|
369
|
+
if (key.upArrow)
|
|
370
|
+
setFormState(reduceForm(pending.questions, formState, "up").state);
|
|
371
|
+
else if (key.downArrow || key.tab)
|
|
372
|
+
setFormState(reduceForm(pending.questions, formState, "down").state);
|
|
373
|
+
else if (key.return || char === " ") {
|
|
374
|
+
const next = reduceForm(pending.questions, formState, "select");
|
|
375
|
+
setFormState(next.state);
|
|
376
|
+
if (next.done) {
|
|
377
|
+
pending.resolve(collectAnswers(pending.questions, next.state));
|
|
378
|
+
setPending(null);
|
|
379
|
+
setFormState(null);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else if (key.escape) {
|
|
383
|
+
pending.resolve(null);
|
|
384
|
+
setPending(null);
|
|
385
|
+
setFormState(null);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (busy)
|
|
390
|
+
return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
|
|
391
|
+
if (key.return) {
|
|
392
|
+
const text = input.trim();
|
|
393
|
+
setInput("");
|
|
394
|
+
setHistIdx(null);
|
|
395
|
+
if (!text)
|
|
396
|
+
return;
|
|
397
|
+
if (text.trim())
|
|
398
|
+
history.push(text); // remember non-empty entries for ↑/↓
|
|
399
|
+
void submitLine(text);
|
|
400
|
+
}
|
|
401
|
+
else if (key.upArrow) {
|
|
402
|
+
// ↑ recall an earlier prompt (newest-first), like a shell.
|
|
403
|
+
if (!history.length)
|
|
404
|
+
return;
|
|
405
|
+
const idx = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
406
|
+
setHistIdx(idx);
|
|
407
|
+
setInput(history[idx]);
|
|
408
|
+
}
|
|
409
|
+
else if (key.downArrow) {
|
|
410
|
+
// ↓ walk back toward the fresh line.
|
|
411
|
+
if (histIdx === null)
|
|
412
|
+
return;
|
|
413
|
+
const idx = histIdx + 1;
|
|
414
|
+
if (idx >= history.length) {
|
|
415
|
+
setHistIdx(null);
|
|
416
|
+
setInput("");
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
setHistIdx(idx);
|
|
420
|
+
setInput(history[idx]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (key.backspace || key.delete) {
|
|
424
|
+
setInput((s) => s.slice(0, -1));
|
|
425
|
+
}
|
|
426
|
+
else if (char && !key.ctrl && !key.meta) {
|
|
427
|
+
// A bulk chunk (a paste / a dropped file path) is normalized: a dropped
|
|
428
|
+
// file becomes a clean absolute path; ordinary pasted prose is unchanged.
|
|
429
|
+
const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
|
|
430
|
+
setHistIdx(null);
|
|
431
|
+
setInput((s) => s + add);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
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("…") : live }) })] })), status !== null && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status] })] })), 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: " " })] }), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
|
|
435
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { statsReport } from "../telemetry.js";
|
|
5
|
+
import { readMemoryTyped, MEMORY_PATH } from "../memory.js";
|
|
6
|
+
import { getTodos, renderTodos } from "../todos.js";
|
|
7
|
+
import { listBackground } from "../background.js";
|
|
8
|
+
import { listTeam } from "../team.js";
|
|
9
|
+
import { boardSummary } from "../board.js";
|
|
10
|
+
import { undoLast, sessionChanges } from "../undo.js";
|
|
11
|
+
import { renderDiff } from "../diff.js";
|
|
12
|
+
// The non-interactive slash commands: each produces a block of text to commit as
|
|
13
|
+
// a note. Lifted from agent.ts's handleCommand, with console.log → a returned
|
|
14
|
+
// string so the Ink REPL can render them inside its tree. The stateful commands
|
|
15
|
+
// (/clear, /compact, /plan, /model, /resume, /skill) stay in the App — they
|
|
16
|
+
// mutate the conversation or pop an interactive menu.
|
|
17
|
+
// The in-session command reference shown by /help.
|
|
18
|
+
export const SESSION_HELP = `commands:
|
|
19
|
+
/help this text
|
|
20
|
+
/clear wipe the conversation (and the file read-state) — fresh start
|
|
21
|
+
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
22
|
+
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
23
|
+
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
24
|
+
/memory show the durable facts the agent remembers about this project
|
|
25
|
+
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
26
|
+
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
27
|
+
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
28
|
+
/bg list background tasks this session (run_bash_background) and their status
|
|
29
|
+
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
30
|
+
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
31
|
+
/undo revert the most recent file write (write_file / edit_file) this session
|
|
32
|
+
/diff show every file changed this session, as a diff from where it started
|
|
33
|
+
/resume list recent sessions in this project and continue one of them
|
|
34
|
+
/skills list the reusable skills available in this project
|
|
35
|
+
/skill <name> run a skill yourself (works even for user-only skills)
|
|
36
|
+
exit leave (Ctrl+C does the same)
|
|
37
|
+
|
|
38
|
+
keys (at the prompt):
|
|
39
|
+
Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
|
|
40
|
+
Ctrl+T reveal the folded tool-call trace for the last answer`;
|
|
41
|
+
// Run a non-interactive command. Returns the note text to display, or null if
|
|
42
|
+
// `line` isn't one of these (the App handles the rest).
|
|
43
|
+
export function runInfoCommand(line, ctx) {
|
|
44
|
+
switch (line) {
|
|
45
|
+
case "/help":
|
|
46
|
+
return chalk.dim(SESSION_HELP);
|
|
47
|
+
case "/cost":
|
|
48
|
+
return chalk.dim(ctx.costMeter.report()); // tokens, cache hit rate, estimated spend (local)
|
|
49
|
+
case "/stats":
|
|
50
|
+
return chalk.dim(statsReport()); // local counters, busiest first
|
|
51
|
+
case "/memory": {
|
|
52
|
+
const entries = readMemoryTyped();
|
|
53
|
+
return chalk.dim(entries.length
|
|
54
|
+
? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
|
|
55
|
+
: `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`);
|
|
56
|
+
}
|
|
57
|
+
case "/todos": {
|
|
58
|
+
const todos = getTodos();
|
|
59
|
+
return todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)");
|
|
60
|
+
}
|
|
61
|
+
case "/bg": {
|
|
62
|
+
const bg = listBackground();
|
|
63
|
+
if (!bg.length)
|
|
64
|
+
return chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)");
|
|
65
|
+
const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
|
|
66
|
+
return chalk.dim("background tasks this session:\n") + bg.map((t) => chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`)).join("\n");
|
|
67
|
+
}
|
|
68
|
+
case "/team": {
|
|
69
|
+
const team = listTeam();
|
|
70
|
+
if (!team.length)
|
|
71
|
+
return chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)");
|
|
72
|
+
const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
|
|
73
|
+
return (chalk.dim("agent team this session:\n") +
|
|
74
|
+
team.map((t) => chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${t.pending ? chalk.yellow(` · ${t.pending} unread`) : ""}`)).join("\n"));
|
|
75
|
+
}
|
|
76
|
+
case "/tasks": {
|
|
77
|
+
const summary = boardSummary();
|
|
78
|
+
return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
|
|
79
|
+
}
|
|
80
|
+
case "/skills": {
|
|
81
|
+
if (!ctx.skills.length)
|
|
82
|
+
return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
|
|
83
|
+
return (chalk.dim("skills in this project:\n") +
|
|
84
|
+
ctx.skills.map((s) => chalk.dim(` ${s.name} [${s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user")}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`)).join("\n"));
|
|
85
|
+
}
|
|
86
|
+
case "/undo": {
|
|
87
|
+
const undone = undoLast();
|
|
88
|
+
if (!undone)
|
|
89
|
+
return chalk.dim("(nothing to undo — no file writes recorded this session)");
|
|
90
|
+
return chalk.dim(`↩ ${undone.summary}`) + "\n" + renderDiff(undone.after ?? "", undone.before ?? "");
|
|
91
|
+
}
|
|
92
|
+
case "/diff": {
|
|
93
|
+
const changes = sessionChanges();
|
|
94
|
+
if (!changes.length)
|
|
95
|
+
return chalk.dim("(no file changes this session)");
|
|
96
|
+
const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
|
|
97
|
+
const head = chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`);
|
|
98
|
+
return head + "\n" + changes.map((c) => `\n${verb[c.status]} ${path.relative(process.cwd(), c.path) || c.path}\n${renderDiff(c.baseline, c.current)}`).join("");
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return null; // not an info command — the App handles it (or it's a normal task line)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { buildInkSession } from "./setup.js"; // the front-end-agnostic session bootstrap
|
|
4
|
+
import { makeRunTurn } from "./chat.js"; // drives one turn through the real loop
|
|
5
|
+
import { App } from "./app.js"; // the Ink REPL
|
|
6
|
+
// Launch the Ink REPL and resolve when it exits. buildInkSession does the same
|
|
7
|
+
// wiring agent.ts does (MCP, skills, judge, memory, cost, hooks, telemetry,
|
|
8
|
+
// optional resume); App renders it and runs the loop. Exported so BOTH the
|
|
9
|
+
// `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
|
|
10
|
+
// once it hands interactive sessions to Ink) can start it.
|
|
11
|
+
export async function launchInk(opts = {}) {
|
|
12
|
+
const session = await buildInkSession({ resume: opts.resume });
|
|
13
|
+
const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
|
|
14
|
+
await waitUntilExit(); // keep the process alive until the user quits
|
|
15
|
+
}
|
package/dist/ink/main.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { launchInk } from "./launch.js"; // the reusable launcher (shared with agent.ts)
|
|
2
|
+
// Direct entry for the Ink REPL: npm run ink. agent.ts launches the same thing
|
|
3
|
+
// for a normal `mini-agent` interactive session.
|
|
4
|
+
await launchInk({ resume: process.argv.includes("-r") || process.argv.includes("--resume") });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { CONFIG, requireApiKey } from "../config.js";
|
|
5
|
+
import { buildSystemMessage } from "../prompt.js";
|
|
6
|
+
import { estimateHistoryTokens } from "../context.js";
|
|
7
|
+
import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
|
|
8
|
+
import { gitBranch } from "../tui.js";
|
|
9
|
+
import { newSessionId, latestSession } from "../session.js";
|
|
10
|
+
import { initTelemetry, emit } from "../telemetry.js";
|
|
11
|
+
import { runHooks } from "../hooks.js";
|
|
12
|
+
import { connectMcpServers } from "../mcp.js";
|
|
13
|
+
import { Judge } from "../judge.js";
|
|
14
|
+
import { registerExternalTool } from "../tools.js";
|
|
15
|
+
import { rememberTool, readMemory } from "../memory.js";
|
|
16
|
+
import { loadSkills, buildSkillTool } from "../skills.js";
|
|
17
|
+
import { killAllBackground } from "../background.js";
|
|
18
|
+
import { banner } from "../ui.js";
|
|
19
|
+
// The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
|
|
20
|
+
// does before the prompt loop, but front-end-agnostic: it touches none of
|
|
21
|
+
// readline/editLine, just the shared modules (MCP, skills, judge, memory, cost,
|
|
22
|
+
// hooks, telemetry, session). It returns everything the Ink App needs to run a
|
|
23
|
+
// real session, so main.tsx stays a thin render() call. (agent.ts is left
|
|
24
|
+
// untouched so `npm start` keeps working until 2d flips the default.)
|
|
25
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
26
|
+
// Build the session. `resume` mirrors `-r/--resume`: fold the most recent
|
|
27
|
+
// conversation in this directory into the fresh constitution.
|
|
28
|
+
export async function buildInkSession(opts = {}) {
|
|
29
|
+
requireApiKey();
|
|
30
|
+
const client = new OpenAI({ baseURL: CONFIG.baseURL, apiKey: CONFIG.apiKey, maxRetries: 0 });
|
|
31
|
+
const systemMessage = buildSystemMessage(); // stable prefix = cache hits every request
|
|
32
|
+
const messages = [{ role: "system", content: systemMessage }];
|
|
33
|
+
const notices = [];
|
|
34
|
+
let sessionId = newSessionId();
|
|
35
|
+
const startedAt = Date.now();
|
|
36
|
+
if (opts.resume) {
|
|
37
|
+
const prev = latestSession();
|
|
38
|
+
if (prev) {
|
|
39
|
+
messages.push(...prev.messages);
|
|
40
|
+
sessionId = prev.id; // keep appending to the same file
|
|
41
|
+
notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
notices.push("(no previous session here — starting fresh)");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
initTelemetry(sessionId);
|
|
48
|
+
emit("agent_session_start", { mode: "repl" });
|
|
49
|
+
// MCP servers — must finish before the first model call so their tools appear
|
|
50
|
+
// in the manual. A server that fails to start is skipped, never fatal.
|
|
51
|
+
const disconnectMcp = await connectMcpServers();
|
|
52
|
+
process.on("exit", disconnectMcp);
|
|
53
|
+
process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
|
|
54
|
+
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
55
|
+
if (judge)
|
|
56
|
+
notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
|
|
57
|
+
// The remember tool flows through the same permission gate as any tool.
|
|
58
|
+
registerExternalTool(rememberTool);
|
|
59
|
+
const memCount = readMemory().length;
|
|
60
|
+
if (memCount)
|
|
61
|
+
notices.push(`(long-term memory: ${memCount} facts loaded)`);
|
|
62
|
+
// Skills: model-invocable ones get the single `skill` tool; user-only ones are
|
|
63
|
+
// reachable via /skill <name>.
|
|
64
|
+
const skills = loadSkills();
|
|
65
|
+
if (skills.some((s) => !s.disableModelInvocation))
|
|
66
|
+
registerExternalTool(buildSkillTool(skills));
|
|
67
|
+
if (skills.length)
|
|
68
|
+
notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
|
|
69
|
+
const costMeter = initCostMeter({
|
|
70
|
+
inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
|
|
71
|
+
cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
|
|
72
|
+
outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
|
|
73
|
+
});
|
|
74
|
+
// SessionStart hooks run once; their stdout becomes context on the first turn.
|
|
75
|
+
const sessionStart = await runHooks("SessionStart", {});
|
|
76
|
+
if (sessionStart.stdout)
|
|
77
|
+
messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
|
|
78
|
+
const dir = path.basename(process.cwd());
|
|
79
|
+
const branch = gitBranch();
|
|
80
|
+
const getStatus = () => ({
|
|
81
|
+
ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
|
|
82
|
+
cost: costMeter.cost(),
|
|
83
|
+
elapsedMs: Date.now() - startedAt,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
client,
|
|
87
|
+
messages,
|
|
88
|
+
systemMessage,
|
|
89
|
+
initialSessionId: sessionId,
|
|
90
|
+
startedAt,
|
|
91
|
+
costMeter,
|
|
92
|
+
skills,
|
|
93
|
+
judge,
|
|
94
|
+
model: CONFIG.model,
|
|
95
|
+
dir,
|
|
96
|
+
branch,
|
|
97
|
+
bannerText: banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host),
|
|
98
|
+
notices,
|
|
99
|
+
getStatus,
|
|
100
|
+
disconnectMcp,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/ink/sink.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Build a LoopOutput backed by Ink state. Pure glue: every method maps a loop
|
|
2
|
+
// event to a state change.
|
|
3
|
+
export function makeInkSink(api) {
|
|
4
|
+
return {
|
|
5
|
+
// A spinner becomes the single "status" line below the conversation. A fresh
|
|
6
|
+
// handle per model call (the loop makes one per round); `active` mirrors
|
|
7
|
+
// ora's isSpinning so the loop's `spinner?.spinning` checks behave the same.
|
|
8
|
+
spinner(text) {
|
|
9
|
+
api.setStatus(text);
|
|
10
|
+
let active = true;
|
|
11
|
+
return {
|
|
12
|
+
set: (t) => {
|
|
13
|
+
if (active)
|
|
14
|
+
api.setStatus(t);
|
|
15
|
+
},
|
|
16
|
+
stop: () => {
|
|
17
|
+
if (active) {
|
|
18
|
+
active = false;
|
|
19
|
+
api.setStatus(null);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
get spinning() {
|
|
23
|
+
return active;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
// The "💭 thought for Ns" indicator is just a committed line.
|
|
28
|
+
reasoning: (line) => api.pushItem({ kind: "note", text: line }),
|
|
29
|
+
// The streamed answer accumulates in the dynamic region as raw text (live,
|
|
30
|
+
// "it's typing"), then commits as ONE markdown-rendered item when it ends —
|
|
31
|
+
// partial markdown mid-stream renders badly, so we format only once it's whole.
|
|
32
|
+
answer() {
|
|
33
|
+
let buf = "";
|
|
34
|
+
api.setLive("");
|
|
35
|
+
return {
|
|
36
|
+
push: (tok) => {
|
|
37
|
+
buf += tok;
|
|
38
|
+
api.setLive(buf);
|
|
39
|
+
},
|
|
40
|
+
end: () => {
|
|
41
|
+
api.setLive(null); // clear the live preview first, then commit (no flicker/duplicate)
|
|
42
|
+
if (buf.trim())
|
|
43
|
+
api.pushItem({ kind: "answer", text: buf });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
// Generic narration — already chalk-formatted by the loop; just commit it.
|
|
48
|
+
note: (line) => api.pushItem({ kind: "note", text: line }),
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/loop.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import chalk from "chalk"; // terminal colors for status lines
|
|
2
|
-
import ora from "ora"; // the "thinking..." spinner while waiting for the first token
|
|
3
2
|
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
4
3
|
import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
5
4
|
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
@@ -13,7 +12,7 @@ import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / Pos
|
|
|
13
12
|
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
14
13
|
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
15
14
|
import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
|
|
16
|
-
import {
|
|
15
|
+
import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
|
|
17
16
|
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
18
17
|
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
19
18
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
@@ -36,6 +35,11 @@ export var TerminateReason;
|
|
|
36
35
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
37
36
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
38
37
|
})(TerminateReason || (TerminateReason = {}));
|
|
38
|
+
// Resolve the output sink for a loop: the caller's sink, or stdout (the old
|
|
39
|
+
// behaviour) when none was passed. Centralised so every site reads the same way.
|
|
40
|
+
function sink(opts) {
|
|
41
|
+
return opts.output ?? STDOUT_OUTPUT;
|
|
42
|
+
}
|
|
39
43
|
// Which model should a delegated sub-agent run on? The configured sub-agent
|
|
40
44
|
// model if set and non-blank, otherwise the same model as the orchestrator.
|
|
41
45
|
// Pulled out as a pure function so the tiering rule is testable on its own.
|
|
@@ -281,7 +285,7 @@ async function runSubAgent(description, opts) {
|
|
|
281
285
|
// Show the delegation, and the model when it differs from the orchestrator's —
|
|
282
286
|
// the tiering should be visible, not a silent surprise on the bill.
|
|
283
287
|
if (!opts.quiet)
|
|
284
|
-
|
|
288
|
+
sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
|
|
285
289
|
await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
|
|
286
290
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
287
291
|
try {
|
|
@@ -301,7 +305,7 @@ async function runSubAgent(description, opts) {
|
|
|
301
305
|
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
302
306
|
await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
|
|
303
307
|
if (!opts.quiet)
|
|
304
|
-
|
|
308
|
+
sink(opts).note(mark.subAgentDone); // close the bracket
|
|
305
309
|
}
|
|
306
310
|
}
|
|
307
311
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
@@ -335,7 +339,7 @@ opts) {
|
|
|
335
339
|
let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
|
|
336
340
|
const spinner = opts.quiet
|
|
337
341
|
? null // the eval harness wants silence
|
|
338
|
-
:
|
|
342
|
+
: sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent, opts.model)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
|
|
339
343
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
340
344
|
let stallWarned = false; // only warn once per quiet stretch
|
|
341
345
|
// The two-level watchdog, checked once per second:
|
|
@@ -345,8 +349,8 @@ opts) {
|
|
|
345
349
|
const watchdog = setInterval(() => {
|
|
346
350
|
// While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
|
|
347
351
|
// reads differently from "it's waiting on the first token".
|
|
348
|
-
if (spinner?.
|
|
349
|
-
spinner.
|
|
352
|
+
if (spinner?.spinning)
|
|
353
|
+
spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4)));
|
|
350
354
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
351
355
|
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
352
356
|
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
@@ -355,7 +359,7 @@ opts) {
|
|
|
355
359
|
else if (quietMs > STALL_WARN_MS && !stallWarned) {
|
|
356
360
|
stallWarned = true; // don't repeat the warning every second
|
|
357
361
|
emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
|
|
358
|
-
|
|
362
|
+
sink(opts).note(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
|
|
359
363
|
}
|
|
360
364
|
}, 1000);
|
|
361
365
|
try {
|
|
@@ -370,8 +374,7 @@ opts) {
|
|
|
370
374
|
// teammate's manual simply omits spawn_teammate and task.
|
|
371
375
|
{ model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
372
376
|
let content = ""; // accumulated answer text
|
|
373
|
-
let
|
|
374
|
-
let md = null; // renders the streamed answer as markdown, block by block
|
|
377
|
+
let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
|
|
375
378
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
376
379
|
// The model has finished thinking and is about to say/do something. Close out
|
|
377
380
|
// the spinner, stash the trace, and print a single one-line indicator with the
|
|
@@ -382,7 +385,7 @@ opts) {
|
|
|
382
385
|
return;
|
|
383
386
|
reasoningShown = true;
|
|
384
387
|
reasoningActive = false;
|
|
385
|
-
if (spinner?.
|
|
388
|
+
if (spinner?.spinning)
|
|
386
389
|
spinner.stop();
|
|
387
390
|
if (opts.quiet || opts.subAgent)
|
|
388
391
|
return; // no UI for silent / nested runs
|
|
@@ -390,7 +393,7 @@ opts) {
|
|
|
390
393
|
const secs = Math.round((Date.now() - startedAt) / 1000);
|
|
391
394
|
const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
|
|
392
395
|
const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
|
|
393
|
-
|
|
396
|
+
sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
|
|
394
397
|
};
|
|
395
398
|
for await (const chunk of stream) {
|
|
396
399
|
lastEvent = Date.now(); // feed the watchdog
|
|
@@ -414,27 +417,24 @@ opts) {
|
|
|
414
417
|
if (delta.content) {
|
|
415
418
|
flushReasoning(); // thinking is over — show the one-line indicator first
|
|
416
419
|
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
417
|
-
if (spinner?.
|
|
420
|
+
if (spinner?.spinning)
|
|
418
421
|
spinner.stop(); // first token: replace the spinner with real output
|
|
419
422
|
if (!opts.quiet && !opts.subAgent) {
|
|
420
423
|
// Only the top-level agent streams to the screen — a sub-agent's
|
|
421
|
-
// inner monologue would be mistaken for the answer.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
printedPrefix = true;
|
|
429
|
-
}
|
|
430
|
-
md.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
424
|
+
// inner monologue would be mistaken for the answer. The sink decides
|
|
425
|
+
// how to paint: the stdout sink buffers a markdown block (paragraph /
|
|
426
|
+
// heading / list / table) and flushes it whole — live, but no raw
|
|
427
|
+
// `##`/`|---|`; the Ink sink accumulates into React state.
|
|
428
|
+
if (!ans)
|
|
429
|
+
ans = sink(opts).answer(); // first token: start the answer renderer
|
|
430
|
+
ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
431
431
|
}
|
|
432
432
|
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
433
433
|
}
|
|
434
434
|
if (delta.tool_calls?.length)
|
|
435
435
|
flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
|
|
436
436
|
for (const tc of delta.tool_calls ?? []) {
|
|
437
|
-
if (spinner?.
|
|
437
|
+
if (spinner?.spinning)
|
|
438
438
|
spinner.stop(); // tool call starting — spinner served its purpose
|
|
439
439
|
const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
|
|
440
440
|
if (tc.id)
|
|
@@ -446,15 +446,13 @@ opts) {
|
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
448
|
flushReasoning(); // a reasoning-only turn (no content, no tools) still records + indicates
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
if (printedPrefix)
|
|
452
|
-
process.stdout.write("\n"); // end the streamed line cleanly
|
|
449
|
+
if (ans)
|
|
450
|
+
ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
|
|
453
451
|
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
454
452
|
}
|
|
455
453
|
finally {
|
|
456
454
|
clearInterval(watchdog); // always stop the timer
|
|
457
|
-
if (spinner?.
|
|
455
|
+
if (spinner?.spinning)
|
|
458
456
|
spinner.stop(); // and never leave a zombie spinner
|
|
459
457
|
}
|
|
460
458
|
}
|
|
@@ -487,13 +485,13 @@ async function runOneCall(call, opts) {
|
|
|
487
485
|
if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
|
|
488
486
|
const preview = previewChange(call.name, call.args);
|
|
489
487
|
if (preview)
|
|
490
|
-
|
|
488
|
+
sink(opts).note(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
|
|
491
489
|
}
|
|
492
490
|
let content; // what goes back to the model as the tool result
|
|
493
491
|
if (v.decision === "deny") {
|
|
494
492
|
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
495
493
|
if (!opts.quiet)
|
|
496
|
-
|
|
494
|
+
sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
|
|
497
495
|
content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
|
|
498
496
|
}
|
|
499
497
|
else if (v.decision === "ask") {
|
|
@@ -510,14 +508,14 @@ async function runOneCall(call, opts) {
|
|
|
510
508
|
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
511
509
|
autoAllowed = true;
|
|
512
510
|
if (!opts.quiet)
|
|
513
|
-
|
|
511
|
+
sink(opts).note(mark.judge); // visible — the user can see the judge worked
|
|
514
512
|
}
|
|
515
513
|
}
|
|
516
514
|
const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
|
|
517
515
|
if (!ok)
|
|
518
516
|
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
519
517
|
if (!ok && !opts.quiet)
|
|
520
|
-
|
|
518
|
+
sink(opts).note(mark.declined); // make the refusal visible
|
|
521
519
|
content = ok
|
|
522
520
|
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
523
521
|
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
@@ -528,7 +526,7 @@ async function runOneCall(call, opts) {
|
|
|
528
526
|
// todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
|
|
529
527
|
// the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
|
|
530
528
|
if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
|
|
531
|
-
|
|
529
|
+
sink(opts).note(renderTodos(getTodos()));
|
|
532
530
|
}
|
|
533
531
|
return { id: call.id, content }; // paired by id for the API
|
|
534
532
|
}
|
|
@@ -546,7 +544,7 @@ async function runWithHooks(call, opts) {
|
|
|
546
544
|
if (pre.block) {
|
|
547
545
|
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
548
546
|
if (!opts.quiet)
|
|
549
|
-
|
|
547
|
+
sink(opts).note(mark.hookBlock("PreToolUse")); // make it visible
|
|
550
548
|
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
551
549
|
}
|
|
552
550
|
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
@@ -556,7 +554,7 @@ async function runWithHooks(call, opts) {
|
|
|
556
554
|
try {
|
|
557
555
|
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
558
556
|
if (!opts.quiet)
|
|
559
|
-
|
|
557
|
+
sink(opts).note(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
|
|
560
558
|
call = { ...call, args: pre.rewrite };
|
|
561
559
|
}
|
|
562
560
|
catch {
|
|
@@ -744,7 +742,7 @@ async function runCreateTask(call, opts) {
|
|
|
744
742
|
return "[error] create_task needs a 'subject'.";
|
|
745
743
|
const task = createTask(subject, description, blockedBy);
|
|
746
744
|
if (!opts.quiet)
|
|
747
|
-
|
|
745
|
+
sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
|
|
748
746
|
return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
|
|
749
747
|
}
|
|
750
748
|
async function runListTasks(_call, opts) {
|
|
@@ -781,7 +779,7 @@ async function runCompleteTask(call, opts) {
|
|
|
781
779
|
if (!res.ok)
|
|
782
780
|
return `[error] ${res.error}`;
|
|
783
781
|
if (!opts.quiet)
|
|
784
|
-
|
|
782
|
+
sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
|
|
785
783
|
const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
|
|
786
784
|
return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
|
|
787
785
|
}
|
|
@@ -817,7 +815,7 @@ async function runSpawnTeammate(call, opts) {
|
|
|
817
815
|
const done = runTeammate(name, role, task, opts);
|
|
818
816
|
registerTeammate(name, role, done);
|
|
819
817
|
if (!opts.quiet)
|
|
820
|
-
|
|
818
|
+
sink(opts).note(mark.teammateStart(name, role));
|
|
821
819
|
return `Teammate "${name}" (${role}) spawned and is now working in parallel. It will message you (as "lead") with progress and its final result. Reply with send_message; keep coordinating the rest of the team. Do not wait idly — continue your own work.`;
|
|
822
820
|
}
|
|
823
821
|
// Run one teammate to completion: a fresh conversation seeded with its task, a
|
|
@@ -847,7 +845,7 @@ async function runTeammate(name, role, task, opts) {
|
|
|
847
845
|
subAgent: true,
|
|
848
846
|
teammate: { name },
|
|
849
847
|
maxRounds: TEAMMATE_MAX_ROUNDS,
|
|
850
|
-
confirm: teammateConfirm(name),
|
|
848
|
+
confirm: teammateConfirm(name, sink(opts)),
|
|
851
849
|
askUser: undefined, // teammates have no line to the human
|
|
852
850
|
});
|
|
853
851
|
// How it ended decides what the lead hears. A shutdown handshake gets a
|
|
@@ -861,7 +859,7 @@ async function runTeammate(name, role, task, opts) {
|
|
|
861
859
|
sendMessage(name, LEAD, summary, "result");
|
|
862
860
|
finishTeammate(name, result.reason === TerminateReason.Done);
|
|
863
861
|
if (!opts.quiet)
|
|
864
|
-
|
|
862
|
+
sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
|
|
865
863
|
}
|
|
866
864
|
catch (err) {
|
|
867
865
|
// A teammate must never take the whole process down. Report the failure to
|
|
@@ -885,11 +883,11 @@ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to ex
|
|
|
885
883
|
// DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
|
|
886
884
|
// called, so they always hold. Production Claude Code instead "bubbles" the
|
|
887
885
|
// request up to the lead and on to the human — omitted here, like the reference.
|
|
888
|
-
function teammateConfirm(name) {
|
|
886
|
+
function teammateConfirm(name, output) {
|
|
889
887
|
return async (_question, toolName) => {
|
|
890
888
|
const ok = toolName === "write_file" || toolName === "edit_file";
|
|
891
889
|
if (!ok)
|
|
892
|
-
|
|
890
|
+
output.note(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
|
|
893
891
|
return ok;
|
|
894
892
|
};
|
|
895
893
|
}
|
|
@@ -925,7 +923,7 @@ compaction) {
|
|
|
925
923
|
try {
|
|
926
924
|
if (!opts.subAgent)
|
|
927
925
|
await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
|
|
928
|
-
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
926
|
+
await compactHistory(messages, opts.client, opts.model, opts.signal, (s) => sink(opts).note(s)); // do the actual work — narration through the sink (so it doesn't corrupt an Ink live region)
|
|
929
927
|
compaction.count++; // one more successful compaction this query
|
|
930
928
|
compaction.failures = 0; // success resets the failure streak
|
|
931
929
|
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
@@ -956,7 +954,7 @@ function injectBackgroundNotifications(messages, opts) {
|
|
|
956
954
|
const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
|
|
957
955
|
messages.push({ role: "user", content: note }); // the model reacts to it on the next round
|
|
958
956
|
if (!opts.quiet)
|
|
959
|
-
|
|
957
|
+
sink(opts).note(mark.bgNote(count));
|
|
960
958
|
return true;
|
|
961
959
|
}
|
|
962
960
|
// Consume this agent's mailbox into its conversation, ROUTING protocol messages
|
|
@@ -1005,7 +1003,7 @@ function consumeInbox(messages, opts) {
|
|
|
1005
1003
|
const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
|
|
1006
1004
|
messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
|
|
1007
1005
|
if (!opts.quiet)
|
|
1008
|
-
|
|
1006
|
+
sink(opts).note(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
|
|
1009
1007
|
return true;
|
|
1010
1008
|
}
|
|
1011
1009
|
// At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
|
|
@@ -1041,7 +1039,7 @@ async function continueForTeam(messages, opts) {
|
|
|
1041
1039
|
// reference's identity-reinjection, folded into the work hand-off).
|
|
1042
1040
|
messages.push({ role: "user", content: `You are ${me}. You claimed task ${claimed.id} from the board: "${claimed.subject}".\n${claimed.description}\nWhen it is done and verified, call complete_task("${claimed.id}") — then you'll be offered the next ready task.` });
|
|
1043
1041
|
if (!opts.quiet)
|
|
1044
|
-
|
|
1042
|
+
sink(opts).note(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
|
|
1045
1043
|
setTeammateState(me, "active");
|
|
1046
1044
|
return true;
|
|
1047
1045
|
}
|
|
@@ -1069,7 +1067,7 @@ async function shutdownTeam(opts) {
|
|
|
1069
1067
|
if (!running.length)
|
|
1070
1068
|
return;
|
|
1071
1069
|
if (!opts.quiet)
|
|
1072
|
-
|
|
1070
|
+
sink(opts).note(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
|
|
1073
1071
|
for (const name of running) {
|
|
1074
1072
|
const id = createRequest("shutdown", LEAD, name, "task complete");
|
|
1075
1073
|
markShutdown(name, id);
|
|
@@ -1152,7 +1150,7 @@ opts) {
|
|
|
1152
1150
|
if (attempts.total >= MAX_RETRIES)
|
|
1153
1151
|
return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
|
|
1154
1152
|
const wait = backoffMs(attempts.consecutive); // how long to back off this time
|
|
1155
|
-
|
|
1153
|
+
sink(opts).note(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
|
|
1156
1154
|
await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
|
|
1157
1155
|
continue; // retry the same round
|
|
1158
1156
|
}
|
|
@@ -1187,7 +1185,7 @@ opts) {
|
|
|
1187
1185
|
if (stop.block) {
|
|
1188
1186
|
emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
|
|
1189
1187
|
if (!opts.quiet)
|
|
1190
|
-
|
|
1188
|
+
sink(opts).note(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
1191
1189
|
messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
|
|
1192
1190
|
break; // exit the inner while → advance the round counter
|
|
1193
1191
|
}
|
|
@@ -1235,7 +1233,7 @@ opts) {
|
|
|
1235
1233
|
// Fold this round's tool calls into ONE tally line instead of one line per
|
|
1236
1234
|
// call (the per-call announcements were recorded for Ctrl+T in runOneCall).
|
|
1237
1235
|
if (!opts.quiet && out.toolCalls.length) {
|
|
1238
|
-
|
|
1236
|
+
sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
|
|
1239
1237
|
}
|
|
1240
1238
|
// Background tasks (Day 37): if a job the model started has finished, slip
|
|
1241
1239
|
// its <task_notification> in here, right after this round's tool results —
|
|
@@ -1255,7 +1253,7 @@ opts) {
|
|
|
1255
1253
|
if (nag) {
|
|
1256
1254
|
messages.push({ role: "user", content: nag });
|
|
1257
1255
|
if (!opts.quiet)
|
|
1258
|
-
|
|
1256
|
+
sink(opts).note(chalk.dim(" ⎿ plan reminder injected"));
|
|
1259
1257
|
}
|
|
1260
1258
|
}
|
|
1261
1259
|
break; // round complete, move to the next one
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import ora from "ora"; // the "thinking…" spinner the default sink drives
|
|
2
|
+
import { MarkdownStream } from "./markdown.js"; // the default sink renders the streamed answer as terminal markdown
|
|
3
|
+
import { mark } from "./ui.js"; // the ⏺ answer marker, kept consistent with the rest of the UI
|
|
4
|
+
// ---- the default sink: stdout, identical to the pre-sink behaviour -----------
|
|
5
|
+
class StdoutSpinner {
|
|
6
|
+
s;
|
|
7
|
+
constructor(text) {
|
|
8
|
+
// discardStdin:false — ora would otherwise swallow Ctrl+C while spinning.
|
|
9
|
+
this.s = ora({ text, discardStdin: false }).start();
|
|
10
|
+
}
|
|
11
|
+
set(text) {
|
|
12
|
+
if (this.s.isSpinning)
|
|
13
|
+
this.s.text = text;
|
|
14
|
+
}
|
|
15
|
+
stop() {
|
|
16
|
+
if (this.s.isSpinning)
|
|
17
|
+
this.s.stop();
|
|
18
|
+
}
|
|
19
|
+
get spinning() {
|
|
20
|
+
return this.s.isSpinning;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// The sink the readline REPL (and every non-Ink caller) gets when it passes no
|
|
24
|
+
// `output`. Each method is the exact line the loop used to run inline.
|
|
25
|
+
export const STDOUT_OUTPUT = {
|
|
26
|
+
spinner: (text) => new StdoutSpinner(text),
|
|
27
|
+
reasoning: (line) => process.stdout.write(line + "\n"),
|
|
28
|
+
answer: () => {
|
|
29
|
+
// Lazily build the MarkdownStream on the first token (so a tool-only or
|
|
30
|
+
// empty turn prints nothing), then end with a single trailing newline —
|
|
31
|
+
// exactly the old `if (printedPrefix) process.stdout.write("\n")`.
|
|
32
|
+
let md = null;
|
|
33
|
+
let printed = false;
|
|
34
|
+
return {
|
|
35
|
+
push(token) {
|
|
36
|
+
if (!md)
|
|
37
|
+
md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
|
|
38
|
+
printed = true;
|
|
39
|
+
md.push(token);
|
|
40
|
+
},
|
|
41
|
+
end() {
|
|
42
|
+
if (md)
|
|
43
|
+
md.end();
|
|
44
|
+
if (printed)
|
|
45
|
+
process.stdout.write("\n");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
note: (line) => console.log(line),
|
|
50
|
+
};
|
package/dist/tui.js
CHANGED
|
@@ -107,6 +107,18 @@ export function revealToolCalls() {
|
|
|
107
107
|
process.stdout.write(toolCallLog.join("\n") + "\n");
|
|
108
108
|
return true;
|
|
109
109
|
}
|
|
110
|
+
// Ink variants of the two reveals: RETURN the formatted block (or null) instead
|
|
111
|
+
// of writing to stdout, so the Ink REPL can commit it as a note in its tree.
|
|
112
|
+
export function getReasoning() {
|
|
113
|
+
if (!reasoningTrace.length)
|
|
114
|
+
return null;
|
|
115
|
+
return chalk.dim("💭 model's thinking:\n") + chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " "));
|
|
116
|
+
}
|
|
117
|
+
export function getToolCalls() {
|
|
118
|
+
if (!toolCallLog.length)
|
|
119
|
+
return null;
|
|
120
|
+
return chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`) + toolCallLog.join("\n");
|
|
121
|
+
}
|
|
110
122
|
// ---- cleanup -----------------------------------------------------------------
|
|
111
123
|
// Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
|
|
112
124
|
// of the screen), but keep the hook so callers don't have to change if that
|
package/dist/ui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk"; // the one place color lives
|
|
2
|
+
import { displayWidth } from "./editor.js"; // CJK-/ANSI-aware width, to pad the sent-message bar to the full row
|
|
2
3
|
// The app's visual theme, in one place. Before this, emoji and colors were
|
|
3
4
|
// sprinkled across agent.ts and loop.ts — no coherent look, and impossible to
|
|
4
5
|
// restyle without hunting. This module owns "what the tool looks like": the
|
|
@@ -124,11 +125,18 @@ export function inputRule() {
|
|
|
124
125
|
export function framedPrompt(planMode) {
|
|
125
126
|
return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
|
|
126
127
|
}
|
|
127
|
-
// How a SUBMITTED message is echoed into the scrollback once you hit Enter:
|
|
128
|
-
//
|
|
129
|
-
// sent
|
|
128
|
+
// How a SUBMITTED message is echoed into the scrollback once you hit Enter:
|
|
129
|
+
// a "> …" line on a full-width highlight BAR (like Claude Code), so each turn
|
|
130
|
+
// reads as a distinct sent message scrolling up — not just faint text. The line
|
|
131
|
+
// is padded to the terminal width so the background spans the whole row; if the
|
|
132
|
+
// message is longer than the row it just wraps (the tail line won't be padded,
|
|
133
|
+
// a minor cosmetic). Colors fall back gracefully when the terminal lacks 256.
|
|
130
134
|
export function sentMessage(text) {
|
|
131
|
-
|
|
135
|
+
const cols = Math.max(20, process.stdout.columns || 80);
|
|
136
|
+
const content = "> " + text;
|
|
137
|
+
const w = displayWidth(content);
|
|
138
|
+
const padded = w < cols ? content + " ".repeat(cols - w) : content; // fill the row when it fits
|
|
139
|
+
return chalk.bgAnsi256(237).ansi256(252)(padded); // light-grey text on a subtle dark-grey bar
|
|
132
140
|
}
|
|
133
141
|
// ---- the selection menu -----------------------------------------------------
|
|
134
142
|
// Render a numbered list of options with one highlighted. The interactive part
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "tsx --env-file=.env src/agent.ts",
|
|
15
|
+
"ink": "tsx --env-file=.env src/ink/main.tsx",
|
|
15
16
|
"typecheck": "tsc --noEmit",
|
|
16
17
|
"eval": "tsx --env-file=.env eval/run.ts",
|
|
17
18
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -25,14 +26,17 @@
|
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"chalk": "^5.6.2",
|
|
27
28
|
"cli-table3": "^0.6.5",
|
|
29
|
+
"ink": "^5.2.1",
|
|
28
30
|
"marked": "^15.0.12",
|
|
29
31
|
"marked-terminal": "^7.3.0",
|
|
30
32
|
"openai": "^6.42.0",
|
|
31
33
|
"ora": "^9.4.0",
|
|
34
|
+
"react": "^18.3.1",
|
|
32
35
|
"wrap-ansi": "^10.0.0"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
38
|
"@types/node": "^25.9.2",
|
|
39
|
+
"@types/react": "^18.3.31",
|
|
36
40
|
"tsx": "^4.22.4",
|
|
37
41
|
"typescript": "^6.0.3"
|
|
38
42
|
}
|