agent-from-zero 0.35.1 → 0.35.2

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/ink/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { stripVTControlCharacters } from "node:util"; // plain text of a styled status line, to find the verb
2
3
  import { statusCommand } from "../status.js";
3
4
  import React, { useState, useEffect, useRef } from "react";
4
5
  import { Box, Text, Static, useInput, useApp } from "ink";
@@ -10,7 +11,7 @@ import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist +
10
11
  import { effortMenu, setEffort } from "../effort.js";
11
12
  import { useSlashCompletion } from "./completion.js";
12
13
  import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
13
- import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
14
+ import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
14
15
  import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
15
16
  import { clearUndo } from "../undo.js";
16
17
  import { clearTodos } from "../todos.js";
@@ -23,7 +24,7 @@ import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.
23
24
  import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
24
25
  import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
25
26
  import { isPlanMode, setPlanMode } from "../permissions.js";
26
- import { findSkill, skillInstructions } from "../skills.js";
27
+ import { findSkill, userSkillMessages } from "../skills.js";
27
28
  import { extractMemories } from "../memory.js";
28
29
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
29
30
  import { runHooks } from "../hooks.js";
@@ -37,10 +38,16 @@ import { detailPage, summarizeActivity } from "./activity.js";
37
38
  import { useTerminalSize } from "./viewport.js";
38
39
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
39
40
  import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
