agent-from-zero 0.33.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +77 -3
- package/dist/ink/app.js +76 -3
- package/dist/ink/commands.js +14 -0
- package/dist/mcp.js +593 -40
- package/dist/session.js +23 -4
- package/dist/title.js +49 -0
- package/dist/tools.js +5 -0
- package/package.json +2 -2
package/dist/agent.js
CHANGED
|
@@ -8,10 +8,11 @@ import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.
|
|
|
8
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
9
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
10
|
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
-
import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
11
|
+
import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
12
|
+
import { generateSessionTitle } from "./title.js"; // concise session name, generated after the first message
|
|
12
13
|
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
13
14
|
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
14
|
-
import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
|
|
15
|
+
import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
|
|
15
16
|
import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
16
17
|
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
17
18
|
import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
|
|
@@ -71,6 +72,7 @@ const SESSION_HELP = `commands:
|
|
|
71
72
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
72
73
|
/memory show the durable facts the agent remembers about this project
|
|
73
74
|
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
75
|
+
/mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
|
|
74
76
|
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
75
77
|
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
76
78
|
/bg list background tasks this session (run_bash_background) and their status
|
|
@@ -201,6 +203,8 @@ async function main() {
|
|
|
201
203
|
let running = false; // is a task currently executing?
|
|
202
204
|
let interrupted = false; // has the current task been interrupted?
|
|
203
205
|
let controller = new AbortController(); // aborts the in-flight API request
|
|
206
|
+
let titleAttempted = false; // one-shot: name the session once, after the first real message
|
|
207
|
+
let pendingTitle; // the generated title, passed into the next save
|
|
204
208
|
// ---- One-shot print mode -------------------------------------------------------
|
|
205
209
|
// The same loop, the same permission gate (fail closed without a TTY), no REPL.
|
|
206
210
|
// Designed for scripts: the exit code distinguishes success from every failure.
|
|
@@ -409,6 +413,51 @@ async function main() {
|
|
|
409
413
|
}
|
|
410
414
|
apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
|
|
411
415
|
};
|
|
416
|
+
// /mcp — list configured MCP servers + status, then manage one (like Claude Code).
|
|
417
|
+
const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : chalk.dim("disabled");
|
|
418
|
+
const handleMcpCommand = async (arg) => {
|
|
419
|
+
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
|
420
|
+
if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
|
|
421
|
+
const name = parts.slice(1).join(" ");
|
|
422
|
+
const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
|
|
423
|
+
try {
|
|
424
|
+
console.log(chalk.dim(await runMcpAction(name, action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
console.log(chalk.yellow(err.message));
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const servers = listMcpServers();
|
|
432
|
+
if (!servers.length) {
|
|
433
|
+
console.log(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
console.log(chalk.dim("MCP servers:"));
|
|
437
|
+
const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s.status)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
|
|
438
|
+
for (const label of labels)
|
|
439
|
+
console.log(chalk.dim(` ${label}`));
|
|
440
|
+
if (!process.stdin.isTTY)
|
|
441
|
+
return;
|
|
442
|
+
const choice = await promptSelect(rl, labels.map((l) => l.replace(/ · /g, " — ")));
|
|
443
|
+
if (choice < 0) {
|
|
444
|
+
console.log(chalk.dim("(cancelled)"));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const server = servers[choice];
|
|
448
|
+
const actions = mcpActionsFor(server);
|
|
449
|
+
const actionChoice = await promptSelect(rl, actions.map((a) => a.label));
|
|
450
|
+
if (actionChoice < 0) {
|
|
451
|
+
console.log(chalk.dim("(cancelled)"));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
console.log(chalk.yellow(err.message));
|
|
459
|
+
}
|
|
460
|
+
};
|
|
412
461
|
// Handle a /slash command. Returns true if the line was a command.
|
|
413
462
|
const handleCommand = async (line) => {
|
|
414
463
|
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
@@ -442,6 +491,11 @@ async function main() {
|
|
|
442
491
|
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
|
|
443
492
|
return true;
|
|
444
493
|
}
|
|
494
|
+
// /mcp [reconnect|auth|enable|disable <name>]
|
|
495
|
+
if (line === "/mcp" || line.startsWith("/mcp ")) {
|
|
496
|
+
await handleMcpCommand(line.slice("/mcp".length).trim());
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
445
499
|
switch (line) {
|
|
446
500
|
case "/skills": {
|
|
447
501
|
if (!skills.length) {
|
|
@@ -468,6 +522,8 @@ async function main() {
|
|
|
468
522
|
clearReasoning(); // drop the collapsed thinking too
|
|
469
523
|
clearToolCalls(); // ...and the folded tool-call trace
|
|
470
524
|
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
525
|
+
titleAttempted = false; // a fresh session gets its own title on its first message
|
|
526
|
+
pendingTitle = undefined;
|
|
471
527
|
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
472
528
|
return true;
|
|
473
529
|
case "/todos": {
|
|
@@ -589,6 +645,8 @@ async function main() {
|
|
|
589
645
|
}
|
|
590
646
|
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
591
647
|
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
648
|
+
titleAttempted = false; // a resumed session without a title gets one on its next message
|
|
649
|
+
pendingTitle = chosen.title;
|
|
592
650
|
forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
|
|
593
651
|
clearUndo(); // the previous session's writes are not ours to undo
|
|
594
652
|
clearTodos(); // the resumed task starts without the old session's stale plan
|
|
@@ -713,6 +771,22 @@ async function main() {
|
|
|
713
771
|
console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
714
772
|
if (refused.length)
|
|
715
773
|
console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
774
|
+
// Name the session once, after the first real user message. Same as the Ink
|
|
775
|
+
// REPL: a cheap model call distills the prompt into a 3-7 word title, fired
|
|
776
|
+
// and forgotten so it never delays the turn; a failure leaves the raw prompt.
|
|
777
|
+
if (!titleAttempted) {
|
|
778
|
+
titleAttempted = true;
|
|
779
|
+
if (!loadSession(sessionId)?.title) {
|
|
780
|
+
void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, line)
|
|
781
|
+
.then((title) => {
|
|
782
|
+
if (title) {
|
|
783
|
+
pendingTitle = title;
|
|
784
|
+
setSessionTitle(sessionId, title);
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
.catch(() => { });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
716
790
|
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
717
791
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
|
|
718
792
|
clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
|
|
@@ -730,7 +804,7 @@ async function main() {
|
|
|
730
804
|
judge, // optional LLM classifier for the "ask" middle ground
|
|
731
805
|
});
|
|
732
806
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
733
|
-
saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
|
|
807
|
+
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
734
808
|
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
735
809
|
// that saves durable facts — especially user corrections — so memory
|
|
736
810
|
// captures itself instead of relying on the model to call `remember`.
|
package/dist/ink/app.js
CHANGED
|
@@ -17,7 +17,8 @@ import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as
|
|
|
17
17
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
18
18
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
19
19
|
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
20
|
-
import { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
|
|
20
|
+
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
21
|
+
import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
|
|
21
22
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
22
23
|
import { findSkill, skillInstructions } from "../skills.js";
|
|
23
24
|
import { extractMemories } from "../memory.js";
|
|
@@ -25,7 +26,8 @@ import { displayWidth } from "../editor.js"; // display-width measurement (CJK-a
|
|
|
25
26
|
import { runHooks } from "../hooks.js";
|
|
26
27
|
import { emit } from "../telemetry.js";
|
|
27
28
|
import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
|
|
28
|
-
import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
|
|
29
|
+
import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
|
|
30
|
+
import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
|
|
29
31
|
// Injected when plan mode turns on, so the model knows the rules it now lives in.
|
|
30
32
|
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.`;
|
|
31
33
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
|
|
@@ -117,6 +119,8 @@ export function App({ session, runTurn }) {
|
|
|
117
119
|
const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
|
|
118
120
|
const history = useRef([]).current; // past prompts, for ↑/↓ recall
|
|
119
121
|
const turn = useRef(null); // the in-flight turn, for Esc-interrupt
|
|
122
|
+
const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
|
|
123
|
+
const pendingTitle = useRef(undefined); // the generated title, passed into the next save
|
|
120
124
|
useEffect(() => {
|
|
121
125
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
122
126
|
return () => clearInterval(id);
|
|
@@ -164,7 +168,7 @@ export function App({ session, runTurn }) {
|
|
|
164
168
|
.then(async (result) => {
|
|
165
169
|
if (result.reason !== TerminateReason.Done)
|
|
166
170
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
167
|
-
saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
|
|
171
|
+
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
168
172
|
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
169
173
|
try {
|
|
170
174
|
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
@@ -225,15 +229,80 @@ export function App({ session, runTurn }) {
|
|
|
225
229
|
note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
226
230
|
if (refused.length)
|
|
227
231
|
note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
232
|
+
// Name the session once, after the first real user message. A cheap model
|
|
233
|
+
// call distills the prompt into a 3-7 word title (like Claude Code's aiTitle);
|
|
234
|
+
// it runs fire-and-forget so it never delays the turn, and a failure just
|
|
235
|
+
// leaves the raw first prompt as the title. Resumed sessions that already
|
|
236
|
+
// have a title are left alone.
|
|
237
|
+
if (!titleAttempted.current) {
|
|
238
|
+
titleAttempted.current = true;
|
|
239
|
+
if (!loadSession(sessionId)?.title) {
|
|
240
|
+
void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, text)
|
|
241
|
+
.then((title) => {
|
|
242
|
+
if (title) {
|
|
243
|
+
pendingTitle.current = title;
|
|
244
|
+
setSessionTitle(sessionId, title);
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
.catch(() => { });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
228
250
|
runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
|
|
229
251
|
};
|
|
230
252
|
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
253
|
+
// /mcp — list configured servers + status, then pick one to manage it.
|
|
254
|
+
const handleMcpCommand = async (arg) => {
|
|
255
|
+
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
|
256
|
+
// Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
|
|
257
|
+
if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
|
|
258
|
+
const name = parts.slice(1).join(" ");
|
|
259
|
+
setBusy(true);
|
|
260
|
+
try {
|
|
261
|
+
const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
|
|
262
|
+
const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
|
|
263
|
+
note(chalk.dim(msg));
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
note(chalk.yellow(err.message));
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
setBusy(false);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const servers = listMcpServers();
|
|
274
|
+
if (!servers.length) {
|
|
275
|
+
note(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
|
|
279
|
+
openSelect("MCP servers (pick one to manage):", labels, (i) => {
|
|
280
|
+
if (i < 0)
|
|
281
|
+
return note(chalk.dim("(cancelled)"));
|
|
282
|
+
const server = servers[i];
|
|
283
|
+
const actions = mcpActionsFor(server);
|
|
284
|
+
openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
|
|
285
|
+
if (j < 0)
|
|
286
|
+
return note(chalk.dim("(cancelled)"));
|
|
287
|
+
setBusy(true);
|
|
288
|
+
runMcpAction(server.name, actions[j].action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)))
|
|
289
|
+
.then((msg) => note(chalk.dim(msg)))
|
|
290
|
+
.catch((err) => note(chalk.yellow(err.message)))
|
|
291
|
+
.finally(() => setBusy(false));
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
};
|
|
231
295
|
const handleCommand = async (line) => {
|
|
232
296
|
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
233
297
|
if (info !== null) {
|
|
234
298
|
note(info);
|
|
235
299
|
return true;
|
|
236
300
|
}
|
|
301
|
+
// /mcp [reconnect|auth|enable|disable <name>]
|
|
302
|
+
if (line === "/mcp" || line.startsWith("/mcp ")) {
|
|
303
|
+
await handleMcpCommand(line.slice("/mcp".length).trim());
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
237
306
|
// /model [name|save <name>]
|
|
238
307
|
if (line === "/model" || line.startsWith("/model ")) {
|
|
239
308
|
const arg = line.slice("/model".length).trim();
|
|
@@ -301,6 +370,8 @@ export function App({ session, runTurn }) {
|
|
|
301
370
|
clearReasoning();
|
|
302
371
|
clearToolCalls();
|
|
303
372
|
setSessionId(newSessionId());
|
|
373
|
+
titleAttempted.current = false; // a fresh session gets its own title on its first message
|
|
374
|
+
pendingTitle.current = undefined;
|
|
304
375
|
note(chalk.dim("(history cleared)"));
|
|
305
376
|
return true;
|
|
306
377
|
case "/plan":
|
|
@@ -349,6 +420,8 @@ export function App({ session, runTurn }) {
|
|
|
349
420
|
messages.length = 0;
|
|
350
421
|
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
351
422
|
setSessionId(chosen.id);
|
|
423
|
+
titleAttempted.current = false; // a resumed session without a title gets one on its next message
|
|
424
|
+
pendingTitle.current = chosen.title;
|
|
352
425
|
forgetFilesExcept([]);
|
|
353
426
|
clearUndo();
|
|
354
427
|
clearTodos();
|
package/dist/ink/commands.js
CHANGED
|
@@ -24,6 +24,7 @@ export const SESSION_HELP = `commands:
|
|
|
24
24
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
25
25
|
/memory show the durable facts the agent remembers about this project
|
|
26
26
|
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
27
|
+
/mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
|
|
27
28
|
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
28
29
|
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
29
30
|
/bg list background tasks this session (run_bash_background) and their status
|
|
@@ -40,6 +41,19 @@ export const SESSION_HELP = `commands:
|
|
|
40
41
|
keys (at the prompt):
|
|
41
42
|
Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
|
|
42
43
|
Ctrl+T reveal the folded tool-call trace for the last answer`;
|
|
44
|
+
// Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
|
|
45
|
+
export function mcpStatusText(info) {
|
|
46
|
+
switch (info.status) {
|
|
47
|
+
case "connected":
|
|
48
|
+
return chalk.green("connected");
|
|
49
|
+
case "needs-auth":
|
|
50
|
+
return chalk.yellow("needs auth");
|
|
51
|
+
case "failed":
|
|
52
|
+
return chalk.red("failed");
|
|
53
|
+
case "disabled":
|
|
54
|
+
return chalk.dim("disabled");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
43
57
|
// Run a non-interactive command. Returns the note text to display, or null if
|
|
44
58
|
// `line` isn't one of these (the App handles the rest).
|
|
45
59
|
export function runInfoCommand(line, ctx) {
|
package/dist/mcp.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"; // stdio servers are subprocesses
|
|
2
|
+
import { createServer } from "node:http"; // the OAuth callback listener
|
|
3
|
+
import crypto from "node:crypto"; // PKCE + state nonces
|
|
4
|
+
import os from "node:os"; // home directory for token persistence
|
|
5
|
+
import path from "node:path"; // path joining
|
|
6
|
+
import fs from "node:fs"; // token file persistence
|
|
2
7
|
import chalk from "chalk"; // status lines
|
|
3
8
|
import { CONFIG } from "./config.js"; // configured servers
|
|
4
|
-
import { registerExternalTool } from "./tools.js"; // expose discovered tools
|
|
9
|
+
import { registerExternalTool, unregisterExternalTool } from "./tools.js"; // expose discovered tools
|
|
5
10
|
import { emit } from "./telemetry.js"; // observability
|
|
6
11
|
// MCP (Model Context Protocol) lets the agent borrow tools from external
|
|
7
12
|
// servers — a filesystem server, a GitHub server, a database server, anything.
|
|
@@ -16,8 +21,44 @@ import { emit } from "./telemetry.js"; // observability
|
|
|
16
21
|
//
|
|
17
22
|
// Discovered tools are named mcp__<server>__<tool> so they never collide with
|
|
18
23
|
// built-ins and the user can see (and gate) exactly where each came from.
|
|
24
|
+
//
|
|
25
|
+
// Since this file also drives the /mcp command, it keeps a REGISTRY of every
|
|
26
|
+
// configured server and its live connection state (connected / failed /
|
|
27
|
+
// needs-auth / disabled) so the UI can list them and reconnect/authenticate one
|
|
28
|
+
// at a time — the way Claude Code's /mcp does.
|
|
29
|
+
//
|
|
30
|
+
// Remote servers (Linear, Slack, GitHub…) are OAuth-protected. The flow is the
|
|
31
|
+
// MCP authorization spec end to end: the 401 challenge points at the resource
|
|
32
|
+
// metadata (RFC 9728), which names the authorization server (RFC 8414); we
|
|
33
|
+
// register a client dynamically (RFC 7591) unless the config carries a
|
|
34
|
+
// pre-registered one; then authorization-code + PKCE with a loopback redirect,
|
|
35
|
+
// the `resource` indicator (RFC 8707) on every token request, and refresh
|
|
36
|
+
// tokens so a session survives token expiry without another browser round.
|
|
19
37
|
const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
|
|
20
38
|
const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
|
|
39
|
+
const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60_000; // how long the local callback waits for the browser
|
|
40
|
+
const PROTOCOL_VERSION = "2024-11-05"; // the MCP revision we speak in the handshake
|
|
41
|
+
const CLIENT_INFO = { name: "mini-agent", version: "0" }; // who we say we are in the handshake
|
|
42
|
+
// Thrown when an HTTP MCP server answers 401 — the caller marks the server as
|
|
43
|
+
// "needs auth" instead of "failed". The challenge header is parsed and kept:
|
|
44
|
+
// it is the AUTHORITATIVE pointer to the OAuth metadata. Guessing well-known
|
|
45
|
+
// paths is what breaks on servers like Slack, whose metadata sits at the origin
|
|
46
|
+
// root rather than under the /mcp path.
|
|
47
|
+
export class NeedsAuthError extends Error {
|
|
48
|
+
resourceMetadataUrl;
|
|
49
|
+
scope;
|
|
50
|
+
constructor(message, wwwAuthenticate) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "NeedsAuthError";
|
|
53
|
+
// The challenge is `Bearer key="value", key="value"…` — pull out the pairs we use.
|
|
54
|
+
for (const m of (wwwAuthenticate ?? "").matchAll(/(\w+)="([^"]*)"/g)) {
|
|
55
|
+
if (m[1] === "resource_metadata")
|
|
56
|
+
this.resourceMetadataUrl = m[2];
|
|
57
|
+
if (m[1] === "scope")
|
|
58
|
+
this.scope = m[2];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
21
62
|
// ---- stdio transport: one JSON object per line, to a subprocess --------------
|
|
22
63
|
class StdioTransport {
|
|
23
64
|
child;
|
|
@@ -100,9 +141,11 @@ class HttpTransport {
|
|
|
100
141
|
url;
|
|
101
142
|
headers;
|
|
102
143
|
sessionId; // assigned by the server on initialize, echoed on every later request
|
|
103
|
-
constructor(def) {
|
|
144
|
+
constructor(def, token) {
|
|
104
145
|
this.url = def.url;
|
|
105
146
|
this.headers = { ...def.headers };
|
|
147
|
+
if (token)
|
|
148
|
+
this.headers["authorization"] = `Bearer ${token}`; // OAuth access token
|
|
106
149
|
}
|
|
107
150
|
// The response to a POST may come back as a single JSON object OR as an SSE
|
|
108
151
|
// stream carrying it. Either way we want the JSON-RPC message matching our id.
|
|
@@ -118,6 +161,9 @@ class HttpTransport {
|
|
|
118
161
|
body: JSON.stringify(body),
|
|
119
162
|
signal: AbortSignal.timeout(timeoutMs),
|
|
120
163
|
});
|
|
164
|
+
if (res.status === 401) {
|
|
165
|
+
throw new NeedsAuthError(`MCP server at ${this.url} requires authentication`, res.headers.get("www-authenticate"));
|
|
166
|
+
}
|
|
121
167
|
const sid = res.headers.get("mcp-session-id"); // initialize hands us a session to keep
|
|
122
168
|
if (sid)
|
|
123
169
|
this.sessionId = sid;
|
|
@@ -143,6 +189,8 @@ class HttpTransport {
|
|
|
143
189
|
return msg ?? { id: payload.id, error: { message: `MCP request '${payload.method}' returned no response` } };
|
|
144
190
|
}
|
|
145
191
|
catch (err) {
|
|
192
|
+
if (err instanceof NeedsAuthError)
|
|
193
|
+
throw err; // propagate so the registry can mark needs-auth
|
|
146
194
|
return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
|
|
147
195
|
}
|
|
148
196
|
}
|
|
@@ -150,6 +198,9 @@ class HttpTransport {
|
|
|
150
198
|
// Fire-and-forget; errors on a notification are not worth surfacing.
|
|
151
199
|
void this.post({ jsonrpc: "2.0", method, params }, INIT_TIMEOUT_MS, false).catch(() => { });
|
|
152
200
|
}
|
|
201
|
+
setToken(token) {
|
|
202
|
+
this.headers["authorization"] = `Bearer ${token}`; // a refreshed token; the session id stays valid
|
|
203
|
+
}
|
|
153
204
|
close() {
|
|
154
205
|
// Best-effort: tell the server to drop the session. No await, no error care.
|
|
155
206
|
if (this.sessionId) {
|
|
@@ -158,23 +209,23 @@ class HttpTransport {
|
|
|
158
209
|
}
|
|
159
210
|
}
|
|
160
211
|
// Choose the pipe from the config: a url means HTTP, otherwise spawn over stdio.
|
|
161
|
-
function makeTransport(def) {
|
|
162
|
-
return def.url ? new HttpTransport(def) : new StdioTransport(def);
|
|
212
|
+
function makeTransport(def, token) {
|
|
213
|
+
return def.url ? new HttpTransport(def, token) : new StdioTransport(def);
|
|
163
214
|
}
|
|
164
215
|
// The protocol layer: handshake, request ids, tool flattening. It talks to a
|
|
165
216
|
// Transport and is blind to whether that's a subprocess or an HTTP endpoint.
|
|
166
217
|
class McpClient {
|
|
167
218
|
transport;
|
|
168
219
|
nextId = 1; // JSON-RPC request id counter
|
|
169
|
-
constructor(def) {
|
|
170
|
-
this.transport = makeTransport(def);
|
|
220
|
+
constructor(def, token) {
|
|
221
|
+
this.transport = makeTransport(def, token);
|
|
171
222
|
}
|
|
172
223
|
request(method, params, timeoutMs) {
|
|
173
224
|
return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
|
|
174
225
|
}
|
|
175
226
|
// The MCP startup dance: initialize → initialized → list tools.
|
|
176
227
|
async start() {
|
|
177
|
-
const init = await this.request("initialize", { protocolVersion:
|
|
228
|
+
const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
|
|
178
229
|
if (init.error)
|
|
179
230
|
throw new Error(init.error.message); // server refused to initialize
|
|
180
231
|
this.transport.notify("notifications/initialized", {}); // tell the server we're ready
|
|
@@ -192,46 +243,548 @@ class McpClient {
|
|
|
192
243
|
const body = text || "(no output)";
|
|
193
244
|
return res.result?.isError ? `[error] ${body}` : body; // MCP can flag a logical error in a successful response
|
|
194
245
|
}
|
|
246
|
+
// Swap the bearer token after a refresh (a no-op for stdio).
|
|
247
|
+
setToken(token) {
|
|
248
|
+
this.transport.setToken?.(token);
|
|
249
|
+
}
|
|
195
250
|
kill() {
|
|
196
251
|
this.transport.close();
|
|
197
252
|
}
|
|
198
253
|
}
|
|
254
|
+
const registry = new Map();
|
|
255
|
+
// OAuth tokens persist to the global config dir so a reconnect across sessions
|
|
256
|
+
// doesn't force the browser flow again. Plain JSON (self-hosted CLI, not a
|
|
257
|
+
// hardened credential store — Claude Code uses the OS keychain); tokens are
|
|
258
|
+
// bearer secrets — treat accordingly. Next to the tokens we keep everything a
|
|
259
|
+
// refresh needs (token endpoint, client, resource) so it never rediscovers.
|
|
260
|
+
// MINI_AGENT_OAUTH_STORE relocates the file (the test suite points it at a
|
|
261
|
+
// scratch path so a fake server's tokens never land in the real store).
|
|
262
|
+
const OAUTH_STORE_PATH = process.env.MINI_AGENT_OAUTH_STORE || path.join(os.homedir(), ".config", "mini-agent", "mcp-oauth.json");
|
|
263
|
+
const expiryFrom = (t) => (typeof t.expires_in === "number" ? Date.now() + t.expires_in * 1000 : undefined);
|
|
264
|
+
function readOAuthStore() {
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function writeOAuthStore(store) {
|
|
273
|
+
fs.mkdirSync(path.dirname(OAUTH_STORE_PATH), { recursive: true });
|
|
274
|
+
fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n");
|
|
275
|
+
}
|
|
276
|
+
const oauthStore = readOAuthStore();
|
|
277
|
+
function base64url(input) {
|
|
278
|
+
return Buffer.from(input).toString("base64url");
|
|
279
|
+
}
|
|
280
|
+
function pkcePair() {
|
|
281
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
282
|
+
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
283
|
+
return { verifier, challenge };
|
|
284
|
+
}
|
|
285
|
+
// Try to open a URL in the user's browser. Best-effort: if it fails, the caller
|
|
286
|
+
// has already printed the link so the user can click it themselves. Set
|
|
287
|
+
// MINI_AGENT_NO_BROWSER=1 to only print it (an SSH session, a test).
|
|
288
|
+
function openBrowser(url) {
|
|
289
|
+
if (process.env.MINI_AGENT_NO_BROWSER)
|
|
290
|
+
return;
|
|
291
|
+
const platform = process.platform;
|
|
292
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
293
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
294
|
+
try {
|
|
295
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
296
|
+
child.on("error", () => { });
|
|
297
|
+
child.unref();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* ignore — the printed URL is the fallback */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function fetchJson(url) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(INIT_TIMEOUT_MS) });
|
|
306
|
+
if (!res.ok)
|
|
307
|
+
return null;
|
|
308
|
+
return (await res.json());
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Ask the server for its 401 challenge without a token — the cheapest way to
|
|
315
|
+
// learn where the metadata lives when no 401 was recorded this session (a
|
|
316
|
+
// re-authenticate while still connected). Empty when the server doesn't 401.
|
|
317
|
+
async function probeChallenge(serverUrl) {
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(serverUrl, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
322
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO } }),
|
|
323
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
324
|
+
});
|
|
325
|
+
if (res.status === 401)
|
|
326
|
+
return new NeedsAuthError("", res.headers.get("www-authenticate"));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
/* unreachable server — discovery falls back to the well-known paths */
|
|
330
|
+
}
|
|
331
|
+
return {};
|
|
332
|
+
}
|
|
333
|
+
// Discover the OAuth endpoints for an MCP server: protected-resource metadata
|
|
334
|
+
// (RFC 9728) → authorization-server metadata (RFC 8414). The 401 challenge says
|
|
335
|
+
// exactly where the resource metadata lives; the well-known paths under the
|
|
336
|
+
// server's origin are a fallback for servers that don't send one. Exported so
|
|
337
|
+
// the discovery step can be exercised on its own. Returns null when the server
|
|
338
|
+
// has no OAuth metadata at all.
|
|
339
|
+
export async function discoverOAuth(serverUrl, challenge = {}) {
|
|
340
|
+
const base = new URL(serverUrl);
|
|
341
|
+
const rsPath = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
|
|
342
|
+
const candidates = [
|
|
343
|
+
challenge.resourceMetadataUrl, // what the server told us — authoritative
|
|
344
|
+
`${base.origin}/.well-known/oauth-protected-resource${rsPath}`, // RFC 9728 path-scoped (Linear)
|
|
345
|
+
`${base.origin}/.well-known/oauth-protected-resource`, // RFC 9728 origin-wide (Slack)
|
|
346
|
+
].filter((u) => Boolean(u));
|
|
347
|
+
let prm = null;
|
|
348
|
+
for (const url of new Set(candidates)) {
|
|
349
|
+
prm = await fetchJson(url);
|
|
350
|
+
if (prm)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
// The resource names its authorization server(s); default to the origin. A
|
|
354
|
+
// server with a path component would need the RFC 8414 path insertion — none
|
|
355
|
+
// of the servers we target has one, so keep it simple.
|
|
356
|
+
const authServers = Array.isArray(prm?.authorization_servers) ? prm.authorization_servers : [];
|
|
357
|
+
const authServer = (authServers[0] ?? base.origin).replace(/\/$/, "");
|
|
358
|
+
// RFC 8414 first, then the OpenID Connect location some servers use instead.
|
|
359
|
+
const metadata = (await fetchJson(`${authServer}/.well-known/oauth-authorization-server`)) ?? (await fetchJson(`${authServer}/.well-known/openid-configuration`));
|
|
360
|
+
if (!metadata)
|
|
361
|
+
return null;
|
|
362
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
363
|
+
const list = (v) => (Array.isArray(v) && v.length ? v : undefined);
|
|
364
|
+
const authorizationEndpoint = str(metadata.authorization_endpoint);
|
|
365
|
+
const tokenEndpoint = str(metadata.token_endpoint);
|
|
366
|
+
if (!authorizationEndpoint || !tokenEndpoint)
|
|
367
|
+
return null;
|
|
368
|
+
return {
|
|
369
|
+
authorizationEndpoint,
|
|
370
|
+
tokenEndpoint,
|
|
371
|
+
registrationEndpoint: str(metadata.registration_endpoint),
|
|
372
|
+
scopes: challenge.scope?.split(/\s+/).filter(Boolean) ?? list(prm?.scopes_supported) ?? list(metadata.scopes_supported),
|
|
373
|
+
resource: str(prm?.resource) ?? serverUrl,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// Bind the loopback listener for the authorization-code redirect. The browser
|
|
377
|
+
// is sent to http://localhost:<port>/callback (the redirect URI Claude Code
|
|
378
|
+
// registers too, so a pre-registered Slack client id works unchanged), and
|
|
379
|
+
// "localhost" may resolve to 127.0.0.1 or ::1 depending on browser and OS — so
|
|
380
|
+
// listen on both; the IPv6 bind is best-effort. `port` 0 = any free port (the
|
|
381
|
+
// DCR case, where we register whatever we got); a fixed port is what a
|
|
382
|
+
// pre-registered client demands. The code promise is armed on creation, BEFORE
|
|
383
|
+
// the browser opens, so a fast callback can't race us.
|
|
384
|
+
async function listenForCallback(port, state) {
|
|
385
|
+
let settle;
|
|
386
|
+
const handler = (req, res) => {
|
|
387
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
388
|
+
res.setHeader("connection", "close"); // one-shot listener: a kept-alive socket would deliver a LATER flow's callback (same fixed port) to this dead handler
|
|
389
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== state)
|
|
390
|
+
return void res.writeHead(404).end(); // not ours (favicon, a stale tab…)
|
|
391
|
+
res.setHeader("content-type", "text/html");
|
|
392
|
+
res.end("<h1>Connected</h1><p>You can close this window and return to the terminal.</p>");
|
|
393
|
+
// Settle only once the page has been flushed: settling tears the servers
|
|
394
|
+
// down, and tearing down before the write lands would blank the browser tab.
|
|
395
|
+
res.once("finish", () => {
|
|
396
|
+
const code = url.searchParams.get("code");
|
|
397
|
+
if (code)
|
|
398
|
+
settle?.resolve(code);
|
|
399
|
+
else
|
|
400
|
+
settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
const v4 = createServer(handler);
|
|
404
|
+
const bound = await new Promise((resolve, reject) => {
|
|
405
|
+
v4.once("error", (err) => reject(new Error(`Could not bind the OAuth callback on port ${port || "(any)"}: ${err.message}`)));
|
|
406
|
+
v4.listen(port, "127.0.0.1", () => {
|
|
407
|
+
const addr = v4.address();
|
|
408
|
+
resolve(addr && typeof addr === "object" ? addr.port : port);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
const v6 = createServer(handler);
|
|
412
|
+
await new Promise((resolve) => {
|
|
413
|
+
v6.once("error", () => resolve()); // no IPv6 loopback here — the v4 listener carries it
|
|
414
|
+
v6.listen(bound, "::1", () => resolve());
|
|
415
|
+
});
|
|
416
|
+
const close = () => {
|
|
417
|
+
// Release the port for real: close() alone leaves open sockets alive, and a
|
|
418
|
+
// pre-registered client's next flow needs the SAME port back immediately.
|
|
419
|
+
for (const s of [v4, v6]) {
|
|
420
|
+
s.closeAllConnections();
|
|
421
|
+
s.close();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const code = new Promise((resolve, reject) => {
|
|
425
|
+
const timer = setTimeout(() => {
|
|
426
|
+
close();
|
|
427
|
+
reject(new Error("Timed out waiting for OAuth authorization in the browser"));
|
|
428
|
+
}, OAUTH_CALLBACK_TIMEOUT_MS);
|
|
429
|
+
settle = {
|
|
430
|
+
resolve: (c) => (clearTimeout(timer), close(), resolve(c)),
|
|
431
|
+
reject: (e) => (clearTimeout(timer), close(), reject(e)),
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
return { port: bound, close, code };
|
|
435
|
+
}
|
|
436
|
+
// Run the OAuth authorization-code flow for an HTTP server: PKCE always;
|
|
437
|
+
// dynamic client registration (RFC 7591) unless the config carries a
|
|
438
|
+
// pre-registered client; a loopback redirect; then the code → token exchange
|
|
439
|
+
// with the `resource` indicator (RFC 8707). When onUrl is provided, the
|
|
440
|
+
// authorization URL is handed back so the UI can print it in case the browser
|
|
441
|
+
// doesn't open. Resolves with everything the store needs to reconnect + refresh.
|
|
442
|
+
async function runOAuthFlow(def, challenge, onUrl) {
|
|
443
|
+
if (!def.url)
|
|
444
|
+
throw new Error("OAuth is only available for http MCP servers");
|
|
445
|
+
const discovery = await discoverOAuth(def.url, challenge);
|
|
446
|
+
if (!discovery)
|
|
447
|
+
throw new Error(`No OAuth metadata found for ${def.url}`);
|
|
448
|
+
const pkce = pkcePair();
|
|
449
|
+
const state = base64url(crypto.randomBytes(16));
|
|
450
|
+
// The redirect URI must match what the client registered: for DCR we register
|
|
451
|
+
// whatever port we were given; a pre-registered client (Slack) fixes the port.
|
|
452
|
+
const listener = await listenForCallback(def.oauth?.callbackPort ?? 0, state);
|
|
453
|
+
listener.code.catch(() => { }); // settled later; never an unhandled rejection if we throw before awaiting it
|
|
454
|
+
const redirectUri = `http://localhost:${listener.port}/callback`;
|
|
455
|
+
let clientId = def.oauth?.clientId ?? ""; // a pre-registered client skips DCR
|
|
456
|
+
let clientSecret = def.oauth?.clientSecret;
|
|
457
|
+
if (!clientId && discovery.registrationEndpoint) {
|
|
458
|
+
// Dynamic client registration — most MCP servers register a public client on the spot.
|
|
459
|
+
const regRes = await fetch(discovery.registrationEndpoint, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify({
|
|
463
|
+
redirect_uris: [redirectUri],
|
|
464
|
+
token_endpoint_auth_method: "none",
|
|
465
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
466
|
+
response_types: ["code"],
|
|
467
|
+
client_name: "mini-agent",
|
|
468
|
+
}),
|
|
469
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
470
|
+
}).catch((err) => {
|
|
471
|
+
throw new Error(`OAuth client registration failed for ${def.url}: ${err.message}`);
|
|
472
|
+
});
|
|
473
|
+
if (regRes.ok) {
|
|
474
|
+
const reg = (await regRes.json());
|
|
475
|
+
clientId = reg.client_id ?? "";
|
|
476
|
+
clientSecret = reg.client_secret;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!clientId) {
|
|
480
|
+
listener.close();
|
|
481
|
+
throw new Error(discovery.registrationEndpoint
|
|
482
|
+
? `OAuth client registration failed for ${def.url}`
|
|
483
|
+
: `${def.url} offers no dynamic client registration — set mcpServers.<name>.oauth.clientId (and callbackPort) to a pre-registered client`);
|
|
484
|
+
}
|
|
485
|
+
const authUrl = new URL(discovery.authorizationEndpoint);
|
|
486
|
+
authUrl.searchParams.set("response_type", "code");
|
|
487
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
488
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
489
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
490
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
491
|
+
authUrl.searchParams.set("state", state);
|
|
492
|
+
authUrl.searchParams.set("resource", discovery.resource); // RFC 8707: bind the token to THIS server
|
|
493
|
+
const scopes = def.oauth?.scopes ?? discovery.scopes; // an explicit config list wins over what the server advertises
|
|
494
|
+
if (scopes?.length)
|
|
495
|
+
authUrl.searchParams.set("scope", scopes.join(" "));
|
|
496
|
+
onUrl?.(authUrl.href);
|
|
497
|
+
openBrowser(authUrl.href);
|
|
498
|
+
const code = await listener.code; // the listener closes itself once settled
|
|
499
|
+
const params = new URLSearchParams({
|
|
500
|
+
grant_type: "authorization_code",
|
|
501
|
+
code,
|
|
502
|
+
code_verifier: pkce.verifier,
|
|
503
|
+
redirect_uri: redirectUri,
|
|
504
|
+
client_id: clientId,
|
|
505
|
+
resource: discovery.resource,
|
|
506
|
+
});
|
|
507
|
+
if (clientSecret)
|
|
508
|
+
params.set("client_secret", clientSecret);
|
|
509
|
+
const tokenRes = await fetch(discovery.tokenEndpoint, {
|
|
510
|
+
method: "POST",
|
|
511
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
512
|
+
body: params.toString(),
|
|
513
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
514
|
+
});
|
|
515
|
+
if (!tokenRes.ok) {
|
|
516
|
+
throw new Error(`OAuth token exchange failed: HTTP ${tokenRes.status} ${(await tokenRes.text()).slice(0, 200)}`);
|
|
517
|
+
}
|
|
518
|
+
const tokens = (await tokenRes.json());
|
|
519
|
+
if (!tokens.access_token)
|
|
520
|
+
throw new Error(`OAuth token exchange returned no access_token: ${tokens.error_description ?? tokens.error ?? "unknown error"}`);
|
|
521
|
+
return {
|
|
522
|
+
accessToken: tokens.access_token,
|
|
523
|
+
refreshToken: tokens.refresh_token,
|
|
524
|
+
expiresAt: expiryFrom(tokens),
|
|
525
|
+
clientId,
|
|
526
|
+
clientSecret,
|
|
527
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
528
|
+
resource: discovery.resource,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function entryFor(name) {
|
|
532
|
+
let entry = registry.get(name);
|
|
533
|
+
if (!entry) {
|
|
534
|
+
const def = CONFIG.mcpServers[name];
|
|
535
|
+
if (!def)
|
|
536
|
+
throw new Error(`No MCP server named "${name}"`);
|
|
537
|
+
entry = { def, status: "failed", tools: 0, toolNames: [] };
|
|
538
|
+
registry.set(name, entry);
|
|
539
|
+
}
|
|
540
|
+
return entry;
|
|
541
|
+
}
|
|
542
|
+
function registerTools(name, specs, client) {
|
|
543
|
+
for (const spec of specs) {
|
|
544
|
+
const toolName = `mcp__${name}__${spec.name}`; // namespaced, collision-proof
|
|
545
|
+
const tool = {
|
|
546
|
+
definition: {
|
|
547
|
+
type: "function",
|
|
548
|
+
function: {
|
|
549
|
+
name: toolName,
|
|
550
|
+
description: `[MCP:${name}] ${spec.description ?? spec.name}`, // mark the origin in the manual
|
|
551
|
+
parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
// dispatch routes here; the permission gate ran first. A 401 mid-session
|
|
555
|
+
// (the access token expired or was revoked) gets ONE silent refresh and a
|
|
556
|
+
// retry; if that fails the server is flagged needs-auth and the model gets
|
|
557
|
+
// a readable error pointing at /mcp auth — a tool result never throws.
|
|
558
|
+
run: async (args) => {
|
|
559
|
+
try {
|
|
560
|
+
return await client.callTool(spec.name, args);
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
if (!(err instanceof NeedsAuthError))
|
|
564
|
+
throw err; // dispatch turns any other crash into text
|
|
565
|
+
const fresh = await refreshAccessToken(name);
|
|
566
|
+
if (fresh) {
|
|
567
|
+
client.setToken(fresh);
|
|
568
|
+
try {
|
|
569
|
+
return await client.callTool(spec.name, args);
|
|
570
|
+
}
|
|
571
|
+
catch (again) {
|
|
572
|
+
if (!(again instanceof NeedsAuthError))
|
|
573
|
+
throw again;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
markNeedsAuth(name, err);
|
|
577
|
+
return `[error] MCP server "${name}" needs authentication — run /mcp auth ${name}`;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
|
|
582
|
+
}
|
|
583
|
+
return specs.length;
|
|
584
|
+
}
|
|
585
|
+
// Flag a server as needing the browser flow and remember the challenge so
|
|
586
|
+
// /mcp auth knows where the OAuth metadata lives. Its tools stay registered:
|
|
587
|
+
// they answer with the readable error until the user authenticates.
|
|
588
|
+
function markNeedsAuth(name, challenge) {
|
|
589
|
+
const entry = entryFor(name);
|
|
590
|
+
entry.status = "needs-auth";
|
|
591
|
+
entry.error = challenge.message;
|
|
592
|
+
entry.challenge = challenge;
|
|
593
|
+
}
|
|
594
|
+
// Connect a single configured server and register its tools. Updates the
|
|
595
|
+
// registry with the live status. Used by connectMcpServers() at startup and by
|
|
596
|
+
// /mcp reconnect / enable / auth.
|
|
597
|
+
async function connectOne(name, retried = false) {
|
|
598
|
+
const entry = entryFor(name);
|
|
599
|
+
// Unregister any stale tools from a previous connection.
|
|
600
|
+
for (const n of entry.toolNames)
|
|
601
|
+
unregisterExternalTool(n);
|
|
602
|
+
entry.toolNames = [];
|
|
603
|
+
entry.client?.kill();
|
|
604
|
+
entry.client = undefined;
|
|
605
|
+
// A stored token we KNOW is expired is refreshed before connecting rather
|
|
606
|
+
// than burning a 401 round trip on it.
|
|
607
|
+
let stored = oauthStore[name];
|
|
608
|
+
if (stored?.refreshToken && stored.expiresAt && stored.expiresAt <= Date.now()) {
|
|
609
|
+
await refreshAccessToken(name);
|
|
610
|
+
stored = oauthStore[name];
|
|
611
|
+
}
|
|
612
|
+
const client = new McpClient(entry.def, stored?.accessToken);
|
|
613
|
+
try {
|
|
614
|
+
const specs = await client.start(); // handshake + discover
|
|
615
|
+
entry.client = client;
|
|
616
|
+
entry.tools = registerTools(name, specs, client);
|
|
617
|
+
entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
|
|
618
|
+
entry.status = "connected";
|
|
619
|
+
entry.error = undefined;
|
|
620
|
+
emit("agent_mcp_connected", { server: name, tools: specs.length, transport: entry.def.url ? "http" : "stdio" });
|
|
621
|
+
console.log(chalk.dim(`(mcp: ${name} — ${specs.length} tools${entry.def.url ? " over http" : ""})`)); // visible at startup / on reconnect
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
client.kill(); // don't leak the process/session
|
|
625
|
+
if (err instanceof NeedsAuthError) {
|
|
626
|
+
// A stale token (expired early, revoked): one refresh + retry before giving up.
|
|
627
|
+
if (!retried && stored?.refreshToken && (await refreshAccessToken(name)))
|
|
628
|
+
return connectOne(name, true);
|
|
629
|
+
markNeedsAuth(name, err);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
entry.status = "failed";
|
|
633
|
+
entry.error = err.message;
|
|
634
|
+
}
|
|
635
|
+
emit("agent_mcp_failed", { server: name });
|
|
636
|
+
console.error(chalk.yellow(` [mcp] ${name} ${entry.status === "needs-auth" ? `needs authentication (run /mcp auth ${name})` : "failed to start"}: ${err.message}`));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
199
639
|
// Connect to every configured MCP server, register their tools, and return a
|
|
200
640
|
// cleanup function. Failures are isolated: a server that won't start is logged
|
|
201
641
|
// and skipped — it never stops the agent or the other servers.
|
|
202
642
|
export async function connectMcpServers() {
|
|
203
|
-
const entries = Object.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
643
|
+
const entries = Object.keys(CONFIG.mcpServers); // configured server names
|
|
644
|
+
for (const name of entries) {
|
|
645
|
+
await connectOne(name); // sequential: a slow start doesn't starve the next one
|
|
646
|
+
}
|
|
647
|
+
return () => {
|
|
648
|
+
for (const entry of registry.values())
|
|
649
|
+
entry.client?.kill(); // cleanup on exit
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
// ---- /mcp command support ----------------------------------------------------
|
|
653
|
+
export function listMcpServers() {
|
|
654
|
+
// Include every CONFIGURED server, even ones never attempted this session.
|
|
655
|
+
return Object.entries(CONFIG.mcpServers).map(([name, def]) => {
|
|
656
|
+
const entry = registry.get(name);
|
|
657
|
+
return {
|
|
658
|
+
name,
|
|
659
|
+
transport: def.url ? "http" : "stdio",
|
|
660
|
+
status: entry?.status ?? "failed",
|
|
661
|
+
tools: entry?.tools ?? 0,
|
|
662
|
+
error: entry?.error,
|
|
663
|
+
authenticated: Boolean(oauthStore[name]?.accessToken),
|
|
664
|
+
url: def.url,
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// Which actions make sense for a server in a given state. Mirrors Claude Code's
|
|
669
|
+
// per-server menu: authenticate when it needs auth, reconnect when it isn't,
|
|
670
|
+
// enable/disable to toggle it off/on.
|
|
671
|
+
export function mcpActionsFor(info) {
|
|
672
|
+
if (info.status === "disabled") {
|
|
673
|
+
return [{ label: "Enable", action: "enable" }];
|
|
674
|
+
}
|
|
675
|
+
const out = [];
|
|
676
|
+
if (info.transport === "http") {
|
|
677
|
+
out.push({ label: info.authenticated ? "Re-authenticate" : "Authenticate", action: "authenticate" });
|
|
678
|
+
}
|
|
679
|
+
out.push({ label: "Reconnect", action: "reconnect" });
|
|
680
|
+
out.push({ label: "Disable", action: "disable" });
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
// Trade the stored refresh token for a new access token, at the token endpoint
|
|
684
|
+
// recorded when the user authenticated — no rediscovery. A definitive rejection
|
|
685
|
+
// (400/401: the refresh token itself is dead) wipes the record so the UI tells
|
|
686
|
+
// the truth: the user has to authenticate again. Network trouble keeps it.
|
|
687
|
+
// Returns the new access token, or undefined when there is none to be had.
|
|
688
|
+
async function refreshAccessToken(name) {
|
|
689
|
+
const stored = oauthStore[name];
|
|
690
|
+
if (!stored?.refreshToken || !stored.tokenEndpoint)
|
|
691
|
+
return undefined;
|
|
692
|
+
const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: stored.refreshToken });
|
|
693
|
+
if (stored.clientId)
|
|
694
|
+
params.set("client_id", stored.clientId);
|
|
695
|
+
if (stored.clientSecret)
|
|
696
|
+
params.set("client_secret", stored.clientSecret);
|
|
697
|
+
if (stored.resource)
|
|
698
|
+
params.set("resource", stored.resource);
|
|
699
|
+
let res;
|
|
700
|
+
try {
|
|
701
|
+
res = await fetch(stored.tokenEndpoint, {
|
|
702
|
+
method: "POST",
|
|
703
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
704
|
+
body: params.toString(),
|
|
705
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return undefined; // offline / timeout — keep the record, it may work next time
|
|
710
|
+
}
|
|
711
|
+
if (res.status === 400 || res.status === 401) {
|
|
712
|
+
delete oauthStore[name]; // invalid_grant: the refresh token is gone for good
|
|
713
|
+
writeOAuthStore(oauthStore);
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
if (!res.ok)
|
|
717
|
+
return undefined; // a server hiccup — keep the record
|
|
718
|
+
const tokens = (await res.json().catch(() => ({})));
|
|
719
|
+
if (!tokens.access_token)
|
|
720
|
+
return undefined;
|
|
721
|
+
oauthStore[name] = { ...stored, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? stored.refreshToken, expiresAt: expiryFrom(tokens) };
|
|
722
|
+
writeOAuthStore(oauthStore);
|
|
723
|
+
return tokens.access_token;
|
|
724
|
+
}
|
|
725
|
+
// Authenticate a server via browser OAuth and store the tokens. Returns a
|
|
726
|
+
// human-readable result for the UI.
|
|
727
|
+
export async function authenticateMcpServer(name, onUrl) {
|
|
728
|
+
const def = CONFIG.mcpServers[name];
|
|
729
|
+
if (!def)
|
|
730
|
+
return `No MCP server named "${name}"`;
|
|
731
|
+
if (!def.url)
|
|
732
|
+
return `${name} is a stdio server — it needs no OAuth authentication`;
|
|
733
|
+
// Where the OAuth metadata lives: the last 401 told us; if none was seen this
|
|
734
|
+
// session (re-authenticating while connected), ask the server now.
|
|
735
|
+
const entry = entryFor(name);
|
|
736
|
+
const challenge = entry.challenge ?? (await probeChallenge(def.url));
|
|
737
|
+
oauthStore[name] = await runOAuthFlow(def, challenge, onUrl);
|
|
738
|
+
writeOAuthStore(oauthStore);
|
|
739
|
+
// Immediately reconnect with the fresh token so the server shows connected.
|
|
740
|
+
await connectOne(name);
|
|
741
|
+
const status = entry.status;
|
|
742
|
+
return status === "connected"
|
|
743
|
+
? `Authentication successful. Connected to ${name}.`
|
|
744
|
+
: `Authentication done, but ${name} is ${status} — use /mcp reconnect ${name} to retry.`;
|
|
745
|
+
}
|
|
746
|
+
// Disconnect and reconnect a single server (re-registering its tools). A stale
|
|
747
|
+
// stored token is refreshed inside connectOne, so this is just: connect again.
|
|
748
|
+
export async function reconnectMcpServer(name) {
|
|
749
|
+
const def = CONFIG.mcpServers[name];
|
|
750
|
+
if (!def)
|
|
751
|
+
return `No MCP server named "${name}"`;
|
|
752
|
+
await connectOne(name);
|
|
753
|
+
const info = listMcpServers().find((s) => s.name === name);
|
|
754
|
+
return info.status === "connected"
|
|
755
|
+
? `Connected to ${name} (${info.tools} tool${info.tools === 1 ? "" : "s"}).`
|
|
756
|
+
: `${name} is ${info.status}: ${info.error ?? "unknown error"}`;
|
|
757
|
+
}
|
|
758
|
+
// Toggle a server off/on (session-only — the config file is not rewritten).
|
|
759
|
+
export async function disableMcpServer(name) {
|
|
760
|
+
const entry = entryFor(name);
|
|
761
|
+
for (const n of entry.toolNames)
|
|
762
|
+
unregisterExternalTool(n);
|
|
763
|
+
entry.toolNames = [];
|
|
764
|
+
entry.client?.kill();
|
|
765
|
+
entry.client = undefined;
|
|
766
|
+
entry.status = "disabled";
|
|
767
|
+
entry.error = undefined;
|
|
768
|
+
return `Disabled ${name}.`;
|
|
769
|
+
}
|
|
770
|
+
export async function enableMcpServer(name) {
|
|
771
|
+
const entry = entryFor(name);
|
|
772
|
+
if (entry.status !== "disabled")
|
|
773
|
+
return `${name} is not disabled.`;
|
|
774
|
+
await connectOne(name);
|
|
775
|
+
const info = listMcpServers().find((s) => s.name === name);
|
|
776
|
+
return info.status === "connected" ? `Enabled ${name}.` : `${name} is ${info.status}: ${info.error ?? "unknown error"}`;
|
|
777
|
+
}
|
|
778
|
+
// Run one action from mcpActionsFor and return the note to display.
|
|
779
|
+
export async function runMcpAction(name, action, onUrl) {
|
|
780
|
+
switch (action) {
|
|
781
|
+
case "authenticate":
|
|
782
|
+
return authenticateMcpServer(name, onUrl);
|
|
783
|
+
case "reconnect":
|
|
784
|
+
return reconnectMcpServer(name);
|
|
785
|
+
case "disable":
|
|
786
|
+
return disableMcpServer(name);
|
|
787
|
+
case "enable":
|
|
788
|
+
return enableMcpServer(name);
|
|
235
789
|
}
|
|
236
|
-
return () => clients.forEach((c) => c.kill()); // the caller calls this on exit
|
|
237
790
|
}
|
package/dist/session.js
CHANGED
|
@@ -15,16 +15,17 @@ export function newSessionId() {
|
|
|
15
15
|
// history is not append-only — compaction rewrites it — so a snapshot is the
|
|
16
16
|
// only representation that is always correct. Crash mid-write leaves the
|
|
17
17
|
// previous snapshot intact.
|
|
18
|
-
export function saveSession(id, model, messages) {
|
|
18
|
+
export function saveSession(id, model, messages, title) {
|
|
19
19
|
const dir = sessionsDir();
|
|
20
20
|
fs.mkdirSync(dir, { recursive: true }); // first save creates the directory
|
|
21
21
|
const file = path.join(dir, `${id}.json`); // the real destination
|
|
22
|
-
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
|
|
22
|
+
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt + title
|
|
23
23
|
const data = {
|
|
24
24
|
id,
|
|
25
25
|
startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
|
|
26
26
|
savedAt: new Date().toISOString(), // always refreshed
|
|
27
27
|
model, // informational — resume does not force the same model
|
|
28
|
+
title: title ?? existing?.title, // a generated title sticks across saves
|
|
28
29
|
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
29
30
|
};
|
|
30
31
|
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
@@ -32,6 +33,23 @@ export function saveSession(id, model, messages) {
|
|
|
32
33
|
fs.renameSync(tmp, file); // atomic swap
|
|
33
34
|
prune(); // keep the directory bounded
|
|
34
35
|
}
|
|
36
|
+
// Overwrite just the title of an existing session. Used when the title model
|
|
37
|
+
// finishes AFTER the first save — the next saveSession would otherwise drop it.
|
|
38
|
+
export function setSessionTitle(id, title) {
|
|
39
|
+
const file = path.join(sessionsDir(), `${id}.json`);
|
|
40
|
+
let s;
|
|
41
|
+
try {
|
|
42
|
+
s = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return; // the session hasn't been saved yet — the title will land on the next save
|
|
46
|
+
}
|
|
47
|
+
s.title = title;
|
|
48
|
+
s.savedAt = new Date().toISOString(); // a title is a change; keep the list ordering honest
|
|
49
|
+
const tmp = `${file}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(s)); // atomic, same as saveSession
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
}
|
|
35
53
|
// Session files on disk, newest (most recently saved) first. Shared by every
|
|
36
54
|
// reader below. Skips tmp files and strangers.
|
|
37
55
|
function sessionFilesNewestFirst() {
|
|
@@ -69,7 +87,8 @@ export function loadSession(id) {
|
|
|
69
87
|
// A one-line, human-readable title for a session: the first thing the user
|
|
70
88
|
// actually typed, with any @file attachment block stripped off and the text
|
|
71
89
|
// flattened to a single trimmed line. This is what makes a session list
|
|
72
|
-
// pickable instead of a wall of timestamps.
|
|
90
|
+
// pickable instead of a wall of timestamps. It's the FALLBACK when no concise
|
|
91
|
+
// generated title has been stored (see saveSession title + setSessionTitle).
|
|
73
92
|
export function sessionTitle(messages) {
|
|
74
93
|
const firstUser = messages.find((m) => m.role === "user");
|
|
75
94
|
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
@@ -86,7 +105,7 @@ export function listSessions(limit = 10) {
|
|
|
86
105
|
break;
|
|
87
106
|
try {
|
|
88
107
|
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir(), f), "utf8"));
|
|
89
|
-
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: sessionTitle(s.messages) });
|
|
108
|
+
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: s.title ?? sessionTitle(s.messages) });
|
|
90
109
|
}
|
|
91
110
|
catch {
|
|
92
111
|
/* skip a corrupt file */
|
package/dist/title.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// One cheap model call that turns the user's first prompt into a short,
|
|
2
|
+
// pickable session name — the same idea Claude Code uses (a Haiku-tier call
|
|
3
|
+
// producing a 3-7 word sentence-case title, stored as the session's aiTitle).
|
|
4
|
+
// It runs once, fire-and-forget, after the first real user message, so it never
|
|
5
|
+
// blocks the turn and a failure silently falls back to the raw first prompt.
|
|
6
|
+
const TITLE_SYSTEM = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of the user's request. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.
|
|
7
|
+
Return ONLY the title — no quotes, no leading/trailing punctuation, no explanation.
|
|
8
|
+
|
|
9
|
+
Good examples:
|
|
10
|
+
Fix login button on mobile
|
|
11
|
+
Add OAuth authentication
|
|
12
|
+
Debug failing CI tests
|
|
13
|
+
Refactor API client error handling
|
|
14
|
+
|
|
15
|
+
Bad (too vague): Code changes
|
|
16
|
+
Bad (too long): Investigate and fix the issue where the login button does not respond on mobile devices
|
|
17
|
+
Bad (wrong case): Fix Login Button On Mobile`;
|
|
18
|
+
// Strip the model's inevitable chatter: surrounding quotes, trailing periods,
|
|
19
|
+
// markdown fences, and any multi-line prose. Pure + lenient on purpose.
|
|
20
|
+
export function cleanSessionTitle(text) {
|
|
21
|
+
let t = text.replace(/^```[^\n]*\n?|\n?```$/g, ""); // drop a markdown fence if present
|
|
22
|
+
t = t.split("\n")[0] ?? ""; // first line only
|
|
23
|
+
t = t.replace(/^[\s"'“”‘’]+|[\s"'“”‘’.。]+$/g, "").trim(); // strip quotes + trailing punctuation
|
|
24
|
+
return t.replace(/\s+/g, " ").trim();
|
|
25
|
+
}
|
|
26
|
+
// Generate a session title from the first user message. Returns null on any
|
|
27
|
+
// failure (bad model response, network error) — callers fall back to the prompt.
|
|
28
|
+
export async function generateSessionTitle(client, model, firstPrompt) {
|
|
29
|
+
const trimmed = firstPrompt.replace(/\s+/g, " ").trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
return null;
|
|
32
|
+
let text = "";
|
|
33
|
+
try {
|
|
34
|
+
const res = await client.chat.completions.create({
|
|
35
|
+
model,
|
|
36
|
+
messages: [
|
|
37
|
+
{ role: "system", content: TITLE_SYSTEM },
|
|
38
|
+
{ role: "user", content: trimmed },
|
|
39
|
+
],
|
|
40
|
+
stream: false,
|
|
41
|
+
});
|
|
42
|
+
text = res.choices?.[0]?.message?.content ?? "";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null; // a failed title must never surface or break the session
|
|
46
|
+
}
|
|
47
|
+
const title = cleanSessionTitle(text);
|
|
48
|
+
return title || null;
|
|
49
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -412,6 +412,11 @@ export const tools = {
|
|
|
412
412
|
export function registerExternalTool(tool) {
|
|
413
413
|
tools[tool.definition.function.name] = tool;
|
|
414
414
|
}
|
|
415
|
+
// Remove an external tool by name (used when an MCP server is disconnected or
|
|
416
|
+
// reconnected — its stale tools must never stay registered under the same name).
|
|
417
|
+
export function unregisterExternalTool(name) {
|
|
418
|
+
delete tools[name];
|
|
419
|
+
}
|
|
415
420
|
// The manuals sent to the model. A function (not a const) since Day 15: MCP
|
|
416
421
|
// tools register after module load, so the list must be computed when asked.
|
|
417
422
|
export function toolDefinitions() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"eval": "tsx --env-file=.env eval/run.ts",
|
|
18
18
|
"build": "tsc -p tsconfig.build.json",
|
|
19
19
|
"prepare": "npm run build",
|
|
20
|
-
"test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
|
|
20
|
+
"test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20.6"
|