agent-from-zero 0.35.0 → 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/README.md +93 -195
- package/dist/agent.js +39 -24
- package/dist/completion.js +1 -1
- package/dist/config.js +36 -1
- package/dist/context.js +46 -6
- package/dist/ink/app.js +103 -25
- package/dist/ink/chat.js +3 -1
- package/dist/ink/commands.js +5 -2
- package/dist/ink/setup.js +15 -15
- package/dist/loop.js +34 -8
- package/dist/mcp.js +332 -16
- package/dist/skills.js +189 -41
- package/dist/ui.js +8 -1
- package/package.json +1 -2
package/dist/context.js
CHANGED
|
@@ -2,14 +2,33 @@ import { effortMessages } from "./effort.js";
|
|
|
2
2
|
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
3
3
|
import chalk from "chalk"; // status lines
|
|
4
4
|
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
5
|
-
import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
|
|
5
|
+
import { recentFiles, forgetFilesExcept, toolDefinitions } from "./tools.js"; // session file state (what was read/edited, and when) + the tool manuals we send
|
|
6
6
|
// ---- The context window ------------------------------------------------------
|
|
7
7
|
// The default (1,048,565) came from a real API error message, not from docs.
|
|
8
8
|
// Trust what the API tells you over what you remember reading.
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
9
|
+
//
|
|
10
|
+
// The window belongs to a MODEL, not to the session: /model can switch to one
|
|
11
|
+
// with a smaller window mid-conversation. Resolution, most specific first:
|
|
12
|
+
// env MINI_AGENT_CONTEXT_WINDOW > settings contextWindows[<model>] > settings contextWindow > default
|
|
13
|
+
export function contextWindowFor(model = CONFIG.model) {
|
|
14
|
+
return Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || CONFIG.contextWindows[model] || CONFIG.contextWindow;
|
|
15
|
+
}
|
|
16
|
+
// When to compact, the Claude Code way: not at a fixed percentage, but at
|
|
17
|
+
// "window minus what we must keep free". Two reservations:
|
|
18
|
+
// - room for the model's reply (the compaction call itself writes a summary)
|
|
19
|
+
// - a safety buffer for what we can't count exactly (the tail estimate)
|
|
20
|
+
// On a ~1M window that's ~97% full; on a 128k window it's ~74%, because the
|
|
21
|
+
// same 33k is a bigger slice of a smaller window. The floor keeps a tiny window
|
|
22
|
+
// from getting a threshold at or below zero.
|
|
23
|
+
export const RESERVED_OUTPUT_TOKENS = 20_000; // Claude Code reserves min(max output, 20k)
|
|
24
|
+
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000; // Claude Code's AUTOCOMPACT_BUFFER_TOKENS
|
|
25
|
+
export function compactThreshold(model = CONFIG.model) {
|
|
26
|
+
const env = Number(process.env.MINI_AGENT_COMPACT_AT); // explicit override (tests use it to trigger compaction cheaply)
|
|
27
|
+
if (env)
|
|
28
|
+
return env;
|
|
29
|
+
const window = contextWindowFor(model);
|
|
30
|
+
return Math.max(Math.floor(window * 0.6), window - RESERVED_OUTPUT_TOKENS - AUTOCOMPACT_BUFFER_TOKENS);
|
|
31
|
+
}
|
|
13
32
|
// How many times one query may compact / fail to compact before we give up.
|
|
14
33
|
export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
|
|
15
34
|
export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
|
|
@@ -37,6 +56,27 @@ export function estimateHistoryTokens(messages) {
|
|
|
37
56
|
}
|
|
38
57
|
return total;
|
|
39
58
|
}
|
|
59
|
+
const usageAnchors = new WeakMap();
|
|
60
|
+
export function recordContextUsage(messages, sentCount, usage) {
|
|
61
|
+
const promptTokens = Number(usage?.prompt_tokens ?? 0);
|
|
62
|
+
if (!sentCount || !promptTokens)
|
|
63
|
+
return; // nothing sent, or a provider that doesn't report input tokens
|
|
64
|
+
usageAnchors.set(messages, { last: messages[sentCount - 1], count: sentCount, promptTokens });
|
|
65
|
+
}
|
|
66
|
+
// The tool manuals ride along on every request but aren't messages — a
|
|
67
|
+
// history-only estimate misses them (dozens of MCP tools can be tens of thousands of tokens).
|
|
68
|
+
export const estimateToolTokens = () => estimateTokens(JSON.stringify(toolDefinitions()), true);
|
|
69
|
+
export function contextTokens(messages) {
|
|
70
|
+
const a = usageAnchors.get(messages);
|
|
71
|
+
if (a && messages.length >= a.count && messages[a.count - 1] === a.last)
|
|
72
|
+
return a.promptTokens + estimateHistoryTokens(messages.slice(a.count));
|
|
73
|
+
return estimateHistoryTokens(messages) + estimateToolTokens(); // no (valid) measurement yet — estimate it all
|
|
74
|
+
}
|
|
75
|
+
// For the status bar: how close we are to AUTO-COMPACTION (not to the hard
|
|
76
|
+
// limit). 100% means the next model call compacts first.
|
|
77
|
+
export function contextPercent(messages, model = CONFIG.model) {
|
|
78
|
+
return Math.min(100, Math.round((contextTokens(messages) / compactThreshold(model)) * 100));
|
|
79
|
+
}
|
|
40
80
|
// ---- The summary prompt ---------------------------------------------------------
|
|
41
81
|
// Six numbered sections. Each exists because losing that piece of information
|
|
42
82
|
// causes a specific, real failure mode after compaction (e.g. losing section 4
|
|
@@ -88,7 +128,7 @@ client, // the same client the loop uses
|
|
|
88
128
|
model, // the same model summarizes its own conversation
|
|
89
129
|
signal, // Ctrl+C must abort compaction too
|
|
90
130
|
log = (s) => console.log(s)) {
|
|
91
|
-
const before =
|
|
131
|
+
const before = contextTokens(messages); // for the log line — measured, not just estimated
|
|
92
132
|
log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
93
133
|
const res = await client.chat.completions.create({
|
|
94
134
|
model, // same model — no need for a fancier one to summarize
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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(() =>
|
|
71
|
+
const id = setInterval(() => setTick((t) => t + 1), SHIMMER_TICK_MS);
|
|
63
72
|
return () => clearInterval(id);
|
|
64
73
|
}, []);
|
|
65
|
-
return
|
|
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 && [
|
|
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 =
|
|
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}
|
|
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: ${
|
|
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
|
|
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
|
|
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(
|
|
484
|
-
runConversationTurn(
|
|
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) && (
|
|
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(
|
|
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,
|
package/dist/ink/commands.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
56
|
-
//
|
|
57
|
-
|
|
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
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
|
418
|
-
//
|
|
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(
|
|
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
|
|
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
|
-
|
|
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);
|