@standardagents/code 0.0.2-dev.b3cdaaf → 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/README.md +11 -3
- package/dist/index.js +3463 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -4
- package/bin/standardcode.mjs +0 -25
- package/src/api.ts +0 -169
- package/src/approvals.ts +0 -42
- package/src/bridge.ts +0 -303
- package/src/credentials.ts +0 -49
- package/src/events-stream.ts +0 -99
- package/src/host-tools.ts +0 -570
- package/src/index.ts +0 -1152
- package/src/markdown.ts +0 -226
- package/src/mcp-config.ts +0 -137
- package/src/mcp.ts +0 -563
- package/src/permissions.ts +0 -53
- package/src/process-registry.ts +0 -122
- package/src/stream.ts +0 -134
- package/src/tui.ts +0 -911
- package/src/types.ts +0 -78
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
|
-
});
|