40
- import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
41
+ import { listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpProblems, mcpServerDetails, reloadMcpServers, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
41
42
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
42
43
  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.`;
43
- const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
44
+ // No ✳ (U+2733): it has an emoji presentation, and terminals that honour it draw
45
+ // it two cells wide — the text after the glyph then jumps on every other frame.
46
+ const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
47
+ const SPINNER_COLOR = "#D77757"; // Claude Code's orange
48
+ const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
49
+ const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
50
+ const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
44
51
  // Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
45
52
  // Ink redraws the whole dynamic region (everything below <Static>) on every
46
53
  // token, and when that region grows TALLER than the terminal, Ink can no longer
@@ -56,13 +63,49 @@ function liveTail(s, rows, columns, queued) {
56
63
  const page = detailPage(s, 0, maxLines, Math.max(1, columns - 2));
57
64
  return (page.pages > 1 ? "… " : "") + page.text;
58
65
  }
59
- function Spinner() {
60
- const [f, setF] = useState(0);
66
+ // A shared animation clock. One interval drives both the glyph and the shimmer,
67
+ // so the live region repaints once per tick instead of once per effect.
68
+ function useTick() {
69
+ const [tick, setTick] = useState(0);
61
70
  useEffect(() => {
62
- const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 120);
71
+ const id = setInterval(() => setTick((t) => t + 1), SHIMMER_TICK_MS);
63
72
  return () => clearInterval(id);
64
73
  }, []);
65
- return _jsx(Text, { color: "#D77757", children: SPINNER_FRAMES[f] });
74
+ return tick;
75
+ }
76
+ // The glyph lives in a fixed two-cell box: whatever the frame, the text after it
77
+ // starts in the same column.
78
+ function Glyph({ tick }) {
79
+ return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: SPINNER_COLOR, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
80
+ }
81
+ function Spinner() {
82
+ return _jsx(Glyph, { tick: useTick() });
83
+ }
84
+ // Blend two #rrggbb colours; t = 0 → a, 1 → b.
85
+ function mix(a, b, t) {
86
+ const ch = (hex, i) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
87
+ return "#" + [0, 1, 2].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, "0")).join("");
88
+ }
89
+ // The light sweep: each character is coloured by its distance from a moving
90
+ // highlight. Only COLOUR changes between frames — never a character, never a
91
+ // width — so the line shimmers without a single cell moving.
92
+ export function shimmer(text, pos) {
93
+ return [...text].map((c, i) => {
94
+ const glow = Math.max(0, 1 - Math.abs(i - pos) / SHIMMER_RADIUS);
95
+ return chalk.hex(mix(SPINNER_COLOR, SHIMMER_PEAK, glow))(c);
96
+ }).join("");
97
+ }
98
+ // "✶ Thinking… (3s · ↓ 281 tokens · thought for 1s)": the verb (everything up to
99
+ // and including the first "…") shimmers; the stats after it stay put. A pause
100
+ // between sweeps (the highlight travels past the end) keeps it calm.
101
+ function SpinnerLine({ text }) {
102
+ const tick = useTick();
103
+ const plain = stripVTControlCharacters(text);
104
+ const cut = plain.indexOf("…");
105
+ const verb = cut >= 0 && text.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
106
+ const period = verb.length + SHIMMER_RADIUS * 2 + 8;
107
+ const pos = (tick % period) - SHIMMER_RADIUS;
108
+ return (_jsxs(Box, { children: [_jsx(Glyph, { tick: tick }), _jsxs(Text, { color: SPINNER_COLOR, wrap: "truncate-end", children: [verb ? shimmer(verb, pos) : "", text.slice(verb.length)] })] }));
66
109
  }
67
110
  // One committed line of conversation. `user` is a highlight bar; `answer` is a
68
111
  // finished reply rendered as markdown; `note` is already-formatted narration.
@@ -88,6 +131,24 @@ function StatusBar({ model, dir, branch, status }) {
88
131
  ];
89
132
  return (_jsx(Text, { wrap: "truncate-end", children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
90
133
  }
134
+ // Bottom-right MCP health, like Claude Code's footer notice: red when a
135
+ // server failed, yellow when one needs sign-in, dim while servers are still
136
+ // connecting in the background; nothing at all when every server is fine.
137
+ // Re-read on every render (the app ticks once a second), so it follows
138
+ // background connects, /mcp actions and settings reloads with no wiring.
139
+ function McpBadge() {
140
+ const { failed, needsAuth, pending } = mcpProblems();
141
+ const parts = [];
142
+ if (failed.length)
143
+ parts.push(_jsxs(Text, { color: "red", children: ["\u2717 MCP failed: ", failed.join(", ")] }, "f"));
144
+ if (needsAuth.length)
145
+ parts.push(_jsxs(Text, { color: "yellow", children: ["\u25B3 ", needsAuth.join(", "), " needs auth"] }, "a"));
146
+ if (pending)
147
+ parts.push(_jsxs(Text, { dimColor: true, children: ["\u25CC connecting ", pending, " MCP server", pending === 1 ? "" : "s", "\u2026"] }, "p"));
148
+ if (!parts.length)
149
+ return null;
150
+ return (_jsx(Box, { flexShrink: 1, marginLeft: 2, children: _jsxs(Text, { wrap: "truncate-end", children: [parts.map((p, i) => _jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i)), failed.length || needsAuth.length ? _jsx(Text, { dimColor: true, children: " · /mcp" }) : null] }) }));
151
+ }
91
152
  // One human note per non-Done ending, so the user always learns why a turn stopped.
92
153
  const EXIT_NOTES = {
93
154
  [TerminateReason.ReviewLimit]: "Repeated automatic denials — stopped. Tell the agent how to proceed; prohibited actions were not executed.",
@@ -102,7 +163,7 @@ const EXIT_NOTES = {
102
163
  };
103
164
  export function App({ session, runTurn, clipboard }) {
104
165
  const { rows, columns } = useTerminalSize();
105
- const { client, messages, systemMessage, costMeter, skills, judge, autoMode, model, dir, branch, getStatus } = session;
166
+ const { client, messages, systemMessage, costMeter, skills: liveSkills, judge, autoMode, model, dir, branch, getStatus } = session;
106
167
  // Seed the scrollback with the welcome banner + the dim startup notices.
107
168
  const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
108
169
  const [status, setStatus] = useState(null); // the live spinner line
@@ -150,7 +211,7 @@ export function App({ session, runTurn, clipboard }) {
150
211
  void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
151
212
  }, [wantsModels, client]);
152
213
  const completion = useSlashCompletion(input, completing, {
153
- model: CONFIG.model, skills, models: completionModels, servers: listMcpServers().map((s) => s.name),
214
+ model: CONFIG.model, skills: liveSkills(), models: completionModels, servers: listMcpServers().map((s) => s.name),
154
215
  }, rows - 10);
155
216
  useEffect(() => {
156
217
  const id = setInterval(() => setTick((t) => t + 1), 1000);
@@ -162,10 +223,10 @@ export function App({ session, runTurn, clipboard }) {
162
223
  setTerminalTitle(session.initialTitle);
163
224
  }, [session.initialTitle]);
164
225
  // Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
165
- const openSelect = (header, options, onChoose) => {
226
+ const openSelect = (header, options, onChoose, body) => {
166
227
  setDetails(null);
167
228
  setMenuSel(0);
168
- setPending({ kind: "select", header, options, onChoose });
229
+ setPending({ kind: "select", header, body, options, onChoose });
169
230
  };
170
231
  // Run one conversation turn through the real loop. `display` is what scrolls up
171
232
  // as the prompt (a user highlight bar, or a note for /skill); the loop appends
@@ -336,12 +397,24 @@ export function App({ session, runTurn, clipboard }) {
336
397
  // /mcp — list configured servers + status, then pick one to manage it.
337
398
  const handleMcpCommand = async (arg) => {
338
399
  const parts = arg.trim().split(/\s+/).filter(Boolean);
400
+ // /mcp reload — re-read settings.json now (the file watcher normally does this on save).
401
+ if (parts[0] === "reload") {
402
+ setBusy(true);
403
+ try {
404
+ const changes = await reloadMcpServers();
405
+ note(chalk.dim(changes.length ? `(mcp: ${changes.join(", ")})` : "(mcp: no changes in settings.json)"));
406
+ }
407
+ finally {
408
+ setBusy(false);
409
+ }
410
+ return;
411
+ }
339
412
  // Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
340
- if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
413
+ if (parts.length >= 2 && MCP_SUBCOMMANDS[parts[0]]) {
341
414
  const name = parts.slice(1).join(" ");
342
415
  setBusy(true);
343
416
  try {
344
- const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
417
+ const action = MCP_SUBCOMMANDS[parts[0]];
345
418
  const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
346
419
  note(chalk.dim(msg));
347
420
  }
@@ -364,7 +437,7 @@ export function App({ session, runTurn, clipboard }) {
364
437
  return note(chalk.dim("(cancelled)"));
365
438
  const server = servers[i];
366
439
  const actions = mcpActionsFor(server);
367
- openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
440
+ openSelect(`${server.name} MCP Server`, actions.map((a) => a.label), (j) => {
368
441
  if (j < 0)
369
442
  return note(chalk.dim("(cancelled)"));
370
443
  setBusy(true);
@@ -372,7 +445,7 @@ export function App({ session, runTurn, clipboard }) {
372
445
  .then((msg) => note(chalk.dim(msg)))
373
446
  .catch((err) => note(chalk.yellow(err.message)))
374
447
  .finally(() => setBusy(false));
375
- });
448
+ }, mcpServerDetails(server)); // Claude Code's server screen: status, auth, protocol, URL, config, capabilities, tools
376
449
  });
377
450
  };
378
451
  const handleCommand = async (line) => {
@@ -418,12 +491,12 @@ export function App({ session, runTurn, clipboard }) {
418
491
  setAutoEnabled(autoMode.enabled);
419
492
  return true;
420
493
  }
421
- const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
494
+ const info = runInfoCommand(line, { skills: liveSkills(), costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
422
495
  if (info !== null) {
423
496
  note(info);
424
497
  return true;
425
498
  }
426
- // /mcp [reconnect|auth|enable|disable <name>]
499
+ // /mcp [reload | reconnect|auth|enable|disable <name>]
427
500
  if (line === "/mcp" || line.startsWith("/mcp ")) {
428
501
  await handleMcpCommand(line.slice("/mcp".length).trim());
429
502
  return true;
@@ -448,7 +521,7 @@ export function App({ session, runTurn, clipboard }) {
448
521
  apply(name);
449
522
  return true;
450
523
  }
451
- note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
524
+ note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${contextWindowFor()} tokens (auto-compact at ~${compactThreshold()})`));
452
525
  let models = [];
