@matterailab/orbcode 0.2.1 → 0.2.3

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.
@@ -3,6 +3,7 @@ import { fileEdit, fileWrite, multiFileEdit, readFile } from "./executors/files.
3
3
  import { listFiles } from "./executors/listFiles.js";
4
4
  import { searchFiles } from "./executors/searchFiles.js";
5
5
  import { executeCommand } from "./executors/executeCommand.js";
6
+ import { useSkill } from "./executors/skills.js";
6
7
  import { webFetch, webSearch } from "./executors/web.js";
7
8
  export { nativeTools };
8
9
  /** Tools that modify the user's system and need approval before running. */
@@ -44,11 +45,18 @@ export function describeToolCall(toolName, args) {
44
45
  return String(args.url ?? "");
45
46
  case "update_todo_list":
46
47
  return "updating tasks";
48
+ case "use_skill":
49
+ return String(args.skill_name ?? "");
47
50
  case "ask_followup_question":
48
51
  return String(args.question ?? "");
49
52
  case "attempt_completion":
50
53
  return "task complete";
51
54
  default:
55
+ // MCP tools (mcp__<server>__<tool>) — show the server + tool name.
56
+ if (/^mcp__/.test(toolName)) {
57
+ const match = /^mcp__([^_]+)__(.+)$/.exec(toolName);
58
+ return match ? `${match[2]} (${match[1]})` : toolName;
59
+ }
52
60
  return toolName;
53
61
  }
54
62
  }
@@ -62,13 +70,20 @@ const executors = {
62
70
  execute_command: executeCommand,
63
71
  web_search: webSearch,
64
72
  web_fetch: webFetch,
73
+ use_skill: useSkill,
65
74
  update_todo_list: async (args, context) => {
66
75
  context.setTodos(String(args.todos ?? ""));
67
76
  return { text: "Todo list updated." };
68
77
  },
69
78
  // ask_followup_question and attempt_completion are handled by the agent loop.
79
+ // MCP tools (mcp__<server>__<tool>) are routed via the McpManager in executeTool.
70
80
  };
