@standardagents/code 0.0.2-dev.517db40 → 0.0.2-dev.b3cdaaf
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 +3 -11
- package/bin/standardcode.mjs +25 -0
- package/package.json +4 -5
- package/src/api.ts +169 -0
- package/src/approvals.ts +42 -0
- package/src/bridge.ts +303 -0
- package/src/credentials.ts +49 -0
- package/src/events-stream.ts +99 -0
- package/src/host-tools.ts +570 -0
- package/src/index.ts +1152 -0
- package/src/markdown.ts +226 -0
- package/src/mcp-config.ts +137 -0
- package/src/mcp.ts +563 -0
- package/src/permissions.ts +53 -0
- package/src/process-registry.ts +122 -0
- package/src/stream.ts +134 -0
- package/src/tui.ts +911 -0
- package/src/types.ts +78 -0
- package/dist/index.js +0 -3463
- package/dist/index.js.map +0 -1
package/dist/index.js
DELETED
|
@@ -1,3463 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import os4 from 'os';
|
|
3
|
-
import fs2 from 'fs';
|
|
4
|
-
import path3 from 'path';
|
|
5
|
-
import readline2 from 'readline/promises';
|
|
6
|
-
import { spawn } from 'child_process';
|
|
7
|
-
import { stdout, stdin } from 'process';
|
|
8
|
-
import fsp from 'fs/promises';
|
|
9
|
-
import crypto2 from 'crypto';
|
|
10
|
-
import readline from 'readline';
|
|
11
|
-
|
|
12
|
-
// src/api.ts
|
|
13
|
-
var ApiClient = class {
|
|
14
|
-
constructor(endpoint, token) {
|
|
15
|
-
this.endpoint = endpoint;
|
|
16
|
-
this.token = token;
|
|
17
|
-
}
|
|
18
|
-
endpoint;
|
|
19
|
-
token;
|
|
20
|
-
get wsEndpoint() {
|
|
21
|
-
return this.endpoint.replace(/^http/, "ws");
|
|
22
|
-
}
|
|
23
|
-
/** The instance origin (used to build admin-UI links). */
|
|
24
|
-
get origin() {
|
|
25
|
-
return this.endpoint;
|
|
26
|
-
}
|
|
27
|
-
get bearer() {
|
|
28
|
-
return this.token;
|
|
29
|
-
}
|
|
30
|
-
async json(pathname, init) {
|
|
31
|
-
const res = await fetch(`${this.endpoint}${pathname}`, {
|
|
32
|
-
...init,
|
|
33
|
-
headers: {
|
|
34
|
-
"Content-Type": "application/json",
|
|
35
|
-
Authorization: `Bearer ${this.token}`,
|
|
36
|
-
...init?.headers || {}
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
const text = await res.text();
|
|
40
|
-
if (!res.ok) {
|
|
41
|
-
throw new Error(`${init?.method || "GET"} ${pathname} -> ${res.status}: ${text.slice(0, 300)}`);
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
return JSON.parse(text);
|
|
45
|
-
} catch {
|
|
46
|
-
return text;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
async verify() {
|
|
50
|
-
try {
|
|
51
|
-
await this.json("/api/auth/me");
|
|
52
|
-
return true;
|
|
53
|
-
} catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
/** Agent name → display title map (best effort; for labeling subagents). */
|
|
58
|
-
async listAgents() {
|
|
59
|
-
const res = await this.json("/api/agents");
|
|
60
|
-
const arr = Array.isArray(res) ? res : res.agents || [];
|
|
61
|
-
return arr.filter((a) => a && typeof a.name === "string").map((a) => ({ name: a.name, title: typeof a.title === "string" ? a.title : a.name }));
|
|
62
|
-
}
|
|
63
|
-
async createThread(agentId, tags) {
|
|
64
|
-
const res = await this.json("/api/threads", {
|
|
65
|
-
method: "POST",
|
|
66
|
-
body: JSON.stringify({ agent_id: agentId, tags })
|
|
67
|
-
});
|
|
68
|
-
const id = res.threadId || res.id;
|
|
69
|
-
if (!id) throw new Error("Thread create returned no id");
|
|
70
|
-
return id;
|
|
71
|
-
}
|
|
72
|
-
/** List threads for an agent, optionally filtering to those carrying all given tags. */
|
|
73
|
-
async listThreads(agentId, requireTags) {
|
|
74
|
-
const res = await this.json(
|
|
75
|
-
`/api/threads?agent_id=${encodeURIComponent(agentId)}&limit=100`
|
|
76
|
-
);
|
|
77
|
-
const arr = Array.isArray(res) ? res : res.threads || [];
|
|
78
|
-
return arr.map((t) => ({
|
|
79
|
-
id: t.id,
|
|
80
|
-
tags: Array.isArray(t.tags) ? t.tags : [],
|
|
81
|
-
created_at: t.created_at,
|
|
82
|
-
title: t.title,
|
|
83
|
-
preview: t.preview || t.last_message
|
|
84
|
-
})).filter((t) => requireTags.every((tag) => t.tags.includes(tag)));
|
|
85
|
-
}
|
|
86
|
-
async sendMessage(threadId, content) {
|
|
87
|
-
await this.json(`/api/threads/${threadId}/messages`, {
|
|
88
|
-
method: "POST",
|
|
89
|
-
body: JSON.stringify({ role: "user", content })
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
async getMessages(threadId, limit = 50) {
|
|
93
|
-
const res = await this.json(
|
|
94
|
-
`/api/threads/${threadId}/messages?limit=${limit}`
|
|
95
|
-
);
|
|
96
|
-
return Array.isArray(res) ? res : res.messages || [];
|
|
97
|
-
}
|
|
98
|
-
async getLogs(threadId, limit = 100) {
|
|
99
|
-
const res = await this.json(
|
|
100
|
-
`/api/threads/${threadId}/logs?limit=${limit}&order=desc`
|
|
101
|
-
);
|
|
102
|
-
return Array.isArray(res) ? res : res.logs || [];
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Deliver a durable forwarded tool result to the thread, resuming the turn.
|
|
106
|
-
* Retries with backoff — this is the durable delivery path, so it must land
|
|
107
|
-
* even if the connection is briefly flaky after a permission wait.
|
|
108
|
-
*/
|
|
109
|
-
async postToolResult(threadId, toolCallId, ok, result, error) {
|
|
110
|
-
const body = JSON.stringify({ tool_call_id: toolCallId, ok, result, error });
|
|
111
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
112
|
-
try {
|
|
113
|
-
await this.json(`/api/threads/${threadId}/tool-result`, {
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: { "Content-Type": "application/json" },
|
|
116
|
-
body
|
|
117
|
-
});
|
|
118
|
-
return true;
|
|
119
|
-
} catch {
|
|
120
|
-
await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 8e3)));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
/** Read a value from the thread's durable KV store (null if absent). */
|
|
126
|
-
async kvGet(threadId, key) {
|
|
127
|
-
try {
|
|
128
|
-
const res = await this.json(
|
|
129
|
-
`/api/threads/${threadId}/kv?key=${encodeURIComponent(key)}`
|
|
130
|
-
);
|
|
131
|
-
return res?.value ?? null;
|
|
132
|
-
} catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
/** Write a value to the thread's durable KV store. */
|
|
137
|
-
async kvSet(threadId, key, value) {
|
|
138
|
-
try {
|
|
139
|
-
await this.json(`/api/threads/${threadId}/kv`, {
|
|
140
|
-
method: "POST",
|
|
141
|
-
headers: { "Content-Type": "application/json" },
|
|
142
|
-
body: JSON.stringify({ key, value })
|
|
143
|
-
});
|
|
144
|
-
} catch {
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
async stop(threadId) {
|
|
148
|
-
try {
|
|
149
|
-
await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
|
|
150
|
-
} catch {
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
// src/permissions.ts
|
|
156
|
-
function decide(state, tool, risk, hasPermissionRequest) {
|
|
157
|
-
const effectiveRisk = typeof risk === "number" ? Math.min(5, Math.max(1, risk)) : hasPermissionRequest ? 3 : 1;
|
|
158
|
-
if (state.alwaysAllow.has(tool)) return "allow";
|
|
159
|
-
if (state.allowRisk.has(effectiveRisk)) return "allow";
|
|
160
|
-
return effectiveRisk <= state.level ? "allow" : "ask";
|
|
161
|
-
}
|
|
162
|
-
var CATASTROPHIC_PATTERNS = [
|
|
163
|
-
/\brm\s+(-[a-z]*\s+)*-[a-z]*f[a-z]*\s+(-[a-z]*\s+)*(\/|~|\$HOME|\/\*|\.\s*$|\/\s*$)/i,
|
|
164
|
-
// rm -rf / , rm -rf ~
|
|
165
|
-
/\brm\s+-rf\s+--no-preserve-root/i,
|
|
166
|
-
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
|
167
|
-
// fork bomb
|
|
168
|
-
/\bmkfs(\.\w+)?\b/i,
|
|
169
|
-
// format filesystem
|
|
170
|
-
/\bdd\b[^\n]*\bof=\/dev\/(sd|disk|nvme|hd)/i,
|
|
171
|
-
// overwrite raw disk
|
|
172
|
-
/\b(shutdown|reboot|halt|poweroff)\b/i,
|
|
173
|
-
/>\s*\/dev\/(sd|disk|nvme|hd)/i,
|
|
174
|
-
/\bchmod\s+-R\s+(000|777)\s+\/(?:\s|$)/i
|
|
175
|
-
];
|
|
176
|
-
function isCatastrophic(command) {
|
|
177
|
-
return CATASTROPHIC_PATTERNS.some((re) => re.test(command));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// src/approvals.ts
|
|
181
|
-
var KEY = "approvals";
|
|
182
|
-
async function loadApprovals(api, threadId) {
|
|
183
|
-
const v = await api.kvGet(threadId, KEY);
|
|
184
|
-
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
185
|
-
const o = v;
|
|
186
|
-
const n = Number(o.level);
|
|
187
|
-
const level = n >= 1 && n <= 5 ? n : void 0;
|
|
188
|
-
return {
|
|
189
|
-
level,
|
|
190
|
-
allowTools: Array.isArray(o.allowTools) ? o.allowTools : [],
|
|
191
|
-
allowRisk: Array.isArray(o.allowRisk) ? o.allowRisk : []
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return { allowTools: [], allowRisk: [] };
|
|
195
|
-
}
|
|
196
|
-
function saveApprovals(api, threadId, perm) {
|
|
197
|
-
const payload = {
|
|
198
|
-
level: perm.level,
|
|
199
|
-
allowTools: Array.from(perm.alwaysAllow).sort(),
|
|
200
|
-
allowRisk: Array.from(perm.allowRisk).sort((a, b) => a - b)
|
|
201
|
-
};
|
|
202
|
-
void api.kvSet(threadId, KEY, payload);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// src/bridge.ts
|
|
206
|
-
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "delete"]);
|
|
207
|
-
var Bridge = class {
|
|
208
|
-
constructor(api, threadId, host, perm, hooks) {
|
|
209
|
-
this.api = api;
|
|
210
|
-
this.threadId = threadId;
|
|
211
|
-
this.host = host;
|
|
212
|
-
this.perm = perm;
|
|
213
|
-
this.hooks = hooks;
|
|
214
|
-
}
|
|
215
|
-
api;
|
|
216
|
-
threadId;
|
|
217
|
-
host;
|
|
218
|
-
perm;
|
|
219
|
-
hooks;
|
|
220
|
-
ws = null;
|
|
221
|
-
closed = false;
|
|
222
|
-
heartbeat = null;
|
|
223
|
-
reconnectAttempt = 0;
|
|
224
|
-
reconnectTimer = null;
|
|
225
|
-
resolveConnected = null;
|
|
226
|
-
// Durable forwarded calls we've started handling, so a server re-send (after a
|
|
227
|
-
// reconnect) doesn't prompt or run them twice.
|
|
228
|
-
handledDurable = /* @__PURE__ */ new Set();
|
|
229
|
-
/**
|
|
230
|
-
* Connect and keep the bridge connected. Resolves on the first successful
|
|
231
|
-
* open; thereafter any drop is reconnected automatically with exponential
|
|
232
|
-
* backoff (disconnections are expected — e.g. a dev-server reload — so this
|
|
233
|
-
* must be rock solid). A short safety timeout resolves startup even if the
|
|
234
|
-
* very first attempt is slow, since reconnection continues in the background.
|
|
235
|
-
*/
|
|
236
|
-
connect() {
|
|
237
|
-
return new Promise((resolve) => {
|
|
238
|
-
let settled = false;
|
|
239
|
-
this.resolveConnected = () => {
|
|
240
|
-
if (!settled) {
|
|
241
|
-
settled = true;
|
|
242
|
-
resolve();
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
setTimeout(() => this.resolveConnected?.(), 8e3);
|
|
246
|
-
this.openSocket();
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
openSocket() {
|
|
250
|
-
if (this.closed) return;
|
|
251
|
-
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/bridge?token=${encodeURIComponent(this.api.bearer)}`;
|
|
252
|
-
let ws;
|
|
253
|
-
try {
|
|
254
|
-
ws = new WebSocket(url);
|
|
255
|
-
} catch {
|
|
256
|
-
this.scheduleReconnect();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
this.ws = ws;
|
|
260
|
-
ws.addEventListener("open", () => {
|
|
261
|
-
const wasReconnecting = this.reconnectAttempt > 0;
|
|
262
|
-
this.reconnectAttempt = 0;
|
|
263
|
-
this.startHeartbeat(ws);
|
|
264
|
-
this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
|
|
265
|
-
this.resolveConnected?.();
|
|
266
|
-
});
|
|
267
|
-
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
268
|
-
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
269
|
-
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
270
|
-
}
|
|
271
|
-
handleDrop(ws) {
|
|
272
|
-
if (this.ws !== ws) return;
|
|
273
|
-
this.ws = null;
|
|
274
|
-
this.stopHeartbeat();
|
|
275
|
-
this.scheduleReconnect();
|
|
276
|
-
}
|
|
277
|
-
scheduleReconnect() {
|
|
278
|
-
if (this.closed || this.reconnectTimer) return;
|
|
279
|
-
this.reconnectAttempt++;
|
|
280
|
-
this.hooks.onConnection?.("reconnecting", this.reconnectAttempt);
|
|
281
|
-
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
282
|
-
const delay = base + Math.floor(Math.random() * 400);
|
|
283
|
-
this.reconnectTimer = setTimeout(() => {
|
|
284
|
-
this.reconnectTimer = null;
|
|
285
|
-
this.openSocket();
|
|
286
|
-
}, delay);
|
|
287
|
-
}
|
|
288
|
-
startHeartbeat(ws) {
|
|
289
|
-
this.stopHeartbeat();
|
|
290
|
-
this.heartbeat = setInterval(() => {
|
|
291
|
-
try {
|
|
292
|
-
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
|
|
293
|
-
} catch {
|
|
294
|
-
}
|
|
295
|
-
}, 5e3);
|
|
296
|
-
}
|
|
297
|
-
stopHeartbeat() {
|
|
298
|
-
if (this.heartbeat) {
|
|
299
|
-
clearInterval(this.heartbeat);
|
|
300
|
-
this.heartbeat = null;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
close() {
|
|
304
|
-
this.closed = true;
|
|
305
|
-
this.stopHeartbeat();
|
|
306
|
-
if (this.reconnectTimer) {
|
|
307
|
-
clearTimeout(this.reconnectTimer);
|
|
308
|
-
this.reconnectTimer = null;
|
|
309
|
-
}
|
|
310
|
-
this.ws?.close();
|
|
311
|
-
}
|
|
312
|
-
send(payload) {
|
|
313
|
-
try {
|
|
314
|
-
this.ws?.send(JSON.stringify(payload));
|
|
315
|
-
} catch {
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
async onMessage(raw) {
|
|
319
|
-
let msg;
|
|
320
|
-
try {
|
|
321
|
-
msg = JSON.parse(raw);
|
|
322
|
-
} catch {
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (msg.type !== "tool_request") return;
|
|
326
|
-
const req = msg;
|
|
327
|
-
await this.handleToolRequest(req);
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* Reply to a tool request. Durable calls (the agent parked them) deliver the
|
|
331
|
-
* result over HTTP so it lands even if this socket later drops; legacy calls
|
|
332
|
-
* reply over the WebSocket.
|
|
333
|
-
*/
|
|
334
|
-
respond(req, ok, result, error) {
|
|
335
|
-
if (req.durable && req.toolCallId) {
|
|
336
|
-
void this.api.postToolResult(this.threadId, req.toolCallId, ok, result, error);
|
|
337
|
-
} else {
|
|
338
|
-
this.send({ type: "tool_response", id: req.id, ok, result, error });
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
async handleToolRequest(req) {
|
|
342
|
-
if (req.durable && req.toolCallId) {
|
|
343
|
-
if (this.handledDurable.has(req.toolCallId)) return;
|
|
344
|
-
this.handledDurable.add(req.toolCallId);
|
|
345
|
-
}
|
|
346
|
-
const summary = describe(req);
|
|
347
|
-
let effectiveRisk = typeof req.risk === "number" ? req.risk : req.requestPermission ? 3 : 1;
|
|
348
|
-
if (PATH_ARG_TOOLS.has(req.tool) && this.host.isOutsideProject(req.args.path)) {
|
|
349
|
-
effectiveRisk = Math.max(effectiveRisk, 4);
|
|
350
|
-
}
|
|
351
|
-
if (req.tool === "bash" && isCatastrophic(String(req.args.command || ""))) {
|
|
352
|
-
this.hooks.onActivity(`\u26D4 blocked dangerous command: ${summary}`);
|
|
353
|
-
this.respond(req, false, void 0, "Blocked: this command is considered catastrophic and was refused by the client safety guard.");
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
const permKey = permissionKey(req);
|
|
357
|
-
const decision = decide(this.perm, permKey, effectiveRisk, !!req.requestPermission);
|
|
358
|
-
if (decision === "deny") {
|
|
359
|
-
this.hooks.onActivity(`\u26D4 ${summary} \u2014 blocked (risk ${effectiveRisk})`);
|
|
360
|
-
this.respond(req, false, void 0, `Denied by policy (risk ${effectiveRisk}).`);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
if (decision === "ask") {
|
|
364
|
-
const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
365
|
-
if (choice === "deny") {
|
|
366
|
-
this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined`);
|
|
367
|
-
this.respond(req, false, void 0, "The user declined to run this operation.");
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (choice === "always") this.perm.alwaysAllow.add(permKey);
|
|
371
|
-
if (choice === "always_risk") this.perm.allowRisk.add(effectiveRisk);
|
|
372
|
-
if (choice === "always" || choice === "always_risk") {
|
|
373
|
-
saveApprovals(this.api, this.threadId, this.perm);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
this.hooks.onStatus?.(summary);
|
|
377
|
-
const result = await this.host.execute(req.tool, req.args);
|
|
378
|
-
this.hooks.onStatus?.(null);
|
|
379
|
-
if (result.ok) {
|
|
380
|
-
this.hooks.onActivity(`\u2713 ${summary}${detailSuffix(req.tool, result.result)}`);
|
|
381
|
-
this.respond(req, true, result.result ?? "");
|
|
382
|
-
} else {
|
|
383
|
-
this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
|
|
384
|
-
this.respond(req, false, void 0, result.error);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
function permissionKey(req) {
|
|
389
|
-
if (req.tool !== "mcp") return req.tool;
|
|
390
|
-
const a = req.args;
|
|
391
|
-
const server = String(a.server || "?");
|
|
392
|
-
const action = String(a.action || "call");
|
|
393
|
-
if (action === "read_resource") return `mcp:${server}/resource`;
|
|
394
|
-
if (action === "list_tools") return `mcp:${server}/list`;
|
|
395
|
-
return `mcp:${server}/${String(a.tool || "?")}`;
|
|
396
|
-
}
|
|
397
|
-
function describe(req) {
|
|
398
|
-
const a = req.args;
|
|
399
|
-
switch (req.tool) {
|
|
400
|
-
case "mcp": {
|
|
401
|
-
const server = String(a.server || "?");
|
|
402
|
-
const action = String(a.action || "call");
|
|
403
|
-
if (action === "list_tools") return `mcp ${server}: list tools`;
|
|
404
|
-
if (action === "read_resource") return `mcp ${server}: read ${a.uri}`;
|
|
405
|
-
return `mcp ${server}: ${a.tool}`;
|
|
406
|
-
}
|
|
407
|
-
case "read_file":
|
|
408
|
-
return `read ${a.path}`;
|
|
409
|
-
case "list_dir":
|
|
410
|
-
return `list ${a.path || "."}`;
|
|
411
|
-
case "grep":
|
|
412
|
-
return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
|
|
413
|
-
case "glob":
|
|
414
|
-
return `find ${a.pattern}`;
|
|
415
|
-
case "write_file":
|
|
416
|
-
return `write ${a.path}`;
|
|
417
|
-
case "edit_file":
|
|
418
|
-
return `edit ${a.path}`;
|
|
419
|
-
case "delete":
|
|
420
|
-
return `delete ${a.path}`;
|
|
421
|
-
case "bash":
|
|
422
|
-
return `bash: ${String(a.command).slice(0, 80)}`;
|
|
423
|
-
default:
|
|
424
|
-
return `${req.tool} ${JSON.stringify(a).slice(0, 80)}`;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
function detailSuffix(tool, result) {
|
|
428
|
-
if (!result) return "";
|
|
429
|
-
if (tool === "write_file" || tool === "edit_file" || tool === "delete") return "";
|
|
430
|
-
if (tool === "bash") {
|
|
431
|
-
const m = result.match(/\[exit code (\d+)\]\s*$/);
|
|
432
|
-
return m ? ` (exit ${m[1]})` : "";
|
|
433
|
-
}
|
|
434
|
-
const lines = result.split("\n").length;
|
|
435
|
-
return ` (${lines} line${lines === 1 ? "" : "s"})`;
|
|
436
|
-
}
|
|
437
|
-
var LOG_DIR = path3.join(os4.homedir(), ".standardagents", "process-logs");
|
|
438
|
-
var KEY2 = "bg_processes";
|
|
439
|
-
function isAlive(pid) {
|
|
440
|
-
try {
|
|
441
|
-
process.kill(pid, 0);
|
|
442
|
-
return true;
|
|
443
|
-
} catch (err) {
|
|
444
|
-
return err.code === "EPERM";
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
var ProcessRegistry = class {
|
|
448
|
-
constructor(api, threadId, machine) {
|
|
449
|
-
this.api = api;
|
|
450
|
-
this.threadId = threadId;
|
|
451
|
-
this.machine = machine;
|
|
452
|
-
}
|
|
453
|
-
api;
|
|
454
|
-
threadId;
|
|
455
|
-
machine;
|
|
456
|
-
async read() {
|
|
457
|
-
const value = await this.api.kvGet(this.threadId, KEY2);
|
|
458
|
-
return Array.isArray(value) ? value : [];
|
|
459
|
-
}
|
|
460
|
-
async write(entries) {
|
|
461
|
-
await this.api.kvSet(this.threadId, KEY2, entries);
|
|
462
|
-
}
|
|
463
|
-
/** Mark this machine's dead "running" entries as exited. Returns true if any changed. */
|
|
464
|
-
reconcileLiveness(entries) {
|
|
465
|
-
let changed = false;
|
|
466
|
-
for (const e of entries) {
|
|
467
|
-
if (e.machine === this.machine && e.status === "running" && !isAlive(e.pid)) {
|
|
468
|
-
e.status = "exited";
|
|
469
|
-
e.endedAt = Date.now();
|
|
470
|
-
changed = true;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
return changed;
|
|
474
|
-
}
|
|
475
|
-
/** All tracked processes, newest first, with liveness re-checked + persisted. */
|
|
476
|
-
async list() {
|
|
477
|
-
const entries = await this.read();
|
|
478
|
-
if (this.reconcileLiveness(entries)) await this.write(entries);
|
|
479
|
-
return entries.sort((a, b) => b.startedAt - a.startedAt);
|
|
480
|
-
}
|
|
481
|
-
async runningCount() {
|
|
482
|
-
const entries = await this.list();
|
|
483
|
-
return entries.filter((e) => e.status === "running").length;
|
|
484
|
-
}
|
|
485
|
-
async get(id) {
|
|
486
|
-
return (await this.read()).find((e) => e.id === id) ?? null;
|
|
487
|
-
}
|
|
488
|
-
async add(entry) {
|
|
489
|
-
const entries = await this.read();
|
|
490
|
-
entries.push(entry);
|
|
491
|
-
await this.write(entries);
|
|
492
|
-
}
|
|
493
|
-
async markExited(id, exitCode) {
|
|
494
|
-
const entries = await this.read();
|
|
495
|
-
const e = entries.find((x) => x.id === id);
|
|
496
|
-
if (e && e.status === "running") {
|
|
497
|
-
e.status = "exited";
|
|
498
|
-
e.exitCode = exitCode;
|
|
499
|
-
e.endedAt = Date.now();
|
|
500
|
-
await this.write(entries);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
async markStopped(id) {
|
|
504
|
-
const entries = await this.read();
|
|
505
|
-
const e = entries.find((x) => x.id === id);
|
|
506
|
-
if (e) {
|
|
507
|
-
e.status = "stopped";
|
|
508
|
-
e.endedAt = Date.now();
|
|
509
|
-
await this.write(entries);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
var DIR = path3.join(os4.homedir(), ".standardagents");
|
|
514
|
-
var FILE = path3.join(DIR, "mcp.json");
|
|
515
|
-
function loadMcpConfig() {
|
|
516
|
-
try {
|
|
517
|
-
const raw = fs2.readFileSync(FILE, "utf8");
|
|
518
|
-
const parsed = JSON.parse(raw);
|
|
519
|
-
if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
|
|
520
|
-
return parsed;
|
|
521
|
-
} catch {
|
|
522
|
-
return { servers: {} };
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
function listMcpServers() {
|
|
526
|
-
const cfg = loadMcpConfig();
|
|
527
|
-
return Object.values(cfg.servers).sort((a, b) => a.name.localeCompare(b.name));
|
|
528
|
-
}
|
|
529
|
-
function saveMcpServer(server) {
|
|
530
|
-
const cfg = loadMcpConfig();
|
|
531
|
-
cfg.servers[server.name] = server;
|
|
532
|
-
write(cfg);
|
|
533
|
-
}
|
|
534
|
-
function removeMcpServer(name) {
|
|
535
|
-
const cfg = loadMcpConfig();
|
|
536
|
-
delete cfg.servers[name];
|
|
537
|
-
write(cfg);
|
|
538
|
-
}
|
|
539
|
-
function setMcpServerEnabled(name, enabled) {
|
|
540
|
-
const cfg = loadMcpConfig();
|
|
541
|
-
const s = cfg.servers[name];
|
|
542
|
-
if (!s) return;
|
|
543
|
-
s.enabled = enabled;
|
|
544
|
-
write(cfg);
|
|
545
|
-
}
|
|
546
|
-
function write(cfg) {
|
|
547
|
-
fs2.mkdirSync(DIR, { recursive: true });
|
|
548
|
-
fs2.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
549
|
-
}
|
|
550
|
-
function parseServerSpec(spec) {
|
|
551
|
-
const trimmed = spec.trim();
|
|
552
|
-
const colon = trimmed.indexOf(":");
|
|
553
|
-
if (colon <= 0) return null;
|
|
554
|
-
const name = trimmed.slice(0, colon).trim();
|
|
555
|
-
const rest = trimmed.slice(colon + 1).trim();
|
|
556
|
-
if (!name || !rest) return null;
|
|
557
|
-
const parts = tokenize(rest);
|
|
558
|
-
if (!parts.length) return null;
|
|
559
|
-
const [command, ...args] = parts;
|
|
560
|
-
return { name, command, args, enabled: true };
|
|
561
|
-
}
|
|
562
|
-
function serverFromCommand(name, commandLine, env) {
|
|
563
|
-
const cleanName = name.trim();
|
|
564
|
-
const parts = tokenize(commandLine.trim());
|
|
565
|
-
if (!cleanName || !parts.length) return null;
|
|
566
|
-
const [command, ...args] = parts;
|
|
567
|
-
return { name: cleanName, command, args, env, enabled: true };
|
|
568
|
-
}
|
|
569
|
-
function tokenize(input2) {
|
|
570
|
-
const out = [];
|
|
571
|
-
let cur = "";
|
|
572
|
-
let quote = null;
|
|
573
|
-
for (let i = 0; i < input2.length; i++) {
|
|
574
|
-
const ch = input2[i];
|
|
575
|
-
if (quote) {
|
|
576
|
-
if (ch === quote) quote = null;
|
|
577
|
-
else cur += ch;
|
|
578
|
-
} else if (ch === '"' || ch === "'") {
|
|
579
|
-
quote = ch;
|
|
580
|
-
} else if (/\s/.test(ch)) {
|
|
581
|
-
if (cur) {
|
|
582
|
-
out.push(cur);
|
|
583
|
-
cur = "";
|
|
584
|
-
}
|
|
585
|
-
} else {
|
|
586
|
-
cur += ch;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
if (cur) out.push(cur);
|
|
590
|
-
return out;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// src/host-tools.ts
|
|
594
|
-
var STARTUP_GRACE_MS = 600;
|
|
595
|
-
function isAlive2(pid) {
|
|
596
|
-
try {
|
|
597
|
-
process.kill(pid, 0);
|
|
598
|
-
return true;
|
|
599
|
-
} catch (err) {
|
|
600
|
-
return err.code === "EPERM";
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
async function readLogTail(logPath, n) {
|
|
604
|
-
try {
|
|
605
|
-
const content = await fsp.readFile(logPath, "utf8");
|
|
606
|
-
return content.split("\n").filter(Boolean).slice(-n).join("\n");
|
|
607
|
-
} catch {
|
|
608
|
-
return "";
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
var HostTools = class {
|
|
612
|
-
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange) {
|
|
613
|
-
this.projectDir = projectDir;
|
|
614
|
-
this.registry = registry;
|
|
615
|
-
this.threadId = threadId;
|
|
616
|
-
this.machine = machine;
|
|
617
|
-
this.mcp = mcp;
|
|
618
|
-
this.onMcpCatalogChange = onMcpCatalogChange;
|
|
619
|
-
}
|
|
620
|
-
projectDir;
|
|
621
|
-
registry;
|
|
622
|
-
threadId;
|
|
623
|
-
machine;
|
|
624
|
-
mcp;
|
|
625
|
-
onMcpCatalogChange;
|
|
626
|
-
/** Resolve a user/model-supplied path against the project directory. */
|
|
627
|
-
resolve(p) {
|
|
628
|
-
if (!p || p === ".") return this.projectDir;
|
|
629
|
-
return path3.resolve(this.projectDir, p);
|
|
630
|
-
}
|
|
631
|
-
/** True when the resolved path escapes the project directory. */
|
|
632
|
-
isOutsideProject(p) {
|
|
633
|
-
const abs = this.resolve(p);
|
|
634
|
-
const rel = path3.relative(this.projectDir, abs);
|
|
635
|
-
return rel.startsWith("..") || path3.isAbsolute(rel);
|
|
636
|
-
}
|
|
637
|
-
async execute(tool, args) {
|
|
638
|
-
try {
|
|
639
|
-
switch (tool) {
|
|
640
|
-
case "read_file":
|
|
641
|
-
return await this.readFile(args);
|
|
642
|
-
case "list_dir":
|
|
643
|
-
return await this.listDir(args);
|
|
644
|
-
case "grep":
|
|
645
|
-
return await this.grep(args);
|
|
646
|
-
case "glob":
|
|
647
|
-
return await this.glob(args);
|
|
648
|
-
case "write_file":
|
|
649
|
-
return await this.writeFile(args);
|
|
650
|
-
case "edit_file":
|
|
651
|
-
return await this.editFile(args);
|
|
652
|
-
case "bash":
|
|
653
|
-
return await this.bash(args);
|
|
654
|
-
case "delete":
|
|
655
|
-
return await this.deletePath(args);
|
|
656
|
-
case "background_process": {
|
|
657
|
-
const action = String(args.action || "list");
|
|
658
|
-
if (action === "start") return await this.runBackground(args);
|
|
659
|
-
return await this.backgroundProcesses(args);
|
|
660
|
-
}
|
|
661
|
-
case "mcp": {
|
|
662
|
-
if (!this.mcp) {
|
|
663
|
-
return { ok: false, error: "MCP is not available in this session." };
|
|
664
|
-
}
|
|
665
|
-
if (String(args.action) === "remove") {
|
|
666
|
-
const name = String(args.server || "");
|
|
667
|
-
if (!name) return { ok: false, error: "remove requires a 'server' name." };
|
|
668
|
-
this.mcp.disconnect(name);
|
|
669
|
-
removeMcpServer(name);
|
|
670
|
-
this.onMcpCatalogChange?.();
|
|
671
|
-
return { ok: true, result: JSON.stringify({ removed: name }) };
|
|
672
|
-
}
|
|
673
|
-
return await this.mcp.dispatch(args);
|
|
674
|
-
}
|
|
675
|
-
case "install_mcp":
|
|
676
|
-
return await this.installMcp(args);
|
|
677
|
-
default:
|
|
678
|
-
return { ok: false, error: `Unknown tool: ${tool}` };
|
|
679
|
-
}
|
|
680
|
-
} catch (err) {
|
|
681
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
async readFile(args) {
|
|
685
|
-
const file = this.resolve(String(args.path || ""));
|
|
686
|
-
const stat = await fsp.stat(file).catch(() => null);
|
|
687
|
-
if (!stat) return { ok: false, error: `File not found: ${args.path}` };
|
|
688
|
-
if (stat.isDirectory()) return { ok: false, error: `${args.path} is a directory` };
|
|
689
|
-
if (stat.size > 2e6) return { ok: false, error: `File too large (${stat.size} bytes)` };
|
|
690
|
-
const content = await fsp.readFile(file, "utf8");
|
|
691
|
-
const lines = content.split("\n");
|
|
692
|
-
const offset = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
|
|
693
|
-
const limit = typeof args.limit === "number" ? args.limit : lines.length;
|
|
694
|
-
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
695
|
-
const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
|
|
696
|
-
return { ok: true, result: numbered || "(empty file)" };
|
|
697
|
-
}
|
|
698
|
-
async listDir(args) {
|
|
699
|
-
const dir = this.resolve(args.path ? String(args.path) : void 0);
|
|
700
|
-
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
701
|
-
const sorted = entries.filter((e) => e.name !== ".git" && e.name !== "node_modules").sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
702
|
-
const lines = sorted.map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
703
|
-
const header = `${path3.relative(this.projectDir, dir) || "."} (${lines.length} entries)`;
|
|
704
|
-
return { ok: true, result: `${header}
|
|
705
|
-
${lines.join("\n")}` };
|
|
706
|
-
}
|
|
707
|
-
async grep(args) {
|
|
708
|
-
const pattern = String(args.pattern || "");
|
|
709
|
-
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
710
|
-
const searchPath = this.resolve(args.path ? String(args.path) : void 0);
|
|
711
|
-
const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--max-count", "200"];
|
|
712
|
-
if (args.ignore_case) rgArgs.push("-i");
|
|
713
|
-
if (args.glob) rgArgs.push("--glob", String(args.glob));
|
|
714
|
-
rgArgs.push("--", pattern, searchPath);
|
|
715
|
-
const rg = await this.run("rg", rgArgs, this.projectDir, 3e4);
|
|
716
|
-
if (rg.code === 127) {
|
|
717
|
-
return { ok: false, error: "ripgrep (rg) not found on host; install it for grep." };
|
|
718
|
-
}
|
|
719
|
-
const out = rg.stdout.trim();
|
|
720
|
-
return { ok: true, result: out || "(no matches)" };
|
|
721
|
-
}
|
|
722
|
-
async glob(args) {
|
|
723
|
-
const pattern = String(args.pattern || "");
|
|
724
|
-
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
725
|
-
const base = this.resolve(args.path ? String(args.path) : void 0);
|
|
726
|
-
const rg = await this.run("rg", ["--files", "--glob", pattern, base], this.projectDir, 3e4);
|
|
727
|
-
if (rg.code === 127) {
|
|
728
|
-
const matches = await this.walkGlob(base, pattern);
|
|
729
|
-
return { ok: true, result: matches.slice(0, 300).join("\n") || "(no files)" };
|
|
730
|
-
}
|
|
731
|
-
const rel = rg.stdout.trim().split("\n").filter(Boolean).map((p) => path3.relative(this.projectDir, p)).slice(0, 300);
|
|
732
|
-
return { ok: true, result: rel.join("\n") || "(no files)" };
|
|
733
|
-
}
|
|
734
|
-
async writeFile(args) {
|
|
735
|
-
const file = this.resolve(String(args.path || ""));
|
|
736
|
-
const content = String(args.content ?? "");
|
|
737
|
-
await fsp.mkdir(path3.dirname(file), { recursive: true });
|
|
738
|
-
const existed = fs2.existsSync(file);
|
|
739
|
-
await fsp.writeFile(file, content, "utf8");
|
|
740
|
-
return {
|
|
741
|
-
ok: true,
|
|
742
|
-
result: `${existed ? "Overwrote" : "Created"} ${path3.relative(this.projectDir, file)} (${Buffer.byteLength(content)} bytes)`
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
async editFile(args) {
|
|
746
|
-
const file = this.resolve(String(args.path || ""));
|
|
747
|
-
const oldStr = String(args.old_string ?? "");
|
|
748
|
-
const newStr = String(args.new_string ?? "");
|
|
749
|
-
const replaceAll = args.replace_all === true;
|
|
750
|
-
const stat = await fsp.stat(file).catch(() => null);
|
|
751
|
-
if (!stat) return { ok: false, error: `File not found: ${args.path}` };
|
|
752
|
-
const content = await fsp.readFile(file, "utf8");
|
|
753
|
-
if (oldStr === "") return { ok: false, error: "old_string cannot be empty" };
|
|
754
|
-
const count = content.split(oldStr).length - 1;
|
|
755
|
-
if (count === 0) return { ok: false, error: "old_string not found in file (it must match exactly)." };
|
|
756
|
-
if (count > 1 && !replaceAll) {
|
|
757
|
-
return { ok: false, error: `old_string is not unique (${count} matches). Add more context or set replace_all.` };
|
|
758
|
-
}
|
|
759
|
-
const updated = replaceAll ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
|
|
760
|
-
await fsp.writeFile(file, updated, "utf8");
|
|
761
|
-
return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
|
|
762
|
-
}
|
|
763
|
-
async deletePath(args) {
|
|
764
|
-
const target = this.resolve(String(args.path || ""));
|
|
765
|
-
const recursive = args.recursive === true;
|
|
766
|
-
const stat = await fsp.stat(target).catch(() => null);
|
|
767
|
-
if (!stat) return { ok: false, error: `Path not found: ${args.path}` };
|
|
768
|
-
if (stat.isDirectory() && !recursive) {
|
|
769
|
-
return { ok: false, error: `${args.path} is a directory; set recursive to delete it.` };
|
|
770
|
-
}
|
|
771
|
-
await fsp.rm(target, { recursive, force: false });
|
|
772
|
-
return { ok: true, result: `Deleted ${path3.relative(this.projectDir, target) || target}` };
|
|
773
|
-
}
|
|
774
|
-
async bash(args) {
|
|
775
|
-
const command = String(args.command || "");
|
|
776
|
-
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
777
|
-
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
778
|
-
const timeout = typeof args.timeout_ms === "number" ? args.timeout_ms : 12e4;
|
|
779
|
-
const res = await this.run("bash", ["-lc", command], cwd, timeout);
|
|
780
|
-
const combined = [res.stdout, res.stderr].filter(Boolean).join("\n").trim();
|
|
781
|
-
const truncated = combined.length > 3e4 ? combined.slice(0, 3e4) + "\n\u2026(truncated)" : combined;
|
|
782
|
-
if (res.timedOut) {
|
|
783
|
-
return { ok: false, error: `Command timed out after ${timeout}ms.
|
|
784
|
-
${truncated}` };
|
|
785
|
-
}
|
|
786
|
-
const status = `exit code ${res.code}`;
|
|
787
|
-
return {
|
|
788
|
-
ok: res.code === 0,
|
|
789
|
-
result: `${truncated || "(no output)"}
|
|
790
|
-
[${status}]`,
|
|
791
|
-
error: res.code === 0 ? void 0 : `Command failed (${status}).
|
|
792
|
-
${truncated}`
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
/** Start a tracked long-running process, detached, with output to a log file. */
|
|
796
|
-
async runBackground(args) {
|
|
797
|
-
const command = String(args.command || "");
|
|
798
|
-
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
799
|
-
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
800
|
-
const id = crypto2.randomUUID().slice(0, 8);
|
|
801
|
-
const logPath = path3.join(LOG_DIR, `${id}.log`);
|
|
802
|
-
let out;
|
|
803
|
-
try {
|
|
804
|
-
await fsp.mkdir(LOG_DIR, { recursive: true });
|
|
805
|
-
out = fs2.openSync(logPath, "a");
|
|
806
|
-
} catch (err) {
|
|
807
|
-
return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
|
|
808
|
-
}
|
|
809
|
-
let child;
|
|
810
|
-
try {
|
|
811
|
-
child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
|
|
812
|
-
} catch (err) {
|
|
813
|
-
fs2.closeSync(out);
|
|
814
|
-
return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
|
|
815
|
-
}
|
|
816
|
-
fs2.closeSync(out);
|
|
817
|
-
const pid = child.pid;
|
|
818
|
-
if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
|
|
819
|
-
let earlyExit;
|
|
820
|
-
const onEarlyExit = (code) => {
|
|
821
|
-
earlyExit = code;
|
|
822
|
-
};
|
|
823
|
-
child.on("exit", onEarlyExit);
|
|
824
|
-
await new Promise((r) => setTimeout(r, STARTUP_GRACE_MS));
|
|
825
|
-
if (earlyExit !== void 0 || !isAlive2(pid)) {
|
|
826
|
-
const tail = await readLogTail(logPath, 15);
|
|
827
|
-
const code = earlyExit ?? "unknown";
|
|
828
|
-
return {
|
|
829
|
-
ok: false,
|
|
830
|
-
error: `The process exited immediately (exit code ${code}) \u2014 it did not stay running, so nothing was started or tracked.` + (tail ? `
|
|
831
|
-
|
|
832
|
-
Output:
|
|
833
|
-
${tail}` : " No output was captured.")
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
child.removeListener("exit", onEarlyExit);
|
|
837
|
-
child.unref();
|
|
838
|
-
if (this.registry) {
|
|
839
|
-
const entry = {
|
|
840
|
-
id,
|
|
841
|
-
pid,
|
|
842
|
-
command,
|
|
843
|
-
description: typeof args.description === "string" ? args.description : void 0,
|
|
844
|
-
cwd,
|
|
845
|
-
machine: this.machine ?? "",
|
|
846
|
-
logPath,
|
|
847
|
-
startedAt: Date.now(),
|
|
848
|
-
status: "running"
|
|
849
|
-
};
|
|
850
|
-
await this.registry.add(entry);
|
|
851
|
-
child.on("exit", (code) => void this.registry?.markExited(id, code));
|
|
852
|
-
}
|
|
853
|
-
return {
|
|
854
|
-
ok: true,
|
|
855
|
-
result: JSON.stringify(
|
|
856
|
-
{
|
|
857
|
-
id,
|
|
858
|
-
pid,
|
|
859
|
-
status: "running",
|
|
860
|
-
logPath,
|
|
861
|
-
note: `Started in the background as process ${id} (pid ${pid}) and confirmed running. Output is logged to ${logPath}. Use background_process (list/logs/stop) to check on it or stop it.`
|
|
862
|
-
},
|
|
863
|
-
null,
|
|
864
|
-
2
|
|
865
|
-
)
|
|
866
|
-
};
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Install + connect an MCP server from a name + launch command (e.g.
|
|
870
|
-
* "npx -y @playwright/mcp@latest"). Saves it to the user's MCP config,
|
|
871
|
-
* launches it, runs the handshake, and republishes the catalog on success so
|
|
872
|
-
* the agent immediately sees its tools. The connect IS the install — for `npx`
|
|
873
|
-
* commands the package is fetched on first run.
|
|
874
|
-
*/
|
|
875
|
-
async installMcp(args) {
|
|
876
|
-
if (!this.mcp) return { ok: false, error: "MCP is not available in this session." };
|
|
877
|
-
const name = String(args.name || "");
|
|
878
|
-
const command = String(args.command || "");
|
|
879
|
-
if (!name || !command) {
|
|
880
|
-
return { ok: false, error: 'install_mcp needs a `name` and a `command` (e.g. "npx -y @playwright/mcp@latest").' };
|
|
881
|
-
}
|
|
882
|
-
let env;
|
|
883
|
-
if (typeof args.env_json === "string" && args.env_json.trim()) {
|
|
884
|
-
try {
|
|
885
|
-
const parsed = JSON.parse(args.env_json);
|
|
886
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) env = parsed;
|
|
887
|
-
else return { ok: false, error: "env_json must encode a JSON object of environment variables." };
|
|
888
|
-
} catch (e) {
|
|
889
|
-
return { ok: false, error: `env_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
const cfg = serverFromCommand(name, command, env);
|
|
893
|
-
if (!cfg) return { ok: false, error: `Could not parse the command: "${command}".` };
|
|
894
|
-
saveMcpServer(cfg);
|
|
895
|
-
try {
|
|
896
|
-
const client = await this.mcp.connect(cfg);
|
|
897
|
-
this.onMcpCatalogChange?.();
|
|
898
|
-
const tools = client.tools.map((t) => t.name);
|
|
899
|
-
return {
|
|
900
|
-
ok: true,
|
|
901
|
-
result: JSON.stringify(
|
|
902
|
-
{
|
|
903
|
-
server: cfg.name,
|
|
904
|
-
command: `${cfg.command} ${cfg.args.join(" ")}`.trim(),
|
|
905
|
-
connected: true,
|
|
906
|
-
toolCount: tools.length,
|
|
907
|
-
tools,
|
|
908
|
-
note: `Installed and connected MCP server "${cfg.name}" with ${tools.length} tool${tools.length === 1 ? "" : "s"}${tools.length ? `: ${tools.join(", ")}` : ""}. They are available now and will auto-connect next session.`
|
|
909
|
-
},
|
|
910
|
-
null,
|
|
911
|
-
2
|
|
912
|
-
)
|
|
913
|
-
};
|
|
914
|
-
} catch (err) {
|
|
915
|
-
this.onMcpCatalogChange?.();
|
|
916
|
-
return {
|
|
917
|
-
ok: false,
|
|
918
|
-
error: `Saved MCP server "${cfg.name}" but it failed to start: ${err instanceof Error ? err.message : String(err)}`
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
/** List / inspect logs of / stop tracked background processes. */
|
|
923
|
-
async backgroundProcesses(args) {
|
|
924
|
-
const action = String(args.action || "list");
|
|
925
|
-
if (!this.registry) {
|
|
926
|
-
return { ok: true, result: "Background-process tracking is not available in this session." };
|
|
927
|
-
}
|
|
928
|
-
if (action === "list") {
|
|
929
|
-
const procs = await this.registry.list();
|
|
930
|
-
if (!procs.length) return { ok: true, result: "No background processes for this session." };
|
|
931
|
-
const lines = procs.map((p) => {
|
|
932
|
-
const age = relativeAge(p.startedAt);
|
|
933
|
-
const status = p.status === "running" ? "running" : `${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}`;
|
|
934
|
-
return `${p.id} [${status}] pid ${p.pid} started ${age}
|
|
935
|
-
${p.command}`;
|
|
936
|
-
});
|
|
937
|
-
return { ok: true, result: lines.join("\n") };
|
|
938
|
-
}
|
|
939
|
-
const id = String(args.id || "");
|
|
940
|
-
const proc = await this.registry.get(id);
|
|
941
|
-
if (!proc) return { ok: false, error: `No background process with id ${id}` };
|
|
942
|
-
if (action === "logs") {
|
|
943
|
-
const maxLines = typeof args.lines === "number" ? args.lines : 60;
|
|
944
|
-
let content = "";
|
|
945
|
-
try {
|
|
946
|
-
content = await fsp.readFile(proc.logPath, "utf8");
|
|
947
|
-
} catch {
|
|
948
|
-
return { ok: true, result: `(no output captured yet for ${id})` };
|
|
949
|
-
}
|
|
950
|
-
const tail = content.split("\n").slice(-maxLines).join("\n");
|
|
951
|
-
return { ok: true, result: tail || `(no output yet for ${id})` };
|
|
952
|
-
}
|
|
953
|
-
if (action === "stop") {
|
|
954
|
-
if (proc.status !== "running") {
|
|
955
|
-
return { ok: true, result: `Process ${id} is already ${proc.status}.` };
|
|
956
|
-
}
|
|
957
|
-
try {
|
|
958
|
-
process.kill(-proc.pid, "SIGTERM");
|
|
959
|
-
setTimeout(() => {
|
|
960
|
-
try {
|
|
961
|
-
process.kill(-proc.pid, "SIGKILL");
|
|
962
|
-
} catch {
|
|
963
|
-
}
|
|
964
|
-
}, 3e3);
|
|
965
|
-
} catch {
|
|
966
|
-
try {
|
|
967
|
-
process.kill(proc.pid, "SIGKILL");
|
|
968
|
-
} catch {
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
await this.registry.markStopped(id);
|
|
972
|
-
return { ok: true, result: `Stopped background process ${id} (pid ${proc.pid}).` };
|
|
973
|
-
}
|
|
974
|
-
return { ok: false, error: `Unknown action: ${action}` };
|
|
975
|
-
}
|
|
976
|
-
run(cmd, args, cwd, timeoutMs) {
|
|
977
|
-
return new Promise((resolve) => {
|
|
978
|
-
let stdout = "";
|
|
979
|
-
let stderr = "";
|
|
980
|
-
let timedOut = false;
|
|
981
|
-
let exitCode = 0;
|
|
982
|
-
let settled = false;
|
|
983
|
-
const MAX_OUTPUT = 256 * 1024;
|
|
984
|
-
let child;
|
|
985
|
-
try {
|
|
986
|
-
child = spawn(cmd, args, { cwd, detached: true });
|
|
987
|
-
} catch {
|
|
988
|
-
resolve({ stdout: "", stderr: "", code: 127, timedOut: false });
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
child.unref();
|
|
992
|
-
let graceTimer = null;
|
|
993
|
-
const settle = (code) => {
|
|
994
|
-
if (settled) return;
|
|
995
|
-
settled = true;
|
|
996
|
-
clearTimeout(timer);
|
|
997
|
-
if (graceTimer) clearTimeout(graceTimer);
|
|
998
|
-
resolve({ stdout, stderr, code, timedOut });
|
|
999
|
-
};
|
|
1000
|
-
const timer = setTimeout(() => {
|
|
1001
|
-
timedOut = true;
|
|
1002
|
-
try {
|
|
1003
|
-
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
1004
|
-
} catch {
|
|
1005
|
-
try {
|
|
1006
|
-
child.kill("SIGKILL");
|
|
1007
|
-
} catch {
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
setTimeout(() => settle(exitCode), 250);
|
|
1011
|
-
}, timeoutMs);
|
|
1012
|
-
child.stdout?.on("data", (d) => {
|
|
1013
|
-
if (stdout.length < MAX_OUTPUT) stdout += d.toString();
|
|
1014
|
-
});
|
|
1015
|
-
child.stderr?.on("data", (d) => {
|
|
1016
|
-
if (stderr.length < MAX_OUTPUT) stderr += d.toString();
|
|
1017
|
-
});
|
|
1018
|
-
child.on("error", (err) => {
|
|
1019
|
-
if (!stderr) stderr = String(err);
|
|
1020
|
-
settle(err.code === "ENOENT" ? 127 : 1);
|
|
1021
|
-
});
|
|
1022
|
-
child.on("close", (code) => settle(code ?? exitCode));
|
|
1023
|
-
child.on("exit", (code) => {
|
|
1024
|
-
exitCode = code ?? 0;
|
|
1025
|
-
graceTimer = setTimeout(() => settle(exitCode), 250);
|
|
1026
|
-
});
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
async walkGlob(base, pattern) {
|
|
1030
|
-
const re = globToRegExp(pattern);
|
|
1031
|
-
const out = [];
|
|
1032
|
-
const walk = async (dir) => {
|
|
1033
|
-
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1034
|
-
for (const e of entries) {
|
|
1035
|
-
if (e.name === ".git" || e.name === "node_modules") continue;
|
|
1036
|
-
const full = path3.join(dir, e.name);
|
|
1037
|
-
if (e.isDirectory()) await walk(full);
|
|
1038
|
-
else {
|
|
1039
|
-
const rel = path3.relative(this.projectDir, full);
|
|
1040
|
-
if (re.test(rel) || re.test(e.name)) out.push(rel);
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
};
|
|
1044
|
-
await walk(base);
|
|
1045
|
-
return out;
|
|
1046
|
-
}
|
|
1047
|
-
};
|
|
1048
|
-
function relativeAge(startedAt) {
|
|
1049
|
-
const diff = (Date.now() - startedAt) / 1e3;
|
|
1050
|
-
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
|
1051
|
-
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
1052
|
-
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
1053
|
-
return `${Math.floor(diff / 86400)}d ago`;
|
|
1054
|
-
}
|
|
1055
|
-
function globToRegExp(glob) {
|
|
1056
|
-
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/ /g, ".*").replace(/\?/g, ".");
|
|
1057
|
-
return new RegExp(`^${escaped}$`);
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// src/stream.ts
|
|
1061
|
-
var MessageStream = class {
|
|
1062
|
-
constructor(api, threadId, hooks) {
|
|
1063
|
-
this.api = api;
|
|
1064
|
-
this.threadId = threadId;
|
|
1065
|
-
this.hooks = hooks;
|
|
1066
|
-
}
|
|
1067
|
-
api;
|
|
1068
|
-
threadId;
|
|
1069
|
-
hooks;
|
|
1070
|
-
ws = null;
|
|
1071
|
-
closed = false;
|
|
1072
|
-
reconnectAttempt = 0;
|
|
1073
|
-
reconnectTimer = null;
|
|
1074
|
-
resolveConnected = null;
|
|
1075
|
-
/**
|
|
1076
|
-
* Connect and stay connected. Resolves on first open; reconnects on any drop
|
|
1077
|
-
* with exponential backoff. Silent — the bridge surfaces the user-facing
|
|
1078
|
-
* connection status; completion is detected via HTTP polling regardless, so a
|
|
1079
|
-
* dropped stream only affects live display, not correctness.
|
|
1080
|
-
*/
|
|
1081
|
-
connect() {
|
|
1082
|
-
return new Promise((resolve) => {
|
|
1083
|
-
let settled = false;
|
|
1084
|
-
this.resolveConnected = () => {
|
|
1085
|
-
if (!settled) {
|
|
1086
|
-
settled = true;
|
|
1087
|
-
resolve();
|
|
1088
|
-
}
|
|
1089
|
-
};
|
|
1090
|
-
setTimeout(() => this.resolveConnected?.(), 8e3);
|
|
1091
|
-
this.openSocket();
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
openSocket() {
|
|
1095
|
-
if (this.closed) return;
|
|
1096
|
-
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
|
|
1097
|
-
let ws;
|
|
1098
|
-
try {
|
|
1099
|
-
ws = new WebSocket(url);
|
|
1100
|
-
} catch {
|
|
1101
|
-
this.scheduleReconnect();
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
this.ws = ws;
|
|
1105
|
-
ws.addEventListener("open", () => {
|
|
1106
|
-
this.reconnectAttempt = 0;
|
|
1107
|
-
this.resolveConnected?.();
|
|
1108
|
-
});
|
|
1109
|
-
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
1110
|
-
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1111
|
-
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1112
|
-
}
|
|
1113
|
-
handleDrop(ws) {
|
|
1114
|
-
if (this.ws !== ws) return;
|
|
1115
|
-
this.ws = null;
|
|
1116
|
-
this.scheduleReconnect();
|
|
1117
|
-
}
|
|
1118
|
-
scheduleReconnect() {
|
|
1119
|
-
if (this.closed || this.reconnectTimer) return;
|
|
1120
|
-
this.reconnectAttempt++;
|
|
1121
|
-
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
1122
|
-
const delay = base + Math.floor(Math.random() * 400);
|
|
1123
|
-
this.reconnectTimer = setTimeout(() => {
|
|
1124
|
-
this.reconnectTimer = null;
|
|
1125
|
-
this.openSocket();
|
|
1126
|
-
}, delay);
|
|
1127
|
-
}
|
|
1128
|
-
close() {
|
|
1129
|
-
this.closed = true;
|
|
1130
|
-
if (this.reconnectTimer) {
|
|
1131
|
-
clearTimeout(this.reconnectTimer);
|
|
1132
|
-
this.reconnectTimer = null;
|
|
1133
|
-
}
|
|
1134
|
-
this.ws?.close();
|
|
1135
|
-
}
|
|
1136
|
-
onMessage(raw) {
|
|
1137
|
-
let msg;
|
|
1138
|
-
try {
|
|
1139
|
-
msg = JSON.parse(raw);
|
|
1140
|
-
} catch {
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
if (msg.type === "event" && typeof msg.eventType === "string") {
|
|
1144
|
-
this.hooks.onEvent?.(msg.eventType, msg.data);
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
|
|
1148
|
-
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
|
|
1152
|
-
const data = msg.data || {};
|
|
1153
|
-
if (data.role === "assistant" && typeof data.content === "string" && data.content.trim()) {
|
|
1154
|
-
const tc = data.tool_calls;
|
|
1155
|
-
const hasToolCalls2 = Array.isArray(tc) ? tc.length > 0 : typeof tc === "string" && tc.trim() !== "" && tc.trim() !== "null" && tc.trim() !== "[]";
|
|
1156
|
-
this.hooks.onAssistantText(data.content, hasToolCalls2);
|
|
1157
|
-
}
|
|
1158
|
-
if (data.role === "assistant" && data.status === "failed" && data.error) {
|
|
1159
|
-
this.hooks.onError(String(data.error));
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
};
|
|
1164
|
-
|
|
1165
|
-
// src/events-stream.ts
|
|
1166
|
-
var SystemEvents = class {
|
|
1167
|
-
constructor(api, hooks) {
|
|
1168
|
-
this.api = api;
|
|
1169
|
-
this.hooks = hooks;
|
|
1170
|
-
}
|
|
1171
|
-
api;
|
|
1172
|
-
hooks;
|
|
1173
|
-
ws = null;
|
|
1174
|
-
closed = false;
|
|
1175
|
-
reconnectAttempt = 0;
|
|
1176
|
-
reconnectTimer = null;
|
|
1177
|
-
connect() {
|
|
1178
|
-
this.openSocket();
|
|
1179
|
-
}
|
|
1180
|
-
openSocket() {
|
|
1181
|
-
if (this.closed) return;
|
|
1182
|
-
const url = `${this.api.wsEndpoint}/api/events?token=${encodeURIComponent(this.api.bearer)}`;
|
|
1183
|
-
let ws;
|
|
1184
|
-
try {
|
|
1185
|
-
ws = new WebSocket(url);
|
|
1186
|
-
} catch {
|
|
1187
|
-
this.scheduleReconnect();
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
this.ws = ws;
|
|
1191
|
-
ws.addEventListener("open", () => {
|
|
1192
|
-
this.reconnectAttempt = 0;
|
|
1193
|
-
});
|
|
1194
|
-
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
1195
|
-
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1196
|
-
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1197
|
-
}
|
|
1198
|
-
onMessage(raw) {
|
|
1199
|
-
let msg;
|
|
1200
|
-
try {
|
|
1201
|
-
msg = JSON.parse(raw);
|
|
1202
|
-
} catch {
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
if (msg?.type === "thread_created" && msg.thread) this.hooks.onThreadCreated(msg.thread);
|
|
1206
|
-
else if (msg?.type === "thread_updated" && msg.thread) this.hooks.onThreadUpdated(msg.thread);
|
|
1207
|
-
else if (msg?.type === "thread_deleted" && typeof msg.threadId === "string") {
|
|
1208
|
-
this.hooks.onThreadDeleted(msg.threadId);
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
handleDrop(ws) {
|
|
1212
|
-
if (this.ws !== ws) return;
|
|
1213
|
-
this.ws = null;
|
|
1214
|
-
this.scheduleReconnect();
|
|
1215
|
-
}
|
|
1216
|
-
scheduleReconnect() {
|
|
1217
|
-
if (this.closed || this.reconnectTimer) return;
|
|
1218
|
-
this.reconnectAttempt++;
|
|
1219
|
-
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
1220
|
-
const delay = base + Math.floor(Math.random() * 400);
|
|
1221
|
-
this.reconnectTimer = setTimeout(() => {
|
|
1222
|
-
this.reconnectTimer = null;
|
|
1223
|
-
this.openSocket();
|
|
1224
|
-
}, delay);
|
|
1225
|
-
}
|
|
1226
|
-
close() {
|
|
1227
|
-
this.closed = true;
|
|
1228
|
-
if (this.reconnectTimer) {
|
|
1229
|
-
clearTimeout(this.reconnectTimer);
|
|
1230
|
-
this.reconnectTimer = null;
|
|
1231
|
-
}
|
|
1232
|
-
this.ws?.close();
|
|
1233
|
-
}
|
|
1234
|
-
};
|
|
1235
|
-
|
|
1236
|
-
// src/types.ts
|
|
1237
|
-
var LEVELS = [1, 2, 3, 4, 5];
|
|
1238
|
-
var LEVEL_DETAIL = {
|
|
1239
|
-
1: "only safe reads run automatically",
|
|
1240
|
-
2: "+ safe writes & builds",
|
|
1241
|
-
3: "+ installs & project edits",
|
|
1242
|
-
4: "+ outside-project & git-history",
|
|
1243
|
-
5: "everything \u2014 never ask"
|
|
1244
|
-
};
|
|
1245
|
-
function levelLabel(level) {
|
|
1246
|
-
return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// src/tui.ts
|
|
1250
|
-
var C = {
|
|
1251
|
-
reset: "\x1B[0m",
|
|
1252
|
-
dim: "\x1B[2m",
|
|
1253
|
-
bold: "\x1B[1m",
|
|
1254
|
-
cyan: "\x1B[36m",
|
|
1255
|
-
green: "\x1B[32m",
|
|
1256
|
-
yellow: "\x1B[33m",
|
|
1257
|
-
red: "\x1B[31m",
|
|
1258
|
-
blue: "\x1B[34m",
|
|
1259
|
-
magenta: "\x1B[35m",
|
|
1260
|
-
gray: "\x1B[90m",
|
|
1261
|
-
teal: "\x1B[38;5;37m"
|
|
1262
|
-
};
|
|
1263
|
-
var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
|
|
1264
|
-
var Tui = class {
|
|
1265
|
-
constructor(level = 1) {
|
|
1266
|
-
this.level = level;
|
|
1267
|
-
readline.emitKeypressEvents(process.stdin);
|
|
1268
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
1269
|
-
process.stdin.on("keypress", (str, key) => this.dispatch(str, key));
|
|
1270
|
-
process.stdin.resume();
|
|
1271
|
-
process.stdout.write("\x1B[?2004h");
|
|
1272
|
-
process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
|
|
1273
|
-
}
|
|
1274
|
-
level;
|
|
1275
|
-
// input + indicators
|
|
1276
|
-
inputBuffer = "";
|
|
1277
|
-
cursorPos = 0;
|
|
1278
|
-
// caret index within inputBuffer (0..length)
|
|
1279
|
-
lastCursorRow = 0;
|
|
1280
|
-
// caret's row offset from the input region top, last render
|
|
1281
|
-
working = false;
|
|
1282
|
-
workingStart = 0;
|
|
1283
|
-
spinnerTimer = null;
|
|
1284
|
-
bgCount = 0;
|
|
1285
|
-
queuedCount = 0;
|
|
1286
|
-
subagents = [];
|
|
1287
|
-
// labels of subagents currently working (one line each)
|
|
1288
|
-
tokensIn = 0;
|
|
1289
|
-
// cumulative input tokens
|
|
1290
|
-
tokensOut = 0;
|
|
1291
|
-
// cumulative output tokens (includes the in-progress live count)
|
|
1292
|
-
contextPct = null;
|
|
1293
|
-
// % of the model context window currently used
|
|
1294
|
-
// Inline slash-command palette: when the input starts with "/", the filtered
|
|
1295
|
-
// command list renders above the prompt and arrows/enter/tab drive it.
|
|
1296
|
-
commands = [];
|
|
1297
|
-
slashIdx = 0;
|
|
1298
|
-
// highlighted index within the FILTERED command list
|
|
1299
|
-
step = null;
|
|
1300
|
-
// current step label (e.g. "writing index.html")
|
|
1301
|
-
stepStart = 0;
|
|
1302
|
-
// when the current step began (for the step's own elapsed)
|
|
1303
|
-
stepOut = 0;
|
|
1304
|
-
// output tokens produced during the current step
|
|
1305
|
-
connected = true;
|
|
1306
|
-
bottomDrawn = false;
|
|
1307
|
-
started = false;
|
|
1308
|
-
// takeover (approval / menu) state
|
|
1309
|
-
takeoverHandler = null;
|
|
1310
|
-
bufferedPrints = [];
|
|
1311
|
-
// double-press-to-quit state: the first ctrl-c arms a brief window and shows a
|
|
1312
|
-
// transient hint; a second ctrl-c within the window actually quits.
|
|
1313
|
-
quitArmed = false;
|
|
1314
|
-
quitTimer = null;
|
|
1315
|
-
// bracketed-paste state
|
|
1316
|
-
pasting = false;
|
|
1317
|
-
pasteTimer = null;
|
|
1318
|
-
// event hooks (wired by index.ts)
|
|
1319
|
-
onSubmit = () => {
|
|
1320
|
-
};
|
|
1321
|
-
onInterrupt = () => {
|
|
1322
|
-
};
|
|
1323
|
-
onUpArrow = () => {
|
|
1324
|
-
};
|
|
1325
|
-
onQuit = () => process.exit(0);
|
|
1326
|
-
levelListeners = [];
|
|
1327
|
-
get colors() {
|
|
1328
|
-
return C;
|
|
1329
|
-
}
|
|
1330
|
-
setQuitHandler(fn) {
|
|
1331
|
-
this.onQuit = fn;
|
|
1332
|
-
}
|
|
1333
|
-
/**
|
|
1334
|
-
* Handle a ctrl-c. The first press arms a 2-second window and surfaces a
|
|
1335
|
-
* transient "Press Control-C again to exit" hint in the bottom region; a
|
|
1336
|
-
* second press within the window quits. After the window lapses the hint
|
|
1337
|
-
* clears and the next ctrl-c starts over — so it always takes two.
|
|
1338
|
-
*/
|
|
1339
|
-
requestQuit() {
|
|
1340
|
-
if (this.quitArmed) {
|
|
1341
|
-
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1342
|
-
this.quitTimer = null;
|
|
1343
|
-
this.quitArmed = false;
|
|
1344
|
-
this.onQuit();
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
this.quitArmed = true;
|
|
1348
|
-
this.renderBottom();
|
|
1349
|
-
this.quitTimer = setTimeout(() => {
|
|
1350
|
-
this.quitArmed = false;
|
|
1351
|
-
this.quitTimer = null;
|
|
1352
|
-
this.renderBottom();
|
|
1353
|
-
}, 2e3);
|
|
1354
|
-
}
|
|
1355
|
-
/** Tear down the bottom region and restore the terminal (called on quit). */
|
|
1356
|
-
end() {
|
|
1357
|
-
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1358
|
-
this.quitTimer = null;
|
|
1359
|
-
this.clearBottom();
|
|
1360
|
-
process.stdout.write("\x1B[?2004l\x1B[?25h");
|
|
1361
|
-
}
|
|
1362
|
-
onLevelChange(fn) {
|
|
1363
|
-
this.levelListeners.push(fn);
|
|
1364
|
-
}
|
|
1365
|
-
/** Carrot colour by level — cooler/safer (low) to warmer/permissive (high). */
|
|
1366
|
-
levelColor() {
|
|
1367
|
-
return { 1: C.cyan, 2: C.green, 3: C.yellow, 4: C.magenta, 5: C.red }[this.level];
|
|
1368
|
-
}
|
|
1369
|
-
// ─── key dispatch ──────────────────────────────────────────────────────────
|
|
1370
|
-
dispatch(str, key) {
|
|
1371
|
-
if (key && key.ctrl && key.name === "c") {
|
|
1372
|
-
this.requestQuit();
|
|
1373
|
-
return;
|
|
1374
|
-
}
|
|
1375
|
-
if (key && key.name === "tab" && key.shift) {
|
|
1376
|
-
this.cycleLevel();
|
|
1377
|
-
return;
|
|
1378
|
-
}
|
|
1379
|
-
const seq = key && key.sequence || str || "";
|
|
1380
|
-
if (!this.pasting && seq.includes("\x1B[200~")) {
|
|
1381
|
-
this.pasting = true;
|
|
1382
|
-
this.armPasteSafety();
|
|
1383
|
-
this.handlePasteChunk(seq.slice(seq.indexOf("\x1B[200~") + 6));
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
if (this.pasting) {
|
|
1387
|
-
this.armPasteSafety();
|
|
1388
|
-
this.handlePasteChunk(seq);
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
if (this.takeoverHandler) {
|
|
1392
|
-
this.takeoverHandler(str, key);
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
if (!key) return;
|
|
1396
|
-
if (this.paletteOpen()) {
|
|
1397
|
-
const matches = this.filteredCommands();
|
|
1398
|
-
const cur = matches.length ? Math.min(this.slashIdx, matches.length - 1) : 0;
|
|
1399
|
-
if (key.name === "up") {
|
|
1400
|
-
if (matches.length) {
|
|
1401
|
-
this.slashIdx = (cur - 1 + matches.length) % matches.length;
|
|
1402
|
-
this.renderBottom();
|
|
1403
|
-
}
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
if (key.name === "down") {
|
|
1407
|
-
if (matches.length) {
|
|
1408
|
-
this.slashIdx = (cur + 1) % matches.length;
|
|
1409
|
-
this.renderBottom();
|
|
1410
|
-
}
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
if (key.name === "tab") {
|
|
1414
|
-
if (matches.length) {
|
|
1415
|
-
this.inputBuffer = "/" + matches[cur].name;
|
|
1416
|
-
this.cursorPos = this.inputBuffer.length;
|
|
1417
|
-
this.slashIdx = 0;
|
|
1418
|
-
this.renderBottom();
|
|
1419
|
-
}
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
if (key.name === "return" || key.name === "enter") {
|
|
1423
|
-
if (matches.length) this.runCommand(matches[cur]);
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
if (key.name === "escape") {
|
|
1427
|
-
this.inputBuffer = "";
|
|
1428
|
-
this.cursorPos = 0;
|
|
1429
|
-
this.slashIdx = 0;
|
|
1430
|
-
this.renderBottom();
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
if (key.name === "escape") {
|
|
1435
|
-
this.onInterrupt();
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
if (key.name === "left") {
|
|
1439
|
-
if (this.cursorPos > 0) {
|
|
1440
|
-
this.cursorPos--;
|
|
1441
|
-
this.renderBottom();
|
|
1442
|
-
}
|
|
1443
|
-
return;
|
|
1444
|
-
}
|
|
1445
|
-
if (key.name === "right") {
|
|
1446
|
-
if (this.cursorPos < this.inputBuffer.length) {
|
|
1447
|
-
this.cursorPos++;
|
|
1448
|
-
this.renderBottom();
|
|
1449
|
-
}
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
if (key.name === "home" || key.ctrl && key.name === "a") {
|
|
1453
|
-
this.cursorPos = 0;
|
|
1454
|
-
this.renderBottom();
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
if (key.name === "end" || key.ctrl && key.name === "e") {
|
|
1458
|
-
this.cursorPos = this.inputBuffer.length;
|
|
1459
|
-
this.renderBottom();
|
|
1460
|
-
return;
|
|
1461
|
-
}
|
|
1462
|
-
if (key.name === "up") {
|
|
1463
|
-
this.onUpArrow();
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
if (key.name === "return" || key.name === "enter") {
|
|
1467
|
-
const text = this.inputBuffer;
|
|
1468
|
-
this.inputBuffer = "";
|
|
1469
|
-
this.cursorPos = 0;
|
|
1470
|
-
this.renderBottom();
|
|
1471
|
-
if (text.trim()) this.onSubmit(text.trim());
|
|
1472
|
-
return;
|
|
1473
|
-
}
|
|
1474
|
-
if (key.name === "backspace") {
|
|
1475
|
-
if (this.cursorPos > 0) {
|
|
1476
|
-
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
|
|
1477
|
-
this.cursorPos--;
|
|
1478
|
-
this.slashIdx = 0;
|
|
1479
|
-
this.renderBottom();
|
|
1480
|
-
}
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
if (key.name === "delete") {
|
|
1484
|
-
if (this.cursorPos < this.inputBuffer.length) {
|
|
1485
|
-
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
|
|
1486
|
-
this.slashIdx = 0;
|
|
1487
|
-
this.renderBottom();
|
|
1488
|
-
}
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
if (key.ctrl || key.meta || key.name === "tab") return;
|
|
1492
|
-
if (str && str >= " ") {
|
|
1493
|
-
this.insertAtCursor(str);
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
insertAtCursor(text) {
|
|
1497
|
-
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
|
|
1498
|
-
this.cursorPos += text.length;
|
|
1499
|
-
this.slashIdx = 0;
|
|
1500
|
-
this.renderBottom();
|
|
1501
|
-
}
|
|
1502
|
-
/** Insert a paste fragment at the caret; collapse newlines (single-line input). */
|
|
1503
|
-
handlePasteChunk(chunk) {
|
|
1504
|
-
const end = chunk.indexOf("\x1B[201~");
|
|
1505
|
-
const content = (end >= 0 ? chunk.slice(0, end) : chunk).replace(/[\r\n]+/g, " ");
|
|
1506
|
-
if (content) {
|
|
1507
|
-
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
|
|
1508
|
-
this.cursorPos += content.length;
|
|
1509
|
-
}
|
|
1510
|
-
if (end >= 0) {
|
|
1511
|
-
this.pasting = false;
|
|
1512
|
-
if (this.pasteTimer) {
|
|
1513
|
-
clearTimeout(this.pasteTimer);
|
|
1514
|
-
this.pasteTimer = null;
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
this.renderBottom();
|
|
1518
|
-
}
|
|
1519
|
-
/** Never let a missed end-marker wedge the input: clear paste mode shortly. */
|
|
1520
|
-
armPasteSafety() {
|
|
1521
|
-
if (this.pasteTimer) clearTimeout(this.pasteTimer);
|
|
1522
|
-
this.pasteTimer = setTimeout(() => {
|
|
1523
|
-
this.pasting = false;
|
|
1524
|
-
this.pasteTimer = null;
|
|
1525
|
-
this.renderBottom();
|
|
1526
|
-
}, 2e3);
|
|
1527
|
-
}
|
|
1528
|
-
cycleLevel() {
|
|
1529
|
-
const idx = LEVELS.indexOf(this.level);
|
|
1530
|
-
this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
|
|
1531
|
-
}
|
|
1532
|
-
setLevel(level) {
|
|
1533
|
-
if (level === this.level) return;
|
|
1534
|
-
this.level = level;
|
|
1535
|
-
this.levelListeners.forEach((fn) => fn(this.level));
|
|
1536
|
-
this.print(`${this.levelColor()}${levelLabel(this.level)}${C.reset}`);
|
|
1537
|
-
}
|
|
1538
|
-
// ─── bottom input line ───────────────────────────────────────────────────
|
|
1539
|
-
/** Begin showing the persistent input line. */
|
|
1540
|
-
start() {
|
|
1541
|
-
this.started = true;
|
|
1542
|
-
this.renderBottom();
|
|
1543
|
-
}
|
|
1544
|
-
/** Format an elapsed millisecond span as 45s / 2m 05s / 1h 05m. */
|
|
1545
|
-
formatElapsed(ms) {
|
|
1546
|
-
const s = Math.floor(ms / 1e3);
|
|
1547
|
-
if (s < 60) return `${s}s`;
|
|
1548
|
-
const m = Math.floor(s / 60);
|
|
1549
|
-
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
1550
|
-
const h = Math.floor(m / 60);
|
|
1551
|
-
return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
1552
|
-
}
|
|
1553
|
-
/** Compact token count: 945 → "945", 12345 → "12.35k", 1_250_000 → "1.25M". */
|
|
1554
|
-
fmtTokens(n) {
|
|
1555
|
-
if (n < 1e3) return String(n);
|
|
1556
|
-
if (n < 1e6) return `${(n / 1e3).toFixed(2)}k`;
|
|
1557
|
-
return `${(n / 1e6).toFixed(2)}M`;
|
|
1558
|
-
}
|
|
1559
|
-
spinnerFrame() {
|
|
1560
|
-
return `${C.bold}${C.cyan}${FRAMES[Math.floor(Date.now() / 100) % FRAMES.length]}${C.reset}`;
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* "↑X ↓Y" cumulative token totals (greyed — low-priority), plus a context
|
|
1564
|
-
* window gauge "ctx N%" when known. The gauge colour ramps with fill (green →
|
|
1565
|
-
* yellow → red) so the user can see compaction approaching at a glance.
|
|
1566
|
-
*/
|
|
1567
|
-
tokensText() {
|
|
1568
|
-
const parts = [];
|
|
1569
|
-
if (this.tokensIn > 0) parts.push(`\u2191${this.fmtTokens(this.tokensIn)}`);
|
|
1570
|
-
if (this.tokensOut > 0) parts.push(`\u2193${this.fmtTokens(this.tokensOut)}`);
|
|
1571
|
-
let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
|
|
1572
|
-
if (this.contextPct != null) {
|
|
1573
|
-
const pct = this.contextPct;
|
|
1574
|
-
const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : C.green;
|
|
1575
|
-
const gauge = `${col}ctx ${pct}%${C.reset}`;
|
|
1576
|
-
out = out ? `${out} ${gauge}` : gauge;
|
|
1577
|
-
}
|
|
1578
|
-
return out;
|
|
1579
|
-
}
|
|
1580
|
-
/**
|
|
1581
|
-
* Set the context-window fill percentage (0–100), or null to hide it.
|
|
1582
|
-
* Driven by the runtime's `context_usage` KV (latest request input tokens ÷
|
|
1583
|
-
* model context window).
|
|
1584
|
-
*/
|
|
1585
|
-
setContextPct(pct) {
|
|
1586
|
-
const next = pct == null ? null : Math.max(0, Math.min(100, Math.round(pct)));
|
|
1587
|
-
if (next === this.contextPct) return;
|
|
1588
|
-
this.contextPct = next;
|
|
1589
|
-
this.renderBottom();
|
|
1590
|
-
}
|
|
1591
|
-
/** Plain "ctx N%" label (no ANSI) for menu hints, or "" when unknown. */
|
|
1592
|
-
contextPctLabel() {
|
|
1593
|
-
return this.contextPct == null ? "" : `ctx ${this.contextPct}%`;
|
|
1594
|
-
}
|
|
1595
|
-
/** Register the slash commands shown in the inline `/` palette. */
|
|
1596
|
-
setCommands(commands) {
|
|
1597
|
-
this.commands = commands;
|
|
1598
|
-
}
|
|
1599
|
-
/** Is the `/` command palette currently showing? (input starts with "/".) */
|
|
1600
|
-
paletteOpen() {
|
|
1601
|
-
return this.started && !this.takeoverHandler && this.commands.length > 0 && this.inputBuffer.startsWith("/");
|
|
1602
|
-
}
|
|
1603
|
-
/** Commands matching the text typed after "/", in declared order. */
|
|
1604
|
-
filteredCommands() {
|
|
1605
|
-
if (!this.inputBuffer.startsWith("/")) return [];
|
|
1606
|
-
const q = this.inputBuffer.slice(1).trim().toLowerCase();
|
|
1607
|
-
if (q === "") return this.commands;
|
|
1608
|
-
return this.commands.filter(
|
|
1609
|
-
(c2) => c2.name.startsWith(q) || c2.name.includes(q) || c2.label.toLowerCase().includes(q)
|
|
1610
|
-
);
|
|
1611
|
-
}
|
|
1612
|
-
runCommand(cmd) {
|
|
1613
|
-
this.inputBuffer = "";
|
|
1614
|
-
this.cursorPos = 0;
|
|
1615
|
-
this.slashIdx = 0;
|
|
1616
|
-
this.renderBottom();
|
|
1617
|
-
void Promise.resolve(cmd.run()).catch(() => {
|
|
1618
|
-
});
|
|
1619
|
-
}
|
|
1620
|
-
/**
|
|
1621
|
-
* The summary line ABOVE the prompt. While working it reads
|
|
1622
|
-
* `⣷ Working <step> <elapsed> ↑in ↓out`; when idle it keeps the cumulative
|
|
1623
|
-
* token totals visible (`↑in ↓out`) so they live in the summary rather than
|
|
1624
|
-
* crowding the prompt. Null when idle with nothing counted yet.
|
|
1625
|
-
*/
|
|
1626
|
-
statusLineText(cols) {
|
|
1627
|
-
const tk = this.tokensText();
|
|
1628
|
-
if (this.working) {
|
|
1629
|
-
const el = this.formatElapsed(Date.now() - this.workingStart);
|
|
1630
|
-
const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
|
|
1631
|
-
const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
|
|
1632
|
-
const avail = Math.max(0, cols - this.visibleWidth(head) - this.visibleWidth(right) - 2);
|
|
1633
|
-
let stepPart = "";
|
|
1634
|
-
if (this.step && avail > 1) {
|
|
1635
|
-
let s = this.step;
|
|
1636
|
-
if (s.length > avail) s = s.slice(0, avail - 1) + "\u2026";
|
|
1637
|
-
stepPart = ` ${C.dim}${s}${C.reset}`;
|
|
1638
|
-
}
|
|
1639
|
-
return `${head}${stepPart} ${right}`;
|
|
1640
|
-
}
|
|
1641
|
-
return tk ? tk : null;
|
|
1642
|
-
}
|
|
1643
|
-
/** The prompt line prefix (with ANSI colour) that precedes the typed text. */
|
|
1644
|
-
promptPrefix() {
|
|
1645
|
-
const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
|
|
1646
|
-
const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
|
|
1647
|
-
return `${q}${bg}${this.levelColor()}\u203A${C.reset} `;
|
|
1648
|
-
}
|
|
1649
|
-
visibleWidth(s) {
|
|
1650
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1651
|
-
}
|
|
1652
|
-
/**
|
|
1653
|
-
* Build the slash-palette rows for the current filter. Each row is clamped to
|
|
1654
|
-
* ONE physical line (a wrapped row would desync the move-up redraw), with the
|
|
1655
|
-
* `/name` highlighted, the label dimmed, and the hint right-aligned.
|
|
1656
|
-
*/
|
|
1657
|
-
paletteLines(cols) {
|
|
1658
|
-
if (!this.paletteOpen()) return [];
|
|
1659
|
-
const matches = this.filteredCommands();
|
|
1660
|
-
if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
|
|
1661
|
-
const cur = Math.min(this.slashIdx, matches.length - 1);
|
|
1662
|
-
const pointerW = 2;
|
|
1663
|
-
return matches.map((cmd, i) => {
|
|
1664
|
-
const sel = i === cur;
|
|
1665
|
-
const hint = (typeof cmd.hint === "function" ? cmd.hint() : cmd.hint) ?? "";
|
|
1666
|
-
const hintW = hint.length;
|
|
1667
|
-
const name = `/${cmd.name}`;
|
|
1668
|
-
let visible = `${name} ${cmd.label}`;
|
|
1669
|
-
const labelMax = Math.max(6, cols - pointerW - (hintW ? hintW + 2 : 0));
|
|
1670
|
-
if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
|
|
1671
|
-
const desc = visible.slice(name.length);
|
|
1672
|
-
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
1673
|
-
const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
|
|
1674
|
-
let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
|
|
1675
|
-
if (hintW) {
|
|
1676
|
-
const gap = Math.max(2, cols - pointerW - visible.length - hintW);
|
|
1677
|
-
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
1678
|
-
}
|
|
1679
|
-
return line;
|
|
1680
|
-
});
|
|
1681
|
-
}
|
|
1682
|
-
/** Move the cursor to the top-left of the current bottom region. */
|
|
1683
|
-
moveToRegionTop() {
|
|
1684
|
-
process.stdout.write("\r");
|
|
1685
|
-
if (this.bottomDrawn && this.lastCursorRow > 0) process.stdout.write(`\x1B[${this.lastCursorRow}A`);
|
|
1686
|
-
}
|
|
1687
|
-
/**
|
|
1688
|
-
* Render the bottom region: an optional step line, then the prompt + input
|
|
1689
|
-
* (wrapping across as many rows as needed), with the caret placed at cursorPos.
|
|
1690
|
-
* Uses only relative cursor moves so it survives terminal scrolling when the
|
|
1691
|
-
* region grows near the bottom of the screen.
|
|
1692
|
-
*/
|
|
1693
|
-
renderBottom() {
|
|
1694
|
-
if (!this.started || this.takeoverHandler) return;
|
|
1695
|
-
const cols = process.stdout.columns || 80;
|
|
1696
|
-
this.moveToRegionTop();
|
|
1697
|
-
process.stdout.write("\x1B[J");
|
|
1698
|
-
const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
|
|
1699
|
-
const noticeRows = noticeLine ? 1 : 0;
|
|
1700
|
-
if (noticeLine) process.stdout.write(noticeLine + "\r\n");
|
|
1701
|
-
const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
|
|
1702
|
-
const quitRows = quitLine ? 1 : 0;
|
|
1703
|
-
if (quitLine) process.stdout.write(quitLine + "\r\n");
|
|
1704
|
-
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
1705
|
-
for (const label of this.subagents) {
|
|
1706
|
-
const line = `${C.magenta}${frame}${C.reset} ${C.magenta}${label}${C.reset} ${C.dim}working${C.reset}`;
|
|
1707
|
-
process.stdout.write(line + "\r\n");
|
|
1708
|
-
}
|
|
1709
|
-
const statusLine = this.statusLineText(cols);
|
|
1710
|
-
const statusRows = statusLine ? 1 : 0;
|
|
1711
|
-
if (statusLine) process.stdout.write(statusLine + "\r\n");
|
|
1712
|
-
const paletteLines = this.paletteLines(cols);
|
|
1713
|
-
for (const line of paletteLines) process.stdout.write(line + "\r\n");
|
|
1714
|
-
const aboveRows = noticeRows + quitRows + this.subagents.length + statusRows + paletteLines.length;
|
|
1715
|
-
const prefix = this.promptPrefix();
|
|
1716
|
-
const pw = this.visibleWidth(prefix);
|
|
1717
|
-
const buf = this.inputBuffer;
|
|
1718
|
-
process.stdout.write(prefix + buf);
|
|
1719
|
-
const inputRows = Math.max(1, Math.ceil((pw + buf.length) / cols));
|
|
1720
|
-
if (this.cursorPos < buf.length) {
|
|
1721
|
-
const curCell = pw + this.cursorPos;
|
|
1722
|
-
const cursorRowInInput = Math.floor(curCell / cols);
|
|
1723
|
-
const cursorCol = curCell % cols;
|
|
1724
|
-
process.stdout.write("\r");
|
|
1725
|
-
const up = inputRows - 1 - cursorRowInInput;
|
|
1726
|
-
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1727
|
-
if (cursorCol > 0) process.stdout.write(`\x1B[${cursorCol}C`);
|
|
1728
|
-
this.lastCursorRow = aboveRows + cursorRowInInput;
|
|
1729
|
-
} else {
|
|
1730
|
-
this.lastCursorRow = aboveRows + (inputRows - 1);
|
|
1731
|
-
}
|
|
1732
|
-
this.bottomDrawn = true;
|
|
1733
|
-
}
|
|
1734
|
-
clearBottom() {
|
|
1735
|
-
if (!this.bottomDrawn) return;
|
|
1736
|
-
this.moveToRegionTop();
|
|
1737
|
-
process.stdout.write("\x1B[J");
|
|
1738
|
-
this.bottomDrawn = false;
|
|
1739
|
-
}
|
|
1740
|
-
/** Print a line of transcript above the persistent input. */
|
|
1741
|
-
print(text) {
|
|
1742
|
-
if (this.takeoverHandler) {
|
|
1743
|
-
this.bufferedPrints.push(text);
|
|
1744
|
-
return;
|
|
1745
|
-
}
|
|
1746
|
-
this.clearBottom();
|
|
1747
|
-
process.stdout.write(text + "\n");
|
|
1748
|
-
this.renderBottom();
|
|
1749
|
-
}
|
|
1750
|
-
/** Multi-line convenience. */
|
|
1751
|
-
printLines(lines) {
|
|
1752
|
-
for (const l of lines) this.print(l);
|
|
1753
|
-
}
|
|
1754
|
-
/**
|
|
1755
|
-
* Print a user message as a highlighted block so it stands out in the
|
|
1756
|
-
* transcript (à la Codex). The bar is sized to the text (not full width, which
|
|
1757
|
-
* would wrap awkwardly), padded with a space on each side and a blank line
|
|
1758
|
-
* above and below, and a teal `›` marks the first row.
|
|
1759
|
-
*/
|
|
1760
|
-
printUserMessage(text) {
|
|
1761
|
-
const cols = Math.max(20, process.stdout.columns || 80);
|
|
1762
|
-
const bg = "\x1B[48;5;238m";
|
|
1763
|
-
const limit = Math.max(8, cols - 6);
|
|
1764
|
-
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
1765
|
-
const lines = [];
|
|
1766
|
-
let cur = "";
|
|
1767
|
-
for (let w of words) {
|
|
1768
|
-
while (w.length > limit) {
|
|
1769
|
-
if (cur) {
|
|
1770
|
-
lines.push(cur);
|
|
1771
|
-
cur = "";
|
|
1772
|
-
}
|
|
1773
|
-
lines.push(w.slice(0, limit));
|
|
1774
|
-
w = w.slice(limit);
|
|
1775
|
-
}
|
|
1776
|
-
if (!cur) cur = w;
|
|
1777
|
-
else if (cur.length + 1 + w.length <= limit) cur += " " + w;
|
|
1778
|
-
else {
|
|
1779
|
-
lines.push(cur);
|
|
1780
|
-
cur = w;
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
if (cur || !lines.length) lines.push(cur);
|
|
1784
|
-
const innerW = 2 + Math.max(...lines.map((l) => l.length));
|
|
1785
|
-
this.print("");
|
|
1786
|
-
lines.forEach((line, i) => {
|
|
1787
|
-
const rowText = (i === 0 ? "\u203A " : " ") + line;
|
|
1788
|
-
const padded = rowText.padEnd(innerW);
|
|
1789
|
-
const inner = i === 0 ? `${C.teal}\u203A${C.reset}${bg}${padded.slice(1)}` : padded;
|
|
1790
|
-
this.print(`${bg} ${inner} ${C.reset}`);
|
|
1791
|
-
});
|
|
1792
|
-
this.print("");
|
|
1793
|
-
}
|
|
1794
|
-
// ─── working indicator (turn state) ───────────────────────────────────────
|
|
1795
|
-
setWorking(on) {
|
|
1796
|
-
if (on && !this.working) {
|
|
1797
|
-
this.working = true;
|
|
1798
|
-
this.workingStart = Date.now();
|
|
1799
|
-
} else if (!on) {
|
|
1800
|
-
this.working = false;
|
|
1801
|
-
}
|
|
1802
|
-
this.syncSpinner();
|
|
1803
|
-
this.renderBottom();
|
|
1804
|
-
}
|
|
1805
|
-
/** Labels of subagents currently working, one persistent line each. */
|
|
1806
|
-
setSubagents(labels) {
|
|
1807
|
-
this.subagents = labels;
|
|
1808
|
-
this.syncSpinner();
|
|
1809
|
-
this.renderBottom();
|
|
1810
|
-
}
|
|
1811
|
-
/** Run the spinner animation while anything (the agent or a subagent) is active. */
|
|
1812
|
-
syncSpinner() {
|
|
1813
|
-
const spinning = this.working || this.subagents.length > 0;
|
|
1814
|
-
if (spinning && !this.spinnerTimer) {
|
|
1815
|
-
this.spinnerTimer = setInterval(() => this.renderBottom(), 100);
|
|
1816
|
-
} else if (!spinning && this.spinnerTimer) {
|
|
1817
|
-
clearInterval(this.spinnerTimer);
|
|
1818
|
-
this.spinnerTimer = null;
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
get isWorking() {
|
|
1822
|
-
return this.working;
|
|
1823
|
-
}
|
|
1824
|
-
setBackgroundCount(n) {
|
|
1825
|
-
this.bgCount = n;
|
|
1826
|
-
this.renderBottom();
|
|
1827
|
-
}
|
|
1828
|
-
setQueuedCount(n) {
|
|
1829
|
-
this.queuedCount = n;
|
|
1830
|
-
this.renderBottom();
|
|
1831
|
-
}
|
|
1832
|
-
setConnected(connected) {
|
|
1833
|
-
this.connected = connected;
|
|
1834
|
-
this.renderBottom();
|
|
1835
|
-
}
|
|
1836
|
-
// ─── input buffer access (for up-arrow editing of queued messages) ─────────
|
|
1837
|
-
getInput() {
|
|
1838
|
-
return this.inputBuffer;
|
|
1839
|
-
}
|
|
1840
|
-
setInput(text) {
|
|
1841
|
-
this.inputBuffer = text;
|
|
1842
|
-
this.cursorPos = text.length;
|
|
1843
|
-
this.renderBottom();
|
|
1844
|
-
}
|
|
1845
|
-
/** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
|
|
1846
|
-
setTokens(inTokens, outTokens) {
|
|
1847
|
-
this.tokensIn = inTokens;
|
|
1848
|
-
this.tokensOut = outTokens;
|
|
1849
|
-
this.renderBottom();
|
|
1850
|
-
}
|
|
1851
|
-
/**
|
|
1852
|
-
* Current step the agent is working on, shown on the line above the prompt.
|
|
1853
|
-
* `outTokens` is the output produced during this step. The step's own timer
|
|
1854
|
-
* resets whenever the label changes.
|
|
1855
|
-
*/
|
|
1856
|
-
setStep(label, outTokens) {
|
|
1857
|
-
const next = label && label.trim() ? label.trim() : null;
|
|
1858
|
-
if (next !== this.step) {
|
|
1859
|
-
this.step = next;
|
|
1860
|
-
this.stepStart = Date.now();
|
|
1861
|
-
}
|
|
1862
|
-
this.stepOut = outTokens;
|
|
1863
|
-
this.renderBottom();
|
|
1864
|
-
}
|
|
1865
|
-
// ─── takeover helpers (approval / menus) ───────────────────────────────────
|
|
1866
|
-
beginTakeover() {
|
|
1867
|
-
this.clearBottom();
|
|
1868
|
-
process.stdout.write("\r\x1B[K");
|
|
1869
|
-
process.stdout.write("\x1B[?25l");
|
|
1870
|
-
this.bottomDrawn = false;
|
|
1871
|
-
}
|
|
1872
|
-
endTakeover() {
|
|
1873
|
-
this.takeoverHandler = null;
|
|
1874
|
-
process.stdout.write("\x1B[?25h");
|
|
1875
|
-
const buffered = this.bufferedPrints;
|
|
1876
|
-
this.bufferedPrints = [];
|
|
1877
|
-
for (const t of buffered) {
|
|
1878
|
-
process.stdout.write(t + "\n");
|
|
1879
|
-
}
|
|
1880
|
-
this.renderBottom();
|
|
1881
|
-
}
|
|
1882
|
-
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input. */
|
|
1883
|
-
approval(question, risk) {
|
|
1884
|
-
return new Promise((resolve) => {
|
|
1885
|
-
const options = [
|
|
1886
|
-
{ value: "allow", label: "Allow once", shortcut: "y", color: C.green },
|
|
1887
|
-
{ value: "always", label: "Always allow this tool", shortcut: "a", color: C.cyan },
|
|
1888
|
-
{ value: "always_risk", label: `Allow all level ${risk} this session`, shortcut: "l", color: C.cyan },
|
|
1889
|
-
{ value: "deny", label: "Deny", shortcut: "n", color: C.red }
|
|
1890
|
-
];
|
|
1891
|
-
let idx = 0;
|
|
1892
|
-
const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
|
|
1893
|
-
this.beginTakeover();
|
|
1894
|
-
process.stdout.write(
|
|
1895
|
-
`
|
|
1896
|
-
${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}
|
|
1897
|
-
${C.yellow}\u2503${C.reset} ${question}
|
|
1898
|
-
`
|
|
1899
|
-
);
|
|
1900
|
-
const renderLine = (i) => {
|
|
1901
|
-
const o = options[i];
|
|
1902
|
-
const sel = i === idx;
|
|
1903
|
-
const pointer = sel ? `${o.color}\u276F${C.reset}` : " ";
|
|
1904
|
-
const label = sel ? `${C.bold}${o.label}${C.reset}` : o.label;
|
|
1905
|
-
return `${C.yellow}\u2503${C.reset} ${pointer} ${label} ${C.gray}(${o.shortcut})${C.reset}`;
|
|
1906
|
-
};
|
|
1907
|
-
const draw = (moveUp) => {
|
|
1908
|
-
if (moveUp) process.stdout.write(`\x1B[${options.length}A`);
|
|
1909
|
-
for (let i = 0; i < options.length; i++) process.stdout.write(`\r\x1B[K${renderLine(i)}
|
|
1910
|
-
`);
|
|
1911
|
-
};
|
|
1912
|
-
draw(false);
|
|
1913
|
-
const finish = (choice) => {
|
|
1914
|
-
this.endTakeover();
|
|
1915
|
-
resolve(choice);
|
|
1916
|
-
};
|
|
1917
|
-
this.takeoverHandler = (str, key) => {
|
|
1918
|
-
if (key?.name === "up" || str === "k") {
|
|
1919
|
-
idx = (idx - 1 + options.length) % options.length;
|
|
1920
|
-
draw(true);
|
|
1921
|
-
} else if (key?.name === "down" || str === "j") {
|
|
1922
|
-
idx = (idx + 1) % options.length;
|
|
1923
|
-
draw(true);
|
|
1924
|
-
} else if (key?.name === "return" || key?.name === "enter") {
|
|
1925
|
-
finish(options[idx].value);
|
|
1926
|
-
} else {
|
|
1927
|
-
const k = (str || "").toLowerCase();
|
|
1928
|
-
if (k === "y") finish("allow");
|
|
1929
|
-
else if (k === "a") finish("always");
|
|
1930
|
-
else if (k === "l") finish("always_risk");
|
|
1931
|
-
else if (k === "n" || key?.name === "escape") finish("deny");
|
|
1932
|
-
}
|
|
1933
|
-
};
|
|
1934
|
-
});
|
|
1935
|
-
}
|
|
1936
|
-
/** Arrow-key selection menu (slash menu, process menu, resume). Pauses input. */
|
|
1937
|
-
select(title, items) {
|
|
1938
|
-
return new Promise((resolve) => {
|
|
1939
|
-
let idx = 0;
|
|
1940
|
-
this.beginTakeover();
|
|
1941
|
-
if (title) process.stdout.write(`
|
|
1942
|
-
${title}
|
|
1943
|
-
|
|
1944
|
-
`);
|
|
1945
|
-
const renderLine = (i) => {
|
|
1946
|
-
const w = process.stdout.columns || 80;
|
|
1947
|
-
const it = items[i];
|
|
1948
|
-
const sel = i === idx;
|
|
1949
|
-
const hint = it.hint ?? "";
|
|
1950
|
-
const hintW = hint.length;
|
|
1951
|
-
const pointerW = 2;
|
|
1952
|
-
const labelMax = Math.max(6, w - 2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
1953
|
-
let label = it.label;
|
|
1954
|
-
if (label.length > labelMax) label = label.slice(0, labelMax - 1) + "\u2026";
|
|
1955
|
-
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
1956
|
-
const styledLabel = sel ? `${C.bold}${C.cyan}${label}${C.reset}` : label;
|
|
1957
|
-
let line = `${pointer}${styledLabel}`;
|
|
1958
|
-
if (hintW) {
|
|
1959
|
-
const gap = Math.max(2, w - 2 - pointerW - label.length - hintW);
|
|
1960
|
-
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
1961
|
-
}
|
|
1962
|
-
return line;
|
|
1963
|
-
};
|
|
1964
|
-
const draw = (moveUp) => {
|
|
1965
|
-
if (moveUp) process.stdout.write(`\x1B[${items.length}A`);
|
|
1966
|
-
for (let i = 0; i < items.length; i++) process.stdout.write(`\r\x1B[K${renderLine(i)}
|
|
1967
|
-
`);
|
|
1968
|
-
};
|
|
1969
|
-
draw(false);
|
|
1970
|
-
const titleRows = title ? 3 : 0;
|
|
1971
|
-
const erase = () => {
|
|
1972
|
-
process.stdout.write("\r");
|
|
1973
|
-
const up = titleRows + items.length;
|
|
1974
|
-
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1975
|
-
process.stdout.write("\x1B[J");
|
|
1976
|
-
};
|
|
1977
|
-
const close = (value) => {
|
|
1978
|
-
erase();
|
|
1979
|
-
this.endTakeover();
|
|
1980
|
-
resolve(value);
|
|
1981
|
-
};
|
|
1982
|
-
this.takeoverHandler = (str, key) => {
|
|
1983
|
-
if (!key) return;
|
|
1984
|
-
if (key.name === "up" || str === "k") {
|
|
1985
|
-
idx = (idx - 1 + items.length) % items.length;
|
|
1986
|
-
draw(true);
|
|
1987
|
-
} else if (key.name === "down" || str === "j") {
|
|
1988
|
-
idx = (idx + 1) % items.length;
|
|
1989
|
-
draw(true);
|
|
1990
|
-
} else if (key.name === "return" || key.name === "enter") {
|
|
1991
|
-
close(items[idx].value);
|
|
1992
|
-
} else if (key.name === "escape") {
|
|
1993
|
-
close(void 0);
|
|
1994
|
-
}
|
|
1995
|
-
};
|
|
1996
|
-
});
|
|
1997
|
-
}
|
|
1998
|
-
/**
|
|
1999
|
-
* Free-text prompt (single line). Pauses the main input and reads a line —
|
|
2000
|
-
* used where a menu can't, e.g. entering an MCP server command. Enter submits,
|
|
2001
|
-
* Escape (or empty submit) cancels with null.
|
|
2002
|
-
*/
|
|
2003
|
-
prompt(question, placeholder = "") {
|
|
2004
|
-
return new Promise((resolve) => {
|
|
2005
|
-
let buf = "";
|
|
2006
|
-
this.beginTakeover();
|
|
2007
|
-
process.stdout.write("\x1B[?25h");
|
|
2008
|
-
process.stdout.write(`
|
|
2009
|
-
${C.cyan}\u2503${C.reset} ${question}
|
|
2010
|
-
`);
|
|
2011
|
-
if (placeholder) process.stdout.write(`${C.gray}\u2503 e.g. ${placeholder}${C.reset}
|
|
2012
|
-
`);
|
|
2013
|
-
const draw = () => {
|
|
2014
|
-
process.stdout.write(`\r\x1B[K${C.cyan}\u2503${C.reset} ${C.bold}\u203A${C.reset} ${buf}`);
|
|
2015
|
-
};
|
|
2016
|
-
draw();
|
|
2017
|
-
const finish = (value) => {
|
|
2018
|
-
process.stdout.write("\n");
|
|
2019
|
-
this.endTakeover();
|
|
2020
|
-
resolve(value);
|
|
2021
|
-
};
|
|
2022
|
-
this.takeoverHandler = (str, key) => {
|
|
2023
|
-
if (key?.name === "escape") return finish(null);
|
|
2024
|
-
if (key?.name === "return" || key?.name === "enter") return finish(buf.trim() || null);
|
|
2025
|
-
if (key?.name === "backspace") {
|
|
2026
|
-
buf = buf.slice(0, -1);
|
|
2027
|
-
draw();
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
if (str && !key?.ctrl && !key?.meta && str >= " ") {
|
|
2031
|
-
buf += str;
|
|
2032
|
-
draw();
|
|
2033
|
-
}
|
|
2034
|
-
};
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
banner(lines) {
|
|
2038
|
-
this.clearBottom();
|
|
2039
|
-
process.stdout.write("\n");
|
|
2040
|
-
for (const l of lines) process.stdout.write(l + "\n");
|
|
2041
|
-
}
|
|
2042
|
-
};
|
|
2043
|
-
|
|
2044
|
-
// src/markdown.ts
|
|
2045
|
-
var ESC = "\x1B[";
|
|
2046
|
-
var R = ESC + "0m";
|
|
2047
|
-
var BOLD = ESC + "1m";
|
|
2048
|
-
var DIM = ESC + "2m";
|
|
2049
|
-
var ITAL = ESC + "3m";
|
|
2050
|
-
var UNDER = ESC + "4m";
|
|
2051
|
-
var TEAL = ESC + "38;5;37m";
|
|
2052
|
-
var CYAN = ESC + "36m";
|
|
2053
|
-
var GRAY = ESC + "90m";
|
|
2054
|
-
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
2055
|
-
function visibleWidth(s) {
|
|
2056
|
-
return s.replace(ANSI, "").length;
|
|
2057
|
-
}
|
|
2058
|
-
function padEndVisible(s, width) {
|
|
2059
|
-
const pad = width - visibleWidth(s);
|
|
2060
|
-
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
2061
|
-
}
|
|
2062
|
-
function inline(s) {
|
|
2063
|
-
const codes = [];
|
|
2064
|
-
s = s.replace(/`([^`]+)`/g, (_, code) => {
|
|
2065
|
-
codes.push(code);
|
|
2066
|
-
return "\0" + (codes.length - 1) + "\0";
|
|
2067
|
-
});
|
|
2068
|
-
s = s.replace(
|
|
2069
|
-
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
|
2070
|
-
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM}${url}${R}`
|
|
2071
|
-
);
|
|
2072
|
-
s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
|
|
2073
|
-
s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
|
|
2074
|
-
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM}${t}${R}`);
|
|
2075
|
-
s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
|
|
2076
|
-
return s;
|
|
2077
|
-
}
|
|
2078
|
-
function wrapStyled(text, width) {
|
|
2079
|
-
if (width < 4 || visibleWidth(text) <= width) return [text];
|
|
2080
|
-
const words = text.split(" ");
|
|
2081
|
-
const lines = [];
|
|
2082
|
-
let cur = "";
|
|
2083
|
-
let curLen = 0;
|
|
2084
|
-
for (const w of words) {
|
|
2085
|
-
const wLen = visibleWidth(w);
|
|
2086
|
-
if (cur === "") {
|
|
2087
|
-
cur = w;
|
|
2088
|
-
curLen = wLen;
|
|
2089
|
-
} else if (curLen + 1 + wLen <= width) {
|
|
2090
|
-
cur += " " + w;
|
|
2091
|
-
curLen += 1 + wLen;
|
|
2092
|
-
} else {
|
|
2093
|
-
lines.push(cur);
|
|
2094
|
-
cur = w;
|
|
2095
|
-
curLen = wLen;
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
if (cur !== "" || lines.length === 0) lines.push(cur);
|
|
2099
|
-
return lines;
|
|
2100
|
-
}
|
|
2101
|
-
function wrapBlock(out, cols, leadFirst, leadRest, leadWidth, text) {
|
|
2102
|
-
const wrapped = wrapStyled(text, Math.max(8, cols - leadWidth));
|
|
2103
|
-
wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
|
|
2104
|
-
}
|
|
2105
|
-
function tableCells(row) {
|
|
2106
|
-
let r = row.trim();
|
|
2107
|
-
if (r.startsWith("|")) r = r.slice(1);
|
|
2108
|
-
if (r.endsWith("|")) r = r.slice(0, -1);
|
|
2109
|
-
return r.split("|").map((c2) => c2.trim());
|
|
2110
|
-
}
|
|
2111
|
-
var SEPARATOR = /^[\s|:-]+$/;
|
|
2112
|
-
function isTableSeparator(line) {
|
|
2113
|
-
return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
|
|
2114
|
-
}
|
|
2115
|
-
function renderTable(rows) {
|
|
2116
|
-
const cols = Math.max(...rows.map((r) => r.length));
|
|
2117
|
-
const widths = [];
|
|
2118
|
-
for (let c2 = 0; c2 < cols; c2++) {
|
|
2119
|
-
widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
|
|
2120
|
-
}
|
|
2121
|
-
const sep = `${GRAY} \u2502 ${R}`;
|
|
2122
|
-
const out = [];
|
|
2123
|
-
rows.forEach((r, ri) => {
|
|
2124
|
-
const cells = [];
|
|
2125
|
-
for (let c2 = 0; c2 < cols; c2++) {
|
|
2126
|
-
const raw = r[c2] ?? "";
|
|
2127
|
-
const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
|
|
2128
|
-
cells.push(padEndVisible(styled, widths[c2]));
|
|
2129
|
-
}
|
|
2130
|
-
out.push((" " + cells.join(sep)).replace(/\s+$/, ""));
|
|
2131
|
-
if (ri === 0) {
|
|
2132
|
-
const rule = widths.map((w) => `${GRAY}${"\u2500".repeat(w)}${R}`).join(`${GRAY}\u2500\u253C\u2500${R}`);
|
|
2133
|
-
out.push(" " + rule);
|
|
2134
|
-
}
|
|
2135
|
-
});
|
|
2136
|
-
return out;
|
|
2137
|
-
}
|
|
2138
|
-
function renderMarkdown(src, cols = 80) {
|
|
2139
|
-
const lines = src.replace(/\r\n/g, "\n").split("\n");
|
|
2140
|
-
const out = [];
|
|
2141
|
-
let inFence = false;
|
|
2142
|
-
let i = 0;
|
|
2143
|
-
while (i < lines.length) {
|
|
2144
|
-
const line = lines[i];
|
|
2145
|
-
if (/^\s*```/.test(line)) {
|
|
2146
|
-
inFence = !inFence;
|
|
2147
|
-
i++;
|
|
2148
|
-
continue;
|
|
2149
|
-
}
|
|
2150
|
-
if (inFence) {
|
|
2151
|
-
out.push(`${GRAY}\u2502${R} ${line}`);
|
|
2152
|
-
i++;
|
|
2153
|
-
continue;
|
|
2154
|
-
}
|
|
2155
|
-
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
2156
|
-
const block = [tableCells(line)];
|
|
2157
|
-
i += 2;
|
|
2158
|
-
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
|
|
2159
|
-
block.push(tableCells(lines[i]));
|
|
2160
|
-
i++;
|
|
2161
|
-
}
|
|
2162
|
-
out.push(...renderTable(block));
|
|
2163
|
-
continue;
|
|
2164
|
-
}
|
|
2165
|
-
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
2166
|
-
if (heading) {
|
|
2167
|
-
for (const ln of wrapStyled(heading[2].trim(), cols)) out.push(`${BOLD}${TEAL}${ln}${R}`);
|
|
2168
|
-
i++;
|
|
2169
|
-
continue;
|
|
2170
|
-
}
|
|
2171
|
-
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
2172
|
-
out.push(`${GRAY}\u2500\u2500\u2500\u2500\u2500\u2500${R}`);
|
|
2173
|
-
i++;
|
|
2174
|
-
continue;
|
|
2175
|
-
}
|
|
2176
|
-
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
2177
|
-
if (quote) {
|
|
2178
|
-
for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols - 2))) {
|
|
2179
|
-
out.push(`${GRAY}\u2502${R} ${DIM}${ln}${R}`);
|
|
2180
|
-
}
|
|
2181
|
-
i++;
|
|
2182
|
-
continue;
|
|
2183
|
-
}
|
|
2184
|
-
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
2185
|
-
if (bullet) {
|
|
2186
|
-
const leadWidth = bullet[1].length + 2;
|
|
2187
|
-
wrapBlock(out, cols, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
|
|
2188
|
-
i++;
|
|
2189
|
-
continue;
|
|
2190
|
-
}
|
|
2191
|
-
const numbered = line.match(/^(\s*)(\d+)([.)])\s+(.*)$/);
|
|
2192
|
-
if (numbered) {
|
|
2193
|
-
const marker = `${numbered[2]}${numbered[3]}`;
|
|
2194
|
-
const leadWidth = numbered[1].length + marker.length + 1;
|
|
2195
|
-
wrapBlock(out, cols, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
|
|
2196
|
-
i++;
|
|
2197
|
-
continue;
|
|
2198
|
-
}
|
|
2199
|
-
if (line.trim()) wrapBlock(out, cols, "", "", 0, inline(line));
|
|
2200
|
-
else out.push("");
|
|
2201
|
-
i++;
|
|
2202
|
-
}
|
|
2203
|
-
return out;
|
|
2204
|
-
}
|
|
2205
|
-
var MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
2206
|
-
var SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
2207
|
-
var CLIENT_INFO = { name: "standard-code", version: "0.1.0" };
|
|
2208
|
-
var RPC_TIMEOUT_MS = 3e4;
|
|
2209
|
-
var INIT_TIMEOUT_MS = 9e4;
|
|
2210
|
-
var McpClient = class {
|
|
2211
|
-
constructor(config) {
|
|
2212
|
-
this.config = config;
|
|
2213
|
-
this.serverInfo = { name: config.name };
|
|
2214
|
-
}
|
|
2215
|
-
config;
|
|
2216
|
-
child = null;
|
|
2217
|
-
nextId = 1;
|
|
2218
|
-
pending = /* @__PURE__ */ new Map();
|
|
2219
|
-
buffer = "";
|
|
2220
|
-
closed = false;
|
|
2221
|
-
serverInfo;
|
|
2222
|
-
protocolVersion = MCP_PROTOCOL_VERSION;
|
|
2223
|
-
capabilities = {};
|
|
2224
|
-
instructions = "";
|
|
2225
|
-
tools = [];
|
|
2226
|
-
resources = [];
|
|
2227
|
-
lastError = null;
|
|
2228
|
-
/** Spawn the server, run the initialize handshake, and discover capabilities. */
|
|
2229
|
-
async connect(defaultCwd) {
|
|
2230
|
-
const child = spawn(this.config.command, this.config.args, {
|
|
2231
|
-
cwd: this.config.cwd || defaultCwd,
|
|
2232
|
-
env: { ...process.env, ...this.config.env || {} },
|
|
2233
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
2234
|
-
});
|
|
2235
|
-
this.child = child;
|
|
2236
|
-
child.on("error", (err) => this.failAll(new Error(`MCP server '${this.config.name}' failed to start: ${err.message}`)));
|
|
2237
|
-
child.on("exit", (code) => {
|
|
2238
|
-
if (!this.closed) this.failAll(new Error(`MCP server '${this.config.name}' exited (code ${code ?? "unknown"}).`));
|
|
2239
|
-
});
|
|
2240
|
-
child.stdout.setEncoding("utf8");
|
|
2241
|
-
child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
2242
|
-
child.stderr.setEncoding("utf8");
|
|
2243
|
-
let stderrTail = "";
|
|
2244
|
-
child.stderr.on("data", (d) => {
|
|
2245
|
-
stderrTail = (stderrTail + d).slice(-2e3);
|
|
2246
|
-
});
|
|
2247
|
-
try {
|
|
2248
|
-
const initResult = await this.request(
|
|
2249
|
-
"initialize",
|
|
2250
|
-
{
|
|
2251
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
2252
|
-
capabilities: { tools: {}, resources: {} },
|
|
2253
|
-
clientInfo: CLIENT_INFO
|
|
2254
|
-
},
|
|
2255
|
-
INIT_TIMEOUT_MS
|
|
2256
|
-
);
|
|
2257
|
-
const negotiated = initResult.protocolVersion || MCP_PROTOCOL_VERSION;
|
|
2258
|
-
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
|
|
2259
|
-
throw new Error(
|
|
2260
|
-
`Server requires unsupported MCP protocol version '${negotiated}' (this client speaks ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}).`
|
|
2261
|
-
);
|
|
2262
|
-
}
|
|
2263
|
-
this.protocolVersion = negotiated;
|
|
2264
|
-
this.capabilities = initResult.capabilities || {};
|
|
2265
|
-
this.serverInfo = initResult.serverInfo || { name: this.config.name };
|
|
2266
|
-
this.instructions = initResult.instructions || "";
|
|
2267
|
-
this.notify("notifications/initialized");
|
|
2268
|
-
if (this.capabilities.tools) await this.refreshTools();
|
|
2269
|
-
if (this.capabilities.resources) await this.refreshResources();
|
|
2270
|
-
} catch (err) {
|
|
2271
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2272
|
-
this.lastError = stderrTail ? `${msg}
|
|
2273
|
-
${stderrTail.trim()}` : msg;
|
|
2274
|
-
this.close();
|
|
2275
|
-
throw new Error(this.lastError);
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
async refreshTools() {
|
|
2279
|
-
const res = await this.request("tools/list", {});
|
|
2280
|
-
this.tools = Array.isArray(res?.tools) ? res.tools : [];
|
|
2281
|
-
}
|
|
2282
|
-
async refreshResources() {
|
|
2283
|
-
try {
|
|
2284
|
-
const res = await this.request("resources/list", {});
|
|
2285
|
-
this.resources = Array.isArray(res?.resources) ? res.resources : [];
|
|
2286
|
-
} catch {
|
|
2287
|
-
this.resources = [];
|
|
2288
|
-
}
|
|
2289
|
-
}
|
|
2290
|
-
/** Call a tool and return its flattened text + a provenance attestation. */
|
|
2291
|
-
async callTool(name, args) {
|
|
2292
|
-
const res = await this.request("tools/call", { name, arguments: args });
|
|
2293
|
-
const text = flattenContent(res?.content, res?.structuredContent);
|
|
2294
|
-
return this.attest(name, args, text, !!res?.isError);
|
|
2295
|
-
}
|
|
2296
|
-
/** Read a resource and return its flattened text + attestation. */
|
|
2297
|
-
async readResource(uri) {
|
|
2298
|
-
const res = await this.request("resources/read", { uri });
|
|
2299
|
-
const text = flattenResourceContents(res?.contents);
|
|
2300
|
-
return this.attest(uri, { uri }, text, false);
|
|
2301
|
-
}
|
|
2302
|
-
attest(target, args, text, isError) {
|
|
2303
|
-
const attestation = {
|
|
2304
|
-
server: this.config.name,
|
|
2305
|
-
serverInfo: this.serverInfo,
|
|
2306
|
-
protocolVersion: this.protocolVersion,
|
|
2307
|
-
target,
|
|
2308
|
-
argsSha256: sha256(canonicalJson(args)),
|
|
2309
|
-
resultSha256: sha256(text),
|
|
2310
|
-
nonce: crypto2.randomBytes(8).toString("hex"),
|
|
2311
|
-
isError,
|
|
2312
|
-
at: Date.now()
|
|
2313
|
-
};
|
|
2314
|
-
return { ok: !isError, text, attestation };
|
|
2315
|
-
}
|
|
2316
|
-
catalogEntry() {
|
|
2317
|
-
return {
|
|
2318
|
-
name: this.config.name,
|
|
2319
|
-
status: this.lastError ? "error" : "connected",
|
|
2320
|
-
serverInfo: this.serverInfo,
|
|
2321
|
-
protocolVersion: this.protocolVersion,
|
|
2322
|
-
instructions: this.instructions || void 0,
|
|
2323
|
-
capabilities: this.capabilities,
|
|
2324
|
-
tools: this.tools,
|
|
2325
|
-
resources: this.resources,
|
|
2326
|
-
error: this.lastError || void 0
|
|
2327
|
-
};
|
|
2328
|
-
}
|
|
2329
|
-
close() {
|
|
2330
|
-
this.closed = true;
|
|
2331
|
-
this.failAll(new Error("connection closed"));
|
|
2332
|
-
try {
|
|
2333
|
-
this.child?.stdin.end();
|
|
2334
|
-
} catch {
|
|
2335
|
-
}
|
|
2336
|
-
try {
|
|
2337
|
-
this.child?.kill("SIGTERM");
|
|
2338
|
-
} catch {
|
|
2339
|
-
}
|
|
2340
|
-
this.child = null;
|
|
2341
|
-
}
|
|
2342
|
-
// ── JSON-RPC plumbing ─────────────────────────────────────────────────────
|
|
2343
|
-
request(method, params, timeoutMs = RPC_TIMEOUT_MS) {
|
|
2344
|
-
return new Promise((resolve, reject) => {
|
|
2345
|
-
if (!this.child || this.closed) {
|
|
2346
|
-
reject(new Error(`MCP server '${this.config.name}' is not connected.`));
|
|
2347
|
-
return;
|
|
2348
|
-
}
|
|
2349
|
-
const id = this.nextId++;
|
|
2350
|
-
const payload = { jsonrpc: "2.0", id, method, params };
|
|
2351
|
-
const timer = setTimeout(() => {
|
|
2352
|
-
this.pending.delete(id);
|
|
2353
|
-
reject(new Error(`MCP request '${method}' to '${this.config.name}' timed out after ${timeoutMs}ms.`));
|
|
2354
|
-
}, timeoutMs);
|
|
2355
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
2356
|
-
this.write(payload);
|
|
2357
|
-
});
|
|
2358
|
-
}
|
|
2359
|
-
notify(method, params) {
|
|
2360
|
-
const payload = { jsonrpc: "2.0", method, params };
|
|
2361
|
-
this.write(payload);
|
|
2362
|
-
}
|
|
2363
|
-
write(payload) {
|
|
2364
|
-
if (!this.child) return;
|
|
2365
|
-
try {
|
|
2366
|
-
this.child.stdin.write(JSON.stringify(payload) + "\n");
|
|
2367
|
-
} catch (err) {
|
|
2368
|
-
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
onData(chunk) {
|
|
2372
|
-
this.buffer += chunk;
|
|
2373
|
-
let nl;
|
|
2374
|
-
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
2375
|
-
const line = this.buffer.slice(0, nl).trim();
|
|
2376
|
-
this.buffer = this.buffer.slice(nl + 1);
|
|
2377
|
-
if (!line) continue;
|
|
2378
|
-
let msg;
|
|
2379
|
-
try {
|
|
2380
|
-
msg = JSON.parse(line);
|
|
2381
|
-
} catch {
|
|
2382
|
-
continue;
|
|
2383
|
-
}
|
|
2384
|
-
this.dispatch(msg);
|
|
2385
|
-
}
|
|
2386
|
-
}
|
|
2387
|
-
dispatch(msg) {
|
|
2388
|
-
if (typeof msg.id === "number" && ("result" in msg || "error" in msg) && msg.method === void 0) {
|
|
2389
|
-
const entry = this.pending.get(msg.id);
|
|
2390
|
-
if (!entry) return;
|
|
2391
|
-
this.pending.delete(msg.id);
|
|
2392
|
-
clearTimeout(entry.timer);
|
|
2393
|
-
if (msg.error) entry.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
|
|
2394
|
-
else entry.resolve(msg.result);
|
|
2395
|
-
return;
|
|
2396
|
-
}
|
|
2397
|
-
if (msg.method && typeof msg.id === "number") {
|
|
2398
|
-
if (msg.method === "ping") {
|
|
2399
|
-
this.write({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
2400
|
-
} else {
|
|
2401
|
-
this.write({
|
|
2402
|
-
jsonrpc: "2.0",
|
|
2403
|
-
id: msg.id,
|
|
2404
|
-
error: { code: -32601, message: `Method not supported by host: ${msg.method}` }
|
|
2405
|
-
});
|
|
2406
|
-
}
|
|
2407
|
-
return;
|
|
2408
|
-
}
|
|
2409
|
-
if (msg.method && msg.id === void 0) {
|
|
2410
|
-
if (msg.method === "notifications/tools/list_changed") void this.refreshTools().catch(() => {
|
|
2411
|
-
});
|
|
2412
|
-
if (msg.method === "notifications/resources/list_changed") void this.refreshResources().catch(() => {
|
|
2413
|
-
});
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
failAll(err) {
|
|
2417
|
-
for (const [, entry] of this.pending) {
|
|
2418
|
-
clearTimeout(entry.timer);
|
|
2419
|
-
entry.reject(err);
|
|
2420
|
-
}
|
|
2421
|
-
this.pending.clear();
|
|
2422
|
-
}
|
|
2423
|
-
};
|
|
2424
|
-
var McpManager = class {
|
|
2425
|
-
constructor(defaultCwd) {
|
|
2426
|
-
this.defaultCwd = defaultCwd;
|
|
2427
|
-
}
|
|
2428
|
-
defaultCwd;
|
|
2429
|
-
clients = /* @__PURE__ */ new Map();
|
|
2430
|
-
/** Last attestations produced this session (most recent last). */
|
|
2431
|
-
attestations = [];
|
|
2432
|
-
/** Connect one server (replacing any existing client of the same name). */
|
|
2433
|
-
async connect(config) {
|
|
2434
|
-
this.disconnect(config.name);
|
|
2435
|
-
const client = new McpClient(config);
|
|
2436
|
-
this.clients.set(config.name, client);
|
|
2437
|
-
await client.connect(this.defaultCwd);
|
|
2438
|
-
return client;
|
|
2439
|
-
}
|
|
2440
|
-
disconnect(name) {
|
|
2441
|
-
const existing = this.clients.get(name);
|
|
2442
|
-
if (existing) {
|
|
2443
|
-
existing.close();
|
|
2444
|
-
this.clients.delete(name);
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
closeAll() {
|
|
2448
|
-
for (const [, c2] of this.clients) c2.close();
|
|
2449
|
-
this.clients.clear();
|
|
2450
|
-
}
|
|
2451
|
-
get(name) {
|
|
2452
|
-
return this.clients.get(name);
|
|
2453
|
-
}
|
|
2454
|
-
connectedNames() {
|
|
2455
|
-
return Array.from(this.clients.keys()).sort();
|
|
2456
|
-
}
|
|
2457
|
-
toolCount() {
|
|
2458
|
-
let n = 0;
|
|
2459
|
-
for (const [, c2] of this.clients) n += c2.tools.length;
|
|
2460
|
-
return n;
|
|
2461
|
-
}
|
|
2462
|
-
/** A JSON-serializable catalog of every connected server for the KV/context. */
|
|
2463
|
-
catalog() {
|
|
2464
|
-
return {
|
|
2465
|
-
servers: Array.from(this.clients.values()).map((c2) => c2.catalogEntry()),
|
|
2466
|
-
generatedAt: Date.now()
|
|
2467
|
-
};
|
|
2468
|
-
}
|
|
2469
|
-
recentAttestations(n = 10) {
|
|
2470
|
-
return this.attestations.slice(-n);
|
|
2471
|
-
}
|
|
2472
|
-
/** Execute a forwarded `mcp` tool request and return host-shaped text. */
|
|
2473
|
-
async dispatch(args) {
|
|
2474
|
-
const action = String(args.action || "call");
|
|
2475
|
-
const serverName = typeof args.server === "string" ? args.server : "";
|
|
2476
|
-
if (action === "list") {
|
|
2477
|
-
const servers = this.catalog().servers.map((s) => ({
|
|
2478
|
-
name: s.name,
|
|
2479
|
-
tools: s.tools.length,
|
|
2480
|
-
resources: s.resources?.length ?? 0
|
|
2481
|
-
}));
|
|
2482
|
-
return {
|
|
2483
|
-
ok: true,
|
|
2484
|
-
result: servers.length ? JSON.stringify({ servers }, null, 2) : "No MCP servers are connected. The user can add one with the /mcp command, or you can install one with install_mcp."
|
|
2485
|
-
};
|
|
2486
|
-
}
|
|
2487
|
-
if (action === "list_tools") {
|
|
2488
|
-
const cat = this.catalog().servers.filter((s) => !serverName || s.name === serverName);
|
|
2489
|
-
if (!cat.length) return { ok: false, error: serverName ? `No connected MCP server named '${serverName}'.` : "No MCP servers are connected. The user can add one with the /mcp command." };
|
|
2490
|
-
return { ok: true, result: JSON.stringify(cat, null, 2) };
|
|
2491
|
-
}
|
|
2492
|
-
const client = this.clients.get(serverName);
|
|
2493
|
-
if (!client) {
|
|
2494
|
-
const avail = this.connectedNames();
|
|
2495
|
-
return {
|
|
2496
|
-
ok: false,
|
|
2497
|
-
error: avail.length ? `No connected MCP server named '${serverName}'. Connected servers: ${avail.join(", ")}.` : `No MCP servers are connected. The user can add one with the /mcp command.`
|
|
2498
|
-
};
|
|
2499
|
-
}
|
|
2500
|
-
try {
|
|
2501
|
-
let res;
|
|
2502
|
-
if (action === "read_resource") {
|
|
2503
|
-
const uri = String(args.uri || "");
|
|
2504
|
-
if (!uri) return { ok: false, error: "read_resource requires a 'uri'." };
|
|
2505
|
-
res = await client.readResource(uri);
|
|
2506
|
-
} else {
|
|
2507
|
-
const toolName = String(args.tool || "");
|
|
2508
|
-
if (!toolName) return { ok: false, error: "call requires a 'tool' name." };
|
|
2509
|
-
const callArgs = parseArgs(args.arguments_json);
|
|
2510
|
-
if (callArgs instanceof Error) return { ok: false, error: callArgs.message };
|
|
2511
|
-
res = await client.callTool(toolName, callArgs);
|
|
2512
|
-
}
|
|
2513
|
-
this.attestations.push(res.attestation);
|
|
2514
|
-
const footer = formatAttestation(res.attestation);
|
|
2515
|
-
if (!res.ok) {
|
|
2516
|
-
return { ok: false, error: `${res.text || "The MCP tool reported an error."}
|
|
2517
|
-
${footer}` };
|
|
2518
|
-
}
|
|
2519
|
-
return { ok: true, result: `${res.text}
|
|
2520
|
-
${footer}` };
|
|
2521
|
-
} catch (err) {
|
|
2522
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
};
|
|
2526
|
-
function parseArgs(raw) {
|
|
2527
|
-
if (raw == null || raw === "") return {};
|
|
2528
|
-
if (typeof raw === "object") return raw;
|
|
2529
|
-
if (typeof raw !== "string") return new Error("arguments_json must be a JSON object string.");
|
|
2530
|
-
try {
|
|
2531
|
-
const parsed = JSON.parse(raw);
|
|
2532
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2533
|
-
return new Error("arguments_json must encode a JSON object.");
|
|
2534
|
-
} catch (e) {
|
|
2535
|
-
return new Error(`arguments_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
function flattenContent(content, structured) {
|
|
2539
|
-
const parts = [];
|
|
2540
|
-
for (const block of content || []) {
|
|
2541
|
-
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
2542
|
-
else if (block.type === "resource" && block.resource && typeof block.resource === "object") {
|
|
2543
|
-
const r = block.resource;
|
|
2544
|
-
if (typeof r.text === "string") parts.push(r.text);
|
|
2545
|
-
else parts.push(`[resource ${String(r.uri ?? "")}]`);
|
|
2546
|
-
} else if (block.type === "image") parts.push(`[image ${String(block.mimeType ?? "")}]`);
|
|
2547
|
-
else if (block.type === "audio") parts.push(`[audio ${String(block.mimeType ?? "")}]`);
|
|
2548
|
-
else parts.push(JSON.stringify(block));
|
|
2549
|
-
}
|
|
2550
|
-
if (!parts.length && structured !== void 0) parts.push(JSON.stringify(structured, null, 2));
|
|
2551
|
-
return parts.join("\n").trim();
|
|
2552
|
-
}
|
|
2553
|
-
function flattenResourceContents(contents) {
|
|
2554
|
-
const parts = [];
|
|
2555
|
-
for (const c2 of contents || []) {
|
|
2556
|
-
if (typeof c2.text === "string") parts.push(c2.text);
|
|
2557
|
-
else if (typeof c2.blob === "string") parts.push(`[binary resource ${String(c2.uri ?? "")} (${c2.blob.length} b64 chars)]`);
|
|
2558
|
-
}
|
|
2559
|
-
return parts.join("\n").trim();
|
|
2560
|
-
}
|
|
2561
|
-
function formatAttestation(a) {
|
|
2562
|
-
const id = `${a.serverInfo.name}${a.serverInfo.version ? `@${a.serverInfo.version}` : ""}`;
|
|
2563
|
-
return `[attestation] server=${a.server} (${id}) protocol=${a.protocolVersion} target=${a.target} args_sha256=${a.argsSha256.slice(0, 16)} result_sha256=${a.resultSha256.slice(0, 16)} nonce=${a.nonce}`;
|
|
2564
|
-
}
|
|
2565
|
-
function canonicalJson(value) {
|
|
2566
|
-
return JSON.stringify(sortKeys(value));
|
|
2567
|
-
}
|
|
2568
|
-
function sortKeys(value) {
|
|
2569
|
-
if (Array.isArray(value)) return value.map(sortKeys);
|
|
2570
|
-
if (value && typeof value === "object") {
|
|
2571
|
-
const out = {};
|
|
2572
|
-
for (const k of Object.keys(value).sort()) {
|
|
2573
|
-
out[k] = sortKeys(value[k]);
|
|
2574
|
-
}
|
|
2575
|
-
return out;
|
|
2576
|
-
}
|
|
2577
|
-
return value;
|
|
2578
|
-
}
|
|
2579
|
-
function sha256(input2) {
|
|
2580
|
-
return crypto2.createHash("sha256").update(input2).digest("hex");
|
|
2581
|
-
}
|
|
2582
|
-
var DIR2 = path3.join(os4.homedir(), ".standardagents");
|
|
2583
|
-
var FILE2 = path3.join(DIR2, "credentials");
|
|
2584
|
-
function normalizeEndpoint(endpoint) {
|
|
2585
|
-
let e = endpoint.trim();
|
|
2586
|
-
if (!/^https?:\/\//i.test(e)) e = "http://" + e;
|
|
2587
|
-
return e.replace(/\/+$/, "");
|
|
2588
|
-
}
|
|
2589
|
-
function loadCredentials() {
|
|
2590
|
-
try {
|
|
2591
|
-
const raw = fs2.readFileSync(FILE2, "utf8");
|
|
2592
|
-
const parsed = JSON.parse(raw);
|
|
2593
|
-
if (!parsed.instances) parsed.instances = {};
|
|
2594
|
-
return parsed;
|
|
2595
|
-
} catch {
|
|
2596
|
-
return { instances: {} };
|
|
2597
|
-
}
|
|
2598
|
-
}
|
|
2599
|
-
function getCredential(endpoint) {
|
|
2600
|
-
const creds = loadCredentials();
|
|
2601
|
-
return creds.instances[normalizeEndpoint(endpoint)] ?? null;
|
|
2602
|
-
}
|
|
2603
|
-
function saveCredential(cred) {
|
|
2604
|
-
const creds = loadCredentials();
|
|
2605
|
-
const endpoint = normalizeEndpoint(cred.endpoint);
|
|
2606
|
-
creds.instances[endpoint] = { ...cred, endpoint };
|
|
2607
|
-
creds.default_endpoint = endpoint;
|
|
2608
|
-
fs2.mkdirSync(DIR2, { recursive: true });
|
|
2609
|
-
fs2.writeFileSync(FILE2, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
2610
|
-
try {
|
|
2611
|
-
fs2.chmodSync(FILE2, 384);
|
|
2612
|
-
} catch {
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
function defaultEndpoint() {
|
|
2616
|
-
return loadCredentials().default_endpoint ?? null;
|
|
2617
|
-
}
|
|
2618
|
-
|
|
2619
|
-
// src/index.ts
|
|
2620
|
-
var AGENT_ID = "standard_code_agent";
|
|
2621
|
-
var c = {
|
|
2622
|
-
reset: "\x1B[0m",
|
|
2623
|
-
dim: "\x1B[2m",
|
|
2624
|
-
bold: "\x1B[1m",
|
|
2625
|
-
white: "\x1B[97m",
|
|
2626
|
-
cyan: "\x1B[36m",
|
|
2627
|
-
green: "\x1B[32m",
|
|
2628
|
-
gray: "\x1B[90m",
|
|
2629
|
-
magenta: "\x1B[35m",
|
|
2630
|
-
yellow: "\x1B[33m",
|
|
2631
|
-
red: "\x1B[31m",
|
|
2632
|
-
teal: "\x1B[38;5;37m"
|
|
2633
|
-
// brand teal (matches the marketing site's teal accent)
|
|
2634
|
-
};
|
|
2635
|
-
var LOGO_MARK = [
|
|
2636
|
-
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
|
2637
|
-
" \u2588\u2588\u2588 \u2588\u2588",
|
|
2638
|
-
" \u2588\u2588 \u2588",
|
|
2639
|
-
"\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
|
|
2640
|
-
"\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588",
|
|
2641
|
-
"\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588",
|
|
2642
|
-
"\u2588 \u2588\u2588",
|
|
2643
|
-
"\u2588\u2588 \u2588\u2588\u2588",
|
|
2644
|
-
"\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
|
|
2645
|
-
];
|
|
2646
|
-
function printAssistant(tui, text) {
|
|
2647
|
-
const cols = Math.max(20, (process.stdout.columns || 80) - 1);
|
|
2648
|
-
tui.print("");
|
|
2649
|
-
for (const line of renderMarkdown(text, cols)) tui.print(line);
|
|
2650
|
-
tui.print("");
|
|
2651
|
-
}
|
|
2652
|
-
function farewell() {
|
|
2653
|
-
stdout.write(`
|
|
2654
|
-
${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
|
|
2655
|
-
`);
|
|
2656
|
-
}
|
|
2657
|
-
function isLocalHost(host) {
|
|
2658
|
-
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local") || host.endsWith(".localhost") || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
2659
|
-
}
|
|
2660
|
-
function relaxTlsForLocalEndpoint(endpoint) {
|
|
2661
|
-
let host = "";
|
|
2662
|
-
try {
|
|
2663
|
-
host = new URL(endpoint).hostname;
|
|
2664
|
-
} catch {
|
|
2665
|
-
return false;
|
|
2666
|
-
}
|
|
2667
|
-
if (!endpoint.startsWith("https:") || !isLocalHost(host)) return false;
|
|
2668
|
-
const origEmit = process.emitWarning.bind(process);
|
|
2669
|
-
process.emitWarning = ((warning, ...args) => {
|
|
2670
|
-
const msg = typeof warning === "string" ? warning : warning?.message ?? "";
|
|
2671
|
-
if (msg.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
|
|
2672
|
-
return origEmit(warning, ...args);
|
|
2673
|
-
});
|
|
2674
|
-
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
2675
|
-
return true;
|
|
2676
|
-
}
|
|
2677
|
-
function readVersion() {
|
|
2678
|
-
try {
|
|
2679
|
-
const pkg = JSON.parse(fs2.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
2680
|
-
return typeof pkg.version === "string" ? pkg.version : "";
|
|
2681
|
-
} catch {
|
|
2682
|
-
return "";
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
function printWelcome(endpoint, projectDir) {
|
|
2686
|
-
const home = os4.homedir();
|
|
2687
|
-
const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2688
|
-
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2689
|
-
const version = readVersion();
|
|
2690
|
-
const pad = " ";
|
|
2691
|
-
const meta = [
|
|
2692
|
-
`${c.bold}${c.white}Standard Code${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
|
|
2693
|
-
`${c.dim}terminal coding agent${c.reset}`,
|
|
2694
|
-
`${c.teal}${host}${c.reset}`,
|
|
2695
|
-
`${c.dim}${dir}${c.reset}`
|
|
2696
|
-
];
|
|
2697
|
-
const markWidth = Math.max(...LOGO_MARK.map((l) => [...l].length));
|
|
2698
|
-
const metaTop = Math.floor((LOGO_MARK.length - meta.length) / 2);
|
|
2699
|
-
stdout.write("\n");
|
|
2700
|
-
for (let i = 0; i < LOGO_MARK.length; i++) {
|
|
2701
|
-
const glyph = LOGO_MARK[i].padEnd(markWidth);
|
|
2702
|
-
const line = meta[i - metaTop];
|
|
2703
|
-
stdout.write(`${pad}${glyph}${line ? ` ${line}` : ""}
|
|
2704
|
-
`);
|
|
2705
|
-
}
|
|
2706
|
-
stdout.write("\n");
|
|
2707
|
-
}
|
|
2708
|
-
function colorActivity(line) {
|
|
2709
|
-
const m = line.match(/^(\s*)([✓✗⛔])\s?([\s\S]*)$/);
|
|
2710
|
-
if (!m) return `${c.dim}${line}${c.reset}`;
|
|
2711
|
-
const [, indent, glyph, rest] = m;
|
|
2712
|
-
if (glyph === "\u2713") {
|
|
2713
|
-
const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
|
|
2714
|
-
return `${indent}${c.green}\u2713${c.reset} ${body}`;
|
|
2715
|
-
}
|
|
2716
|
-
if (glyph === "\u2717") return `${indent}${c.red}\u2717${c.reset} ${rest}`;
|
|
2717
|
-
return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
|
|
2718
|
-
}
|
|
2719
|
-
async function main() {
|
|
2720
|
-
const args = process.argv.slice(2);
|
|
2721
|
-
let endpointArg;
|
|
2722
|
-
let dirArg;
|
|
2723
|
-
for (let i = 0; i < args.length; i++) {
|
|
2724
|
-
if (args[i] === "--endpoint" || args[i] === "-e") endpointArg = args[++i];
|
|
2725
|
-
else if (!args[i].startsWith("-")) dirArg = args[i];
|
|
2726
|
-
}
|
|
2727
|
-
const projectDir = path3.resolve(dirArg || process.cwd());
|
|
2728
|
-
const machine = os4.hostname();
|
|
2729
|
-
const reader = { rl: null };
|
|
2730
|
-
let handoffClosing = false;
|
|
2731
|
-
let preflightArmed = false;
|
|
2732
|
-
let preflightTimer = null;
|
|
2733
|
-
const onPreflightSigint = () => {
|
|
2734
|
-
if (preflightArmed) {
|
|
2735
|
-
if (preflightTimer) clearTimeout(preflightTimer);
|
|
2736
|
-
reader.rl?.close();
|
|
2737
|
-
farewell();
|
|
2738
|
-
process.exit(0);
|
|
2739
|
-
}
|
|
2740
|
-
preflightArmed = true;
|
|
2741
|
-
stdout.write(`
|
|
2742
|
-
${c.dim}Press Control-C again to exit${c.reset}
|
|
2743
|
-
`);
|
|
2744
|
-
preflightTimer = setTimeout(() => {
|
|
2745
|
-
preflightArmed = false;
|
|
2746
|
-
preflightTimer = null;
|
|
2747
|
-
}, 2e3);
|
|
2748
|
-
};
|
|
2749
|
-
const ask = async (question) => {
|
|
2750
|
-
if (!reader.rl) {
|
|
2751
|
-
reader.rl = readline2.createInterface({ input: stdin, output: stdout });
|
|
2752
|
-
reader.rl.on("SIGINT", onPreflightSigint);
|
|
2753
|
-
reader.rl.on("close", () => {
|
|
2754
|
-
if (handoffClosing) return;
|
|
2755
|
-
farewell();
|
|
2756
|
-
process.exit(0);
|
|
2757
|
-
});
|
|
2758
|
-
}
|
|
2759
|
-
return reader.rl.question(question);
|
|
2760
|
-
};
|
|
2761
|
-
process.on("SIGINT", onPreflightSigint);
|
|
2762
|
-
let endpoint = endpointArg || defaultEndpoint() || "";
|
|
2763
|
-
if (!endpoint) {
|
|
2764
|
-
const answer = await ask(
|
|
2765
|
-
`${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
|
|
2766
|
-
);
|
|
2767
|
-
endpoint = answer.trim();
|
|
2768
|
-
}
|
|
2769
|
-
endpoint = normalizeEndpoint(endpoint);
|
|
2770
|
-
const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
|
|
2771
|
-
printWelcome(endpoint, projectDir);
|
|
2772
|
-
if (tlsRelaxed) {
|
|
2773
|
-
stdout.write(`${c.dim} TLS verification relaxed for local endpoint.${c.reset}
|
|
2774
|
-
|
|
2775
|
-
`);
|
|
2776
|
-
}
|
|
2777
|
-
const stored = getCredential(endpoint);
|
|
2778
|
-
let api = stored ? new ApiClient(endpoint, stored.access_token) : null;
|
|
2779
|
-
if (!api || !await api.verify()) {
|
|
2780
|
-
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2781
|
-
stdout.write(
|
|
2782
|
-
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 paste an API token to connect to${c.reset} ${c.teal}${host}${c.reset}
|
|
2783
|
-
`
|
|
2784
|
-
);
|
|
2785
|
-
stdout.write(`${c.dim}Create one in your instance settings under API tokens.${c.reset}
|
|
2786
|
-
|
|
2787
|
-
`);
|
|
2788
|
-
for (; ; ) {
|
|
2789
|
-
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token${c.reset} `)).trim();
|
|
2790
|
-
if (!token) {
|
|
2791
|
-
stdout.write(`${c.dim}A token is required.${c.reset}
|
|
2792
|
-
`);
|
|
2793
|
-
continue;
|
|
2794
|
-
}
|
|
2795
|
-
api = new ApiClient(endpoint, token);
|
|
2796
|
-
if (await api.verify()) {
|
|
2797
|
-
saveCredential({ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() });
|
|
2798
|
-
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
2799
|
-
`);
|
|
2800
|
-
break;
|
|
2801
|
-
}
|
|
2802
|
-
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
|
|
2803
|
-
`);
|
|
2804
|
-
}
|
|
2805
|
-
}
|
|
2806
|
-
if (!api) process.exit(1);
|
|
2807
|
-
handoffClosing = true;
|
|
2808
|
-
reader.rl?.close();
|
|
2809
|
-
const tags = [`path:${projectDir}`, `machine:${machine}`];
|
|
2810
|
-
let existing = [];
|
|
2811
|
-
try {
|
|
2812
|
-
existing = await api.listThreads(AGENT_ID, tags);
|
|
2813
|
-
} catch {
|
|
2814
|
-
existing = [];
|
|
2815
|
-
}
|
|
2816
|
-
const tui = new Tui(1);
|
|
2817
|
-
let threadId;
|
|
2818
|
-
let resumed = false;
|
|
2819
|
-
if (existing.length > 0) {
|
|
2820
|
-
const summaries = await summarizeThreads(api, existing.slice(0, 8));
|
|
2821
|
-
const items = summaries.map((s) => ({
|
|
2822
|
-
label: s.label,
|
|
2823
|
-
hint: s.hint,
|
|
2824
|
-
value: s.id
|
|
2825
|
-
}));
|
|
2826
|
-
items.push({ label: "\uFF0B Start a new session", value: null });
|
|
2827
|
-
const home = os4.homedir();
|
|
2828
|
-
const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2829
|
-
const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
|
|
2830
|
-
const picked = await tui.select(
|
|
2831
|
-
`${c.bold}${c.magenta}Resume a session${c.reset} ${c.gray}${shortDir}${c.reset} ${c.dim}\u2191\u2193 \xB7 enter \xB7 esc to cancel${c.reset}`,
|
|
2832
|
-
items
|
|
2833
|
-
);
|
|
2834
|
-
if (typeof picked === "string") {
|
|
2835
|
-
threadId = picked;
|
|
2836
|
-
resumed = true;
|
|
2837
|
-
} else {
|
|
2838
|
-
threadId = await api.createThread(AGENT_ID, tags);
|
|
2839
|
-
}
|
|
2840
|
-
} else {
|
|
2841
|
-
threadId = await api.createThread(AGENT_ID, tags);
|
|
2842
|
-
}
|
|
2843
|
-
await runInteractive(tui, api, threadId, projectDir, machine, resumed);
|
|
2844
|
-
}
|
|
2845
|
-
async function summarizeThreads(api, threads) {
|
|
2846
|
-
return Promise.all(
|
|
2847
|
-
threads.map(async (t) => {
|
|
2848
|
-
let preview = "";
|
|
2849
|
-
try {
|
|
2850
|
-
const msgs = await api.getMessages(t.id, 30);
|
|
2851
|
-
const users = msgs.filter((m) => m.role === "user" && typeof m.content === "string" && m.content.trim()).sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
|
2852
|
-
if (users[0]) preview = String(users[0].content).replace(/\s+/g, " ").trim();
|
|
2853
|
-
} catch {
|
|
2854
|
-
}
|
|
2855
|
-
const label = preview ? preview.length > 64 ? preview.slice(0, 63) + "\u2026" : preview : "(empty session)";
|
|
2856
|
-
const when = t.created_at ? relativeTime(t.created_at) : "";
|
|
2857
|
-
const hint = [t.id.slice(0, 8), when].filter(Boolean).join(" \xB7 ");
|
|
2858
|
-
return { id: t.id, label, hint };
|
|
2859
|
-
})
|
|
2860
|
-
);
|
|
2861
|
-
}
|
|
2862
|
-
function subagentLabel(t, titles) {
|
|
2863
|
-
const agentName = (t.agent_name || "").trim();
|
|
2864
|
-
const title = titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
|
|
2865
|
-
const nameTag = (t.tags || []).find((tag) => tag.startsWith("name:"));
|
|
2866
|
-
const tagged = nameTag?.slice("name:".length).trim();
|
|
2867
|
-
return tagged ? `${title} \xB7 ${tagged}` : title;
|
|
2868
|
-
}
|
|
2869
|
-
function openUrl(url) {
|
|
2870
|
-
const platform = process.platform;
|
|
2871
|
-
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
2872
|
-
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2873
|
-
try {
|
|
2874
|
-
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
2875
|
-
child.unref();
|
|
2876
|
-
} catch {
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
function relativeTime(unixSeconds) {
|
|
2880
|
-
const diff = Date.now() / 1e3 - unixSeconds;
|
|
2881
|
-
if (diff < 60) return "just now";
|
|
2882
|
-
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
2883
|
-
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
2884
|
-
return `${Math.floor(diff / 86400)}d ago`;
|
|
2885
|
-
}
|
|
2886
|
-
function hasToolCalls(m) {
|
|
2887
|
-
const tc = m?.tool_calls;
|
|
2888
|
-
if (Array.isArray(tc)) return tc.length > 0;
|
|
2889
|
-
if (typeof tc === "string") {
|
|
2890
|
-
const s = tc.trim();
|
|
2891
|
-
return s.length > 0 && s !== "null" && s !== "[]";
|
|
2892
|
-
}
|
|
2893
|
-
return false;
|
|
2894
|
-
}
|
|
2895
|
-
function messageText(content) {
|
|
2896
|
-
if (typeof content === "string") return content;
|
|
2897
|
-
if (Array.isArray(content)) {
|
|
2898
|
-
return content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
|
|
2899
|
-
}
|
|
2900
|
-
return "";
|
|
2901
|
-
}
|
|
2902
|
-
function threadBusy(msgs) {
|
|
2903
|
-
if (!msgs.length) return false;
|
|
2904
|
-
if (msgs.some((m) => m.status === "pending")) return true;
|
|
2905
|
-
const last = [...msgs].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
|
|
2906
|
-
if (!last) return false;
|
|
2907
|
-
if (last.role === "user" || last.role === "tool") return true;
|
|
2908
|
-
if (last.role === "assistant") return hasToolCalls(last);
|
|
2909
|
-
return false;
|
|
2910
|
-
}
|
|
2911
|
-
async function printHistory(api, threadId, tui) {
|
|
2912
|
-
let msgs;
|
|
2913
|
-
try {
|
|
2914
|
-
msgs = await api.getMessages(threadId, 200);
|
|
2915
|
-
} catch {
|
|
2916
|
-
return;
|
|
2917
|
-
}
|
|
2918
|
-
const convo = msgs.filter((m) => m.role === "user" || m.role === "assistant" && messageText(m.content).trim()).sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
|
2919
|
-
if (!convo.length) return;
|
|
2920
|
-
const shown = convo.slice(-24);
|
|
2921
|
-
tui.print(`${c.dim}\u2500\u2500 resuming session \xB7 ${convo.length} message${convo.length === 1 ? "" : "s"} \u2500\u2500${c.reset}`);
|
|
2922
|
-
if (shown.length < convo.length) tui.print(`${c.dim} \u2026 earlier messages omitted${c.reset}`);
|
|
2923
|
-
for (const m of shown) {
|
|
2924
|
-
const text = messageText(m.content).trim();
|
|
2925
|
-
if (!text) continue;
|
|
2926
|
-
if (m.role === "user") tui.printUserMessage(text);
|
|
2927
|
-
else printAssistant(tui, text);
|
|
2928
|
-
}
|
|
2929
|
-
tui.print(`${c.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.reset}`);
|
|
2930
|
-
}
|
|
2931
|
-
async function runInteractive(tui, api, threadId, projectDir, machine, resumed) {
|
|
2932
|
-
const registry = new ProcessRegistry(api, threadId, machine);
|
|
2933
|
-
const mcp = new McpManager(projectDir);
|
|
2934
|
-
const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
|
|
2935
|
-
});
|
|
2936
|
-
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
|
|
2937
|
-
const refreshBgCount = () => {
|
|
2938
|
-
void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
|
|
2939
|
-
});
|
|
2940
|
-
};
|
|
2941
|
-
const perm = { level: tui.level, alwaysAllow: /* @__PURE__ */ new Set(), allowRisk: /* @__PURE__ */ new Set() };
|
|
2942
|
-
const savedApprovals = await loadApprovals(api, threadId);
|
|
2943
|
-
for (const t of savedApprovals.allowTools) perm.alwaysAllow.add(t);
|
|
2944
|
-
for (const r of savedApprovals.allowRisk) perm.allowRisk.add(r);
|
|
2945
|
-
if (savedApprovals.level) {
|
|
2946
|
-
perm.level = savedApprovals.level;
|
|
2947
|
-
tui.setLevel(savedApprovals.level);
|
|
2948
|
-
}
|
|
2949
|
-
tui.onLevelChange((l) => {
|
|
2950
|
-
perm.level = l;
|
|
2951
|
-
saveApprovals(api, threadId, perm);
|
|
2952
|
-
});
|
|
2953
|
-
let busy = false;
|
|
2954
|
-
let interrupting = false;
|
|
2955
|
-
const queued = [];
|
|
2956
|
-
let editingQueued = false;
|
|
2957
|
-
const shownIds = /* @__PURE__ */ new Set();
|
|
2958
|
-
let tokensIn = 0;
|
|
2959
|
-
let tokensOut = 0;
|
|
2960
|
-
let liveOut = 0;
|
|
2961
|
-
const countedLogs = /* @__PURE__ */ new Set();
|
|
2962
|
-
const activeSteps = /* @__PURE__ */ new Map();
|
|
2963
|
-
const refreshStatus = () => {
|
|
2964
|
-
tui.setTokens(tokensIn, tokensOut + liveOut);
|
|
2965
|
-
let label = null;
|
|
2966
|
-
for (const v of activeSteps.values()) label = v;
|
|
2967
|
-
tui.setStep(label, liveOut);
|
|
2968
|
-
};
|
|
2969
|
-
const bridge = new Bridge(api, threadId, host, perm, {
|
|
2970
|
-
onActivity: (line) => {
|
|
2971
|
-
tui.print(colorActivity(line));
|
|
2972
|
-
refreshBgCount();
|
|
2973
|
-
},
|
|
2974
|
-
onStatus: () => {
|
|
2975
|
-
},
|
|
2976
|
-
// the working indicator is driven by the busy poller
|
|
2977
|
-
onConnection: (state, attempt) => {
|
|
2978
|
-
if (state === "reconnecting") {
|
|
2979
|
-
if (attempt >= 4) tui.setConnected(false);
|
|
2980
|
-
} else {
|
|
2981
|
-
tui.setConnected(true);
|
|
2982
|
-
}
|
|
2983
|
-
},
|
|
2984
|
-
requestApproval: (req, summary, risk) => tui.approval(
|
|
2985
|
-
`${summary}${req.requestPermission ? `
|
|
2986
|
-
${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
2987
|
-
risk
|
|
2988
|
-
)
|
|
2989
|
-
});
|
|
2990
|
-
const stream = new MessageStream(api, threadId, {
|
|
2991
|
-
onChunk: () => {
|
|
2992
|
-
},
|
|
2993
|
-
onAssistantText: () => {
|
|
2994
|
-
},
|
|
2995
|
-
onEvent: (eventType, data) => {
|
|
2996
|
-
if (eventType === "generation" && typeof data?.outputTokens === "number") {
|
|
2997
|
-
liveOut = data.outputTokens;
|
|
2998
|
-
refreshStatus();
|
|
2999
|
-
} else if (eventType === "tool_call_started" && data?.id) {
|
|
3000
|
-
activeSteps.set(data.id, data.progress || data.name || "working");
|
|
3001
|
-
refreshStatus();
|
|
3002
|
-
} else if (eventType === "tool_call_done" && data?.id) {
|
|
3003
|
-
activeSteps.delete(data.id);
|
|
3004
|
-
refreshStatus();
|
|
3005
|
-
}
|
|
3006
|
-
},
|
|
3007
|
-
onError: () => {
|
|
3008
|
-
}
|
|
3009
|
-
});
|
|
3010
|
-
const activeSubagents = /* @__PURE__ */ new Map();
|
|
3011
|
-
const agentTitles = /* @__PURE__ */ new Map();
|
|
3012
|
-
void api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
|
|
3013
|
-
});
|
|
3014
|
-
const pushSubagents = () => tui.setSubagents([...activeSubagents.values()]);
|
|
3015
|
-
const events = new SystemEvents(api, {
|
|
3016
|
-
onThreadCreated: (t) => {
|
|
3017
|
-
if (t.parent === threadId && !t.terminated) {
|
|
3018
|
-
activeSubagents.set(t.id, subagentLabel(t, agentTitles));
|
|
3019
|
-
pushSubagents();
|
|
3020
|
-
}
|
|
3021
|
-
},
|
|
3022
|
-
onThreadUpdated: (t) => {
|
|
3023
|
-
if (t.parent !== threadId) return;
|
|
3024
|
-
if (t.terminated) activeSubagents.delete(t.id);
|
|
3025
|
-
else activeSubagents.set(t.id, subagentLabel(t, agentTitles));
|
|
3026
|
-
pushSubagents();
|
|
3027
|
-
},
|
|
3028
|
-
onThreadDeleted: (id) => {
|
|
3029
|
-
if (activeSubagents.delete(id)) pushSubagents();
|
|
3030
|
-
}
|
|
3031
|
-
});
|
|
3032
|
-
const quit = () => {
|
|
3033
|
-
tui.end();
|
|
3034
|
-
bridge.close();
|
|
3035
|
-
stream.close();
|
|
3036
|
-
events.close();
|
|
3037
|
-
mcp.closeAll();
|
|
3038
|
-
farewell();
|
|
3039
|
-
process.exit(0);
|
|
3040
|
-
};
|
|
3041
|
-
tui.setQuitHandler(quit);
|
|
3042
|
-
const viewThread = () => {
|
|
3043
|
-
const url = `${api.origin}/threads/${threadId}`;
|
|
3044
|
-
openUrl(url);
|
|
3045
|
-
tui.print(`${c.gray}opened ${c.cyan}${url}${c.reset}`);
|
|
3046
|
-
};
|
|
3047
|
-
const bgMgr = {
|
|
3048
|
-
list: () => registry.list(),
|
|
3049
|
-
stop: async (id) => {
|
|
3050
|
-
await host.execute("background_process", { action: "stop", id });
|
|
3051
|
-
refreshBgCount();
|
|
3052
|
-
}
|
|
3053
|
-
};
|
|
3054
|
-
const mcpCtl = {
|
|
3055
|
-
configured: () => listMcpServers(),
|
|
3056
|
-
connectedNames: () => mcp.connectedNames(),
|
|
3057
|
-
catalog: () => mcp.catalog(),
|
|
3058
|
-
connect: async (cfg) => {
|
|
3059
|
-
try {
|
|
3060
|
-
const client = await mcp.connect(cfg);
|
|
3061
|
-
publishMcpCatalog();
|
|
3062
|
-
return { ok: true, tools: client.tools.length };
|
|
3063
|
-
} catch (e) {
|
|
3064
|
-
publishMcpCatalog();
|
|
3065
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
3066
|
-
}
|
|
3067
|
-
},
|
|
3068
|
-
disconnect: (name) => {
|
|
3069
|
-
mcp.disconnect(name);
|
|
3070
|
-
publishMcpCatalog();
|
|
3071
|
-
},
|
|
3072
|
-
add: async (cfg) => {
|
|
3073
|
-
saveMcpServer(cfg);
|
|
3074
|
-
return mcpCtl.connect(cfg);
|
|
3075
|
-
},
|
|
3076
|
-
// Seed the request into the main chat — the agent researches + installs it
|
|
3077
|
-
// there (using research_agent + install_mcp), visible in the transcript.
|
|
3078
|
-
requestInstall: (query) => {
|
|
3079
|
-
void sendNow(
|
|
3080
|
-
`Install an MCP server for me: ${query}. Research the best one and its exact launch command, then install it.`
|
|
3081
|
-
);
|
|
3082
|
-
},
|
|
3083
|
-
remove: (name) => {
|
|
3084
|
-
mcp.disconnect(name);
|
|
3085
|
-
removeMcpServer(name);
|
|
3086
|
-
publishMcpCatalog();
|
|
3087
|
-
},
|
|
3088
|
-
setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
|
|
3089
|
-
};
|
|
3090
|
-
const sendNow = async (text) => {
|
|
3091
|
-
tui.printUserMessage(text);
|
|
3092
|
-
try {
|
|
3093
|
-
await api.sendMessage(threadId, text);
|
|
3094
|
-
} catch (e) {
|
|
3095
|
-
tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
3096
|
-
return;
|
|
3097
|
-
}
|
|
3098
|
-
interrupting = false;
|
|
3099
|
-
busy = true;
|
|
3100
|
-
tui.setWorking(true);
|
|
3101
|
-
};
|
|
3102
|
-
const flushQueued = async () => {
|
|
3103
|
-
if (!queued.length) return;
|
|
3104
|
-
const toSend = queued.splice(0);
|
|
3105
|
-
tui.setQueuedCount(0);
|
|
3106
|
-
for (const t of toSend) await sendNow(t);
|
|
3107
|
-
};
|
|
3108
|
-
const requestCompaction = async () => {
|
|
3109
|
-
try {
|
|
3110
|
-
await api.kvSet(threadId, "compaction_request", { requestedAt: Date.now() });
|
|
3111
|
-
tui.print(
|
|
3112
|
-
`${c.cyan}\u27F3${c.reset} compacting the conversation in the background \u2014 recent messages stay live.`
|
|
3113
|
-
);
|
|
3114
|
-
} catch (err) {
|
|
3115
|
-
tui.print(`${c.red}\u2717${c.reset} couldn't request compaction: ${err.message}`);
|
|
3116
|
-
}
|
|
3117
|
-
};
|
|
3118
|
-
tui.setCommands([
|
|
3119
|
-
{
|
|
3120
|
-
name: "compact",
|
|
3121
|
-
label: "Compact conversation now",
|
|
3122
|
-
hint: () => tui.contextPctLabel() || "free up context",
|
|
3123
|
-
run: requestCompaction
|
|
3124
|
-
},
|
|
3125
|
-
{ name: "level", label: "Auto-accept level", hint: () => `level ${tui.level}`, run: () => runLevelMenu(tui, perm) },
|
|
3126
|
-
{
|
|
3127
|
-
name: "permissions",
|
|
3128
|
-
label: "Approved commands",
|
|
3129
|
-
hint: () => {
|
|
3130
|
-
const n = perm.alwaysAllow.size + perm.allowRisk.size;
|
|
3131
|
-
return n ? `${n} approved` : "none";
|
|
3132
|
-
},
|
|
3133
|
-
run: () => runApprovalsMenu(tui, perm, () => saveApprovals(api, threadId, perm))
|
|
3134
|
-
},
|
|
3135
|
-
{
|
|
3136
|
-
name: "mcp",
|
|
3137
|
-
label: "MCP servers",
|
|
3138
|
-
hint: () => {
|
|
3139
|
-
const n = mcpCtl.connectedNames().length;
|
|
3140
|
-
return n ? `${n} connected` : "none";
|
|
3141
|
-
},
|
|
3142
|
-
run: () => runMcpMenu(tui, mcpCtl)
|
|
3143
|
-
},
|
|
3144
|
-
{ name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
|
|
3145
|
-
{ name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
|
|
3146
|
-
{ name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
|
|
3147
|
-
{ name: "quit", label: "Quit", run: () => quit() }
|
|
3148
|
-
]);
|
|
3149
|
-
tui.onSubmit = (text) => {
|
|
3150
|
-
if (editingQueued) {
|
|
3151
|
-
editingQueued = false;
|
|
3152
|
-
queued.push(text);
|
|
3153
|
-
tui.setQueuedCount(queued.length);
|
|
3154
|
-
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
|
|
3155
|
-
return;
|
|
3156
|
-
}
|
|
3157
|
-
if (busy) {
|
|
3158
|
-
queued.push(text);
|
|
3159
|
-
tui.setQueuedCount(queued.length);
|
|
3160
|
-
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
|
|
3161
|
-
} else {
|
|
3162
|
-
void sendNow(text);
|
|
3163
|
-
}
|
|
3164
|
-
};
|
|
3165
|
-
tui.onInterrupt = () => {
|
|
3166
|
-
if (queued.length > 0) {
|
|
3167
|
-
tui.print(`${c.yellow}\u21AA steering \u2014 stopping current work and sending your message\u2026${c.reset}`);
|
|
3168
|
-
void api.stop(threadId).catch(() => {
|
|
3169
|
-
}).then(() => flushQueued());
|
|
3170
|
-
} else if (busy) {
|
|
3171
|
-
interrupting = true;
|
|
3172
|
-
busy = false;
|
|
3173
|
-
activeSteps.clear();
|
|
3174
|
-
liveOut = 0;
|
|
3175
|
-
tui.setWorking(false);
|
|
3176
|
-
refreshStatus();
|
|
3177
|
-
tui.print(`${c.yellow}[interrupted by user]${c.reset}`);
|
|
3178
|
-
void api.stop(threadId).catch(() => {
|
|
3179
|
-
});
|
|
3180
|
-
}
|
|
3181
|
-
};
|
|
3182
|
-
tui.onUpArrow = () => {
|
|
3183
|
-
if (tui.getInput().trim() || queued.length === 0) return;
|
|
3184
|
-
const text = queued.pop();
|
|
3185
|
-
tui.setQueuedCount(queued.length);
|
|
3186
|
-
editingQueued = true;
|
|
3187
|
-
tui.setInput(text);
|
|
3188
|
-
};
|
|
3189
|
-
events.connect();
|
|
3190
|
-
await Promise.all([bridge.connect(), stream.connect()]);
|
|
3191
|
-
tui.banner([
|
|
3192
|
-
`${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
|
|
3193
|
-
`${c.gray}project:${c.reset} ${projectDir}`,
|
|
3194
|
-
`${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`,
|
|
3195
|
-
`${c.dim}type anytime \xB7 shift-tab cycles auto-accept level \xB7 / for options \xB7 esc interrupts/steers \xB7 ctrl-c quits${c.reset}`
|
|
3196
|
-
]);
|
|
3197
|
-
if (resumed) await printHistory(api, threadId, tui);
|
|
3198
|
-
try {
|
|
3199
|
-
(await api.getMessages(threadId, 200)).forEach((m) => shownIds.add(m.id));
|
|
3200
|
-
} catch {
|
|
3201
|
-
}
|
|
3202
|
-
const runningProcs = (await registry.list()).filter((p) => p.status === "running");
|
|
3203
|
-
if (runningProcs.length) {
|
|
3204
|
-
tui.print(
|
|
3205
|
-
`${c.cyan}\u2699 ${runningProcs.length} background process${runningProcs.length === 1 ? "" : "es"} running:${c.reset}`
|
|
3206
|
-
);
|
|
3207
|
-
for (const p of runningProcs) tui.print(`${c.gray} ${p.id} ${p.description || p.command}${c.reset}`);
|
|
3208
|
-
}
|
|
3209
|
-
refreshBgCount();
|
|
3210
|
-
const enabledServers = listMcpServers().filter((s) => s.enabled);
|
|
3211
|
-
for (const s of enabledServers) {
|
|
3212
|
-
const res = await mcpCtl.connect(s);
|
|
3213
|
-
if (res.ok) {
|
|
3214
|
-
tui.print(`${c.cyan}\u26A1 MCP "${s.name}" connected${c.reset} ${c.gray}(${res.tools} tool${res.tools === 1 ? "" : "s"})${c.reset}`);
|
|
3215
|
-
} else {
|
|
3216
|
-
tui.print(`${c.red}\u26A0 MCP "${s.name}" failed:${c.reset} ${c.gray}${res.error}${c.reset}`);
|
|
3217
|
-
}
|
|
3218
|
-
}
|
|
3219
|
-
publishMcpCatalog();
|
|
3220
|
-
tui.start();
|
|
3221
|
-
const poll = async () => {
|
|
3222
|
-
let msgs;
|
|
3223
|
-
try {
|
|
3224
|
-
msgs = await api.getMessages(threadId, 60);
|
|
3225
|
-
} catch {
|
|
3226
|
-
return;
|
|
3227
|
-
}
|
|
3228
|
-
const sorted = [...msgs].sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
|
3229
|
-
for (const m of sorted) {
|
|
3230
|
-
if (shownIds.has(m.id) || m.status === "pending") continue;
|
|
3231
|
-
shownIds.add(m.id);
|
|
3232
|
-
const text = messageText(m.content).trim();
|
|
3233
|
-
if (m.role === "assistant" && text) printAssistant(tui, text);
|
|
3234
|
-
else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
|
|
3235
|
-
}
|
|
3236
|
-
const polledBusy = threadBusy(msgs);
|
|
3237
|
-
if (interrupting) {
|
|
3238
|
-
if (!polledBusy) interrupting = false;
|
|
3239
|
-
busy = false;
|
|
3240
|
-
} else {
|
|
3241
|
-
busy = polledBusy;
|
|
3242
|
-
}
|
|
3243
|
-
tui.setWorking(busy);
|
|
3244
|
-
if (!busy) {
|
|
3245
|
-
if (activeSteps.size) activeSteps.clear();
|
|
3246
|
-
liveOut = 0;
|
|
3247
|
-
refreshStatus();
|
|
3248
|
-
if (queued.length > 0 && !editingQueued) await flushQueued();
|
|
3249
|
-
}
|
|
3250
|
-
refreshBgCount();
|
|
3251
|
-
try {
|
|
3252
|
-
const logs = await api.getLogs(threadId, 100);
|
|
3253
|
-
let landed = 0;
|
|
3254
|
-
for (const l of logs) {
|
|
3255
|
-
if (!l.is_complete) continue;
|
|
3256
|
-
const id = l.id ?? `${l.created_at}:${l.total_tokens}`;
|
|
3257
|
-
if (countedLogs.has(id)) continue;
|
|
3258
|
-
countedLogs.add(id);
|
|
3259
|
-
const inT = Number(l.input_tokens) || 0;
|
|
3260
|
-
const outT = Number(l.output_tokens) || 0;
|
|
3261
|
-
tokensIn += inT;
|
|
3262
|
-
tokensOut += outT;
|
|
3263
|
-
landed += outT;
|
|
3264
|
-
}
|
|
3265
|
-
if (landed > 0) liveOut = 0;
|
|
3266
|
-
refreshStatus();
|
|
3267
|
-
} catch {
|
|
3268
|
-
}
|
|
3269
|
-
try {
|
|
3270
|
-
const cu = await api.kvGet(threadId, "context_usage");
|
|
3271
|
-
const used = Number(cu?.inputTokens) || 0;
|
|
3272
|
-
const max = Number(cu?.maxContextTokens) || 0;
|
|
3273
|
-
tui.setContextPct(max > 0 && used > 0 ? used / max * 100 : null);
|
|
3274
|
-
} catch {
|
|
3275
|
-
}
|
|
3276
|
-
};
|
|
3277
|
-
setInterval(() => void poll().catch(() => {
|
|
3278
|
-
}), 1200);
|
|
3279
|
-
await new Promise(() => {
|
|
3280
|
-
});
|
|
3281
|
-
}
|
|
3282
|
-
async function runLevelMenu(tui, perm) {
|
|
3283
|
-
const picked = await tui.select(
|
|
3284
|
-
`${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
|
|
3285
|
-
LEVELS.map((l) => ({
|
|
3286
|
-
label: levelLabel(l),
|
|
3287
|
-
hint: l === tui.level ? "current" : "",
|
|
3288
|
-
value: l
|
|
3289
|
-
}))
|
|
3290
|
-
);
|
|
3291
|
-
if (picked) {
|
|
3292
|
-
tui.setLevel(picked);
|
|
3293
|
-
perm.level = picked;
|
|
3294
|
-
}
|
|
3295
|
-
}
|
|
3296
|
-
function showKeybindings(tui) {
|
|
3297
|
-
tui.print(`${c.gray}shortcuts:${c.reset}`);
|
|
3298
|
-
tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
|
|
3299
|
-
tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
|
|
3300
|
-
tui.print(`${c.gray} ctrl-c${c.reset} quit`);
|
|
3301
|
-
}
|
|
3302
|
-
async function runProcessMenu(tui, bg) {
|
|
3303
|
-
const procs = await bg.list();
|
|
3304
|
-
if (!procs.length) {
|
|
3305
|
-
tui.print(`${c.gray}No background processes for this session.${c.reset}`);
|
|
3306
|
-
return;
|
|
3307
|
-
}
|
|
3308
|
-
const items = procs.map((p) => {
|
|
3309
|
-
const status = p.status === "running" ? `${c.green}running${c.reset}` : `${c.gray}${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}${c.reset}`;
|
|
3310
|
-
return {
|
|
3311
|
-
label: `${p.description || p.command}`,
|
|
3312
|
-
hint: `${p.id} \xB7 ${status}`,
|
|
3313
|
-
value: p.id
|
|
3314
|
-
};
|
|
3315
|
-
});
|
|
3316
|
-
const picked = await tui.select(
|
|
3317
|
-
`${c.bold}Background processes${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter to manage \xB7 esc to close)${c.reset}`,
|
|
3318
|
-
items
|
|
3319
|
-
);
|
|
3320
|
-
if (!picked) return;
|
|
3321
|
-
const proc = procs.find((p) => p.id === picked);
|
|
3322
|
-
if (!proc || proc.status !== "running") {
|
|
3323
|
-
tui.print(`${c.gray}${picked} is not running.${c.reset}`);
|
|
3324
|
-
return;
|
|
3325
|
-
}
|
|
3326
|
-
const action = await tui.select(`${c.bold}${proc.description || proc.command}${c.reset}`, [
|
|
3327
|
-
{ label: "Stop this process", value: "stop" },
|
|
3328
|
-
{ label: "Leave it running", value: "leave" }
|
|
3329
|
-
]);
|
|
3330
|
-
if (action === "stop") {
|
|
3331
|
-
await bg.stop(picked);
|
|
3332
|
-
tui.print(`${c.gray}stopped ${picked}${c.reset}`);
|
|
3333
|
-
}
|
|
3334
|
-
}
|
|
3335
|
-
async function runApprovalsMenu(tui, perm, save) {
|
|
3336
|
-
const tools = Array.from(perm.alwaysAllow).sort();
|
|
3337
|
-
const risks = Array.from(perm.allowRisk).sort((a, b) => a - b);
|
|
3338
|
-
if (!tools.length && !risks.length) {
|
|
3339
|
-
tui.print(
|
|
3340
|
-
`${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}`
|
|
3341
|
-
);
|
|
3342
|
-
return;
|
|
3343
|
-
}
|
|
3344
|
-
const items = [
|
|
3345
|
-
...tools.map((t) => ({ label: `Tool: ${t}`, hint: "always allowed", value: `tool:${t}` })),
|
|
3346
|
-
...risks.map((r) => ({ label: `All level ${r} risk`, hint: "always allowed", value: `risk:${r}` })),
|
|
3347
|
-
{ label: "Clear all approvals", hint: "", value: "clear" }
|
|
3348
|
-
];
|
|
3349
|
-
const picked = await tui.select(
|
|
3350
|
-
`${c.bold}Approved commands${c.reset} ${c.dim}(enter to revoke \xB7 esc to close)${c.reset}`,
|
|
3351
|
-
items
|
|
3352
|
-
);
|
|
3353
|
-
if (!picked) return;
|
|
3354
|
-
if (picked === "clear") {
|
|
3355
|
-
perm.alwaysAllow.clear();
|
|
3356
|
-
perm.allowRisk.clear();
|
|
3357
|
-
tui.print(`${c.gray}cleared all approvals${c.reset}`);
|
|
3358
|
-
} else if (picked.startsWith("tool:")) {
|
|
3359
|
-
const t = picked.slice(5);
|
|
3360
|
-
perm.alwaysAllow.delete(t);
|
|
3361
|
-
tui.print(`${c.gray}revoked tool ${t}${c.reset}`);
|
|
3362
|
-
} else if (picked.startsWith("risk:")) {
|
|
3363
|
-
const r = Number(picked.slice(5));
|
|
3364
|
-
perm.allowRisk.delete(r);
|
|
3365
|
-
tui.print(`${c.gray}revoked level ${r}${c.reset}`);
|
|
3366
|
-
}
|
|
3367
|
-
save();
|
|
3368
|
-
}
|
|
3369
|
-
async function runMcpMenu(tui, mcp) {
|
|
3370
|
-
const configured = mcp.configured();
|
|
3371
|
-
const connected = new Set(mcp.connectedNames());
|
|
3372
|
-
const cat = mcp.catalog();
|
|
3373
|
-
const INSTALL = "__install__";
|
|
3374
|
-
const ADD_MANUAL = "__manual__";
|
|
3375
|
-
const items = configured.map((s) => {
|
|
3376
|
-
const entry = cat.servers.find((e) => e.name === s.name);
|
|
3377
|
-
const status = !s.enabled ? "disabled" : connected.has(s.name) ? `connected \xB7 ${entry?.tools.length ?? 0} tools` : entry?.error ? "error" : "disconnected";
|
|
3378
|
-
return { label: s.name, hint: status, value: s.name };
|
|
3379
|
-
});
|
|
3380
|
-
items.push({ label: "\uFF0B Install a new MCP server\u2026", hint: "find & install", value: INSTALL });
|
|
3381
|
-
items.push({ label: "Add manually (name: command)\u2026", hint: "advanced", value: ADD_MANUAL });
|
|
3382
|
-
const picked = await tui.select(
|
|
3383
|
-
`${c.bold}MCP servers${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
|
|
3384
|
-
items
|
|
3385
|
-
);
|
|
3386
|
-
if (!picked) return;
|
|
3387
|
-
if (picked === INSTALL) {
|
|
3388
|
-
await installMcpServerFlow(tui, mcp);
|
|
3389
|
-
return;
|
|
3390
|
-
}
|
|
3391
|
-
if (picked === ADD_MANUAL) {
|
|
3392
|
-
await addMcpServer(tui, mcp);
|
|
3393
|
-
return;
|
|
3394
|
-
}
|
|
3395
|
-
const server = configured.find((s) => s.name === picked);
|
|
3396
|
-
const isConnected = connected.has(picked);
|
|
3397
|
-
const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
|
|
3398
|
-
{ label: "View tools", value: "tools" },
|
|
3399
|
-
isConnected ? { label: "Disconnect", value: "disconnect" } : { label: "Connect", value: "connect" },
|
|
3400
|
-
server.enabled ? { label: "Disable (don't auto-connect)", value: "disable" } : { label: "Enable (auto-connect on start)", value: "enable" },
|
|
3401
|
-
{ label: "Remove this server", value: "remove" },
|
|
3402
|
-
{ label: "Back", value: "back" }
|
|
3403
|
-
]);
|
|
3404
|
-
if (action === "tools") {
|
|
3405
|
-
const entry = mcp.catalog().servers.find((e) => e.name === picked);
|
|
3406
|
-
if (!entry || entry.status !== "connected") {
|
|
3407
|
-
tui.print(`${c.gray}${picked} is not connected \u2014 connect it to list tools.${c.reset}`);
|
|
3408
|
-
return;
|
|
3409
|
-
}
|
|
3410
|
-
if (!entry.tools.length) tui.print(`${c.gray}${picked} exposes no tools.${c.reset}`);
|
|
3411
|
-
for (const t of entry.tools) tui.print(` ${c.cyan}${t.name}${c.reset}${t.description ? ` ${c.gray}\u2014 ${t.description}${c.reset}` : ""}`);
|
|
3412
|
-
if (entry.resources.length) tui.print(` ${c.gray}${entry.resources.length} resource(s)${c.reset}`);
|
|
3413
|
-
} else if (action === "connect") {
|
|
3414
|
-
const res = await mcp.connect(server);
|
|
3415
|
-
tui.print(res.ok ? `${c.cyan}\u26A1 connected (${res.tools} tools)${c.reset}` : `${c.red}\u26A0 ${res.error}${c.reset}`);
|
|
3416
|
-
} else if (action === "disconnect") {
|
|
3417
|
-
mcp.disconnect(picked);
|
|
3418
|
-
tui.print(`${c.gray}disconnected ${picked}${c.reset}`);
|
|
3419
|
-
} else if (action === "enable") {
|
|
3420
|
-
mcp.setEnabled(picked, true);
|
|
3421
|
-
const res = await mcp.connect(server);
|
|
3422
|
-
tui.print(res.ok ? `${c.cyan}\u26A1 enabled + connected (${res.tools} tools)${c.reset}` : `${c.red}\u26A0 enabled but failed: ${res.error}${c.reset}`);
|
|
3423
|
-
} else if (action === "disable") {
|
|
3424
|
-
mcp.setEnabled(picked, false);
|
|
3425
|
-
mcp.disconnect(picked);
|
|
3426
|
-
tui.print(`${c.gray}disabled + disconnected ${picked}${c.reset}`);
|
|
3427
|
-
} else if (action === "remove") {
|
|
3428
|
-
mcp.remove(picked);
|
|
3429
|
-
tui.print(`${c.gray}removed ${picked}${c.reset}`);
|
|
3430
|
-
}
|
|
3431
|
-
}
|
|
3432
|
-
async function addMcpServer(tui, mcp) {
|
|
3433
|
-
const spec = await tui.prompt(
|
|
3434
|
-
"Add an MCP server \u2014 enter name: command [args\u2026]",
|
|
3435
|
-
"fs: npx -y @modelcontextprotocol/server-filesystem ."
|
|
3436
|
-
);
|
|
3437
|
-
if (!spec) return;
|
|
3438
|
-
const cfg = parseServerSpec(spec);
|
|
3439
|
-
if (!cfg) {
|
|
3440
|
-
tui.print(`${c.yellow}couldn't parse that. Use name: command [args]${c.reset}`);
|
|
3441
|
-
return;
|
|
3442
|
-
}
|
|
3443
|
-
tui.print(`${c.gray}connecting MCP "${cfg.name}" (${cfg.command} ${cfg.args.join(" ")})\u2026${c.reset}`);
|
|
3444
|
-
const res = await mcp.add(cfg);
|
|
3445
|
-
if (res.ok) tui.print(`${c.cyan}\u26A1 MCP "${cfg.name}" connected${c.reset} ${c.gray}(${res.tools} tool${res.tools === 1 ? "" : "s"})${c.reset}`);
|
|
3446
|
-
else tui.print(`${c.red}\u26A0 MCP "${cfg.name}" failed:${c.reset} ${c.gray}${res.error}${c.reset} ${c.dim}(saved; retry from the MCP menu)${c.reset}`);
|
|
3447
|
-
}
|
|
3448
|
-
async function installMcpServerFlow(tui, mcp) {
|
|
3449
|
-
const query = await tui.prompt(
|
|
3450
|
-
"What MCP server do you want to install?",
|
|
3451
|
-
"the best computer-use mcp server for mac"
|
|
3452
|
-
);
|
|
3453
|
-
if (!query) return;
|
|
3454
|
-
mcp.requestInstall(query);
|
|
3455
|
-
}
|
|
3456
|
-
main().catch((err) => {
|
|
3457
|
-
process.stderr.write(`
|
|
3458
|
-
${err instanceof Error ? err.stack || err.message : String(err)}
|
|
3459
|
-
`);
|
|
3460
|
-
process.exit(1);
|
|
3461
|
-
});
|
|
3462
|
-
//# sourceMappingURL=index.js.map
|
|
3463
|
-
//# sourceMappingURL=index.js.map
|