453
526
  try {
454
527
  const page = await client.models.list();
@@ -468,9 +541,12 @@ export function App({ session, runTurn, clipboard }) {
468
541
  });
469
542
  return true;
470
543
  }
471
- // /skill <name> — run a skill on the user's behalf (works for user-only skills)
544
+ // /skill <name> [args] — run a skill on the user's behalf (works for user-only
545
+ // skills). Claude Code's pair: a <command-name> marker, then the body.
472
546
  if (line === "/skill" || line.startsWith("/skill ")) {
473
- const name = line.slice("/skill".length).trim();
547
+ const skills = liveSkills(); // live — a SKILL.md added mid-session is found
548
+ const [name = "", ...rest] = line.slice("/skill".length).trim().split(/\s+/);
549
+ const args = rest.join(" ");
474
550
  if (!name) {
475
551
  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)"));
476
552
  return true;
@@ -480,8 +556,8 @@ export function App({ session, runTurn, clipboard }) {
480
556
  note(chalk.yellow(`(no skill named "${name}")`));
481
557
  return true;
482
558
  }
483
- autoMode.recordRequest(`Run the "${s.name}" skill.`);
484
- runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
559
+ autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
560
+ runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
485
561
  return true;
486
562
  }
487
563
  switch (line) {
@@ -562,6 +638,8 @@ export function App({ session, runTurn, clipboard }) {
562
638
  return true;
563
639
  }
564
640
  default:
641
+ if (line.startsWith("/") && findSkill(liveSkills(), line.slice(1).split(/\s+/)[0]))
642
+ return handleCommand(`/skill ${line.slice(1)}`); // /<skill-name> [args], like Claude Code
565
643
  if (line.startsWith("/")) {
566
644
  note(chalk.dim(`unknown command: ${line} — try /help`));
567
645
  return true; // consumed — don't send typos to the model
@@ -854,7 +932,7 @@ export function App({ session, runTurn, clipboard }) {
854
932
  const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
855
933
  const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
856
934
  const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
857
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { color: "#D77757", wrap: "truncate-end", children: [" ", status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…")] })] })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), 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) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
935
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _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) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
858
936
  const prompt = "❯ ";
859
937
  const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
860
938
  const cols = columns;
@@ -923,5 +1001,5 @@ export function App({ session, runTurn, clipboard }) {
923
1001
  }
924
1002
  return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
925
1003
  });
926
- })() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
1004
+ })() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
927
1005
  }