71
- export async function executeTool(toolName, args, context) {
81
+ export async function executeTool(toolName, args, context, mcp) {
82
+ // MCP tools are namespaced as mcp__<server>__<tool> and routed to the manager.
83
+ if (mcp && mcp.hasTool(toolName)) {
84
+ const result = await mcp.callTool(toolName, args);
85
+ return { text: result.text, isError: result.isError };
86
+ }
72
87
  const executor = executors[toolName];
73
88
  if (!executor) {
74
89
  return { text: `Tool "${toolName}" is not available in OrbCode CLI.`, isError: true };
@@ -80,6 +95,10 @@ export async function executeTool(toolName, args, context) {
80
95
  return { text: `Tool ${toolName} failed: ${error.message}`, isError: true };
81
96
  }
82
97
  }
83
- export function getActiveTools() {
84
- return nativeTools;
98
+ /** Native tools plus any MCP tools from connected servers. */
99
+ export function getActiveTools(mcp) {
100
+ const tools = [...nativeTools];
101
+ if (mcp)
102
+ tools.push(...mcp.getTools());
103
+ return tools;
85
104
  }
@@ -8,11 +8,13 @@ import listFiles from "./list_files.js";
8
8
  import { read_file_single } from "./read_file.js";
9
9
  import searchFiles from "./search_files.js";
10
10
  import updateTodoList from "./update_todo_list.js";
11
+ import useSkill from "./use_skill.js";
11
12
  import webFetch from "./web_fetch.js";
12
13
  import webSearch from "./web_search.js";
13
- // Native tool schemas ported verbatim from the Orbital extension (native-tools).
14
- // Tools that depend on IDE-only services (codebase_search, lsp, use_skill,
15
- // check_past_chat_memories, browser_action, …) are not active in the CLI.
14
+ // Native tool schemas ported from the Orbital extension. IDE-only tools
15
+ // (codebase_search, lsp, check_past_chat_memories, browser_action, …) are not
16
+ // active in the CLI. use_skill is now active: skills are loaded from
17
+ // ~/.orbcode/skills/ and .orbcode/skills/ (see src/skills/loader.ts).
16
18
  export const nativeTools = [
17
19
  fileEdit,
18
20
  multiFileEdit,
@@ -24,6 +26,7 @@ export const nativeTools = [
24
26
  read_file_single,
25
27
  searchFiles,
26
28
  updateTodoList,
29
+ useSkill,
27
30
  webFetch,
28
31
  webSearch,
29
32
  ];
@@ -2,7 +2,7 @@ export default {
2
2
  type: "function",
3
3
  function: {
4
4
  name: "use_skill",
5
- description: "Use a specific skill to guide the task execution. This tool allows you to apply predefined skills stored in the workspace's .agent/skills directory. Each skill contains specialized instructions for performing specific tasks or following particular patterns.",
5
+ description: "Use a specific skill to guide the task execution. This tool applies predefined skills stored in ~/.orbcode/skills/ (user) and .orbcode/skills/ (project). Each skill is a directory containing a SKILL.md file with specialized instructions for performing specific tasks or following particular patterns. The available skills are listed in the 'Available Skills' section of your system prompt — invoke this tool with a skill_name from that list when the task matches a skill's when-to-use condition.",
6
6
  strict: true,
7
7
  parameters: {
8
8
  type: "object",
package/dist/ui/App.js CHANGED
@@ -1,18 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { Box, Static, Text, useApp, useInput } from "ink";
4
4
  import open from "open";
5
5
  import { COLORS, VERSION } from "../branding.js";
6
- import { AXON_MODELS, getModel, isValidAxonModel } from "../api/models.js";
6
+ import { BUILTIN_AXON_MODELS, getModel, isValidAxonModel, } from "../api/models.js";
7
7
  import { LoginView } from "./LoginView.js";
8
- import { APP_URL, fetchBalance, fetchProfile, fetchTaskTitle } from "../auth/auth.js";
9
- import { getAuthToken, getPendingProjectHooks, loadSettings, saveSettings, trustProjectHooks, } from "../config/settings.js";
8
+ import { APP_URL, fetchProfile, fetchTaskTitle, } from "../auth/auth.js";
9
+ import { getAuthToken, getPendingProjectHooks, loadSettings, saveMcpApproval, saveSettings, trustProjectHooks, } from "../config/settings.js";
10
10
  import { Agent } from "../core/agent.js";
11
+ import { McpManager } from "../mcp/manager.js";
11
12
  import { Spinner } from "./components/Spinner.js";
12
13
  import { InputBox } from "./components/InputBox.js";
13
14
  import { ApprovalPrompt } from "./components/ApprovalPrompt.js";
14
15
  import { FollowupPrompt } from "./components/FollowupPrompt.js";
15
16
  import { HookTrustPrompt } from "./components/HookTrustPrompt.js";
17
+ import { McpApprovalPrompt } from "./components/McpApprovalPrompt.js";
18
+ import { McpPicker } from "./components/McpPicker.js";
16
19
  import { StatusBar } from "./components/StatusBar.js";
17
20
  import { ModelPicker } from "./components/ModelPicker.js";
18
21
  import { SessionPicker } from "./components/SessionPicker.js";
@@ -21,16 +24,38 @@ import { RowView, formatToolName } from "./components/rows.js";
21
24
  const SLASH_COMMANDS = [
22
25
  { name: "/help", description: "show available commands" },
23
26
  { name: "/model", description: "select the Axon model to use" },
24
- { name: "/clear", description: "clear the screen — the conversation continues" },
27
+ {
28
+ name: "/clear",
29
+ description: "clear the screen — the conversation continues",
30
+ },
25
31
  { name: "/new", description: "start a new conversation with a clean slate" },
26
32
  { name: "/resume", description: "resume a previous session" },
27
- { name: "/compact", description: "summarize the conversation to free up context" },
33
+ {
34
+ name: "/compact",
35
+ description: "summarize the conversation to free up context",
36
+ },
28
37
  { name: "/tasks", description: "show the current task list" },
29
- { name: "/status", description: "show session status (model, context, cost, account)" },
30
- { name: "/cost", description: "show session cost and balance" },
31
- { name: "/init", description: "analyze this codebase and create an AGENTS.md" },
32
- { name: "/commit", description: "check pending changes and create detailed commits" },
33
- { name: "/code-review", description: "expert review of pending changes: performance, security, bugs, tests" },
38
+ {
39
+ name: "/status",
40
+ description: "show session status (model, context, cost, account)",
41
+ },
42
+ { name: "/usage", description: "fetch plan usage" },
43
+ {
44
+ name: "/init",
45
+ description: "analyze this codebase and create an AGENTS.md",
46
+ },
47
+ {
48
+ name: "/mcp",
49
+ description: "manage MCP servers — enable, disable, reconnect, view status",
50
+ },
51
+ {
52
+ name: "/commit",
53
+ description: "check pending changes and create detailed commits",
54
+ },
55
+ {
56
+ name: "/code-review",
57
+ description: "expert review of pending changes: performance, security, bugs, tests",
58
+ },
34
59
  { name: "/analytics", description: "open your MatterAI analytics dashboard" },
35
60
  { name: "/login", description: "sign in to MatterAI" },
36
61
  { name: "/logout", description: "sign out and remove the saved token" },
@@ -64,17 +89,11 @@ function formatRelativeTime(isoStr) {
64
89
  return `in ${min}m`;
65
90
  return "soon";
66
91
  }
67
- /** Human lines for the /status and /cost usage block (extension profile data). */
92
+ /** Human lines for the /status and /usage usage block (extension profile data). */
68
93
  function usageLines(profile) {
69
94
  const lines = [];
70
95
  if (profile.plan)
71
- lines.push(`Plan ${profile.plan}`);
72
- if (typeof profile.usagePercentage === "number") {
73
- lines.push(`Usage ${profile.usagePercentage}% used · ${Math.max(0, 100 - profile.usagePercentage)}% remaining`);
74
- }
75
- if (typeof profile.remainingReviews === "number") {
76
- lines.push(`Reviews ${profile.remainingReviews} remaining`);
77
- }
96
+ lines.push(`Plan ${profile.plan?.toUpperCase()}`);
78
97
  if (profile.tieredUsage) {
79
98
  const ws = [
80
99
  ["5hr", "5-Hour", profile.tieredUsage.fiveHour],
@@ -82,10 +101,9 @@ function usageLines(profile) {
82
101
  ["mo", "Monthly", profile.tieredUsage.monthly],
83
102
  ];
84
103
  for (const [short, label, w] of ws) {
85
- const remaining = Math.max(0, w.remaining || 0);
86
104
  const pct = Math.max(0, Math.min(100, w.percentage || 0));
87
105
  const reset = formatRelativeTime(w.resetsAt);
88
- lines.push(`${label.padEnd(12)} ${remaining.toFixed(1)}/${w.limit.toFixed(1)} left (${pct}% used) · Resets ${reset}`);
106
+ lines.push(`${label.padEnd(12)}${pct}% used · Resets ${reset}`);
89
107
  }
90
108
  }
91
109
  else if (profile.creditsResetDate) {
@@ -149,7 +167,12 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
149
167
  const [view, setView] = useState(initialView ?? (getAuthToken(settings) ? "chat" : "login"));
150
168
  const [updateInfo, setUpdateInfo] = useState(null);
151
169
  const [rows, setRows] = useState(() => [
152
- { kind: "header", id: "header", cwd: process.cwd(), modelName: getModel(loadSettings().model).name },
170
+ {
171
+ kind: "header",
172
+ id: "header",
173
+ cwd: process.cwd(),
174
+ modelName: getModel(loadSettings().model).name,
175
+ },
153
176
  ]);
154
177
  const [busy, setBusy] = useState(false);
155
178
  const [busyLabel, setBusyLabel] = useState("Thinking");
@@ -167,6 +190,12 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
167
190
  const [queuedMessages, setQueuedMessages] = useState([]);
168
191
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
169
192
  const [resumableSessions, setResumableSessions] = useState(null);
193
+ // MCP manager (created once, shared across agents in this process). Null until
194
+ // the first agent is created so we don't spawn servers before login.
195
+ const mcpManagerRef = useRef(null);
196
+ const [mcpPickerOpen, setMcpPickerOpen] = useState(false);
197
+ // Project-scope MCP servers awaiting the user's approval at startup.
198
+ const [pendingMcpApproval, setPendingMcpApproval] = useState(null);
170
199
  const [staticKey, setStaticKey] = useState(0);
171
200
  const [tasks, setTasks] = useState("");
172
201
  const [contextTokens, setContextTokens] = useState(0);
@@ -184,7 +213,11 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
184
213
  if (!token)
185
214
  return;
186
215
  fetchProfile(token)
187
- .then((profile) => setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage }))
216
+ .then((profile) => setUsage({
217
+ plan: profile.plan,
218
+ usagePercentage: profile.usagePercentage,
219
+ tieredUsage: profile.tieredUsage,
220
+ }))
188
221
  .catch(() => { });
189
222
  }, []);
190
223
  const agentRef = useRef(null);
@@ -243,7 +276,12 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
243
276
  process.stdout.write("\x1b[2J\x1b[H");
244
277
  }
245
278
  setRows([
246
- { kind: "header", id: rowId(), cwd: process.cwd(), modelName: getModel(loadSettings().model).name },
279
+ {
280
+ kind: "header",
281
+ id: rowId(),
282
+ cwd: process.cwd(),
283
+ modelName: getModel(loadSettings().model).name,
284
+ },
247
285
  ]);
248
286
  setStaticKey((k) => k + 1);
249
287
  }, []);
@@ -275,7 +313,11 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
275
313
  setStreamingText("");
276
314
  break;
277
315
  case "tool-start":
278
- setRunningTool({ id: event.id, name: event.name, summary: event.summary });
316
+ setRunningTool({
317
+ id: event.id,
318
+ name: event.name,
319
+ summary: event.summary,
320
+ });
279
321
  setBusyLabel(`Running ${event.name}`);
280
322
  break;
281
323
  case "tool-end":
@@ -305,7 +347,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
305
347
  pushRow({ kind: "completion", text: event.result });
306
348
  break;
307
349
  case "system":
308
- pushRow({ kind: event.isError ? "error" : "info", text: event.message });
350
+ pushRow({
351
+ kind: event.isError ? "error" : "info",
352
+ text: event.message,
353
+ });
309
354
  break;
310
355
  case "error":
311
356
  pushRow({ kind: "error", text: event.message });
@@ -346,6 +391,13 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
346
391
  }, [pushRow, maybeFetchTitle, refreshUsage, drainQueue]);
347
392
  const createAgent = useCallback((resume) => {
348
393
  const current = loadSettings();
394
+ // Create the MCP manager once per process and reuse it across agents
395
+ // (so /new and /resume keep the same server connections). It reads
396
+ // the enabled/disabled lists from settings and connects to approved
397
+ // servers lazily on start().
398
+ if (!mcpManagerRef.current) {
399
+ mcpManagerRef.current = new McpManager(process.cwd(), current.disabledMcpServers ?? [], current.enabledMcpServers ?? []);
400
+ }
349
401
  return new Agent({
350
402
  cwd: process.cwd(),
351
403
  token: getAuthToken(current),
@@ -355,6 +407,7 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
355
407
  autoApproveEdits: current.autoApproveEdits,
356
408
  autoApproveSafeCommands: current.autoApproveSafeCommands,
357
409
  hooks: current.hooks,
410
+ mcp: mcpManagerRef.current,
358
411
  resume,
359
412
  callbacks: {
360
413
  onEvent: handleEvent,
@@ -393,23 +446,33 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
393
446
  if (match)
394
447
  pushRow({ kind: "user", text: match[1] });
395
448
  }
396
- else if (message.role === "assistant" && typeof message.content === "string" && message.content.trim()) {
449
+ else if (message.role === "assistant" &&
450
+ typeof message.content === "string" &&
451
+ message.content.trim()) {
397
452
  pushRow({ kind: "assistant", text: message.content });
398
453
  }
399
454
  }
400
- pushRow({ kind: "info", text: `Resumed session: ${session.title || session.id}` });
455
+ pushRow({
456
+ kind: "info",
457
+ text: `Resumed session: ${session.title || session.id}`,
458
+ });
401
459
  }, [createAgent, pushRow, resetTranscript]);
402
460
  const switchModel = useCallback((modelId) => {
403
461
  const updated = { ...loadSettings(), model: modelId };
404
462
  setSettings(updated);
405
463
  saveSettings(updated);
406
464
  agentRef.current?.setModel(modelId);
407
- pushRow({ kind: "info", text: `Model switched to ${getModel(modelId).name}` });
465
+ pushRow({
466
+ kind: "info",
467
+ text: `Model switched to ${getModel(modelId).name}`,
468
+ });
408
469
  }, [pushRow]);
409
470
  // Fire SessionEnd hooks (best-effort, capped at 3s) before quitting.
410
471
  const endAndExit = useCallback((reason) => {
411
472
  const agent = agentRef.current;
412
473
  if (!agent) {
474
+ // No agent yet, but the MCP manager may have started; tear it down.
475
+ void mcpManagerRef.current?.stop().catch(() => { });
413
476
  exit();
414
477
  return;
415
478
  }
@@ -444,15 +507,26 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
444
507
  });
445
508
  break;
446
509
  case "/model": {
447
- const ids = Object.keys(AXON_MODELS);
448
- if (arg && isValidAxonModel(arg)) {
510
+ // The interactive picker is restricted to Axon's own models for now.
511
+ // Third-party providers (Anthropic, OpenAI-compatible) are still
512
+ // usable headlessly via `orbcode -p "..." --model <id>` — see
513
+ // the README "Other providers" section.
514
+ const pickerIds = Object.keys(BUILTIN_AXON_MODELS);
515
+ if (arg && pickerIds.includes(arg)) {
449
516
  switchModel(arg);
450
517
  }
518
+ else if (arg && isValidAxonModel(arg)) {
519
+ // Recognized id, but it's a third-party model: not selectable in the TUI.
520
+ pushRow({
521
+ kind: "info",
522
+ text: `Model "${arg}" is only available in non-interactive mode. Run: orbcode -p "..." --model ${arg}`,
523
+ });
524
+ }
451
525
  else if (arg) {
452
526
  // Allow short suffixes like "pro" or "mini" to resolve to the
453
527
  // latest matching registered id, so /model pro keeps working
454
528
  // as new model generations are added.
455
- const matches = ids
529
+ const matches = pickerIds
456
530
  .filter((id) => id.endsWith(`-${arg}`))
457
531
  .sort()
458
532
  .reverse();
@@ -460,7 +534,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
460
534
  switchModel(matches[0]);
461
535
  }
462
536
  else {
463
- pushRow({ kind: "error", text: `Unknown model "${arg}". Available: ${ids.join(", ")}` });
537
+ pushRow({
538
+ kind: "error",
539
+ text: `Unknown model "${arg}". Available: ${pickerIds.join(", ")}`,
540
+ });
464
541
  }
465
542
  }
466
543
  else {
@@ -493,7 +570,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
493
570
  case "/resume": {
494
571
  const sessions = listSessions(process.cwd()).filter((s) => s.id !== agentRef.current?.taskId);
495
572
  if (sessions.length === 0) {
496
- pushRow({ kind: "info", text: "No previous sessions found for this directory." });
573
+ pushRow({
574
+ kind: "info",
575
+ text: "No previous sessions found for this directory.",
576
+ });
497
577
  break;
498
578
  }
499
579
  setResumableSessions(sessions);
@@ -553,6 +633,18 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
553
633
  setBusyLabel("Thinking");
554
634
  void getAgent().runTurn(INIT_PROMPT);
555
635
  break;
636
+ case "/mcp": {
637
+ const manager = mcpManagerRef.current;
638
+ if (!manager) {
639
+ pushRow({
640
+ kind: "info",
641
+ text: "MCP not initialized yet — send a message first.",
642
+ });
643
+ break;
644
+ }
645
+ setMcpPickerOpen(true);
646
+ break;
647
+ }
556
648
  case "/commit":
557
649
  if (!getAuthToken(settings)) {
558
650
  setView("login");
@@ -576,15 +668,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
576
668
  case "/version":
577
669
  pushRow({ kind: "info", text: `OrbCode CLI v${VERSION}` });
578
670
  break;
579
- case "/cost": {
580
- pushRow({ kind: "info", text: `Session cost: $${totalCost.toFixed(4)} (fetching balance)` });
671
+ case "/usage": {
672
+ pushRow({ kind: "info", text: "Fetching plan usage" });
581
673
  const token = getAuthToken(settings);
582
674
  if (token) {
583
- fetchBalance(token, settings.organizationId).then((balance) => {
584
- if (balance !== undefined) {
585
- pushRow({ kind: "info", text: `Account balance: $${balance.toFixed(2)}` });
586
- }
587
- });
588
675
  fetchProfile(token)
589
676
  .then((profile) => {
590
677
  const lines = usageLines(profile);
@@ -614,9 +701,24 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
614
701
  endAndExit("prompt_input_exit");
615
702
  break;
616
703
  default:
617
- pushRow({ kind: "error", text: `Unknown command: ${name}. Try /help.` });
704
+ pushRow({
705
+ kind: "error",
706
+ text: `Unknown command: ${name}. Try /help.`,
707
+ });
618
708
  }
619
- }, [settings, tasks, contextTokens, totalCost, sessionTitle, endAndExit, pushRow, getAgent, switchModel, resetTranscript, clearQueue]);
709
+ }, [
710
+ settings,
711
+ tasks,
712
+ contextTokens,
713
+ totalCost,
714
+ sessionTitle,
715
+ endAndExit,
716
+ pushRow,
717
+ getAgent,
718
+ switchModel,
719
+ resetTranscript,
720
+ clearQueue,
721
+ ]);
620
722
  const handleSubmit = useCallback((value) => {
621
723
  if (value.startsWith("/")) {
622
724
  handleCommand(value);
@@ -647,7 +749,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
647
749
  const updated = loadSettings();
648
750
  setSettings(updated);
649
751
  agentRef.current?.setHooks(updated.hooks);
650
- pushRow({ kind: "info", text: "Project hooks trusted — enabled for this workspace." });
752
+ pushRow({
753
+ kind: "info",
754
+ text: "Project hooks trusted — enabled for this workspace.",
755
+ });
651
756
  }
652
757
  else {
653
758
  pushRow({
@@ -660,6 +765,28 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
660
765
  if (deferred)
661
766
  handleSubmit(deferred);
662
767
  }, [handleSubmit, pushRow]);
768
+ // Resolve the MCP server approval prompt: connect to the approved project
769
+ // servers, persist the decision, and proceed with any deferred startup prompt.
770
+ const resolveMcpApproval = useCallback(async (approved) => {
771
+ setPendingMcpApproval(null);
772
+ const manager = mcpManagerRef.current;
773
+ if (manager) {
774
+ // Enable each approved server (connects immediately). Disapproved
775
+ // ones stay disabled and won't prompt again (persisted below).
776
+ await Promise.all(approved.map((name) => manager.enableServer(name).catch(() => { })));
777
+ saveMcpApproval(process.cwd(), manager.getEnabled(), manager.getDisabled());
778
+ const snap = manager.snapshot();
779
+ const connected = snap.servers.filter((s) => s.status === "connected").length;
780
+ pushRow({
781
+ kind: "info",
782
+ text: `MCP: ${connected}/${snap.servers.length} server${snap.servers.length === 1 ? "" : "s"} connected${approved.length ? ` (approved: ${approved.join(", ")})` : ""}.`,
783
+ });
784
+ }
785
+ const deferred = deferredPromptRef.current;
786
+ deferredPromptRef.current = null;
787
+ if (deferred)
788
+ handleSubmit(deferred);
789
+ }, [handleSubmit, pushRow]);
663
790
  // Apply --resume and an initial prompt (`orbcode "do something"`) on startup.
664
791
  const bootedRef = useRef(false);
665
792
  useEffect(() => {
@@ -678,6 +805,19 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
678
805
  else if (initialPrompt) {
679
806
  handleSubmit(initialPrompt);
680
807
  }
808
+ // Start the MCP manager and surface any project-scope servers that need
809
+ // approval. The approval prompt is non-blocking: the agent can start
810
+ // working while the user decides, and approved servers connect live.
811
+ const current = loadSettings();
812
+ if (getAuthToken(current)) {
813
+ const manager = new McpManager(process.cwd(), current.disabledMcpServers ?? [], current.enabledMcpServers ?? []);
814
+ mcpManagerRef.current = manager;
815
+ void manager.start().then(() => {
816
+ const pendingMcp = manager.getPendingApproval();
817
+ if (pendingMcp.length > 0)
818
+ setPendingMcpApproval(pendingMcp);
819
+ });
820
+ }
681
821
  refreshUsage();
682
822
  }, [initialSession, initialPrompt, handleResume, handleSubmit, refreshUsage]);
683
823
  // Resolve the npm version check after first paint so the TUI shows up
@@ -710,7 +850,11 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
710
850
  const next = prev === "ask" ? "edits" : prev === "edits" ? "auto" : "ask";
711
851
  const autoApproveEdits = next !== "ask";
712
852
  const autoApproveSafeCommands = next === "auto";
713
- const updated = { ...loadSettings(), autoApproveEdits, autoApproveSafeCommands };
853
+ const updated = {
854
+ ...loadSettings(),
855
+ autoApproveEdits,
856
+ autoApproveSafeCommands,
857
+ };
714
858
  setSettings(updated);
715
859
  saveSettings(updated);
716
860
  agentRef.current?.setApprovalMode(autoApproveEdits, autoApproveSafeCommands);
@@ -722,7 +866,7 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
722
866
  expandReasoningRef.current = expanded;
723
867
  // Re-render the whole transcript (including past thinking) with the
724
868
  // new expansion state: clear the screen and remount <Static>.
725
- setRows((prev) => prev.map((row) => (row.kind === "reasoning" ? { ...row, expanded } : row)));
869
+ setRows((prev) => prev.map((row) => row.kind === "reasoning" ? { ...row, expanded } : row));
726
870
  if (process.stdout.isTTY) {
727
871
  process.stdout.write("\x1b[2J\x1b[H");
728
872
  }
@@ -735,31 +879,53 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
735
879
  saveSettings(updated);
736
880
  agentRef.current = null;
737
881
  setView("chat");
738
- setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage });
882
+ setUsage({
883
+ plan: profile.plan,
884
+ usagePercentage: profile.usagePercentage,
885
+ tieredUsage: profile.tieredUsage,
886
+ });
739
887
  const who = profile.user?.name || profile.user?.email;
740
- pushRow({ kind: "info", text: `Signed in${who ? ` as ${who}` : ""}. Ready when you are.` });
888
+ pushRow({
889
+ kind: "info",
890
+ text: `Signed in${who ? ` as ${who}` : ""}. Ready when you are.`,
891
+ });
741
892
  }, [pushRow]);
742
- const taskLines = useMemo(() => tasks.split("\n").map((l) => l.trim()).filter(Boolean), [tasks]);
893
+ const taskLines = useMemo(() => tasks
894
+ .split("\n")
895
+ .map((l) => l.trim())
896
+ .filter(Boolean), [tasks]);
743
897
  const inputActive = view === "chat" &&
744
898
  !pendingApproval &&
745
899
  !pendingFollowup &&
746
900
  !pendingHookTrust &&
901
+ !pendingMcpApproval &&
747
902
  !modelPickerOpen &&
903
+ !mcpPickerOpen &&
748
904
  !resumableSessions;
749
905
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [updateInfo?.updateAvailable && updateInfo.latest && (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.warning, paddingX: 2, alignSelf: "flex-start", children: [_jsxs(Text, { color: COLORS.warning, bold: true, children: ["\u2191 Update available: v", updateInfo.current, " \u2192 v", updateInfo.latest] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: COLORS.accent, children: "orbcode update" }), " to install the latest version, then relaunch."] })] })), streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
750
906
  .replace(/^[-*]\s*\[x\]/i, " ■")
751
907
  .replace(/^[-*]\s*\[-\]/, " ◧")
752
- .replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] })] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
908
+ .replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] }))] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
753
909
  setModelPickerOpen(false);
