agent-from-zero 0.5.0 → 0.27.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/loop.js CHANGED
@@ -3,11 +3,14 @@ import ora from "ora"; // the "thinking..." spinner while waiting for the first
3
3
  import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
4
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
5
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
6
+ import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
6
7
  import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
7
8
  import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
8
9
  import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
9
10
  import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
10
- export const MAX_ROUNDS = 15; // model→tool round cap per query
11
+ import { recordUsage } from "./cost.js"; // meter token usage from the stream
12
+ import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
13
+ import { printToolSummary } from "./tui.js"; // collapsible tool output
11
14
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
12
15
  export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
13
16
  export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
@@ -20,7 +23,6 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
20
23
  export var TerminateReason;
21
24
  (function (TerminateReason) {
22
25
  TerminateReason["Done"] = "done";
23
- TerminateReason["RoundCap"] = "round_cap";
24
26
  TerminateReason["CircuitBreaker"] = "circuit_breaker";
25
27
  TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
26
28
  TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
@@ -29,6 +31,12 @@ export var TerminateReason;
29
31
  TerminateReason["FatalApiError"] = "fatal_api_error";
30
32
  TerminateReason["UserInterrupt"] = "user_interrupt";
31
33
  })(TerminateReason || (TerminateReason = {}));
34
+ // Which model should a delegated sub-agent run on? The configured sub-agent
35
+ // model if set and non-blank, otherwise the same model as the orchestrator.
36
+ // Pulled out as a pure function so the tiering rule is testable on its own.
37
+ export function subAgentModelFor(opts) {
38
+ return opts.subAgentModel?.trim() || opts.model;
39
+ }
32
40
  // The task tool: the parent's handle on sub-agents. Defined here (not in
33
41
  // tools.ts) because running it needs the loop itself — it IS a loop.
34
42
  const taskTool = {
@@ -49,19 +57,53 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
49
57
  },
50
58
  },
51
59
  };
60
+ // The ask_user tool: the model's handle on the user. When it needs the human to
61
+ // choose between options, it calls this instead of asking in prose — and gets
62
+ // back clean, structured selections rather than a free-text paragraph to parse.
63
+ const askUserTool = {
64
+ type: "function",
65
+ function: {
66
+ name: "ask_user",
67
+ description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
68
+ Provide each question with 2–4 concrete options. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
69
+ If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
70
+ parameters: {
71
+ type: "object",
72
+ properties: {
73
+ questions: {
74
+ type: "array",
75
+ description: "The questions to ask, each with a list of options",
76
+ items: {
77
+ type: "object",
78
+ properties: {
79
+ question: { type: "string", description: "The question text" },
80
+ options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
81
+ },
82
+ required: ["question", "options"],
83
+ },
84
+ },
85
+ },
86
+ required: ["questions"],
87
+ },
88
+ },
89
+ };
52
90
  // Run one sub-agent: a fresh conversation, same tools minus task, same
53
91
  // permission gate, and the parent's file read-state protected by a snapshot.