package/dist/ink/chat.js CHANGED
@@ -8,11 +8,13 @@ import { userContent } from "../images.js";
8
8
  // rest. All screen output flows through hooks.output (the Ink sink).
9
9
  export function makeRunTurn(client, messages) {
10
10
  let reviewBudget = new AutoReviewBudget();
11
+ // input: one user message, or several in order (a /skill run sends the
12
+ // <command-name> marker, then the skill body); images ride on the first.
11
13
  return async (input, hooks) => {
12
14
  if (input !== null)
13
15
  reviewBudget = new AutoReviewBudget();
14
16
  if (input !== null)
15
- messages.push({ role: "user", content: userContent(input, hooks.images ?? []) });
17
+ [input].flat().forEach((text, i) => messages.push({ role: "user", content: i === 0 ? userContent(text, hooks.images ?? []) : text }));
16
18
  return runLoop(messages, {
17
19
  client,
18
20
  model: CONFIG.model,
@@ -25,7 +25,8 @@ export const SESSION_HELP = `commands:
25
25
  /memory show the durable facts the agent remembers about this project
26
26
  /status session usage and vendor account balance/cost
27
27
  /cost tokens, cache hit rate and estimated spend this session (local)
28
- /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
28
+ /mcp list configured MCP servers + status; select one to view tools / authenticate / clear auth / reconnect / disable
29
+ /mcp reload re-read mcpServers from settings.json (saves are also picked up automatically)
29
30
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
30
31
  /effort [level] list/select the current model’s supported reasoning effort
31
32
  /auto toggle auto mode — risky or uncertain actions still require approval
@@ -39,7 +40,7 @@ export const SESSION_HELP = `commands:
39
40
  /diff show every file changed this session, as a diff from where it started
40
41
  /resume list recent sessions in this project and continue one of them
41
42
  /skills list the reusable skills available in this project
42
- /skill <name> run a skill yourself (works even for user-only skills)
43
+ /skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
43
44
  exit leave (Ctrl+C does the same)
44
45
 
45
46
  keys (during or after a turn):
@@ -57,6 +58,8 @@ keys (during or after a turn):
57
58
  // Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
58
59
  export function mcpStatusText(info) {
59
60
  switch (info.status) {
61
+ case "pending":
62
+ return chalk.dim("connecting…");
60
63
  case "connected":
61
64
  return chalk.green("connected");
62
65
  case "needs-auth":
package/dist/ink/setup.js CHANGED
@@ -3,18 +3,18 @@ import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
4
  import { CONFIG, requireApiKey } from "../config.js";
5
5
  import { buildSystemMessage, readProjectInstructions } from "../prompt.js";
6
- import { estimateHistoryTokens } from "../context.js";
6
+ import { contextPercent } from "../context.js";
7
7
  import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
8
8
  import { gitBranch } from "../tui.js";
9
9
  import { newSessionId, latestSession } from "../session.js";
10
10
  import { initTelemetry, emit } from "../telemetry.js";
11
11
  import { runHooks } from "../hooks.js";
12
- import { connectMcpServers } from "../mcp.js";
12
+ import { connectMcpServers, watchMcpConfig } from "../mcp.js";
13
13
  import { Judge } from "../judge.js";
14
14
  import { AutoMode } from "../auto.js";
15
15
  import { registerExternalTool } from "../tools.js";
16
16
  import { rememberTool, readMemory } from "../memory.js";
17
- import { loadSkills, buildSkillTool } from "../skills.js";
17
+ import { currentSkills } from "../skills.js";
18
18
  import { killAllBackground } from "../background.js";
19
19
  import { killAllSubAgents } from "../loop.js";
20
20
  import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
@@ -52,10 +52,12 @@ export async function buildInkSession(opts = {}) {
52
52
  }
53
53
  initTelemetry(sessionId);
54
54
  emit("agent_session_start", { mode: "repl" });
55
- // MCP servers — must finish before the first model call so their tools appear
56
- // in the manual. A server that fails to start is skipped, never fatal.
57
- const disconnectMcp = await connectMcpServers();
55
+ // MCP servers connect in the BACKGROUND, like Claude Code: the prompt is
56
+ // ready at once, each server's tools join the next model call the moment it
57
+ // connects, and a failure is flagged in the status bar (red, bottom right).
58
+ const disconnectMcp = await connectMcpServers({ background: true });
58
59
  process.on("exit", disconnectMcp);
60
+ process.on("exit", watchMcpConfig()); // hot reload: a settings.json save adds/removes/reconnects servers, no restart
59
61
  process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
60
62
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
61
63
  process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
@@ -75,13 +77,11 @@ export async function buildInkSession(opts = {}) {
75
77
  const memCount = readMemory().length;
76
78
  if (memCount)
77
79
  notices.push(`(long-term memory: ${memCount} facts loaded)`);
78
- // Skills: model-invocable ones get the single `skill` tool; user-only ones are
79
- // reachable via /skill <name>.
80
- const skills = loadSkills();
81
- if (skills.some((s) => !s.disableModelInvocation))
82
- registerExternalTool(buildSkillTool(skills));
83
- if (skills.length)
84
- notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
80
+ // Skills are re-read before every model call (no restart needed); the loop
81
+ // registers the `skill` tool and sends the listing. Just announce them here.
82
+ const startupSkills = currentSkills();
83
+ if (startupSkills.length)
84
+ notices.push(`(skills: ${startupSkills.length} loaded — ${startupSkills.map((s) => s.name).join(", ")})`);
85
85
  const costMeter = initCostMeter({
86
86
  inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
87
87
  cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
@@ -94,7 +94,7 @@ export async function buildInkSession(opts = {}) {
94
94
  const dir = path.basename(process.cwd());
95
95
  const branch = gitBranch();
96
96
  const getStatus = () => ({
97
- ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
97
+ ctxPct: contextPercent(messages), // % of the way to auto-compaction
98
98
  cost: costMeter.cost(),
99
99
  elapsedMs: Date.now() - startedAt,
100
100
  });
@@ -106,7 +106,7 @@ export async function buildInkSession(opts = {}) {
106
106
  initialTitle,
107
107
  startedAt,
108
108
  costMeter,
109
- skills,
109
+ skills: () => currentSkills(),
110
110
  judge,
111
111
  autoMode,
112
112
  model: CONFIG.model,
package/dist/loop.js CHANGED
@@ -5,7 +5,7 @@ import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadO
5
5
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
6
6
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
7
7
  import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
8
- import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
8
+ import { contextTokens, compactHistory, compactThreshold, recordContextUsage, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
9
9
  import { SUB_AGENT_PROMPT, TEAMMATE_PROMPT } from "./prompt.js"; // the sub-agent + teammate constitutions
10
10
  import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam, listTeammateViews } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
11
11
  import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
@@ -18,6 +18,7 @@ import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized termin
18
18
  import { recordToolCall, recordToolResult, recordToolDetail, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
19
19
  import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
20
20
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
21
+ import { currentSkills, skillBodyMessages, skillListingReminder, syncSkillTool } from "./skills.js"; // skill listing reminders + launched skill bodies
21
22
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
22
23
  import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
23
24
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
@@ -391,6 +392,7 @@ opts) {
391
392
  const idleAbort = new AbortController(); // the watchdog's own kill switch
392
393
  const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
393
394
  const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
395
+ const sentCount = messages.length; // how much history this request carries — the usage anchor (context.ts)
394
396
  const startedAt = Date.now(); // for the spinner's live elapsed counter
395
397
  let reportedTokens;
396
398
  if (opts.progress) {
@@ -401,7 +403,9 @@ opts) {
401
403
  // Reasoning is COLLAPSED by default: while the model thinks, the spinner keeps
402
404
  // spinning (with a 💭 hint) instead of dumping the trace; we stash it for Ctrl+R.
403
405
  let reasoningBuf = ""; // the accumulated thinking, hidden behind the spinner
404
- let reasoningActive = false; // true while reasoning tokens are arriving → spinner shows 💭
406
+ let reasoningActive = false; // true while reasoning tokens are arriving → spinner says "thinking"
407
+ let reasoningStartedAt = 0; // when the first reasoning token arrived (0 = no reasoning this call)
408
+ let reasoningEndedAt = 0; // when it stopped → "thought for Ns"
405
409
  let reasoningShown = false; // has this trace been stored for Ctrl+R?
406
410
  const spinner = opts.quiet || opts.subAgent
407
411
  ? null // the eval harness wants silence
@@ -414,10 +418,11 @@ opts) {
414
418
  // - stall (slow but alive for 30s) → log it and keep waiting
415
419
  // It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
416
420
  const watchdog = setInterval(() => {
417
- // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
418
- // reads differently from "it's waiting on the first token".
421
+ // While the model is reasoning the spinner says "thinking", then "thought
422
+ // for Ns" — inside the parentheses, so the verb never shifts sideways.
423
+ const thinking = reasoningStartedAt ? { active: reasoningActive, ms: (reasoningActive ? Date.now() : reasoningEndedAt) - reasoningStartedAt } : undefined;
419
424
  if (spinner?.spinning)
420
- spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, Math.round(streamedChars / 4)));
425
+ spinner.set(spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, Math.round(streamedChars / 4), thinking));
421
426
  const quietMs = Date.now() - lastEvent; // ms since the last event
422
427
  if (quietMs > IDLE_TIMEOUT_MS) {
423
428
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -449,6 +454,7 @@ opts) {
449
454
  return;
450
455
  reasoningShown = true;
451
456
  reasoningActive = false;
457
+ reasoningEndedAt = Date.now();
452
458
  if (opts.quiet || opts.subAgent)
453
459
  return; // no UI for silent / nested runs
454
460
  recordReasoning(reasoningBuf); // keep it for Ctrl+R (never goes into history)
@@ -458,6 +464,7 @@ opts) {
458
464
  stallWarned = false; // the stream spoke — reset the stall warning
459
465
  if (chunk.usage) {
460
466
  recordUsage(chunk.usage);
467
+ recordContextUsage(messages, sentCount, chunk.usage); // the real size of what we just sent — anchors the context count
461
468
  if (typeof chunk.usage.completion_tokens === "number")
462
469
  reportedTokens = chunk.usage.completion_tokens;
463
470
  } // the final usage chunk — meter it
@@ -474,7 +481,9 @@ opts) {
474
481
  if (opts.progress)
475
482
  opts.progress.liveTokens = Math.ceil(streamedChars / 4);
476
483
  reasoningBuf += reasoning; // stash it for Ctrl+R instead of streaming it
477
- reasoningActive = true; // the spinner now shows 💭 (set by the watchdog)
484
+ reasoningActive = true; // the spinner now says "thinking" (set by the watchdog)
485
+ if (!reasoningStartedAt)
486
+ reasoningStartedAt = Date.now();
478
487
  }
479
488
  if (delta.content) {
480
489
  flushReasoning();
@@ -1315,14 +1324,26 @@ opts) {
1315
1324
  return { reason: TerminateReason.ReviewLimit };
1316
1325
  receiveFollowUps();
1317
1326
  // Proactive compaction: act BEFORE the API rejects us. Waiting for the
1318
- // hard limit means the failure already happened.
1319
- if (estimateHistoryTokens(messages) > COMPACT_AT) {
1327
+ // hard limit means the failure already happened. The count is the last
1328
+ // response's real prompt_tokens + an estimate of what was added since, so
1329
+ // the threshold can sit close to the window (see compactThreshold).
1330
+ if (contextTokens(messages) > compactThreshold(opts.model)) {
1320
1331
  if (compaction.count >= MAX_COMPACTIONS_PER_QUERY)
1321
1332
  return { reason: TerminateReason.CompactionFailed, detail: `already compacted ${compaction.count}x this query — the task is too big for one session` };
1322
1333
  const ok = await tryCompact(messages, opts, compaction); // shrink the history
1323
1334
  if (!ok && compaction.failures >= MAX_COMPACT_FAILURES)
1324
1335
  return { reason: TerminateReason.CompactionFailed, detail: `${compaction.failures} consecutive compaction failures` }; // the compaction circuit breaker
1325
1336
  }
1337
+ // Skills: pick up any SKILL.md added or edited since the last call (no
1338
+ // restart), then tell the model about skills it hasn't seen yet — a
1339
+ // <system-reminder> user message with just the delta, as Claude Code does.
1340
+ // Idempotent: a retry of this same call finds the reminder already sent.
1341
+ // Teammates have no skill tool, so they get no listing.
1342
+ const skills = currentSkills();
1343
+ syncSkillTool(skills);
1344
+ const listing = opts.teammate ? null : skillListingReminder(messages, skills);
1345
+ if (listing)
1346
+ messages.push({ role: "user", content: listing });
1326
1347
  let out;
1327
1348
  try {
1328
1349
  out = await streamModelCall(messages, opts); // streaming call with spinner + watchdog
@@ -1461,6 +1482,11 @@ opts) {
1461
1482
  const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
1462
1483
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1463
1484
  }
1485
+ // A launched skill's body arrives as its own user message, after ALL of
1486
+ // this round's tool results (the tool result itself only says "Launching
1487
+ // skill: <name>"). Same shape as Claude Code's skill invocation.
1488
+ for (const body of skillBodyMessages(out.toolCalls, messages))
1489
+ messages.push({ role: "user", content: body });
1464
1490
  // Cron (Day s14): deliver any triggered scheduled jobs right after this
1465
1491
  // round's results so the model sees them on the next round.
1466
1492
  injectCronMessages(messages, opts);