754
910
  switchModel(modelId);
755
- }, onCancel: () => setModelPickerOpen(false) }) })), resumableSessions && (_jsx(Box, { marginTop: 1, children: _jsx(SessionPicker, { sessions: resumableSessions, onSelect: handleResume, onCancel: () => setResumableSessions(null) }) })), pendingHookTrust && (_jsx(Box, { marginTop: 1, children: _jsx(HookTrustPrompt, { cwd: process.cwd(), commands: pendingHookTrust.commands, onDecision: resolveHookTrust }) })), pendingApproval && (_jsx(Box, { marginTop: 1, children: _jsx(ApprovalPrompt, { request: pendingApproval.request, onDecision: (decision) => {
911
+ }, onCancel: () => setModelPickerOpen(false) }) })), resumableSessions && (_jsx(Box, { marginTop: 1, children: _jsx(SessionPicker, { sessions: resumableSessions, onSelect: handleResume, onCancel: () => setResumableSessions(null) }) })), pendingHookTrust && (_jsx(Box, { marginTop: 1, children: _jsx(HookTrustPrompt, { cwd: process.cwd(), commands: pendingHookTrust.commands, onDecision: resolveHookTrust }) })), pendingMcpApproval && (_jsx(Box, { marginTop: 1, children: _jsx(McpApprovalPrompt, { serverNames: pendingMcpApproval, onApprove: resolveMcpApproval }) })), mcpPickerOpen && mcpManagerRef.current && (_jsx(Box, { marginTop: 1, children: _jsx(McpPicker, { manager: mcpManagerRef.current, onChanged: () => {
912
+ const m = mcpManagerRef.current;
913
+ if (m)
914
+ saveMcpApproval(process.cwd(), m.getEnabled(), m.getDisabled());
915
+ }, onCancel: () => setMcpPickerOpen(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, children: _jsx(ApprovalPrompt, { request: pendingApproval.request, onDecision: (decision) => {
756
916
  pendingApproval.resolve(decision);
757
917
  setPendingApproval(null);
758
918
  } }) })), pendingFollowup && (_jsx(Box, { marginTop: 1, children: _jsx(FollowupPrompt, { question: pendingFollowup.question, suggestions: pendingFollowup.suggestions, onAnswer: (answer) => {
759
919
  pushRow({ kind: "user", text: answer });
760
920
  pendingFollowup.resolve(answer);
761
921
  setPendingFollowup(null);
762
- } }) })), busy && !pendingApproval && !pendingFollowup && !pendingHookTrust && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
922
+ } }) })), busy &&
923
+ !pendingApproval &&
924
+ !pendingFollowup &&
925
+ !pendingHookTrust &&
926
+ !pendingMcpApproval &&
927
+ !mcpPickerOpen &&
928
+ !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
763
929
  }
764
930
  function tail(text, lines) {
765
931
  const all = text.split("\n").filter((l) => l.trim());
@@ -771,6 +937,6 @@ function truncateForQueue(text) {
771
937
  return text;
772
938
  return text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…";
773
939
  }
774
- function LoginSection({ onLogin }) {
940
+ function LoginSection({ onLogin, }) {
775
941
  return _jsx(LoginView, { onLogin: onLogin });
776
942
  }