@standardagents/code 0.0.2-dev.51cdb4c → 0.1.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/src/index.ts DELETED
@@ -1,1152 +0,0 @@
1
- /**
2
- * Standard Code CLI entry point.
3
- *
4
- * Pre-flight (plain readline): resolve the instance endpoint, OAuth-login if
5
- * needed, then offer to resume a thread tagged for this project+machine or start
6
- * a new one. Then hand off to the interactive TUI which streams the agent and
7
- * forwards its tools to this host.
8
- */
9
- import os from "node:os";
10
- import fs from "node:fs";
11
- import path from "node:path";
12
- import readline from "node:readline/promises";
13
- import { spawn } from "node:child_process";
14
- import { stdin as input, stdout as output } from "node:process";
15
- import { ApiClient } from "./api.ts";
16
- import { Bridge } from "./bridge.ts";
17
- import { HostTools } from "./host-tools.ts";
18
- import { MessageStream } from "./stream.ts";
19
- import { SystemEvents, type ThreadEntry } from "./events-stream.ts";
20
- import { Tui } from "./tui.ts";
21
- import { renderMarkdown } from "./markdown.ts";
22
- import { ProcessRegistry } from "./process-registry.ts";
23
- import { McpManager } from "./mcp.ts";
24
- import {
25
- listMcpServers,
26
- saveMcpServer,
27
- removeMcpServer,
28
- setMcpServerEnabled,
29
- parseServerSpec,
30
- type McpServerConfig,
31
- } from "./mcp-config.ts";
32
- import {
33
- getCredential,
34
- saveCredential,
35
- defaultEndpoint,
36
- normalizeEndpoint,
37
- } from "./credentials.ts";
38
- import { LEVELS, levelLabel, type Level, type ThreadSummary } from "./types.ts";
39
- import type { PermissionState } from "./permissions.ts";
40
- import { loadApprovals, saveApprovals } from "./approvals.ts";
41
-
42
- const AGENT_ID = "standard_code_agent";
43
-
44
- const c = {
45
- reset: "\x1b[0m",
46
- dim: "\x1b[2m",
47
- bold: "\x1b[1m",
48
- white: "\x1b[97m",
49
- cyan: "\x1b[36m",
50
- green: "\x1b[32m",
51
- gray: "\x1b[90m",
52
- magenta: "\x1b[35m",
53
- yellow: "\x1b[33m",
54
- red: "\x1b[31m",
55
- teal: "\x1b[38;5;37m", // brand teal (matches the marketing site's teal accent)
56
- };
57
-
58
- /**
59
- * The Standard Agents logomark — two interlocking squares crossed by a diagonal
60
- * lens — rendered in full block characters. Printed at the left of the startup
61
- * banner with the session details aligned to its right.
62
- */
63
- const LOGO_MARK = [
64
- " █████████████",
65
- " ███ ██",
66
- " ██ █",
67
- "████ ████████ ██",
68
- "███ ████████ ███",
69
- "██ ████████ ████",
70
- "█ ██",
71
- "██ ███",
72
- "█████████████",
73
- ];
74
-
75
- /**
76
- * Print an assistant reply: light Markdown formatting, padded with a blank line
77
- * above and below so successive messages don't run together.
78
- */
79
- function printAssistant(tui: Tui, text: string): void {
80
- const cols = Math.max(20, (process.stdout.columns || 80) - 1); // -1 cushion to avoid edge-wrap
81
- tui.print("");
82
- for (const line of renderMarkdown(text, cols)) tui.print(line);
83
- tui.print("");
84
- }
85
-
86
- /** A calm, branded goodbye printed on a graceful shutdown. */
87
- function farewell(): void {
88
- output.write(`\n${c.teal}◇${c.reset} ${c.dim}Standard Code — see you soon.${c.reset}\n`);
89
- }
90
-
91
- /** Whether a hostname is a local/private dev address (loopback, .local, RFC-1918). */
92
- function isLocalHost(host: string): boolean {
93
- return (
94
- host === "localhost" ||
95
- host === "127.0.0.1" ||
96
- host === "::1" ||
97
- host.endsWith(".local") ||
98
- host.endsWith(".localhost") ||
99
- /^10\./.test(host) ||
100
- /^192\.168\./.test(host) ||
101
- /^172\.(1[6-9]|2\d|3[01])\./.test(host)
102
- );
103
- }
104
-
105
- /**
106
- * Relax TLS verification for local/private endpoints only. OrbStack (and other
107
- * local dev setups) serve HTTPS with a CA installed in the system keychain —
108
- * which curl and browsers trust, but Node's `fetch`/`WebSocket` do not, so they
109
- * fail with SELF_SIGNED_CERT_IN_CHAIN and every token "doesn't work". For a
110
- * loopback/`.local`/RFC-1918 host we disable verification (covering fetch and
111
- * the bridge/stream WebSockets); public endpoints keep full verification.
112
- * Returns true when verification was relaxed.
113
- */
114
- function relaxTlsForLocalEndpoint(endpoint: string): boolean {
115
- let host = "";
116
- try {
117
- host = new URL(endpoint).hostname;
118
- } catch {
119
- return false;
120
- }
121
- if (!endpoint.startsWith("https:") || !isLocalHost(host)) return false;
122
- // Filter out Node's global "insecure TLS" warning; we surface our own note.
123
- const origEmit = process.emitWarning.bind(process);
124
- process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
125
- const msg = typeof warning === "string" ? warning : warning?.message ?? "";
126
- if (msg.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
127
- return (origEmit as (...a: unknown[]) => void)(warning, ...args);
128
- }) as typeof process.emitWarning;
129
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
130
- return true;
131
- }
132
-
133
- /** Read this package's version from package.json (best effort). */
134
- function readVersion(): string {
135
- try {
136
- const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
137
- return typeof pkg.version === "string" ? pkg.version : "";
138
- } catch {
139
- return "";
140
- }
141
- }
142
-
143
- /**
144
- * Print the startup welcome: the logomark, then the product name, version, the
145
- * connected instance, and the working directory stacked beneath it — a calm,
146
- * branded header before the session begins.
147
- */
148
- function printWelcome(endpoint: string, projectDir: string): void {
149
- const home = os.homedir();
150
- const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
151
- const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
152
- const version = readVersion();
153
- const pad = " ";
154
- // Session details, placed to the right of the mark and vertically centered.
155
- const meta = [
156
- `${c.bold}${c.white}Standard Code${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
157
- `${c.dim}terminal coding agent${c.reset}`,
158
- `${c.teal}${host}${c.reset}`,
159
- `${c.dim}${dir}${c.reset}`,
160
- ];
161
- const markWidth = Math.max(...LOGO_MARK.map((l) => [...l].length));
162
- const metaTop = Math.floor((LOGO_MARK.length - meta.length) / 2);
163
- output.write("\n");
164
- for (let i = 0; i < LOGO_MARK.length; i++) {
165
- const glyph = LOGO_MARK[i].padEnd(markWidth); // logo in default text color
166
- const line = meta[i - metaTop];
167
- output.write(`${pad}${glyph}${line ? ` ${line}` : ""}\n`);
168
- }
169
- output.write("\n");
170
- }
171
-
172
- /**
173
- * Colour a tool-activity line for readable contrast: a bright status glyph
174
- * (green ✓, red ✗, yellow ⛔) with the action text at full contrast and only the
175
- * trailing parenthetical detail dimmed. Replaces the old wrap-everything-in-gray.
176
- */
177
- function colorActivity(line: string): string {
178
- const m = line.match(/^(\s*)([✓✗⛔])\s?([\s\S]*)$/);
179
- if (!m) return `${c.dim}${line}${c.reset}`;
180
- const [, indent, glyph, rest] = m;
181
- if (glyph === "✓") {
182
- const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
183
- return `${indent}${c.green}✓${c.reset} ${body}`;
184
- }
185
- if (glyph === "✗") return `${indent}${c.red}✗${c.reset} ${rest}`;
186
- return `${indent}${c.yellow}⛔ ${rest}${c.reset}`;
187
- }
188
-
189
- async function main(): Promise<void> {
190
- const args = process.argv.slice(2);
191
- let endpointArg: string | undefined;
192
- let dirArg: string | undefined;
193
- for (let i = 0; i < args.length; i++) {
194
- if (args[i] === "--endpoint" || args[i] === "-e") endpointArg = args[++i];
195
- else if (!args[i].startsWith("-")) dirArg = args[i];
196
- }
197
-
198
- const projectDir = path.resolve(dirArg || process.cwd());
199
- const machine = os.hostname();
200
-
201
- // Create the line reader lazily so it never eagerly consumes stdin (which
202
- // would swallow a message the user pre-types before the interactive prompt).
203
- // A `SIGINT` listener implements double-press-to-quit during these pre-flight
204
- // prompts — without it, ctrl-c rejects `question()` with an AbortError and
205
- // crashes the process with a stack trace.
206
- const reader: { rl: readline.Interface | null } = { rl: null };
207
- let handoffClosing = false; // set when we intentionally close the reader for the TUI handoff
208
- let preflightArmed = false;
209
- let preflightTimer: ReturnType<typeof setTimeout> | null = null;
210
- const onPreflightSigint = () => {
211
- if (preflightArmed) {
212
- if (preflightTimer) clearTimeout(preflightTimer);
213
- reader.rl?.close();
214
- farewell();
215
- process.exit(0);
216
- }
217
- preflightArmed = true;
218
- output.write(`\n${c.dim}Press Control-C again to exit${c.reset}\n`);
219
- preflightTimer = setTimeout(() => {
220
- preflightArmed = false;
221
- preflightTimer = null;
222
- }, 2000);
223
- };
224
- const ask = async (question: string): Promise<string> => {
225
- if (!reader.rl) {
226
- reader.rl = readline.createInterface({ input, output });
227
- reader.rl.on("SIGINT", onPreflightSigint);
228
- // EOF (ctrl-d / closed stdin) ends the reader — exit gracefully instead of
229
- // looping into an ERR_USE_AFTER_CLOSE on the next prompt.
230
- reader.rl.on("close", () => {
231
- if (handoffClosing) return; // our own handoff close, not the user leaving
232
- farewell();
233
- process.exit(0);
234
- });
235
- }
236
- return reader.rl.question(question);
237
- };
238
- // Cover the cooked-mode gaps too (e.g. while resolving threads before the TUI
239
- // grabs the keyboard) so a stray ctrl-c there also takes two presses and exits
240
- // gracefully rather than killing the process. Once the TUI enables raw mode,
241
- // ctrl-c arrives as a keypress and this never fires.
242
- process.on("SIGINT", onPreflightSigint);
243
-
244
- // 1. Resolve endpoint.
245
- let endpoint = endpointArg || defaultEndpoint() || "";
246
- if (!endpoint) {
247
- const answer = await ask(
248
- `${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
249
- );
250
- endpoint = answer.trim();
251
- }
252
- endpoint = normalizeEndpoint(endpoint);
253
-
254
- // Local dev instances (OrbStack, etc.) use a CA Node won't trust by default —
255
- // relax TLS for loopback/.local/private hosts so the CLI can actually connect.
256
- const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
257
-
258
- // Welcome banner — printed once the destination instance is known, before any
259
- // auth prompt, so the session opens with a calm, branded header.
260
- printWelcome(endpoint, projectDir);
261
- if (tlsRelaxed) {
262
- output.write(`${c.dim} TLS verification relaxed for local endpoint.${c.reset}\n\n`);
263
- }
264
-
265
- // 2. Resolve credentials. Use a stored API token if it still works, otherwise
266
- // ask for one (create it in your Standard Agents instance settings).
267
- const stored = getCredential(endpoint);
268
- let api = stored ? new ApiClient(endpoint, stored.access_token) : null;
269
- if (!api || !(await api.verify())) {
270
- const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
271
- output.write(
272
- `${c.bold}${c.white}Sign in${c.reset} ${c.dim}— paste an API token to connect to${c.reset} ${c.teal}${host}${c.reset}\n`
273
- );
274
- output.write(`${c.dim}Create one in your instance settings under API tokens.${c.reset}\n\n`);
275
- for (;;) {
276
- const token = (await ask(`${c.teal}❯${c.reset} ${c.dim}token${c.reset} `)).trim();
277
- if (!token) {
278
- output.write(`${c.dim}A token is required.${c.reset}\n`);
279
- continue;
280
- }
281
- api = new ApiClient(endpoint, token);
282
- if (await api.verify()) {
283
- saveCredential({ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() });
284
- output.write(`${c.green}✓${c.reset} Connected to ${c.teal}${host}${c.reset}\n`);
285
- break;
286
- }
287
- output.write(`${c.red}✗${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}\n`);
288
- }
289
- }
290
-
291
- if (!api) process.exit(1);
292
- handoffClosing = true;
293
- reader.rl?.close();
294
-
295
- // 3. Offer to resume a session for this project+machine, or start new.
296
- const tags = [`path:${projectDir}`, `machine:${machine}`];
297
- let existing: ThreadSummary[] = [];
298
- try {
299
- existing = await api.listThreads(AGENT_ID, tags);
300
- } catch {
301
- existing = [];
302
- }
303
-
304
- const tui = new Tui(1); // auto-accept level 1 by default (asks on anything riskier than a read)
305
-
306
- let threadId: string;
307
- let resumed = false;
308
- if (existing.length > 0) {
309
- const summaries = await summarizeThreads(api, existing.slice(0, 8));
310
- const items: { label: string; hint?: string; value: string | null }[] = summaries.map((s) => ({
311
- label: s.label,
312
- hint: s.hint,
313
- value: s.id,
314
- }));
315
- items.push({ label: "+ Start a new session", value: null });
316
- const home = os.homedir();
317
- const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
318
- const shortDir = tilde.length > 38 ? "…" + tilde.slice(-37) : tilde;
319
- const picked = await tui.select(
320
- `${c.bold}${c.magenta}Resume a session${c.reset} ${c.gray}${shortDir}${c.reset} ${c.dim}↑↓ · enter · esc to cancel${c.reset}`,
321
- items
322
- );
323
- if (typeof picked === "string") {
324
- threadId = picked;
325
- resumed = true;
326
- } else {
327
- threadId = await api.createThread(AGENT_ID, tags);
328
- }
329
- } else {
330
- threadId = await api.createThread(AGENT_ID, tags);
331
- }
332
-
333
- await runInteractive(tui, api, threadId, projectDir, machine, resumed);
334
- }
335
-
336
- /** Build a one-line preview (first user message) + timestamp for each thread. */
337
- async function summarizeThreads(
338
- api: ApiClient,
339
- threads: ThreadSummary[]
340
- ): Promise<{ id: string; label: string; hint: string }[]> {
341
- return Promise.all(
342
- threads.map(async (t) => {
343
- let preview = "";
344
- try {
345
- const msgs = await api.getMessages(t.id, 30);
346
- const users = msgs
347
- .filter((m: any) => m.role === "user" && typeof m.content === "string" && m.content.trim())
348
- .sort((a: any, b: any) => (a.created_at ?? 0) - (b.created_at ?? 0));
349
- if (users[0]) preview = String(users[0].content).replace(/\s+/g, " ").trim();
350
- } catch {
351
- // ignore; show a placeholder
352
- }
353
- const label = preview
354
- ? preview.length > 64
355
- ? preview.slice(0, 63) + "…"
356
- : preview
357
- : "(empty session)";
358
- const when = t.created_at ? relativeTime(t.created_at) : "";
359
- const hint = [t.id.slice(0, 8), when].filter(Boolean).join(" · ");
360
- return { id: t.id, label, hint };
361
- })
362
- );
363
- }
364
-
365
- /**
366
- * A human-readable label for a subagent's child thread: prefer its instance name
367
- * tag (`name:<x>`), else a title-cased form of the agent name (`research_agent`
368
- * → "Research Agent").
369
- */
370
- function subagentLabel(t: ThreadEntry, titles: Map<string, string>): string {
371
- // Prefer the agent's display title (e.g. "Research Assistant"); a per-instance
372
- // name tag refines it ("Research Assistant · auth-flow") when present.
373
- const agentName = (t.agent_name || "").trim();
374
- const title =
375
- titles.get(agentName) ||
376
- (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : "Subagent");
377
- const nameTag = (t.tags || []).find((tag) => tag.startsWith("name:"));
378
- const tagged = nameTag?.slice("name:".length).trim();
379
- return tagged ? `${title} · ${tagged}` : title;
380
- }
381
-
382
- /** Open a URL in the system browser (best effort). */
383
- function openUrl(url: string): void {
384
- const platform = process.platform;
385
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
386
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
387
- try {
388
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
389
- child.unref();
390
- } catch {
391
- // ignore — the URL is also printed for manual opening
392
- }
393
- }
394
-
395
- /** Compact relative time from a unix-seconds timestamp. */
396
- function relativeTime(unixSeconds: number): string {
397
- const diff = Date.now() / 1000 - unixSeconds;
398
- if (diff < 60) return "just now";
399
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
400
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
401
- return `${Math.floor(diff / 86400)}d ago`;
402
- }
403
-
404
- /**
405
- * Whether a message carries tool calls. The API serializes `tool_calls` as a
406
- * JSON STRING (not an array), so `Array.isArray` is never true — checking only
407
- * that would treat a tool-call assistant message (which can also have narration
408
- * content) as a final answer, completing the turn while a tool/approval is still
409
- * pending. Handle both shapes.
410
- */
411
- function hasToolCalls(m: any): boolean {
412
- const tc = m?.tool_calls;
413
- if (Array.isArray(tc)) return tc.length > 0;
414
- if (typeof tc === "string") {
415
- const s = tc.trim();
416
- return s.length > 0 && s !== "null" && s !== "[]";
417
- }
418
- return false;
419
- }
420
-
421
- /** Extract plain text from a message's content (string or content-block array). */
422
- function messageText(content: unknown): string {
423
- if (typeof content === "string") return content;
424
- if (Array.isArray(content)) {
425
- return content
426
- .map((b: any) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : ""))
427
- .join("");
428
- }
429
- return "";
430
- }
431
-
432
- /** Whether the thread is currently working (a turn active or queued work pending). */
433
- function threadBusy(msgs: any[]): boolean {
434
- if (!msgs.length) return false;
435
- if (msgs.some((m) => m.status === "pending")) return true;
436
- const last = [...msgs].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
437
- if (!last) return false;
438
- if (last.role === "user" || last.role === "tool") return true; // turn about to run / mid-turn
439
- if (last.role === "assistant") return hasToolCalls(last); // tool-call message = still going
440
- return false; // a final assistant text, or a system/stop message → idle
441
- }
442
-
443
- /** Replay a resumed thread's conversation so it feels like picking up mid-session. */
444
- async function printHistory(api: ApiClient, threadId: string, tui: Tui): Promise<void> {
445
- let msgs: any[];
446
- try {
447
- msgs = await api.getMessages(threadId, 200);
448
- } catch {
449
- return;
450
- }
451
- const convo = msgs
452
- .filter((m) => m.role === "user" || (m.role === "assistant" && messageText(m.content).trim()))
453
- .sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
454
- if (!convo.length) return;
455
- const shown = convo.slice(-24);
456
- tui.print(`${c.dim}── resuming session · ${convo.length} message${convo.length === 1 ? "" : "s"} ──${c.reset}`);
457
- if (shown.length < convo.length) tui.print(`${c.dim} … earlier messages omitted${c.reset}`);
458
- for (const m of shown) {
459
- const text = messageText(m.content).trim();
460
- if (!text) continue;
461
- if (m.role === "user") tui.printUserMessage(text);
462
- else printAssistant(tui, text);
463
- }
464
- tui.print(`${c.dim}────────────────${c.reset}`);
465
- }
466
-
467
- async function runInteractive(
468
- tui: Tui,
469
- api: ApiClient,
470
- threadId: string,
471
- projectDir: string,
472
- machine: string,
473
- resumed: boolean
474
- ): Promise<void> {
475
- // Background-process metadata lives in the thread's KV (server-side), so the
476
- // list resumes from any machine; nothing about it is stored on this client.
477
- const registry = new ProcessRegistry(api, threadId, machine);
478
- // MCP host: the CLI negotiates with locally-launched MCP servers and forwards
479
- // the agent's `mcp` tool calls to them. The discovered tool catalog is mirrored
480
- // into the thread KV so a context hook can show the agent what's available.
481
- const mcp = new McpManager(projectDir);
482
- const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {});
483
- const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
484
- const refreshBgCount = () => {
485
- void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {});
486
- };
487
- const perm: PermissionState = { level: tui.level, alwaysAllow: new Set(), allowRisk: new Set() };
488
- // Restore this session's saved approvals (auto-accept level + pre-approved
489
- // tools and risk levels) from the thread KV, so they resume with the session.
490
- const savedApprovals = await loadApprovals(api, threadId);
491
- for (const t of savedApprovals.allowTools) perm.alwaysAllow.add(t);
492
- for (const r of savedApprovals.allowRisk) perm.allowRisk.add(r);
493
- if (savedApprovals.level) {
494
- perm.level = savedApprovals.level;
495
- tui.setLevel(savedApprovals.level);
496
- }
497
- tui.onLevelChange((l: Level) => {
498
- perm.level = l;
499
- saveApprovals(api, threadId, perm);
500
- });
501
-
502
- // ── state ────────────────────────────────────────────────────────────────
503
- let busy = false; // is the agent currently working
504
- // Set when the user hits esc to interrupt: clears the working indicator
505
- // immediately and suppresses the poller from re-showing it until the backend
506
- // confirms the turn actually stopped (otherwise "Working" lingers a poll or two).
507
- let interrupting = false;
508
- const queued: string[] = []; // messages typed while busy, held to send next
509
- let editingQueued = false; // input currently holds a pulled queued message
510
- const shownIds = new Set<string>(); // message ids already printed to the transcript
511
-
512
- // ── token feed ──────────────────────────────────────────────────────────
513
- // Cumulative tokens are authoritative from logs (one row per completed LLM
514
- // call); a live estimate from streamed content ticks the output count up
515
- // between log updates so a long generation isn't silent.
516
- let tokensIn = 0;
517
- let tokensOut = 0;
518
- let liveOut = 0; // output tokens for the in-progress response (from `generation` events)
519
- const countedLogs = new Set<string>();
520
-
521
- // ── step indicator (what the agent is doing right now) ────────────────────
522
- // Driven by tool_call_started / tool_call_done events. Most recently started
523
- // active tool wins; cleared when the turn goes idle.
524
- const activeSteps = new Map<string, string>(); // tool call id -> label
525
-
526
- // Push the current totals + step to the prompt. Output total folds in the
527
- // live in-progress count; the step's own output is the live count.
528
- const refreshStatus = () => {
529
- tui.setTokens(tokensIn, tokensOut + liveOut);
530
- let label: string | null = null;
531
- for (const v of activeSteps.values()) label = v; // last (most recent) entry
532
- tui.setStep(label, liveOut);
533
- };
534
-
535
- const bridge = new Bridge(api, threadId, host, perm, {
536
- onActivity: (line) => {
537
- tui.print(colorActivity(line));
538
- refreshBgCount();
539
- },
540
- onStatus: () => {}, // the working indicator is driven by the busy poller
541
- onConnection: (state, attempt) => {
542
- // Brief drops are normal (a permission wait, a dev-server reload) and the
543
- // bridge reconnects on its own — stay quiet unless reconnection is actually
544
- // struggling (several seconds of failed attempts). The disconnect shows as a
545
- // TRANSIENT notice line in the bottom region that clears itself the moment we
546
- // reconnect, so nothing about it lingers in the transcript.
547
- if (state === "reconnecting") {
548
- if (attempt >= 4) tui.setConnected(false);
549
- } else {
550
- tui.setConnected(true);
551
- }
552
- },
553
- requestApproval: (req, summary, risk) =>
554
- tui.approval(
555
- `${summary}${req.requestPermission ? `\n${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
556
- risk
557
- ),
558
- });
559
-
560
- // The message stream drives two live signals (the transcript itself is
561
- // rendered from polling for robustness):
562
- // - `generation` events → the live output-token count (moves during ALL
563
- // generation, including big tool arguments like file content).
564
- // - `tool_call_started` / `tool_call_done` → the current step label.
565
- const stream = new MessageStream(api, threadId, {
566
- onChunk: () => {},
567
- onAssistantText: () => {},
568
- onEvent: (eventType, data) => {
569
- if (eventType === "generation" && typeof data?.outputTokens === "number") {
570
- liveOut = data.outputTokens;
571
- refreshStatus();
572
- } else if (eventType === "tool_call_started" && data?.id) {
573
- activeSteps.set(data.id, data.progress || data.name || "working");
574
- refreshStatus();
575
- } else if (eventType === "tool_call_done" && data?.id) {
576
- activeSteps.delete(data.id);
577
- refreshStatus();
578
- }
579
- },
580
- onError: () => {},
581
- });
582
-
583
- // ── subagent tracking ──────────────────────────────────────────────────────
584
- // A subagent runs in its own child thread, so the live signal that one is
585
- // working is its child thread appearing on the system-events channel with our
586
- // thread as `parent` (and terminating when it finishes). One line per active
587
- // subagent shows in the TUI.
588
- const activeSubagents = new Map<string, string>(); // child threadId -> label
589
- // Agent name → title, loaded once so subagent lines read "Research Assistant"
590
- // rather than the raw agent name. Best effort; falls back to a prettified name.
591
- const agentTitles = new Map<string, string>();
592
- void api
593
- .listAgents()
594
- .then((list) => list.forEach((a) => agentTitles.set(a.name, a.title)))
595
- .catch(() => {});
596
- const pushSubagents = () => tui.setSubagents([...activeSubagents.values()]);
597
- const events = new SystemEvents(api, {
598
- onThreadCreated: (t) => {
599
- if (t.parent === threadId && !t.terminated) {
600
- activeSubagents.set(t.id, subagentLabel(t, agentTitles));
601
- pushSubagents();
602
- }
603
- },
604
- onThreadUpdated: (t) => {
605
- if (t.parent !== threadId) return;
606
- if (t.terminated) activeSubagents.delete(t.id);
607
- else activeSubagents.set(t.id, subagentLabel(t, agentTitles));
608
- pushSubagents();
609
- },
610
- onThreadDeleted: (id) => {
611
- if (activeSubagents.delete(id)) pushSubagents();
612
- },
613
- });
614
-
615
- const quit = () => {
616
- tui.end();
617
- bridge.close();
618
- stream.close();
619
- events.close();
620
- mcp.closeAll();
621
- farewell();
622
- process.exit(0);
623
- };
624
- tui.setQuitHandler(quit);
625
-
626
- const viewThread = () => {
627
- const url = `${api.origin}/threads/${threadId}`;
628
- openUrl(url);
629
- tui.print(`${c.gray}opened ${c.cyan}${url}${c.reset}`);
630
- };
631
- const bgMgr: BgManager = {
632
- list: () => registry.list(),
633
- stop: async (id: string) => {
634
- await host.execute("background_process", { action: "stop", id });
635
- refreshBgCount();
636
- },
637
- };
638
-
639
- // ── MCP control surface (for the /mcp command + slash menu) ────────────────
640
- const mcpCtl: McpController = {
641
- configured: () => listMcpServers(),
642
- connectedNames: () => mcp.connectedNames(),
643
- catalog: () => mcp.catalog(),
644
- connect: async (cfg) => {
645
- try {
646
- const client = await mcp.connect(cfg);
647
- publishMcpCatalog();
648
- return { ok: true, tools: client.tools.length };
649
- } catch (e) {
650
- publishMcpCatalog();
651
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
652
- }
653
- },
654
- disconnect: (name) => {
655
- mcp.disconnect(name);
656
- publishMcpCatalog();
657
- },
658
- add: async (cfg) => {
659
- saveMcpServer(cfg);
660
- return mcpCtl.connect(cfg);
661
- },
662
- // Seed the request into the main chat — the agent researches + installs it
663
- // there (using research_agent + install_mcp), visible in the transcript.
664
- requestInstall: (query) => {
665
- void sendNow(
666
- `Install an MCP server for me: ${query}. Research the best one and its exact launch command, then install it.`
667
- );
668
- },
669
- remove: (name) => {
670
- mcp.disconnect(name);
671
- removeMcpServer(name);
672
- publishMcpCatalog();
673
- },
674
- setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled),
675
- };
676
-
677
- // ── submit / queue / steer ─────────────────────────────────────────────────
678
- const sendNow = async (text: string) => {
679
- tui.printUserMessage(text);
680
- try {
681
- await api.sendMessage(threadId, text);
682
- } catch (e) {
683
- tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
684
- return;
685
- }
686
- interrupting = false; // a fresh turn — let the working indicator show again
687
- busy = true;
688
- tui.setWorking(true);
689
- };
690
- const flushQueued = async () => {
691
- if (!queued.length) return;
692
- const toSend = queued.splice(0);
693
- tui.setQueuedCount(0);
694
- for (const t of toSend) await sendNow(t);
695
- };
696
-
697
- // Manually request a background compaction. Writes a request marker to thread
698
- // KV; the agent-side prefilter hook honours it on the next step — spawning the
699
- // non-blocking compaction subagent regardless of the auto-trigger threshold —
700
- // then clears the marker. Cheap and idempotent.
701
- const requestCompaction = async () => {
702
- try {
703
- await api.kvSet(threadId, "compaction_request", { requestedAt: Date.now() });
704
- tui.print(
705
- `${c.cyan}⟳${c.reset} compacting the conversation in the background — recent messages stay live.`
706
- );
707
- } catch (err) {
708
- tui.print(`${c.red}✗${c.reset} couldn't request compaction: ${(err as Error).message}`);
709
- }
710
- };
711
-
712
- // Slash-command palette: typing "/" filters this list inline above the prompt.
713
- // Each command is individually addressable (/compact, /mcp, …). Hints are
714
- // functions so live state (context %, level, counts) stays fresh as it redraws.
715
- tui.setCommands([
716
- {
717
- name: "compact",
718
- label: "Compact conversation now",
719
- hint: () => tui.contextPctLabel() || "free up context",
720
- run: requestCompaction,
721
- },
722
- { name: "level", label: "Auto-accept level", hint: () => `level ${tui.level}`, run: () => runLevelMenu(tui, perm) },
723
- {
724
- name: "permissions",
725
- label: "Approved commands",
726
- hint: () => {
727
- const n = perm.alwaysAllow.size + perm.allowRisk.size;
728
- return n ? `${n} approved` : "none";
729
- },
730
- run: () => runApprovalsMenu(tui, perm, () => saveApprovals(api, threadId, perm)),
731
- },
732
- {
733
- name: "mcp",
734
- label: "MCP servers",
735
- hint: () => {
736
- const n = mcpCtl.connectedNames().length;
737
- return n ? `${n} connected` : "none";
738
- },
739
- run: () => runMcpMenu(tui, mcpCtl),
740
- },
741
- { name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
742
- { name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
743
- { name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
744
- { name: "quit", label: "Quit", run: () => quit() },
745
- ]);
746
-
747
- tui.onSubmit = (text) => {
748
- if (editingQueued) {
749
- editingQueued = false;
750
- queued.push(text);
751
- tui.setQueuedCount(queued.length);
752
- tui.print(`${c.gray}⏳ queued:${c.reset} ${text}`);
753
- return;
754
- }
755
- if (busy) {
756
- queued.push(text);
757
- tui.setQueuedCount(queued.length);
758
- tui.print(`${c.gray}⏳ queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
759
- } else {
760
- void sendNow(text);
761
- }
762
- };
763
-
764
- tui.onInterrupt = () => {
765
- if (queued.length > 0) {
766
- tui.print(`${c.yellow}↪ steering — stopping current work and sending your message…${c.reset}`);
767
- void api
768
- .stop(threadId)
769
- .catch(() => {})
770
- .then(() => flushQueued());
771
- } else if (busy) {
772
- // Optimistically stop the working indicator so esc feels immediate.
773
- interrupting = true;
774
- busy = false;
775
- activeSteps.clear();
776
- liveOut = 0;
777
- tui.setWorking(false);
778
- refreshStatus();
779
- tui.print(`${c.yellow}[interrupted by user]${c.reset}`);
780
- void api.stop(threadId).catch(() => {});
781
- }
782
- };
783
-
784
- tui.onUpArrow = () => {
785
- if (tui.getInput().trim() || queued.length === 0) return;
786
- const text = queued.pop()!;
787
- tui.setQueuedCount(queued.length);
788
- editingQueued = true;
789
- tui.setInput(text);
790
- };
791
-
792
- events.connect();
793
- await Promise.all([bridge.connect(), stream.connect()]);
794
-
795
- tui.banner([
796
- `${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}— coding agent${c.reset}`,
797
- `${c.gray}project:${c.reset} ${projectDir}`,
798
- `${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`,
799
- `${c.dim}type anytime · shift-tab cycles auto-accept level · / for options · esc interrupts/steers · ctrl-c quits${c.reset}`,
800
- ]);
801
-
802
- if (resumed) await printHistory(api, threadId, tui);
803
-
804
- // Seed shown-ids so the poller doesn't re-print existing history.
805
- try {
806
- (await api.getMessages(threadId, 200)).forEach((m: any) => shownIds.add(m.id));
807
- } catch {
808
- // ignore
809
- }
810
-
811
- const runningProcs = (await registry.list()).filter((p) => p.status === "running");
812
- if (runningProcs.length) {
813
- tui.print(
814
- `${c.cyan}⚙ ${runningProcs.length} background process${runningProcs.length === 1 ? "" : "es"} running:${c.reset}`
815
- );
816
- for (const p of runningProcs) tui.print(`${c.gray} ${p.id} ${p.description || p.command}${c.reset}`);
817
- }
818
- refreshBgCount();
819
-
820
- // Connect every enabled MCP server, then publish the catalog so the agent sees
821
- // the tools on its very first step. (The CLI is the MCP host; this is the real
822
- // initialize/tools-list handshake with each local server.)
823
- const enabledServers = listMcpServers().filter((s) => s.enabled);
824
- for (const s of enabledServers) {
825
- const res = await mcpCtl.connect(s);
826
- if (res.ok) {
827
- tui.print(`${c.cyan}⚡ MCP "${s.name}" connected${c.reset} ${c.gray}(${res.tools} tool${res.tools === 1 ? "" : "s"})${c.reset}`);
828
- } else {
829
- tui.print(`${c.red}⚠ MCP "${s.name}" failed:${c.reset} ${c.gray}${res.error}${c.reset}`);
830
- }
831
- }
832
- publishMcpCatalog();
833
-
834
- tui.start(); // begin the persistent input line
835
-
836
- // ── poller: display new messages, drive the working indicator, flush queue ──
837
- const poll = async () => {
838
- let msgs: any[];
839
- try {
840
- msgs = await api.getMessages(threadId, 60);
841
- } catch {
842
- return;
843
- }
844
- const sorted = [...msgs].sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
845
- for (const m of sorted) {
846
- if (shownIds.has(m.id) || m.status === "pending") continue;
847
- shownIds.add(m.id);
848
- const text = messageText(m.content).trim();
849
- if (m.role === "assistant" && text) printAssistant(tui, text);
850
- else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
851
- }
852
- const polledBusy = threadBusy(msgs);
853
- // While interrupting, keep the indicator off until the backend has actually
854
- // gone idle — then clear the latch so future turns show "Working" again.
855
- if (interrupting) {
856
- if (!polledBusy) interrupting = false;
857
- busy = false;
858
- } else {
859
- busy = polledBusy;
860
- }
861
- tui.setWorking(busy);
862
- if (!busy) {
863
- // Turn finished — clear any stale step + in-progress live count.
864
- if (activeSteps.size) activeSteps.clear();
865
- liveOut = 0;
866
- refreshStatus();
867
- if (queued.length > 0 && !editingQueued) await flushQueued();
868
- }
869
- refreshBgCount();
870
-
871
- // Authoritative token totals from completed LLM-call logs.
872
- try {
873
- const logs = await api.getLogs(threadId, 100);
874
- let landed = 0;
875
- for (const l of logs) {
876
- // Skip until the call is finalized — an in-progress log has null tokens,
877
- // and counting it early would mark its id seen before the real counts land.
878
- if (!l.is_complete) continue;
879
- const id = l.id ?? `${l.created_at}:${l.total_tokens}`;
880
- if (countedLogs.has(id)) continue;
881
- countedLogs.add(id);
882
- const inT = Number(l.input_tokens) || 0;
883
- const outT = Number(l.output_tokens) || 0;
884
- tokensIn += inT;
885
- tokensOut += outT;
886
- landed += outT;
887
- }
888
- if (landed > 0) liveOut = 0; // a real count arrived; drop the live estimate
889
- refreshStatus();
890
- } catch {
891
- // ignore — token feed is best-effort
892
- }
893
-
894
- // Context-window gauge: the runtime records the latest request's input tokens
895
- // (the true serialized context size) and the model's window to thread KV.
896
- try {
897
- const cu = (await api.kvGet(threadId, "context_usage")) as
898
- | { inputTokens?: number; maxContextTokens?: number }
899
- | null;
900
- const used = Number(cu?.inputTokens) || 0;
901
- const max = Number(cu?.maxContextTokens) || 0;
902
- tui.setContextPct(max > 0 && used > 0 ? (used / max) * 100 : null);
903
- } catch {
904
- // ignore — gauge is best-effort
905
- }
906
- };
907
- setInterval(() => void poll().catch(() => {}), 1200);
908
-
909
- // Event-driven from here; keep the process alive.
910
- await new Promise<void>(() => {});
911
- }
912
-
913
- interface BgManager {
914
- list: () => Promise<{ id: string; command: string; description?: string; status: string; pid: number; exitCode?: number | null }[]>;
915
- stop: (id: string) => Promise<void>;
916
- }
917
-
918
- interface McpController {
919
- configured: () => McpServerConfig[];
920
- connectedNames: () => string[];
921
- catalog: () => { servers: { name: string; status: string; tools: { name: string; description?: string }[]; resources: { uri: string }[]; error?: string }[] };
922
- connect: (cfg: McpServerConfig) => Promise<{ ok: boolean; tools?: number; error?: string }>;
923
- disconnect: (name: string) => void;
924
- add: (cfg: McpServerConfig) => Promise<{ ok: boolean; tools?: number; error?: string }>;
925
- remove: (name: string) => void;
926
- setEnabled: (name: string, enabled: boolean) => void;
927
- /** Seed an install request into the main chat for the agent to research + install. */
928
- requestInstall: (query: string) => void;
929
- }
930
-
931
- /** The `/` configuration menu: permission mode, approvals, processes, view thread, shortcuts, quit. */
932
- /** The auto-accept level submenu (reached via `/level`). */
933
- async function runLevelMenu(tui: Tui, perm: PermissionState): Promise<void> {
934
- const picked = await tui.select(
935
- `${c.bold}Auto-accept level${c.reset} ${c.dim}(↑/↓ · enter · shift-tab cycles)${c.reset}`,
936
- LEVELS.map((l) => ({
937
- label: levelLabel(l),
938
- hint: l === tui.level ? "current" : "",
939
- value: l,
940
- }))
941
- );
942
- if (picked) {
943
- tui.setLevel(picked);
944
- perm.level = picked;
945
- }
946
- }
947
-
948
- /** Print the keyboard-shortcut reference (reached via `/keybindings`). */
949
- function showKeybindings(tui: Tui): void {
950
- tui.print(`${c.gray}shortcuts:${c.reset}`);
951
- tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1–5)`);
952
- tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
953
- tui.print(`${c.gray} ctrl-c${c.reset} quit`);
954
- }
955
-
956
- /** Submenu listing tracked background processes; lets you stop a running one. */
957
- async function runProcessMenu(tui: Tui, bg: BgManager): Promise<void> {
958
- const procs = await bg.list();
959
- if (!procs.length) {
960
- tui.print(`${c.gray}No background processes for this session.${c.reset}`);
961
- return;
962
- }
963
- const items = procs.map((p) => {
964
- const status =
965
- p.status === "running"
966
- ? `${c.green}running${c.reset}`
967
- : `${c.gray}${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}${c.reset}`;
968
- return {
969
- label: `${p.description || p.command}`,
970
- hint: `${p.id} · ${status}`,
971
- value: p.id,
972
- };
973
- });
974
- const picked = await tui.select(
975
- `${c.bold}Background processes${c.reset} ${c.dim}(↑/↓ · enter to manage · esc to close)${c.reset}`,
976
- items
977
- );
978
- if (!picked) return;
979
- const proc = procs.find((p) => p.id === picked);
980
- if (!proc || proc.status !== "running") {
981
- tui.print(`${c.gray}${picked} is not running.${c.reset}`);
982
- return;
983
- }
984
- const action = await tui.select(`${c.bold}${proc.description || proc.command}${c.reset}`, [
985
- { label: "Stop this process", value: "stop" as const },
986
- { label: "Leave it running", value: "leave" as const },
987
- ]);
988
- if (action === "stop") {
989
- await bg.stop(picked);
990
- tui.print(`${c.gray}stopped ${picked}${c.reset}`);
991
- }
992
- }
993
-
994
- /** Submenu showing the session's pre-approved tools/risk levels; pick one to revoke. */
995
- async function runApprovalsMenu(tui: Tui, perm: PermissionState, save: () => void): Promise<void> {
996
- const tools = Array.from(perm.alwaysAllow).sort();
997
- const risks = Array.from(perm.allowRisk).sort((a, b) => a - b);
998
- if (!tools.length && !risks.length) {
999
- tui.print(
1000
- `${c.gray}No pre-approved commands. At a permission prompt, choose "Always allow this tool" or "Allow all level N" to add some.${c.reset}`
1001
- );
1002
- return;
1003
- }
1004
- const items = [
1005
- ...tools.map((t) => ({ label: `Tool: ${t}`, hint: "always allowed", value: `tool:${t}` })),
1006
- ...risks.map((r) => ({ label: `All level ${r} risk`, hint: "always allowed", value: `risk:${r}` })),
1007
- { label: "Clear all approvals", hint: "", value: "clear" },
1008
- ];
1009
- const picked = await tui.select(
1010
- `${c.bold}Approved commands${c.reset} ${c.dim}(enter to revoke · esc to close)${c.reset}`,
1011
- items
1012
- );
1013
- if (!picked) return;
1014
- if (picked === "clear") {
1015
- perm.alwaysAllow.clear();
1016
- perm.allowRisk.clear();
1017
- tui.print(`${c.gray}cleared all approvals${c.reset}`);
1018
- } else if (picked.startsWith("tool:")) {
1019
- const t = picked.slice(5);
1020
- perm.alwaysAllow.delete(t);
1021
- tui.print(`${c.gray}revoked tool ${t}${c.reset}`);
1022
- } else if (picked.startsWith("risk:")) {
1023
- const r = Number(picked.slice(5));
1024
- perm.allowRisk.delete(r);
1025
- tui.print(`${c.gray}revoked level ${r}${c.reset}`);
1026
- }
1027
- save();
1028
- }
1029
-
1030
- /**
1031
- * Interactive MCP control surface (reached via `/` → "MCP servers"). Lists
1032
- * configured servers with status, and lets you add (free-text command), view
1033
- * tools, connect/disconnect, enable/disable, and remove — all menu-driven.
1034
- */
1035
- async function runMcpMenu(tui: Tui, mcp: McpController): Promise<void> {
1036
- const configured = mcp.configured();
1037
- const connected = new Set(mcp.connectedNames());
1038
- const cat = mcp.catalog();
1039
-
1040
- const INSTALL = "__install__";
1041
- const ADD_MANUAL = "__manual__";
1042
- const items = configured.map((s) => {
1043
- const entry = cat.servers.find((e) => e.name === s.name);
1044
- const status = !s.enabled
1045
- ? "disabled"
1046
- : connected.has(s.name)
1047
- ? `connected · ${entry?.tools.length ?? 0} tools`
1048
- : entry?.error
1049
- ? "error"
1050
- : "disconnected";
1051
- return { label: s.name, hint: status, value: s.name };
1052
- });
1053
- items.push({ label: "+ Install a new MCP server…", hint: "find & install", value: INSTALL });
1054
- items.push({ label: "Add manually (name: command)…", hint: "advanced", value: ADD_MANUAL });
1055
-
1056
- const picked = await tui.select(
1057
- `${c.bold}MCP servers${c.reset} ${c.dim}(↑/↓ · enter · esc to close)${c.reset}`,
1058
- items
1059
- );
1060
- if (!picked) return;
1061
-
1062
- if (picked === INSTALL) {
1063
- await installMcpServerFlow(tui, mcp);
1064
- return;
1065
- }
1066
- if (picked === ADD_MANUAL) {
1067
- await addMcpServer(tui, mcp);
1068
- return;
1069
- }
1070
-
1071
- const server = configured.find((s) => s.name === picked)!;
1072
- const isConnected = connected.has(picked);
1073
-
1074
- const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
1075
- { label: "View tools", value: "tools" as const },
1076
- isConnected
1077
- ? { label: "Disconnect", value: "disconnect" as const }
1078
- : { label: "Connect", value: "connect" as const },
1079
- server.enabled
1080
- ? { label: "Disable (don't auto-connect)", value: "disable" as const }
1081
- : { label: "Enable (auto-connect on start)", value: "enable" as const },
1082
- { label: "Remove this server", value: "remove" as const },
1083
- { label: "Back", value: "back" as const },
1084
- ]);
1085
-
1086
- if (action === "tools") {
1087
- const entry = mcp.catalog().servers.find((e) => e.name === picked);
1088
- if (!entry || entry.status !== "connected") {
1089
- tui.print(`${c.gray}${picked} is not connected — connect it to list tools.${c.reset}`);
1090
- return;
1091
- }
1092
- if (!entry.tools.length) tui.print(`${c.gray}${picked} exposes no tools.${c.reset}`);
1093
- for (const t of entry.tools) tui.print(` ${c.cyan}${t.name}${c.reset}${t.description ? ` ${c.gray}— ${t.description}${c.reset}` : ""}`);
1094
- if (entry.resources.length) tui.print(` ${c.gray}${entry.resources.length} resource(s)${c.reset}`);
1095
- } else if (action === "connect") {
1096
- const res = await mcp.connect(server);
1097
- tui.print(res.ok ? `${c.cyan}⚡ connected (${res.tools} tools)${c.reset}` : `${c.red}⚠ ${res.error}${c.reset}`);
1098
- } else if (action === "disconnect") {
1099
- mcp.disconnect(picked);
1100
- tui.print(`${c.gray}disconnected ${picked}${c.reset}`);
1101
- } else if (action === "enable") {
1102
- mcp.setEnabled(picked, true);
1103
- const res = await mcp.connect(server);
1104
- tui.print(res.ok ? `${c.cyan}⚡ enabled + connected (${res.tools} tools)${c.reset}` : `${c.red}⚠ enabled but failed: ${res.error}${c.reset}`);
1105
- } else if (action === "disable") {
1106
- mcp.setEnabled(picked, false);
1107
- mcp.disconnect(picked);
1108
- tui.print(`${c.gray}disabled + disconnected ${picked}${c.reset}`);
1109
- } else if (action === "remove") {
1110
- mcp.remove(picked);
1111
- tui.print(`${c.gray}removed ${picked}${c.reset}`);
1112
- }
1113
- }
1114
-
1115
- /** Prompt for a new MCP server (`name: command args`), then save + connect it. */
1116
- async function addMcpServer(tui: Tui, mcp: McpController): Promise<void> {
1117
- const spec = await tui.prompt(
1118
- "Add an MCP server — enter name: command [args…]",
1119
- "fs: npx -y @modelcontextprotocol/server-filesystem ."
1120
- );
1121
- if (!spec) return;
1122
- const cfg = parseServerSpec(spec);
1123
- if (!cfg) {
1124
- tui.print(`${c.yellow}couldn't parse that. Use name: command [args]${c.reset}`);
1125
- return;
1126
- }
1127
- tui.print(`${c.gray}connecting MCP "${cfg.name}" (${cfg.command} ${cfg.args.join(" ")})…${c.reset}`);
1128
- const res = await mcp.add(cfg);
1129
- if (res.ok) tui.print(`${c.cyan}⚡ MCP "${cfg.name}" connected${c.reset} ${c.gray}(${res.tools} tool${res.tools === 1 ? "" : "s"})${c.reset}`);
1130
- else tui.print(`${c.red}⚠ MCP "${cfg.name}" failed:${c.reset} ${c.gray}${res.error}${c.reset} ${c.dim}(saved; retry from the MCP menu)${c.reset}`);
1131
- }
1132
-
1133
- /**
1134
- * The "Install a new MCP server" flow: ask what the user wants, then seed that
1135
- * request into the main chat. The agent researches the right server (via
1136
- * research_agent) and installs it (via install_mcp) right there in the
1137
- * transcript — so the same thing happens whether you use this menu or just ask
1138
- * in chat. We land straight back in the conversation.
1139
- */
1140
- async function installMcpServerFlow(tui: Tui, mcp: McpController): Promise<void> {
1141
- const query = await tui.prompt(
1142
- "What MCP server do you want to install?",
1143
- "the best computer-use mcp server for mac"
1144
- );
1145
- if (!query) return;
1146
- mcp.requestInstall(query);
1147
- }
1148
-
1149
- main().catch((err) => {
1150
- process.stderr.write(`\n${err instanceof Error ? err.stack || err.message : String(err)}\n`);
1151
- process.exit(1);
1152
- });