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 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
- console.log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
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
- console.log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
107
+ log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
107
108
  }
@@ -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
+ }
@@ -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
+ }