54
92
  async function runSubAgent(description, opts) {
55
93
  emit("agent_subagent_spawn"); // delegation is worth counting
94
+ const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
95
+ // Show the delegation, and the model when it differs from the orchestrator's —
96
+ // the tiering should be visible, not a silent surprise on the bill.
56
97
  if (!opts.quiet)
57
- console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
98
+ console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
99
+ await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
58
100
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
59
101
  try {
60
102
  const subMessages = [
61
103
  { role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
62
104
  { role: "user", content: description }, // the task is its entire world
63
105
  ];
64
- const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse, flagged as sub-agent
106
+ const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
65
107
  if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
66
108
  // The framing matters: the parent must treat this as material to verify,
67
109
  // not as conclusions to copy — the most common multi-agent failure mode.
@@ -71,8 +113,9 @@ async function runSubAgent(description, opts) {
71
113
  }
72
114
  finally {
73
115
  restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
116
+ await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
74
117
  if (!opts.quiet)
75
- console.log(chalk.blue(" ⎿ sub-agent done")); // close the bracket
118
+ console.log(mark.subAgentDone); // close the bracket
76
119
  }
77
120
  }
78
121
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
@@ -89,21 +132,28 @@ async function interruptibleSleep(ms, isInterrupted) {
89
132
  await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
90
133
  }
91
134
  }
135
+ let modelCallSeq = 0; // increments per model call — seeds the rotating spinner word
92
136
  // One streaming model call: prints text as it arrives, assembles tool calls
93
137
  // from their deltas, and guards the stream with a two-level watchdog.
94
138
  async function streamModelCall(messages, // the full history to send
95
139
  opts) {
96
140
  const idleAbort = new AbortController(); // the watchdog's own kill switch
97
141
  const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
142
+ const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
143
+ const startedAt = Date.now(); // for the spinner's live elapsed counter
144
+ let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
98
145
  const spinner = opts.quiet
99
146
  ? null // the eval harness wants silence
100
- : ora({ text: opts.subAgent ? "sub-agent working..." : "thinking...", discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
147
+ : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
101
148
  let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
102
149
  let stallWarned = false; // only warn once per quiet stretch
103
150
  // The two-level watchdog, checked once per second:
104
151
  // - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
105
152
  // - stall (slow but alive for 30s) → log it and keep waiting
153
+ // It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
106
154
  const watchdog = setInterval(() => {
155
+ if (spinner?.isSpinning)
156
+ spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
107
157
  const quietMs = Date.now() - lastEvent; // ms since the last event
108
158
  if (quietMs > IDLE_TIMEOUT_MS) {
109
159
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -120,29 +170,54 @@ opts) {
120
170
  const stream = await opts.client.chat.completions.create(
121
171
  // Sub-agents do NOT get the task tool: one level of delegation only.
122
172
  // Nested spawning means orphan processes and debugging hell.
123
- { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions : [...toolDefinitions, taskTool], stream: true }, { signal });
173
+ // include_usage adds a final chunk carrying token counts — that is how we
174
+ // meter cost without a second (counting) request.
175
+ { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions() : [...toolDefinitions(), taskTool, askUserTool], stream: true, stream_options: { include_usage: true } }, { signal });
124
176
  let content = ""; // accumulated answer text
125
- let printedPrefix = false; // have we printed the 🤖 prefix yet?
177
+ let printedPrefix = false; // have we printed the answer prefix yet?
178
+ let printedThinking = false; // have we started printing the reasoning trace yet?
126
179
  const calls = []; // tool calls under assembly, indexed by delta.index
127
180
  for await (const chunk of stream) {
128
181
  lastEvent = Date.now(); // feed the watchdog
129
182
  stallWarned = false; // the stream spoke — reset the stall warning
183
+ if (chunk.usage)
184
+ recordUsage(chunk.usage); // the final usage chunk — meter it
130
185
  const delta = chunk.choices[0]?.delta; // this chunk's increment
131
186
  if (!delta)
132
187
  continue; // keep-alive or usage chunk — nothing to do
188
+ // Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
189
+ // separate `reasoning_content` field BEFORE the answer. Show it dimly so a
190
+ // switch to a reasoning model is visibly different — but do NOT keep it:
191
+ // the reasoning is not the answer and must not be replayed in later turns.
192
+ const reasoning = delta.reasoning_content;
193
+ if (reasoning) {
194
+ streamedChars += reasoning.length; // count reasoning toward the live token estimate
195
+ if (spinner?.isSpinning)
196
+ spinner.stop();
197
+ if (!opts.quiet && !opts.subAgent) {
198
+ if (!printedThinking) {
199
+ process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
200
+ printedThinking = true;
201
+ }
202
+ process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
203
+ }
204
+ }
133
205
  if (delta.content) {
206
+ streamedChars += delta.content.length; // count the answer toward the live token estimate
134
207
  if (spinner?.isSpinning)
135
208
  spinner.stop(); // first token: replace the spinner with real output
136
209
  if (!opts.quiet && !opts.subAgent) {
137
210
  // Only the top-level agent streams to the screen — a sub-agent's
138
211
  // inner monologue would be mistaken for the answer.
212
+ if (printedThinking && !printedPrefix)
213
+ process.stdout.write("\n"); // end the thinking block before the answer
139
214
  if (!printedPrefix) {
140
- process.stdout.write("\n🤖 "); // prefix once, then stream raw
215
+ process.stdout.write("\n" + mark.answer); // prefix once (⏺), then stream raw
141
216
  printedPrefix = true;
142
217
  }
143
218
  process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
144
219
  }
145
- content += delta.content; // always keep it for the history
220
+ content += delta.content; // always keep it for the history (the answer only, never the reasoning)
146
221
  }
147
222
  for (const tc of delta.tool_calls ?? []) {
148
223
  if (spinner?.isSpinning)
@@ -172,24 +247,52 @@ opts) {
172
247
  // Promise.all batch; calls that can prompt must be awaited one at a time.
173
248
  async function runOneCall(call, opts) {
174
249
  const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
175
- if (!opts.quiet)
176
- console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
250
+ if (!opts.quiet) {
251
+ // Compact one-line summary — long args are hidden behind Tab.
252
+ const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
253
+ const full = `tool: ${call.name}\nargs: ${call.args}`;
254
+ printToolSummary(summary, full);
255
+ }
177
256
  // The permission gate sits between the model's intent and execution.
178
257
  const v = checkPermission(call.name, call.args);
179
258
  emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
259
+ // For file-changing tools, show the diff BEFORE the gate decides. A filename
260
+ // ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
261
+ // are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
262
+ if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
263
+ const preview = previewChange(call.name, call.args);
264
+ if (preview)
265
+ console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
266
+ }
180
267
  let content; // what goes back to the model as the tool result
181
268
  if (v.decision === "deny") {
182
269
  emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
183
270
  if (!opts.quiet)
184
- console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
271
+ console.log(mark.denied(v.reason)); // tell the user we blocked it
185
272
  content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
186
273
  }
187
274
  else if (v.decision === "ask") {
188
- const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
275
+ // Optional LLM judge: for a run_bash command the rules couldn't classify,
276
+ // ask the judge first. It can only DOWNGRADE ask→allow for the clearly
277
+ // safe; anything else still goes to the human. The judge never sees a deny.
278
+ let autoAllowed = false;
279
+ if (opts.judge && call.name === "run_bash") {
280
+ let cmd = "";
281
+ try {
282
+ cmd = JSON.parse(call.args).command ?? "";
283
+ }
284
+ catch { /* leave empty → judge will ask */ }
285
+ if (cmd && (await opts.judge.classify(cmd)) === "allow") {
286
+ autoAllowed = true;
287
+ if (!opts.quiet)
288
+ console.log(mark.judge); // visible — the user can see the judge worked
289
+ }
290
+ }
291
+ const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
189
292
  if (!ok)
190
293
  emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
191
294
  if (!ok && !opts.quiet)
192
- console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
295
+ console.log(mark.declined); // make the refusal visible
193
296
  content = ok
194
297
  ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
195
298
  : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
@@ -213,9 +316,23 @@ async function runWithHooks(call, opts) {
213
316
  if (pre.block) {
214
317
  emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
215
318
  if (!opts.quiet)
216
- console.log(chalk.red(` ⛔ blocked by PreToolUse hook`)); // make it visible
319
+ console.log(mark.hookBlock("PreToolUse")); // make it visible
217
320
  return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
218
321
  }
322
+ // A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
323
+ // permission gate already ran on the original args; the rewrite only narrows or
324
+ // annotates, never escalates past a deny. Validate it's parseable, then use it.
325
+ if (pre.rewrite) {
326
+ try {
327
+ JSON.parse(pre.rewrite); // must be valid JSON args
328
+ if (!opts.quiet)
329
+ console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
330
+ call = { ...call, args: pre.rewrite };
331
+ }
332
+ catch {
333
+ /* malformed rewrite — ignore, run the original args */
334
+ }
335
+ }
219
336
  const result = await execute(call, opts); // the actual work
220
337
  const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
221
338
  if (post.block) {
@@ -228,6 +345,10 @@ async function runWithHooks(call, opts) {
228
345
  // Execute one approved tool call. The task tool is special — it is not in the
229
346
  // registry because running it needs the loop itself (it IS a loop).
230
347
  async function execute(call, opts) {
348
+ // ask_user is special like task: running it needs the CLI's prompt, not the
349
+ // tool registry. It presents a form and feeds the selections back to the model.
350
+ if (call.name === "ask_user")
351
+ return runAskUser(call, opts);
231
352
  if (call.name !== "task")
232
353
  return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
233
354
  if (opts.subAgent)
@@ -243,6 +364,29 @@ async function execute(call, opts) {
243
364
  return "[error] task requires a non-empty description argument.";
244
365
  return runSubAgent(description, opts); // spawn the worker
245
366
  }
367
+ // Present the ask_user form and turn the selections into a tool result. The
368
+ // model called this to get a decision; we give it clean question→answer pairs.
369
+ async function runAskUser(call, opts) {
370
+ if (opts.subAgent)
371
+ return "[error] Sub-agents cannot ask the user. Decide yourself, or report back to the parent."; // only the top-level agent talks to the human
372
+ if (!opts.askUser)
373
+ return "[error] No interactive prompt is available in this session — ask in plain text instead.";
374
+ let questions = [];
375
+ try {
376
+ questions = JSON.parse(call.args).questions ?? [];
377
+ }
378
+ catch {
379
+ /* fall through */
380
+ }
381
+ // Keep only well-formed questions (text + at least two options).
382
+ questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
383
+ if (!questions.length)
384
+ return "[error] ask_user needs at least one question with two or more options.";
385
+ const answers = await opts.askUser(questions);
386
+ if (!answers)
387
+ return "The user dismissed the question form without answering. Proceed with your best judgment, or ask again if you truly need their input."; // cancelled / non-interactive
388
+ return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
389
+ }
246
390
  // Run one compaction attempt and keep score. Returns true on success.
247
391
  // Every automatic behavior keeps a failure count — that is what lets the
248
392
  // caller stop a hot failure loop instead of compacting forever.
@@ -250,10 +394,14 @@ async function tryCompact(messages, // history to compact (mutated in place)
250
394
  opts, // for client/model/signal
251
395
  compaction) {
252
396
  try {
397
+ if (!opts.subAgent)
398
+ await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
253
399
  await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
254
400
  compaction.count++; // one more successful compaction this query
255
401
  compaction.failures = 0; // success resets the failure streak
256
402
  emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
403
+ if (!opts.subAgent)
404
+ await runHooks("PostCompact", {}); // observational
257
405
  return true;
258
406
  }
259
407
  catch {
@@ -269,7 +417,7 @@ opts) {
269
417
  // The loop's mutable state: budgets and counters, rewritten every iteration.
270
418
  const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
271
419
  const compaction = { count: 0, failures: 0 }; // compaction score card for this query
272
- for (let round = 1; round <= MAX_ROUNDS; round++) {
420
+ for (let round = 1;; round++) {
273
421
  // One round = one successful model call + its tool results.
274
422
  // The inner loop retries the model call until it succeeds or a budget dies.
275
423
  while (true) {
@@ -357,7 +505,7 @@ opts) {
357
505
  if (!opts.quiet)
358
506
  console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
359
507
  messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
360
- break; // exit the inner while → advance the round counter (so a stubborn Stop hook is still bounded by MAX_ROUNDS)
508
+ break; // exit the inner while → advance the round counter
361
509
  }
362
510
  }
363
511
  return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
@@ -387,5 +535,5 @@ opts) {
387
535
  break; // round complete, move to the next one
388
536
  }
389
537
  }
390
- return { reason: TerminateReason.RoundCap }; // ran out of rounds before a final answer
538
+ // loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
391
539
  }
package/dist/mcp.js ADDED
@@ -0,0 +1,237 @@
1
+ import { spawn } from "node:child_process"; // stdio servers are subprocesses
2
+ import chalk from "chalk"; // status lines
3
+ import { CONFIG } from "./config.js"; // configured servers
4
+ import { registerExternalTool } from "./tools.js"; // expose discovered tools
5
+ import { emit } from "./telemetry.js"; // observability
6
+ // MCP (Model Context Protocol) lets the agent borrow tools from external
7
+ // servers — a filesystem server, a GitHub server, a database server, anything.
8
+ // The wire protocol is JSON-RPC 2.0. We do the initialize handshake, ask for the
9
+ // server's tools, and register each one so it flows through the SAME dispatch and
10
+ // the SAME permission gate as a built-in. There is one execution path, period.
11
+ //
12
+ // Since Day 24 there are TWO ways to reach a server: spawn it and talk over
13
+ // stdio (local), or POST to a URL (remote, "Streamable HTTP"). The protocol is
14
+ // identical; only the pipe differs. That difference lives behind a Transport
15
+ // interface, so McpClient — handshake, ids, tool flattening — never has to care.
16
+ //
17
+ // Discovered tools are named mcp__<server>__<tool> so they never collide with
18
+ // built-ins and the user can see (and gate) exactly where each came from.
19
+ const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
20
+ const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
21
+ // ---- stdio transport: one JSON object per line, to a subprocess --------------
22
+ class StdioTransport {
23
+ child;
24
+ pending = new Map(); // id → resolver (responses arrive async on one stream)
25
+ buffer = ""; // partial stdout, reassembled into whole lines
26
+ constructor(def) {
27
+ this.child = spawn(def.command, def.args ?? [], {
28
+ stdio: ["pipe", "pipe", "pipe"], // we own stdin/stdout; stderr is the server's log
29
+ env: { ...process.env, ...def.env }, // pass through + extras
30
+ });
31
+ // Reassemble newline-delimited JSON from the stdout stream.
32
+ this.child.stdout.on("data", (chunk) => {
33
+ this.buffer += chunk.toString();
34
+ let nl;
35
+ while ((nl = this.buffer.indexOf("\n")) >= 0) {
36
+ const line = this.buffer.slice(0, nl).trim(); // one complete message
37
+ this.buffer = this.buffer.slice(nl + 1); // keep the remainder
38
+ if (line)
39
+ this.onMessage(line);
40
+ }
41
+ });
42
+ this.child.stderr.on("data", () => { }); // the server's stderr is its own logging — never our stdout
43
+ }
44
+ onMessage(line) {
45
+ let msg;
46
+ try {
47
+ msg = JSON.parse(line);
48
+ }
49
+ catch {
50
+ return; // ignore anything that isn't valid JSON-RPC
51
+ }
52
+ const resolver = this.pending.get(msg.id);
53
+ if (resolver) {
54
+ this.pending.delete(msg.id);
55
+ resolver(msg);
56
+ }
57
+ }
58
+ request(payload, timeoutMs) {
59
+ const { id } = payload;
60
+ return new Promise((resolve) => {
61
+ let settled = false;
62
+ const finish = (r) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ clearTimeout(timer);
67
+ this.pending.delete(id);
68
+ resolve(r);
69
+ };
70
+ const timer = setTimeout(() => finish({ id, error: { message: `MCP request '${payload.method}' timed out` } }), timeoutMs);
71
+ this.pending.set(id, finish);
72
+ this.child.stdin.write(JSON.stringify(payload) + "\n"); // one line, newline-terminated
73
+ });
74
+ }
75
+ notify(method, params) {
76
+ this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
77
+ }
78
+ close() {
79
+ this.child.kill(); // best-effort cleanup
80
+ }
81
+ }
82
+ // Pull JSON-RPC messages out of a Server-Sent-Events body. SSE frames are
83
+ // separated by blank lines; the payload lives on one or more `data:` lines.
84
+ // Exported because parsing is the fiddly part worth testing on its own.
85
+ export function parseSseData(body) {
86
+ const out = [];
87
+ for (const frame of body.split(/\r?\n\r?\n/)) {
88
+ const data = frame
89
+ .split(/\r?\n/)
90
+ .filter((l) => l.startsWith("data:"))
91
+ .map((l) => l.slice(5).trim()) // strip "data:" and surrounding space
92
+ .join("\n");
93
+ if (data)
94
+ out.push(data);
95
+ }
96
+ return out;
97
+ }
98
+ // ---- HTTP transport: POST to a URL, read JSON or SSE back --------------------
99
+ class HttpTransport {
100
+ url;
101
+ headers;
102
+ sessionId; // assigned by the server on initialize, echoed on every later request
103
+ constructor(def) {
104
+ this.url = def.url;
105
+ this.headers = { ...def.headers };
106
+ }
107
+ // The response to a POST may come back as a single JSON object OR as an SSE
108
+ // stream carrying it. Either way we want the JSON-RPC message matching our id.
109
+ async post(body, timeoutMs, isRequest) {
110
+ const res = await fetch(this.url, {
111
+ method: "POST",
112
+ headers: {
113
+ "content-type": "application/json",
114
+ accept: "application/json, text/event-stream", // we accept either shape
115
+ ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
116
+ ...this.headers,
117
+ },
118
+ body: JSON.stringify(body),
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ });
121
+ const sid = res.headers.get("mcp-session-id"); // initialize hands us a session to keep
122
+ if (sid)
123
+ this.sessionId = sid;
124
+ if (!isRequest)
125
+ return null; // a notification expects no body (202 Accepted)
126
+ const raw = await res.text();
127
+ const payloads = res.headers.get("content-type")?.includes("text/event-stream") ? parseSseData(raw) : [raw];
128
+ for (const p of payloads) {
129
+ try {
130
+ const msg = JSON.parse(p);
131
+ if (msg && (msg.result !== undefined || msg.error !== undefined))
132
+ return msg; // the response we came for
133
+ }
134
+ catch {
135
+ /* skip non-JSON frames (SSE comments, keep-alives) */
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ async request(payload, timeoutMs) {
141
+ try {
142
+ const msg = await this.post(payload, timeoutMs, true);
143
+ return msg ?? { id: payload.id, error: { message: `MCP request '${payload.method}' returned no response` } };
144
+ }
145
+ catch (err) {
146
+ return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
147
+ }
148
+ }
149
+ notify(method, params) {
150
+ // Fire-and-forget; errors on a notification are not worth surfacing.
151
+ void this.post({ jsonrpc: "2.0", method, params }, INIT_TIMEOUT_MS, false).catch(() => { });
152
+ }
153
+ close() {
154
+ // Best-effort: tell the server to drop the session. No await, no error care.
155
+ if (this.sessionId) {
156
+ void fetch(this.url, { method: "DELETE", headers: { "mcp-session-id": this.sessionId, ...this.headers } }).catch(() => { });
157
+ }
158
+ }
159
+ }
160
+ // 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);
163
+ }
164
+ // The protocol layer: handshake, request ids, tool flattening. It talks to a
165
+ // Transport and is blind to whether that's a subprocess or an HTTP endpoint.
166
+ class McpClient {
167
+ transport;
168
+ nextId = 1; // JSON-RPC request id counter
169
+ constructor(def) {
170
+ this.transport = makeTransport(def);
171
+ }
172
+ request(method, params, timeoutMs) {
173
+ return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
174
+ }
175
+ // The MCP startup dance: initialize → initialized → list tools.
176
+ async start() {
177
+ const init = await this.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mini-agent", version: "0" } }, INIT_TIMEOUT_MS);
178
+ if (init.error)
179
+ throw new Error(init.error.message); // server refused to initialize
180
+ this.transport.notify("notifications/initialized", {}); // tell the server we're ready
181
+ const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS); // ask what it offers
182
+ if (list.error)
183
+ throw new Error(list.error.message);
184
+ return list.result?.tools ?? [];
185
+ }
186
+ // Invoke one tool and flatten its content into plain text for the model.
187
+ async callTool(name, args) {
188
+ const res = await this.request("tools/call", { name, arguments: args }, CALL_TIMEOUT_MS);
189
+ if (res.error)
190
+ return `[error] MCP tool failed: ${res.error.message}`; // surface, don't throw
191
+ const text = (res.result?.content ?? []).map((c) => c.text ?? `[${c.type} content]`).join("\n"); // flatten parts
192
+ const body = text || "(no output)";
193
+ return res.result?.isError ? `[error] ${body}` : body; // MCP can flag a logical error in a successful response
194
+ }
195
+ kill() {
196
+ this.transport.close();
197
+ }
198
+ }
199
+ // Connect to every configured MCP server, register their tools, and return a
200
+ // cleanup function. Failures are isolated: a server that won't start is logged
201
+ // and skipped — it never stops the agent or the other servers.
202
+ export async function connectMcpServers() {
203
+ const entries = Object.entries(CONFIG.mcpServers); // [name, def] pairs
204
+ if (!entries.length)
205
+ return () => { }; // the common case: nothing configured
206
+ const clients = []; // for cleanup
207
+ for (const [serverName, def] of entries) {
208
+ const client = new McpClient(def);
209
+ try {
210
+ const specs = await client.start(); // handshake + discover
211
+ clients.push(client);
212
+ for (const spec of specs) {
213
+ const toolName = `mcp__${serverName}__${spec.name}`; // namespaced, collision-proof
214
+ const tool = {
215
+ definition: {
216
+ type: "function",
217
+ function: {
218
+ name: toolName,
219
+ description: `[MCP:${serverName}] ${spec.description ?? spec.name}`, // mark the origin in the manual
220
+ parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
221
+ },
222
+ },
223
+ run: async (args) => client.callTool(spec.name, args), // dispatch routes here; the permission gate ran first
224
+ };
225
+ registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
226
+ }
227
+ emit("agent_mcp_connected", { server: serverName, tools: specs.length, transport: def.url ? "http" : "stdio" });
228
+ console.log(chalk.dim(`(mcp: ${serverName} — ${specs.length} tools${def.url ? " over http" : ""})`)); // visible at startup
229
+ }
230
+ catch (err) {
231
+ client.kill(); // don't leak the process/session
232
+ emit("agent_mcp_failed", { server: serverName });
233
+ console.error(chalk.yellow(` [mcp] ${serverName} failed to start (skipped): ${err.message}`)); // loud, but not fatal
234
+ }
235
+ }
236
+ return () => clients.forEach((c) => c.kill()); // the caller calls this on exit
237
+ }