@standardagents/code 0.0.2-dev.b3cdaaf → 0.1.1
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 +19 -3
- package/dist/index.js +4811 -0
- package/dist/index.js.map +1 -0
- package/package.json +6 -5
- package/bin/standardcode.mjs +0 -25
- package/src/api.ts +0 -169
- package/src/approvals.ts +0 -42
- package/src/bridge.ts +0 -303
- package/src/credentials.ts +0 -49
- package/src/events-stream.ts +0 -99
- package/src/host-tools.ts +0 -570
- package/src/index.ts +0 -1152
- package/src/markdown.ts +0 -226
- package/src/mcp-config.ts +0 -137
- package/src/mcp.ts +0 -563
- package/src/permissions.ts +0 -53
- package/src/process-registry.ts +0 -122
- package/src/stream.ts +0 -134
- package/src/tui.ts +0 -911
- package/src/types.ts +0 -78
package/dist/index.js
ADDED
|
@@ -0,0 +1,4811 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os6 from 'os';
|
|
3
|
+
import fs4 from 'fs';
|
|
4
|
+
import path3 from 'path';
|
|
5
|
+
import readline2 from 'readline/promises';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
|
+
import { stdout, stdin } from 'process';
|
|
8
|
+
import fsp from 'fs/promises';
|
|
9
|
+
import crypto 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
|
+
/**
|
|
87
|
+
* Subagent child threads of a thread, each with its current lifecycle status
|
|
88
|
+
* (`running`/`idle`/`terminated`). Sourced from the parent's child registry,
|
|
89
|
+
* which the live `/api/events` stream doesn't carry — so the CLI reads this to
|
|
90
|
+
* learn which subagents are *actually running* (vs idle or finished), both on
|
|
91
|
+
* (re)connect and whenever a subagent thread changes.
|
|
92
|
+
*/
|
|
93
|
+
async listSubagents(threadId) {
|
|
94
|
+
const res = await this.json(
|
|
95
|
+
`/api/threads/${threadId}/subagents`
|
|
96
|
+
);
|
|
97
|
+
const arr = Array.isArray(res) ? res : res.subagents || [];
|
|
98
|
+
return arr.filter((s) => s && (s.id ?? s.reference)).map((s) => ({
|
|
99
|
+
id: s.id ?? s.reference,
|
|
100
|
+
agent_name: s.agent_name ?? s.name ?? null,
|
|
101
|
+
title: s.title ?? null,
|
|
102
|
+
threadName: s.threadName ?? s.thread_name ?? null,
|
|
103
|
+
status: typeof s.status === "string" ? s.status : "running"
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Send a user message, optionally with attachments (e.g. pasted images).
|
|
108
|
+
* Attachments use the instance's message-POST shape — base64 `data` +
|
|
109
|
+
* `mimeType` — which the server stores in the thread filesystem and injects
|
|
110
|
+
* into the LLM's vision context as real image content blocks.
|
|
111
|
+
*/
|
|
112
|
+
async sendMessage(threadId, content, attachments) {
|
|
113
|
+
const body = { role: "user", content };
|
|
114
|
+
if (attachments && attachments.length > 0) body.attachments = attachments;
|
|
115
|
+
await this.json(`/api/threads/${threadId}/messages`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: JSON.stringify(body)
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async getMessages(threadId, limit = 50) {
|
|
121
|
+
const res = await this.json(
|
|
122
|
+
`/api/threads/${threadId}/messages?limit=${limit}`
|
|
123
|
+
);
|
|
124
|
+
return Array.isArray(res) ? res : res.messages || [];
|
|
125
|
+
}
|
|
126
|
+
async getLogs(threadId, limit = 100) {
|
|
127
|
+
const res = await this.json(
|
|
128
|
+
`/api/threads/${threadId}/logs?limit=${limit}&order=desc`
|
|
129
|
+
);
|
|
130
|
+
return Array.isArray(res) ? res : res.logs || [];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Deliver a durable forwarded tool result to the thread, resuming the turn.
|
|
134
|
+
* Retries with backoff — this is the durable delivery path, so it must land
|
|
135
|
+
* even if the connection is briefly flaky after a permission wait.
|
|
136
|
+
*/
|
|
137
|
+
async postToolResult(threadId, toolCallId, ok, result, error) {
|
|
138
|
+
const body = JSON.stringify({ tool_call_id: toolCallId, ok, result, error });
|
|
139
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
await this.json(`/api/threads/${threadId}/tool-result`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 8e3)));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Download raw file bytes from the thread's filesystem (e.g. an
|
|
155
|
+
* /attachments/* asset a subagent generated). Returns null on any failure.
|
|
156
|
+
*/
|
|
157
|
+
async fetchFile(threadId, fsPath) {
|
|
158
|
+
const clean2 = fsPath.startsWith("/") ? fsPath : `/${fsPath}`;
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(`${this.endpoint}/api/threads/${threadId}/fs${clean2}`, {
|
|
161
|
+
headers: { Authorization: `Bearer ${this.token}` }
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) return null;
|
|
164
|
+
return Buffer.from(await res.arrayBuffer());
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Read a value from the thread's durable KV store (null if absent). */
|
|
170
|
+
async kvGet(threadId, key) {
|
|
171
|
+
try {
|
|
172
|
+
const res = await this.json(
|
|
173
|
+
`/api/threads/${threadId}/kv?key=${encodeURIComponent(key)}`
|
|
174
|
+
);
|
|
175
|
+
return res?.value ?? null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Write a value to the thread's durable KV store. */
|
|
181
|
+
async kvSet(threadId, key, value) {
|
|
182
|
+
try {
|
|
183
|
+
await this.json(`/api/threads/${threadId}/kv`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ key, value })
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── skills (instance-global Agent Skills library) ─────────────────────────
|
|
192
|
+
/** Installed skills — metadata only. Includes disabled skills. */
|
|
193
|
+
async listSkills() {
|
|
194
|
+
const res = await this.json(`/api/skills?all=1`);
|
|
195
|
+
return Array.isArray(res?.skills) ? res.skills : [];
|
|
196
|
+
}
|
|
197
|
+
/** Enable/disable an installed skill. */
|
|
198
|
+
async setSkillEnabled(name, enabled) {
|
|
199
|
+
await this.json(`/api/skills/${encodeURIComponent(name)}`, {
|
|
200
|
+
method: "PATCH",
|
|
201
|
+
body: JSON.stringify({ enabled })
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Uninstall a skill. */
|
|
205
|
+
async removeSkill(name) {
|
|
206
|
+
await this.json(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Kick off background conversation compaction now. Spawns the compaction
|
|
210
|
+
* subagent directly server-side (so it fires even while the thread is idle).
|
|
211
|
+
*/
|
|
212
|
+
async compact(threadId) {
|
|
213
|
+
await this.json(`/api/threads/${threadId}/compact`, { method: "POST" });
|
|
214
|
+
}
|
|
215
|
+
async stop(threadId) {
|
|
216
|
+
try {
|
|
217
|
+
await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** The thread's current goal (set via set_goal / update_goal_step). */
|
|
222
|
+
async getGoal(threadId) {
|
|
223
|
+
try {
|
|
224
|
+
const r = await this.json(`/api/threads/${threadId}/goal`);
|
|
225
|
+
return {
|
|
226
|
+
summary: r?.summary ?? null,
|
|
227
|
+
description: r?.description ?? null,
|
|
228
|
+
steps: Array.isArray(r?.steps) ? r.steps : []
|
|
229
|
+
};
|
|
230
|
+
} catch {
|
|
231
|
+
return { summary: null, description: null, steps: [] };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/permissions.ts
|
|
237
|
+
function decide(state, tool, risk, hasPermissionRequest) {
|
|
238
|
+
const effectiveRisk = typeof risk === "number" ? Math.min(5, Math.max(1, risk)) : hasPermissionRequest ? 3 : 1;
|
|
239
|
+
if (state.alwaysAllow.has(tool)) return "allow";
|
|
240
|
+
if (state.allowRisk.has(effectiveRisk)) return "allow";
|
|
241
|
+
return effectiveRisk <= state.level ? "allow" : "ask";
|
|
242
|
+
}
|
|
243
|
+
var CATASTROPHIC_PATTERNS = [
|
|
244
|
+
/\brm\s+(-[a-z]*\s+)*-[a-z]*f[a-z]*\s+(-[a-z]*\s+)*(\/|~|\$HOME|\/\*|\.\s*$|\/\s*$)/i,
|
|
245
|
+
// rm -rf / , rm -rf ~
|
|
246
|
+
/\brm\s+-rf\s+--no-preserve-root/i,
|
|
247
|
+
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
|
248
|
+
// fork bomb
|
|
249
|
+
/\bmkfs(\.\w+)?\b/i,
|
|
250
|
+
// format filesystem
|
|
251
|
+
/\bdd\b[^\n]*\bof=\/dev\/(sd|disk|nvme|hd)/i,
|
|
252
|
+
// overwrite raw disk
|
|
253
|
+
/\b(shutdown|reboot|halt|poweroff)\b/i,
|
|
254
|
+
/>\s*\/dev\/(sd|disk|nvme|hd)/i,
|
|
255
|
+
/\bchmod\s+-R\s+(000|777)\s+\/(?:\s|$)/i
|
|
256
|
+
];
|
|
257
|
+
function isCatastrophic(command) {
|
|
258
|
+
return CATASTROPHIC_PATTERNS.some((re) => re.test(command));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/approvals.ts
|
|
262
|
+
var KEY = "approvals";
|
|
263
|
+
async function loadApprovals(api, threadId) {
|
|
264
|
+
const v = await api.kvGet(threadId, KEY);
|
|
265
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
266
|
+
const o = v;
|
|
267
|
+
const n = Number(o.level);
|
|
268
|
+
const level = n >= 1 && n <= 5 ? n : void 0;
|
|
269
|
+
return {
|
|
270
|
+
level,
|
|
271
|
+
allowTools: Array.isArray(o.allowTools) ? o.allowTools : [],
|
|
272
|
+
allowRisk: Array.isArray(o.allowRisk) ? o.allowRisk : []
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { allowTools: [], allowRisk: [] };
|
|
276
|
+
}
|
|
277
|
+
function saveApprovals(api, threadId, perm) {
|
|
278
|
+
const payload = {
|
|
279
|
+
level: perm.level,
|
|
280
|
+
allowTools: Array.from(perm.alwaysAllow).sort(),
|
|
281
|
+
allowRisk: Array.from(perm.allowRisk).sort((a, b) => a - b)
|
|
282
|
+
};
|
|
283
|
+
void api.kvSet(threadId, KEY, payload);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/heartbeat.ts
|
|
287
|
+
var HEARTBEAT_INTERVAL_MS = 5e3;
|
|
288
|
+
var CONNECTION_SILENCE_TIMEOUT_MS = 15e3;
|
|
289
|
+
var Heartbeat = class {
|
|
290
|
+
constructor(ws, onDead, options = {}) {
|
|
291
|
+
this.ws = ws;
|
|
292
|
+
this.onDead = onDead;
|
|
293
|
+
this.intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
294
|
+
this.silenceMs = options.silenceMs ?? CONNECTION_SILENCE_TIMEOUT_MS;
|
|
295
|
+
}
|
|
296
|
+
ws;
|
|
297
|
+
onDead;
|
|
298
|
+
timer = null;
|
|
299
|
+
lastRecvAt = 0;
|
|
300
|
+
intervalMs;
|
|
301
|
+
silenceMs;
|
|
302
|
+
start() {
|
|
303
|
+
this.stop();
|
|
304
|
+
this.lastRecvAt = Date.now();
|
|
305
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
306
|
+
}
|
|
307
|
+
/** Record that a frame was received — proof the connection is alive. */
|
|
308
|
+
markAlive() {
|
|
309
|
+
this.lastRecvAt = Date.now();
|
|
310
|
+
}
|
|
311
|
+
stop() {
|
|
312
|
+
if (this.timer) {
|
|
313
|
+
clearInterval(this.timer);
|
|
314
|
+
this.timer = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
tick() {
|
|
318
|
+
if (Date.now() - this.lastRecvAt > this.silenceMs) {
|
|
319
|
+
this.fail();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (this.ws.readyState === WebSocket.OPEN) this.ws.send("ping");
|
|
324
|
+
else this.fail();
|
|
325
|
+
} catch {
|
|
326
|
+
this.fail();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
fail() {
|
|
330
|
+
this.stop();
|
|
331
|
+
try {
|
|
332
|
+
this.ws.close();
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
this.onDead();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/render.ts
|
|
340
|
+
var RESET = "\x1B[0m";
|
|
341
|
+
var DIM = "\x1B[2m";
|
|
342
|
+
var ADD_BG = "\x1B[48;5;22m\x1B[38;5;254m";
|
|
343
|
+
var DEL_BG = "\x1B[48;5;52m\x1B[38;5;254m";
|
|
344
|
+
var MAX_SIDE_LINES = 4;
|
|
345
|
+
function clamp(s, max) {
|
|
346
|
+
return s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
347
|
+
}
|
|
348
|
+
function cols() {
|
|
349
|
+
return Math.max(20, process.stdout.columns || 80);
|
|
350
|
+
}
|
|
351
|
+
function flat(line) {
|
|
352
|
+
return line.replace(/\t/g, " ");
|
|
353
|
+
}
|
|
354
|
+
function diffLines(oldStr, newStr) {
|
|
355
|
+
let oldLines = oldStr.split("\n");
|
|
356
|
+
let newLines = newStr.split("\n");
|
|
357
|
+
while (oldLines.length && newLines.length && oldLines[0] === newLines[0]) {
|
|
358
|
+
oldLines.shift();
|
|
359
|
+
newLines.shift();
|
|
360
|
+
}
|
|
361
|
+
while (oldLines.length && newLines.length && oldLines[oldLines.length - 1] === newLines[newLines.length - 1]) {
|
|
362
|
+
oldLines.pop();
|
|
363
|
+
newLines.pop();
|
|
364
|
+
}
|
|
365
|
+
const width = cols() - 2;
|
|
366
|
+
const out = [];
|
|
367
|
+
const side = (lines, bg, sign) => {
|
|
368
|
+
const shown = lines.slice(0, MAX_SIDE_LINES);
|
|
369
|
+
for (const l of shown) out.push(`${bg}${sign} ${clamp(flat(l), width)}${RESET}`);
|
|
370
|
+
const hidden = lines.length - shown.length;
|
|
371
|
+
if (hidden > 0) out.push(`${DIM} \u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${RESET}`);
|
|
372
|
+
};
|
|
373
|
+
side(oldLines, DEL_BG, "-");
|
|
374
|
+
side(newLines, ADD_BG, "+");
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
function newFileLines(content) {
|
|
378
|
+
const lines = content.split("\n");
|
|
379
|
+
const width = cols() - 2;
|
|
380
|
+
const shown = lines.slice(0, MAX_SIDE_LINES);
|
|
381
|
+
const out = shown.map((l) => `${ADD_BG}+ ${clamp(flat(l), width)}${RESET}`);
|
|
382
|
+
const hidden = lines.length - shown.length;
|
|
383
|
+
if (hidden > 0) out.push(`${DIM} \u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${RESET}`);
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
function highlightBash(cmd) {
|
|
387
|
+
const CYAN2 = "\x1B[36m";
|
|
388
|
+
const GREEN = "\x1B[32m";
|
|
389
|
+
const MAGENTA = "\x1B[35m";
|
|
390
|
+
let out = "";
|
|
391
|
+
let i = 0;
|
|
392
|
+
let expectProgram = true;
|
|
393
|
+
while (i < cmd.length) {
|
|
394
|
+
const ch = cmd[i];
|
|
395
|
+
if (ch === "'" || ch === '"') {
|
|
396
|
+
let j = i + 1;
|
|
397
|
+
while (j < cmd.length && cmd[j] !== ch) {
|
|
398
|
+
if (ch === '"' && cmd[j] === "\\") j++;
|
|
399
|
+
j++;
|
|
400
|
+
}
|
|
401
|
+
out += `${GREEN}${cmd.slice(i, Math.min(j + 1, cmd.length))}${RESET}`;
|
|
402
|
+
i = j + 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (ch === "#") {
|
|
406
|
+
out += `${DIM}${cmd.slice(i)}${RESET}`;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
const op = cmd.slice(i).match(/^(\|\||&&|\||;|>>|>|<)/);
|
|
410
|
+
if (op) {
|
|
411
|
+
out += `${MAGENTA}${op[1]}${RESET}`;
|
|
412
|
+
i += op[1].length;
|
|
413
|
+
expectProgram = true;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const word = cmd.slice(i).match(/^[^\s'"#|;&<>]+/);
|
|
417
|
+
if (word) {
|
|
418
|
+
const w = word[0];
|
|
419
|
+
if (w.startsWith("-")) out += `${DIM}${w}${RESET}`;
|
|
420
|
+
else if (expectProgram) {
|
|
421
|
+
out += `${CYAN2}${w}${RESET}`;
|
|
422
|
+
expectProgram = false;
|
|
423
|
+
} else out += w;
|
|
424
|
+
i += w.length;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
out += ch;
|
|
428
|
+
i++;
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/bridge.ts
|
|
434
|
+
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "grep", "glob", "write_file", "edit_file"]);
|
|
435
|
+
var Bridge = class {
|
|
436
|
+
constructor(api, threadId, host, perm, hooks) {
|
|
437
|
+
this.api = api;
|
|
438
|
+
this.threadId = threadId;
|
|
439
|
+
this.host = host;
|
|
440
|
+
this.perm = perm;
|
|
441
|
+
this.hooks = hooks;
|
|
442
|
+
}
|
|
443
|
+
api;
|
|
444
|
+
threadId;
|
|
445
|
+
host;
|
|
446
|
+
perm;
|
|
447
|
+
hooks;
|
|
448
|
+
ws = null;
|
|
449
|
+
closed = false;
|
|
450
|
+
heartbeat = null;
|
|
451
|
+
reconnectAttempt = 0;
|
|
452
|
+
reconnectTimer = null;
|
|
453
|
+
resolveConnected = null;
|
|
454
|
+
// Durable forwarded calls we've started handling, so a server re-send (after a
|
|
455
|
+
// reconnect) doesn't prompt or run them twice.
|
|
456
|
+
handledDurable = /* @__PURE__ */ new Set();
|
|
457
|
+
/**
|
|
458
|
+
* Connect and keep the bridge connected. Resolves on the first successful
|
|
459
|
+
* open; thereafter any drop is reconnected automatically with exponential
|
|
460
|
+
* backoff (disconnections are expected — e.g. a dev-server reload — so this
|
|
461
|
+
* must be rock solid). A short safety timeout resolves startup even if the
|
|
462
|
+
* very first attempt is slow, since reconnection continues in the background.
|
|
463
|
+
*/
|
|
464
|
+
connect() {
|
|
465
|
+
return new Promise((resolve) => {
|
|
466
|
+
let settled = false;
|
|
467
|
+
this.resolveConnected = () => {
|
|
468
|
+
if (!settled) {
|
|
469
|
+
settled = true;
|
|
470
|
+
resolve();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
setTimeout(() => this.resolveConnected?.(), 8e3);
|
|
474
|
+
this.openSocket();
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
openSocket() {
|
|
478
|
+
if (this.closed) return;
|
|
479
|
+
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/bridge?token=${encodeURIComponent(this.api.bearer)}`;
|
|
480
|
+
let ws;
|
|
481
|
+
try {
|
|
482
|
+
ws = new WebSocket(url);
|
|
483
|
+
} catch {
|
|
484
|
+
this.scheduleReconnect();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.ws = ws;
|
|
488
|
+
ws.addEventListener("open", () => {
|
|
489
|
+
const wasReconnecting = this.reconnectAttempt > 0;
|
|
490
|
+
this.reconnectAttempt = 0;
|
|
491
|
+
this.startHeartbeat(ws);
|
|
492
|
+
this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
|
|
493
|
+
this.resolveConnected?.();
|
|
494
|
+
});
|
|
495
|
+
ws.addEventListener("message", (ev) => {
|
|
496
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
497
|
+
this.onMessage(String(ev.data));
|
|
498
|
+
});
|
|
499
|
+
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
500
|
+
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
501
|
+
}
|
|
502
|
+
handleDrop(ws) {
|
|
503
|
+
if (this.ws !== ws) return;
|
|
504
|
+
this.ws = null;
|
|
505
|
+
this.stopHeartbeat();
|
|
506
|
+
this.scheduleReconnect();
|
|
507
|
+
}
|
|
508
|
+
scheduleReconnect() {
|
|
509
|
+
if (this.closed || this.reconnectTimer) return;
|
|
510
|
+
this.reconnectAttempt++;
|
|
511
|
+
this.hooks.onConnection?.("reconnecting", this.reconnectAttempt);
|
|
512
|
+
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
513
|
+
const delay = base + Math.floor(Math.random() * 400);
|
|
514
|
+
this.reconnectTimer = setTimeout(() => {
|
|
515
|
+
this.reconnectTimer = null;
|
|
516
|
+
this.openSocket();
|
|
517
|
+
}, delay);
|
|
518
|
+
}
|
|
519
|
+
startHeartbeat(ws) {
|
|
520
|
+
this.stopHeartbeat();
|
|
521
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
522
|
+
this.heartbeat.start();
|
|
523
|
+
}
|
|
524
|
+
stopHeartbeat() {
|
|
525
|
+
if (this.heartbeat) {
|
|
526
|
+
this.heartbeat.stop();
|
|
527
|
+
this.heartbeat = null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
close() {
|
|
531
|
+
this.closed = true;
|
|
532
|
+
this.stopHeartbeat();
|
|
533
|
+
if (this.reconnectTimer) {
|
|
534
|
+
clearTimeout(this.reconnectTimer);
|
|
535
|
+
this.reconnectTimer = null;
|
|
536
|
+
}
|
|
537
|
+
this.ws?.close();
|
|
538
|
+
}
|
|
539
|
+
send(payload) {
|
|
540
|
+
try {
|
|
541
|
+
this.ws?.send(JSON.stringify(payload));
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async onMessage(raw) {
|
|
546
|
+
let msg;
|
|
547
|
+
try {
|
|
548
|
+
msg = JSON.parse(raw);
|
|
549
|
+
} catch {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (msg.type !== "tool_request") return;
|
|
553
|
+
const req = msg;
|
|
554
|
+
await this.handleToolRequest(req);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Reply to a tool request. Durable calls (the agent parked them) deliver the
|
|
558
|
+
* result over HTTP so it lands even if this socket later drops; legacy calls
|
|
559
|
+
* reply over the WebSocket.
|
|
560
|
+
*/
|
|
561
|
+
respond(req, ok, result, error) {
|
|
562
|
+
if (req.durable && req.toolCallId) {
|
|
563
|
+
void this.api.postToolResult(this.threadId, req.toolCallId, ok, result, error);
|
|
564
|
+
} else {
|
|
565
|
+
this.send({ type: "tool_response", id: req.id, ok, result, error });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async handleToolRequest(req) {
|
|
569
|
+
if (req.durable && req.toolCallId) {
|
|
570
|
+
if (this.handledDurable.has(req.toolCallId)) return;
|
|
571
|
+
this.handledDurable.add(req.toolCallId);
|
|
572
|
+
}
|
|
573
|
+
const summary = describe(req);
|
|
574
|
+
let effectiveRisk = typeof req.risk === "number" ? req.risk : req.requestPermission ? 3 : 1;
|
|
575
|
+
if (PATH_ARG_TOOLS.has(req.tool) && this.host.isOutsideProject(req.args.path)) {
|
|
576
|
+
effectiveRisk = Math.max(effectiveRisk, 4);
|
|
577
|
+
}
|
|
578
|
+
if (req.tool === "bash" && isCatastrophic(String(req.args.command || ""))) {
|
|
579
|
+
this.hooks.onActivity(`\u26D4 blocked dangerous command: ${summary}`);
|
|
580
|
+
this.respond(req, false, void 0, "Blocked: this command is considered catastrophic and was refused by the client safety guard.");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const permKey = permissionKey(req);
|
|
584
|
+
const decision = decide(this.perm, permKey, effectiveRisk, !!req.requestPermission);
|
|
585
|
+
if (decision === "deny") {
|
|
586
|
+
this.hooks.onActivity(`\u26D4 ${summary} \u2014 blocked (risk ${effectiveRisk})`);
|
|
587
|
+
this.respond(req, false, void 0, `Denied by policy (risk ${effectiveRisk}).`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (decision === "ask") {
|
|
591
|
+
const { choice, reason } = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
592
|
+
if (choice === "deny") {
|
|
593
|
+
const why = reason?.trim();
|
|
594
|
+
this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined${why ? `: ${why}` : ""}`);
|
|
595
|
+
this.respond(
|
|
596
|
+
req,
|
|
597
|
+
false,
|
|
598
|
+
void 0,
|
|
599
|
+
why ? `The user declined to run this operation. Their reason: ${why}` : "The user declined to run this operation."
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (choice === "always") this.perm.alwaysAllow.add(permKey);
|
|
604
|
+
if (choice === "always_risk") this.perm.allowRisk.add(effectiveRisk);
|
|
605
|
+
if (choice === "always" || choice === "always_risk") {
|
|
606
|
+
saveApprovals(this.api, this.threadId, this.perm);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const callKey = req.toolCallId ?? req.id ?? `${req.tool}:${summary}`;
|
|
610
|
+
this.hooks.onStatus?.(callKey, summary);
|
|
611
|
+
let result;
|
|
612
|
+
try {
|
|
613
|
+
result = await this.host.execute(req.tool, req.args);
|
|
614
|
+
} finally {
|
|
615
|
+
this.hooks.onStatus?.(callKey, null);
|
|
616
|
+
}
|
|
617
|
+
if (result.ok) {
|
|
618
|
+
const display = req.tool === "bash" ? `bash: ${highlightBash(String(req.args.command ?? "").slice(0, 200))}` : summary;
|
|
619
|
+
let detail;
|
|
620
|
+
if (req.tool === "edit_file") {
|
|
621
|
+
detail = diffLines(String(req.args.old_string ?? ""), String(req.args.new_string ?? ""));
|
|
622
|
+
} else if (req.tool === "write_file") {
|
|
623
|
+
detail = newFileLines(String(req.args.content ?? ""));
|
|
624
|
+
}
|
|
625
|
+
this.hooks.onActivity(`\u2713 ${display}${detailSuffix(req.tool, result.result)}`, detail);
|
|
626
|
+
this.respond(req, true, result.result ?? "");
|
|
627
|
+
} else {
|
|
628
|
+
this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
|
|
629
|
+
this.respond(req, false, void 0, result.error);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
function permissionKey(req) {
|
|
634
|
+
if (req.tool !== "mcp") return req.tool;
|
|
635
|
+
const a = req.args;
|
|
636
|
+
const server = String(a.server || "?");
|
|
637
|
+
const action = String(a.action || "call");
|
|
638
|
+
if (action === "read_resource") return `mcp:${server}/resource`;
|
|
639
|
+
if (action === "list_tools") return `mcp:${server}/list`;
|
|
640
|
+
return `mcp:${server}/${String(a.tool || "?")}`;
|
|
641
|
+
}
|
|
642
|
+
function describe(req) {
|
|
643
|
+
const a = req.args;
|
|
644
|
+
switch (req.tool) {
|
|
645
|
+
case "mcp": {
|
|
646
|
+
const server = String(a.server || "?");
|
|
647
|
+
const action = String(a.action || "call");
|
|
648
|
+
if (action === "list_tools") return `mcp ${server}: list tools`;
|
|
649
|
+
if (action === "read_resource") return `mcp ${server}: read ${a.uri}`;
|
|
650
|
+
return `mcp ${server}: ${a.tool}`;
|
|
651
|
+
}
|
|
652
|
+
case "read_file":
|
|
653
|
+
return `read ${a.path}`;
|
|
654
|
+
case "grep":
|
|
655
|
+
return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
|
|
656
|
+
case "glob":
|
|
657
|
+
return `find ${a.pattern}`;
|
|
658
|
+
case "write_file":
|
|
659
|
+
return `write ${a.path}`;
|
|
660
|
+
case "edit_file":
|
|
661
|
+
return `edit ${a.path}`;
|
|
662
|
+
case "save_to_disk":
|
|
663
|
+
return `save ${a.source_path} \u2192 ${a.dest_path}`;
|
|
664
|
+
case "run_skill_script":
|
|
665
|
+
return `skill ${a.skill}: run ${a.entry}`;
|
|
666
|
+
case "bash":
|
|
667
|
+
return `bash: ${String(a.command).slice(0, 80)}`;
|
|
668
|
+
default:
|
|
669
|
+
return `${req.tool} ${JSON.stringify(a).slice(0, 80)}`;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function detailSuffix(tool, result) {
|
|
673
|
+
if (!result) return "";
|
|
674
|
+
if (tool === "write_file" || tool === "edit_file") return "";
|
|
675
|
+
if (tool === "bash") {
|
|
676
|
+
const m = result.match(/\[exit code (\d+)\]\s*$/);
|
|
677
|
+
return m ? ` (exit ${m[1]})` : "";
|
|
678
|
+
}
|
|
679
|
+
const lines = result.split("\n").length;
|
|
680
|
+
return ` (${lines} line${lines === 1 ? "" : "s"})`;
|
|
681
|
+
}
|
|
682
|
+
var LOG_DIR = path3.join(os6.homedir(), ".standardagents", "process-logs");
|
|
683
|
+
var KEY2 = "bg_processes";
|
|
684
|
+
function isAlive(pid) {
|
|
685
|
+
try {
|
|
686
|
+
process.kill(pid, 0);
|
|
687
|
+
return true;
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return err.code === "EPERM";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
var ProcessRegistry = class {
|
|
693
|
+
constructor(api, threadId, machine) {
|
|
694
|
+
this.api = api;
|
|
695
|
+
this.threadId = threadId;
|
|
696
|
+
this.machine = machine;
|
|
697
|
+
}
|
|
698
|
+
api;
|
|
699
|
+
threadId;
|
|
700
|
+
machine;
|
|
701
|
+
async read() {
|
|
702
|
+
const value = await this.api.kvGet(this.threadId, KEY2);
|
|
703
|
+
return Array.isArray(value) ? value : [];
|
|
704
|
+
}
|
|
705
|
+
async write(entries) {
|
|
706
|
+
await this.api.kvSet(this.threadId, KEY2, entries);
|
|
707
|
+
}
|
|
708
|
+
/** Mark this machine's dead "running" entries as exited. Returns true if any changed. */
|
|
709
|
+
reconcileLiveness(entries) {
|
|
710
|
+
let changed = false;
|
|
711
|
+
for (const e of entries) {
|
|
712
|
+
if (e.machine === this.machine && e.status === "running" && !isAlive(e.pid)) {
|
|
713
|
+
e.status = "exited";
|
|
714
|
+
e.endedAt = Date.now();
|
|
715
|
+
changed = true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return changed;
|
|
719
|
+
}
|
|
720
|
+
/** All tracked processes, newest first, with liveness re-checked + persisted. */
|
|
721
|
+
async list() {
|
|
722
|
+
const entries = await this.read();
|
|
723
|
+
if (this.reconcileLiveness(entries)) await this.write(entries);
|
|
724
|
+
return entries.sort((a, b) => b.startedAt - a.startedAt);
|
|
725
|
+
}
|
|
726
|
+
async runningCount() {
|
|
727
|
+
const entries = await this.list();
|
|
728
|
+
return entries.filter((e) => e.status === "running").length;
|
|
729
|
+
}
|
|
730
|
+
async get(id) {
|
|
731
|
+
return (await this.read()).find((e) => e.id === id) ?? null;
|
|
732
|
+
}
|
|
733
|
+
async add(entry) {
|
|
734
|
+
const entries = await this.read();
|
|
735
|
+
entries.push(entry);
|
|
736
|
+
await this.write(entries);
|
|
737
|
+
}
|
|
738
|
+
async markExited(id, exitCode) {
|
|
739
|
+
const entries = await this.read();
|
|
740
|
+
const e = entries.find((x) => x.id === id);
|
|
741
|
+
if (e && e.status === "running") {
|
|
742
|
+
e.status = "exited";
|
|
743
|
+
e.exitCode = exitCode;
|
|
744
|
+
e.endedAt = Date.now();
|
|
745
|
+
await this.write(entries);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async markStopped(id) {
|
|
749
|
+
const entries = await this.read();
|
|
750
|
+
const e = entries.find((x) => x.id === id);
|
|
751
|
+
if (e) {
|
|
752
|
+
e.status = "stopped";
|
|
753
|
+
e.endedAt = Date.now();
|
|
754
|
+
await this.write(entries);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
function configFile() {
|
|
759
|
+
return process.env.STANDARDAGENTS_MCP_CONFIG || path3.join(os6.homedir(), ".standardagents", "mcp.json");
|
|
760
|
+
}
|
|
761
|
+
function loadMcpConfig() {
|
|
762
|
+
try {
|
|
763
|
+
const raw = fs4.readFileSync(configFile(), "utf8");
|
|
764
|
+
const parsed = JSON.parse(raw);
|
|
765
|
+
if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
|
|
766
|
+
return parsed;
|
|
767
|
+
} catch {
|
|
768
|
+
return { servers: {} };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function listMcpServers() {
|
|
772
|
+
const cfg = loadMcpConfig();
|
|
773
|
+
return Object.values(cfg.servers).sort((a, b) => a.name.localeCompare(b.name));
|
|
774
|
+
}
|
|
775
|
+
function saveMcpServer(server) {
|
|
776
|
+
const cfg = loadMcpConfig();
|
|
777
|
+
cfg.servers[server.name] = server;
|
|
778
|
+
write(cfg);
|
|
779
|
+
}
|
|
780
|
+
function removeMcpServer(name) {
|
|
781
|
+
const cfg = loadMcpConfig();
|
|
782
|
+
delete cfg.servers[name];
|
|
783
|
+
write(cfg);
|
|
784
|
+
}
|
|
785
|
+
function setMcpServerEnabled(name, enabled) {
|
|
786
|
+
const cfg = loadMcpConfig();
|
|
787
|
+
const s = cfg.servers[name];
|
|
788
|
+
if (!s) return;
|
|
789
|
+
s.enabled = enabled;
|
|
790
|
+
write(cfg);
|
|
791
|
+
}
|
|
792
|
+
function write(cfg) {
|
|
793
|
+
const file = configFile();
|
|
794
|
+
fs4.mkdirSync(path3.dirname(file), { recursive: true });
|
|
795
|
+
fs4.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
796
|
+
}
|
|
797
|
+
function parseServerSpec(spec) {
|
|
798
|
+
const trimmed = spec.trim();
|
|
799
|
+
const colon = trimmed.indexOf(":");
|
|
800
|
+
if (colon <= 0) return null;
|
|
801
|
+
const name = trimmed.slice(0, colon).trim();
|
|
802
|
+
const rest = trimmed.slice(colon + 1).trim();
|
|
803
|
+
if (!name || !rest) return null;
|
|
804
|
+
const parts = tokenize(rest);
|
|
805
|
+
if (!parts.length) return null;
|
|
806
|
+
const [command, ...args] = parts;
|
|
807
|
+
return { name, command, args, enabled: true };
|
|
808
|
+
}
|
|
809
|
+
function serverFromCommand(name, commandLine, env) {
|
|
810
|
+
const cleanName = name.trim();
|
|
811
|
+
const parts = tokenize(commandLine.trim());
|
|
812
|
+
if (!cleanName || !parts.length) return null;
|
|
813
|
+
const [command, ...args] = parts;
|
|
814
|
+
return { name: cleanName, command, args, env, enabled: true };
|
|
815
|
+
}
|
|
816
|
+
function tokenize(input2) {
|
|
817
|
+
const out = [];
|
|
818
|
+
let cur = "";
|
|
819
|
+
let quote = null;
|
|
820
|
+
for (let i = 0; i < input2.length; i++) {
|
|
821
|
+
const ch = input2[i];
|
|
822
|
+
if (quote) {
|
|
823
|
+
if (ch === quote) quote = null;
|
|
824
|
+
else cur += ch;
|
|
825
|
+
} else if (ch === '"' || ch === "'") {
|
|
826
|
+
quote = ch;
|
|
827
|
+
} else if (/\s/.test(ch)) {
|
|
828
|
+
if (cur) {
|
|
829
|
+
out.push(cur);
|
|
830
|
+
cur = "";
|
|
831
|
+
}
|
|
832
|
+
} else {
|
|
833
|
+
cur += ch;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (cur) out.push(cur);
|
|
837
|
+
return out;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/host-tools.ts
|
|
841
|
+
var STARTUP_GRACE_MS = 600;
|
|
842
|
+
function isAlive2(pid) {
|
|
843
|
+
try {
|
|
844
|
+
process.kill(pid, 0);
|
|
845
|
+
return true;
|
|
846
|
+
} catch (err) {
|
|
847
|
+
return err.code === "EPERM";
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function readLogTail(logPath, n) {
|
|
851
|
+
try {
|
|
852
|
+
const content = await fsp.readFile(logPath, "utf8");
|
|
853
|
+
return content.split("\n").filter(Boolean).slice(-n).join("\n");
|
|
854
|
+
} catch {
|
|
855
|
+
return "";
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
var HostTools = class {
|
|
859
|
+
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange, api) {
|
|
860
|
+
this.projectDir = projectDir;
|
|
861
|
+
this.registry = registry;
|
|
862
|
+
this.threadId = threadId;
|
|
863
|
+
this.machine = machine;
|
|
864
|
+
this.mcp = mcp;
|
|
865
|
+
this.onMcpCatalogChange = onMcpCatalogChange;
|
|
866
|
+
this.api = api;
|
|
867
|
+
}
|
|
868
|
+
projectDir;
|
|
869
|
+
registry;
|
|
870
|
+
threadId;
|
|
871
|
+
machine;
|
|
872
|
+
mcp;
|
|
873
|
+
onMcpCatalogChange;
|
|
874
|
+
api;
|
|
875
|
+
/** Resolve a user/model-supplied path against the project directory. */
|
|
876
|
+
resolve(p) {
|
|
877
|
+
if (!p || p === ".") return this.projectDir;
|
|
878
|
+
return path3.resolve(this.projectDir, p);
|
|
879
|
+
}
|
|
880
|
+
/** True when the resolved path escapes the project directory. */
|
|
881
|
+
isOutsideProject(p) {
|
|
882
|
+
const abs = this.resolve(p);
|
|
883
|
+
const rel = path3.relative(this.projectDir, abs);
|
|
884
|
+
return rel.startsWith("..") || path3.isAbsolute(rel);
|
|
885
|
+
}
|
|
886
|
+
async execute(tool, args) {
|
|
887
|
+
try {
|
|
888
|
+
switch (tool) {
|
|
889
|
+
case "read_file":
|
|
890
|
+
return await this.readFile(args);
|
|
891
|
+
case "grep":
|
|
892
|
+
return await this.grep(args);
|
|
893
|
+
case "glob":
|
|
894
|
+
return await this.glob(args);
|
|
895
|
+
case "write_file":
|
|
896
|
+
return await this.writeFile(args);
|
|
897
|
+
case "edit_file":
|
|
898
|
+
return await this.editFile(args);
|
|
899
|
+
case "bash":
|
|
900
|
+
return await this.bash(args);
|
|
901
|
+
case "save_to_disk":
|
|
902
|
+
return await this.saveToDisk(args);
|
|
903
|
+
case "run_skill_script":
|
|
904
|
+
return await this.runSkillScript(args);
|
|
905
|
+
case "background_process": {
|
|
906
|
+
const action = String(args.action || "list");
|
|
907
|
+
if (action === "start") return await this.runBackground(args);
|
|
908
|
+
return await this.backgroundProcesses(args);
|
|
909
|
+
}
|
|
910
|
+
case "mcp": {
|
|
911
|
+
if (!this.mcp) {
|
|
912
|
+
return { ok: false, error: "MCP is not available in this session." };
|
|
913
|
+
}
|
|
914
|
+
if (String(args.action) === "remove") {
|
|
915
|
+
const name = String(args.server || "");
|
|
916
|
+
if (!name) return { ok: false, error: "remove requires a 'server' name." };
|
|
917
|
+
this.mcp.disconnect(name);
|
|
918
|
+
removeMcpServer(name);
|
|
919
|
+
this.onMcpCatalogChange?.();
|
|
920
|
+
return { ok: true, result: JSON.stringify({ removed: name }) };
|
|
921
|
+
}
|
|
922
|
+
return await this.mcp.dispatch(args);
|
|
923
|
+
}
|
|
924
|
+
case "install_mcp":
|
|
925
|
+
return await this.installMcp(args);
|
|
926
|
+
default:
|
|
927
|
+
return { ok: false, error: `Unknown tool: ${tool}` };
|
|
928
|
+
}
|
|
929
|
+
} catch (err) {
|
|
930
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async readFile(args) {
|
|
934
|
+
const file = this.resolve(String(args.path || ""));
|
|
935
|
+
const stat = await fsp.stat(file).catch(() => null);
|
|
936
|
+
if (!stat) return { ok: false, error: `File not found: ${args.path}` };
|
|
937
|
+
if (stat.isDirectory()) return { ok: false, error: `${args.path} is a directory` };
|
|
938
|
+
if (stat.size > 2e6) return { ok: false, error: `File too large (${stat.size} bytes)` };
|
|
939
|
+
const content = await fsp.readFile(file, "utf8");
|
|
940
|
+
const lines = content.split("\n");
|
|
941
|
+
const offset = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
|
|
942
|
+
const limit = typeof args.limit === "number" ? args.limit : lines.length;
|
|
943
|
+
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
944
|
+
const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
|
|
945
|
+
return { ok: true, result: numbered || "(empty file)" };
|
|
946
|
+
}
|
|
947
|
+
async grep(args) {
|
|
948
|
+
const pattern = String(args.pattern || "");
|
|
949
|
+
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
950
|
+
const searchPath = this.resolve(args.path ? String(args.path) : void 0);
|
|
951
|
+
const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--max-count", "200"];
|
|
952
|
+
if (args.ignore_case) rgArgs.push("-i");
|
|
953
|
+
if (args.glob) rgArgs.push("--glob", String(args.glob));
|
|
954
|
+
rgArgs.push("--", pattern, searchPath);
|
|
955
|
+
const rg = await this.run("rg", rgArgs, this.projectDir, 3e4);
|
|
956
|
+
if (rg.code === 127) {
|
|
957
|
+
return { ok: false, error: "ripgrep (rg) not found on host; install it for grep." };
|
|
958
|
+
}
|
|
959
|
+
const out = rg.stdout.trim();
|
|
960
|
+
return { ok: true, result: out || "(no matches)" };
|
|
961
|
+
}
|
|
962
|
+
async glob(args) {
|
|
963
|
+
const pattern = String(args.pattern || "");
|
|
964
|
+
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
965
|
+
const base = this.resolve(args.path ? String(args.path) : void 0);
|
|
966
|
+
const rg = await this.run("rg", ["--files", "--glob", pattern, base], this.projectDir, 3e4);
|
|
967
|
+
if (rg.code === 127) {
|
|
968
|
+
const matches = await this.walkGlob(base, pattern);
|
|
969
|
+
return { ok: true, result: matches.slice(0, 300).join("\n") || "(no files)" };
|
|
970
|
+
}
|
|
971
|
+
const rel = rg.stdout.trim().split("\n").filter(Boolean).map((p) => path3.relative(this.projectDir, p)).slice(0, 300);
|
|
972
|
+
return { ok: true, result: rel.join("\n") || "(no files)" };
|
|
973
|
+
}
|
|
974
|
+
async writeFile(args) {
|
|
975
|
+
const file = this.resolve(String(args.path || ""));
|
|
976
|
+
const content = String(args.content ?? "");
|
|
977
|
+
await fsp.mkdir(path3.dirname(file), { recursive: true });
|
|
978
|
+
const existed = fs4.existsSync(file);
|
|
979
|
+
await fsp.writeFile(file, content, "utf8");
|
|
980
|
+
return {
|
|
981
|
+
ok: true,
|
|
982
|
+
result: `${existed ? "Overwrote" : "Created"} ${path3.relative(this.projectDir, file)} (${Buffer.byteLength(content)} bytes)`
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
async editFile(args) {
|
|
986
|
+
const file = this.resolve(String(args.path || ""));
|
|
987
|
+
const oldStr = String(args.old_string ?? "");
|
|
988
|
+
const newStr = String(args.new_string ?? "");
|
|
989
|
+
const replaceAll = args.replace_all === true;
|
|
990
|
+
const stat = await fsp.stat(file).catch(() => null);
|
|
991
|
+
if (!stat) return { ok: false, error: `File not found: ${args.path}` };
|
|
992
|
+
const content = await fsp.readFile(file, "utf8");
|
|
993
|
+
if (oldStr === "") return { ok: false, error: "old_string cannot be empty" };
|
|
994
|
+
const count = content.split(oldStr).length - 1;
|
|
995
|
+
if (count === 0) return { ok: false, error: "old_string not found in file (it must match exactly)." };
|
|
996
|
+
if (count > 1 && !replaceAll) {
|
|
997
|
+
return { ok: false, error: `old_string is not unique (${count} matches). Add more context or set replace_all.` };
|
|
998
|
+
}
|
|
999
|
+
const updated = replaceAll ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
|
|
1000
|
+
await fsp.writeFile(file, updated, "utf8");
|
|
1001
|
+
return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Copy a file from the THREAD filesystem (e.g. a generated /attachments/*
|
|
1005
|
+
* asset) onto the project disk: download the bytes from the instance API and
|
|
1006
|
+
* write them at the destination. The bytes never pass through the model.
|
|
1007
|
+
*/
|
|
1008
|
+
async saveToDisk(args) {
|
|
1009
|
+
const source = String(args.source_path || "");
|
|
1010
|
+
const destArg = String(args.dest_path || "");
|
|
1011
|
+
if (!source || !destArg) return { ok: false, error: "source_path and dest_path are required" };
|
|
1012
|
+
if (!this.api || !this.threadId) {
|
|
1013
|
+
return { ok: false, error: "No instance API available for thread file downloads." };
|
|
1014
|
+
}
|
|
1015
|
+
const bytes = await this.api.fetchFile(this.threadId, source);
|
|
1016
|
+
if (!bytes) return { ok: false, error: `Could not download ${source} from the thread filesystem.` };
|
|
1017
|
+
const dest = this.resolve(destArg);
|
|
1018
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1019
|
+
const existed = fs4.existsSync(dest);
|
|
1020
|
+
await fsp.writeFile(dest, bytes);
|
|
1021
|
+
return {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
result: `${existed ? "Overwrote" : "Saved"} ${path3.relative(this.projectDir, dest)} (${bytes.length} bytes) from ${source}`
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Execute a SKILL script on the host. Skills live in the instance (cloud is
|
|
1028
|
+
* the source of truth); the server forwards the skill's files with the call
|
|
1029
|
+
* and we materialize them into a content-addressed temp dir — nothing about
|
|
1030
|
+
* a skill is stored on this machine beyond an ephemeral cache. The script
|
|
1031
|
+
* runs with cwd = the PROJECT (so it can operate on the user's files) and
|
|
1032
|
+
* SKILL_DIR pointing at the materialized folder (so it can read its own
|
|
1033
|
+
* references/assets).
|
|
1034
|
+
*/
|
|
1035
|
+
async runSkillScript(args) {
|
|
1036
|
+
const skill = String(args.skill || "skill");
|
|
1037
|
+
const entry = String(args.entry || "");
|
|
1038
|
+
if (!entry) return { ok: false, error: "entry (the script path within the skill) is required" };
|
|
1039
|
+
let files;
|
|
1040
|
+
try {
|
|
1041
|
+
const parsed = JSON.parse(String(args.files_json || "[]"));
|
|
1042
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1043
|
+
files = parsed.map((f) => ({ path: String(f.path), content: String(f.content ?? "") }));
|
|
1044
|
+
} catch {
|
|
1045
|
+
return { ok: false, error: "files_json must be a JSON array of {path, content}" };
|
|
1046
|
+
}
|
|
1047
|
+
if (!files.some((f) => f.path === entry)) {
|
|
1048
|
+
return { ok: false, error: `entry "${entry}" is not among the provided skill files` };
|
|
1049
|
+
}
|
|
1050
|
+
let scriptArgs = [];
|
|
1051
|
+
if (args.args_json) {
|
|
1052
|
+
try {
|
|
1053
|
+
const parsed = JSON.parse(String(args.args_json));
|
|
1054
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1055
|
+
scriptArgs = parsed.map(String);
|
|
1056
|
+
} catch {
|
|
1057
|
+
return { ok: false, error: "args_json must be a JSON array of strings" };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12);
|
|
1061
|
+
const skillDir = path3.join(os6.tmpdir(), "standardcode-skills", `${skill}-${hash}`);
|
|
1062
|
+
for (const f of files) {
|
|
1063
|
+
const dest = path3.resolve(skillDir, f.path);
|
|
1064
|
+
if (path3.relative(skillDir, dest).startsWith("..")) {
|
|
1065
|
+
return { ok: false, error: `Skill file escapes its directory: ${f.path}` };
|
|
1066
|
+
}
|
|
1067
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1068
|
+
await fsp.writeFile(dest, f.content, "utf8");
|
|
1069
|
+
}
|
|
1070
|
+
const entryPath = path3.resolve(skillDir, entry);
|
|
1071
|
+
const entryContent = files.find((f) => f.path === entry).content;
|
|
1072
|
+
let cmd;
|
|
1073
|
+
let argv;
|
|
1074
|
+
if (entryContent.startsWith("#!")) {
|
|
1075
|
+
await fsp.chmod(entryPath, 493);
|
|
1076
|
+
cmd = entryPath;
|
|
1077
|
+
argv = scriptArgs;
|
|
1078
|
+
} else {
|
|
1079
|
+
const ext = path3.extname(entry).toLowerCase();
|
|
1080
|
+
const interp = {
|
|
1081
|
+
".py": ["python3"],
|
|
1082
|
+
".sh": ["bash"],
|
|
1083
|
+
".js": ["node"],
|
|
1084
|
+
".mjs": ["node"],
|
|
1085
|
+
".ts": ["npx", "tsx"]
|
|
1086
|
+
};
|
|
1087
|
+
const found = interp[ext];
|
|
1088
|
+
if (!found) return { ok: false, error: `No interpreter for "${ext}" \u2014 add a shebang line to the script.` };
|
|
1089
|
+
cmd = found[0];
|
|
1090
|
+
argv = [...found.slice(1), entryPath, ...scriptArgs];
|
|
1091
|
+
}
|
|
1092
|
+
const timeoutMs = typeof args.timeout_ms === "number" ? Math.min(args.timeout_ms, 3e5) : 12e4;
|
|
1093
|
+
return await new Promise((resolvePromise) => {
|
|
1094
|
+
const child = spawn(cmd, argv, {
|
|
1095
|
+
cwd: this.projectDir,
|
|
1096
|
+
env: { ...process.env, SKILL_DIR: skillDir },
|
|
1097
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1098
|
+
});
|
|
1099
|
+
let out = "";
|
|
1100
|
+
const cap = (chunk) => {
|
|
1101
|
+
if (out.length < 2e5) out += chunk.toString("utf8");
|
|
1102
|
+
};
|
|
1103
|
+
child.stdout.on("data", cap);
|
|
1104
|
+
child.stderr.on("data", cap);
|
|
1105
|
+
const timer = setTimeout(() => {
|
|
1106
|
+
child.kill("SIGKILL");
|
|
1107
|
+
resolvePromise({ ok: false, error: `Skill script timed out after ${timeoutMs}ms.
|
|
1108
|
+
${out.slice(-4e3)}` });
|
|
1109
|
+
}, timeoutMs);
|
|
1110
|
+
child.on("error", (err) => {
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
resolvePromise({ ok: false, error: `Could not launch ${cmd}: ${err.message}` });
|
|
1113
|
+
});
|
|
1114
|
+
child.on("exit", (code) => {
|
|
1115
|
+
clearTimeout(timer);
|
|
1116
|
+
const body = out.trim() || "(no output)";
|
|
1117
|
+
if (code === 0) resolvePromise({ ok: true, result: body });
|
|
1118
|
+
else resolvePromise({ ok: false, error: `Script exited with code ${code}:
|
|
1119
|
+
${body.slice(-6e3)}` });
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
async bash(args) {
|
|
1124
|
+
const command = String(args.command || "");
|
|
1125
|
+
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
1126
|
+
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
1127
|
+
const timeout = typeof args.timeout_ms === "number" ? args.timeout_ms : 12e4;
|
|
1128
|
+
const res = await this.run("bash", ["-lc", command], cwd, timeout);
|
|
1129
|
+
const combined = [res.stdout, res.stderr].filter(Boolean).join("\n").trim();
|
|
1130
|
+
const truncated = combined.length > 3e4 ? combined.slice(0, 3e4) + "\n\u2026(truncated)" : combined;
|
|
1131
|
+
if (res.timedOut) {
|
|
1132
|
+
return { ok: false, error: `Command timed out after ${timeout}ms.
|
|
1133
|
+
${truncated}` };
|
|
1134
|
+
}
|
|
1135
|
+
const status = `exit code ${res.code}`;
|
|
1136
|
+
return {
|
|
1137
|
+
ok: res.code === 0,
|
|
1138
|
+
result: `${truncated || "(no output)"}
|
|
1139
|
+
[${status}]`,
|
|
1140
|
+
error: res.code === 0 ? void 0 : `Command failed (${status}).
|
|
1141
|
+
${truncated}`
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/** Start a tracked long-running process, detached, with output to a log file. */
|
|
1145
|
+
async runBackground(args) {
|
|
1146
|
+
const command = String(args.command || "");
|
|
1147
|
+
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
1148
|
+
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
1149
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
1150
|
+
const logPath = path3.join(LOG_DIR, `${id}.log`);
|
|
1151
|
+
let out;
|
|
1152
|
+
try {
|
|
1153
|
+
await fsp.mkdir(LOG_DIR, { recursive: true });
|
|
1154
|
+
out = fs4.openSync(logPath, "a");
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
|
|
1157
|
+
}
|
|
1158
|
+
let child;
|
|
1159
|
+
try {
|
|
1160
|
+
child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
fs4.closeSync(out);
|
|
1163
|
+
return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
|
|
1164
|
+
}
|
|
1165
|
+
fs4.closeSync(out);
|
|
1166
|
+
const pid = child.pid;
|
|
1167
|
+
if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
|
|
1168
|
+
let earlyExit;
|
|
1169
|
+
const onEarlyExit = (code) => {
|
|
1170
|
+
earlyExit = code;
|
|
1171
|
+
};
|
|
1172
|
+
child.on("exit", onEarlyExit);
|
|
1173
|
+
await new Promise((r) => setTimeout(r, STARTUP_GRACE_MS));
|
|
1174
|
+
if (earlyExit !== void 0 || !isAlive2(pid)) {
|
|
1175
|
+
const tail = await readLogTail(logPath, 15);
|
|
1176
|
+
const code = earlyExit ?? "unknown";
|
|
1177
|
+
return {
|
|
1178
|
+
ok: false,
|
|
1179
|
+
error: `The process exited immediately (exit code ${code}) \u2014 it did not stay running, so nothing was started or tracked.` + (tail ? `
|
|
1180
|
+
|
|
1181
|
+
Output:
|
|
1182
|
+
${tail}` : " No output was captured.")
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
child.removeListener("exit", onEarlyExit);
|
|
1186
|
+
child.unref();
|
|
1187
|
+
if (this.registry) {
|
|
1188
|
+
const entry = {
|
|
1189
|
+
id,
|
|
1190
|
+
pid,
|
|
1191
|
+
command,
|
|
1192
|
+
description: typeof args.description === "string" ? args.description : void 0,
|
|
1193
|
+
cwd,
|
|
1194
|
+
machine: this.machine ?? "",
|
|
1195
|
+
logPath,
|
|
1196
|
+
startedAt: Date.now(),
|
|
1197
|
+
status: "running"
|
|
1198
|
+
};
|
|
1199
|
+
await this.registry.add(entry);
|
|
1200
|
+
child.on("exit", (code) => void this.registry?.markExited(id, code));
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
ok: true,
|
|
1204
|
+
result: JSON.stringify(
|
|
1205
|
+
{
|
|
1206
|
+
id,
|
|
1207
|
+
pid,
|
|
1208
|
+
status: "running",
|
|
1209
|
+
logPath,
|
|
1210
|
+
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.`
|
|
1211
|
+
},
|
|
1212
|
+
null,
|
|
1213
|
+
2
|
|
1214
|
+
)
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Install + connect an MCP server from a name + launch command (e.g.
|
|
1219
|
+
* "npx -y @playwright/mcp@latest"). Saves it to the user's MCP config,
|
|
1220
|
+
* launches it, runs the handshake, and republishes the catalog on success so
|
|
1221
|
+
* the agent immediately sees its tools. The connect IS the install — for `npx`
|
|
1222
|
+
* commands the package is fetched on first run.
|
|
1223
|
+
*/
|
|
1224
|
+
async installMcp(args) {
|
|
1225
|
+
if (!this.mcp) return { ok: false, error: "MCP is not available in this session." };
|
|
1226
|
+
const name = String(args.name || "");
|
|
1227
|
+
const command = String(args.command || "");
|
|
1228
|
+
if (!name || !command) {
|
|
1229
|
+
return { ok: false, error: 'install_mcp needs a `name` and a `command` (e.g. "npx -y @playwright/mcp@latest").' };
|
|
1230
|
+
}
|
|
1231
|
+
let env;
|
|
1232
|
+
if (typeof args.env_json === "string" && args.env_json.trim()) {
|
|
1233
|
+
try {
|
|
1234
|
+
const parsed = JSON.parse(args.env_json);
|
|
1235
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) env = parsed;
|
|
1236
|
+
else return { ok: false, error: "env_json must encode a JSON object of environment variables." };
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
return { ok: false, error: `env_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const cfg = serverFromCommand(name, command, env);
|
|
1242
|
+
if (!cfg) return { ok: false, error: `Could not parse the command: "${command}".` };
|
|
1243
|
+
saveMcpServer(cfg);
|
|
1244
|
+
try {
|
|
1245
|
+
const client = await this.mcp.connect(cfg);
|
|
1246
|
+
this.onMcpCatalogChange?.();
|
|
1247
|
+
const tools = client.tools.map((t) => t.name);
|
|
1248
|
+
return {
|
|
1249
|
+
ok: true,
|
|
1250
|
+
result: JSON.stringify(
|
|
1251
|
+
{
|
|
1252
|
+
server: cfg.name,
|
|
1253
|
+
command: `${cfg.command} ${cfg.args.join(" ")}`.trim(),
|
|
1254
|
+
connected: true,
|
|
1255
|
+
toolCount: tools.length,
|
|
1256
|
+
tools,
|
|
1257
|
+
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.`
|
|
1258
|
+
},
|
|
1259
|
+
null,
|
|
1260
|
+
2
|
|
1261
|
+
)
|
|
1262
|
+
};
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
this.onMcpCatalogChange?.();
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
error: `Saved MCP server "${cfg.name}" but it failed to start: ${err instanceof Error ? err.message : String(err)}`
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/** List / inspect logs of / stop tracked background processes. */
|
|
1272
|
+
async backgroundProcesses(args) {
|
|
1273
|
+
const action = String(args.action || "list");
|
|
1274
|
+
if (!this.registry) {
|
|
1275
|
+
return { ok: true, result: "Background-process tracking is not available in this session." };
|
|
1276
|
+
}
|
|
1277
|
+
if (action === "list") {
|
|
1278
|
+
const procs = await this.registry.list();
|
|
1279
|
+
if (!procs.length) return { ok: true, result: "No background processes for this session." };
|
|
1280
|
+
const lines = procs.map((p) => {
|
|
1281
|
+
const age = relativeAge(p.startedAt);
|
|
1282
|
+
const status = p.status === "running" ? "running" : `${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}`;
|
|
1283
|
+
return `${p.id} [${status}] pid ${p.pid} started ${age}
|
|
1284
|
+
${p.command}`;
|
|
1285
|
+
});
|
|
1286
|
+
return { ok: true, result: lines.join("\n") };
|
|
1287
|
+
}
|
|
1288
|
+
const id = String(args.id || "");
|
|
1289
|
+
const proc = await this.registry.get(id);
|
|
1290
|
+
if (!proc) return { ok: false, error: `No background process with id ${id}` };
|
|
1291
|
+
if (action === "logs") {
|
|
1292
|
+
const maxLines = typeof args.lines === "number" ? args.lines : 60;
|
|
1293
|
+
let content = "";
|
|
1294
|
+
try {
|
|
1295
|
+
content = await fsp.readFile(proc.logPath, "utf8");
|
|
1296
|
+
} catch {
|
|
1297
|
+
return { ok: true, result: `(no output captured yet for ${id})` };
|
|
1298
|
+
}
|
|
1299
|
+
const tail = content.split("\n").slice(-maxLines).join("\n");
|
|
1300
|
+
return { ok: true, result: tail || `(no output yet for ${id})` };
|
|
1301
|
+
}
|
|
1302
|
+
if (action === "stop") {
|
|
1303
|
+
if (proc.status !== "running") {
|
|
1304
|
+
return { ok: true, result: `Process ${id} is already ${proc.status}.` };
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
1308
|
+
setTimeout(() => {
|
|
1309
|
+
try {
|
|
1310
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
1311
|
+
} catch {
|
|
1312
|
+
}
|
|
1313
|
+
}, 3e3);
|
|
1314
|
+
} catch {
|
|
1315
|
+
try {
|
|
1316
|
+
process.kill(proc.pid, "SIGKILL");
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
await this.registry.markStopped(id);
|
|
1321
|
+
return { ok: true, result: `Stopped background process ${id} (pid ${proc.pid}).` };
|
|
1322
|
+
}
|
|
1323
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Terminate every still-running background process this machine started, so
|
|
1327
|
+
* detached children don't outlive the CLI when the session quits. Sends
|
|
1328
|
+
* SIGTERM to each process group, waits a brief grace, then SIGKILL any
|
|
1329
|
+
* straggler, and records them stopped. Best effort and bounded so quitting
|
|
1330
|
+
* stays snappy. Returns the number of running processes it signaled.
|
|
1331
|
+
*/
|
|
1332
|
+
async stopAllLocalProcesses() {
|
|
1333
|
+
if (!this.registry) return 0;
|
|
1334
|
+
let running = [];
|
|
1335
|
+
try {
|
|
1336
|
+
const procs = await this.registry.list();
|
|
1337
|
+
running = procs.filter((p) => p.status === "running" && p.machine === (this.machine ?? ""));
|
|
1338
|
+
} catch {
|
|
1339
|
+
return 0;
|
|
1340
|
+
}
|
|
1341
|
+
if (!running.length) return 0;
|
|
1342
|
+
const signal = (pid, sig) => {
|
|
1343
|
+
try {
|
|
1344
|
+
process.kill(-pid, sig);
|
|
1345
|
+
} catch {
|
|
1346
|
+
try {
|
|
1347
|
+
process.kill(pid, sig);
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
for (const proc of running) signal(proc.pid, "SIGTERM");
|
|
1353
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1354
|
+
for (const proc of running) signal(proc.pid, "SIGKILL");
|
|
1355
|
+
await Promise.allSettled(running.map((proc) => this.registry.markStopped(proc.id)));
|
|
1356
|
+
return running.length;
|
|
1357
|
+
}
|
|
1358
|
+
run(cmd, args, cwd, timeoutMs) {
|
|
1359
|
+
return new Promise((resolve) => {
|
|
1360
|
+
let stdout = "";
|
|
1361
|
+
let stderr = "";
|
|
1362
|
+
let timedOut = false;
|
|
1363
|
+
let exitCode = 0;
|
|
1364
|
+
let settled = false;
|
|
1365
|
+
const MAX_OUTPUT = 256 * 1024;
|
|
1366
|
+
let child;
|
|
1367
|
+
try {
|
|
1368
|
+
child = spawn(cmd, args, { cwd, detached: true });
|
|
1369
|
+
} catch {
|
|
1370
|
+
resolve({ stdout: "", stderr: "", code: 127, timedOut: false });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
child.unref();
|
|
1374
|
+
let graceTimer = null;
|
|
1375
|
+
const settle = (code) => {
|
|
1376
|
+
if (settled) return;
|
|
1377
|
+
settled = true;
|
|
1378
|
+
clearTimeout(timer);
|
|
1379
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
1380
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
1381
|
+
};
|
|
1382
|
+
const timer = setTimeout(() => {
|
|
1383
|
+
timedOut = true;
|
|
1384
|
+
try {
|
|
1385
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
1386
|
+
} catch {
|
|
1387
|
+
try {
|
|
1388
|
+
child.kill("SIGKILL");
|
|
1389
|
+
} catch {
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
setTimeout(() => settle(exitCode), 250);
|
|
1393
|
+
}, timeoutMs);
|
|
1394
|
+
child.stdout?.on("data", (d) => {
|
|
1395
|
+
if (stdout.length < MAX_OUTPUT) stdout += d.toString();
|
|
1396
|
+
});
|
|
1397
|
+
child.stderr?.on("data", (d) => {
|
|
1398
|
+
if (stderr.length < MAX_OUTPUT) stderr += d.toString();
|
|
1399
|
+
});
|
|
1400
|
+
child.on("error", (err) => {
|
|
1401
|
+
if (!stderr) stderr = String(err);
|
|
1402
|
+
settle(err.code === "ENOENT" ? 127 : 1);
|
|
1403
|
+
});
|
|
1404
|
+
child.on("close", (code) => settle(code ?? exitCode));
|
|
1405
|
+
child.on("exit", (code) => {
|
|
1406
|
+
exitCode = code ?? 0;
|
|
1407
|
+
graceTimer = setTimeout(() => settle(exitCode), 250);
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
async walkGlob(base, pattern) {
|
|
1412
|
+
const re = globToRegExp(pattern);
|
|
1413
|
+
const out = [];
|
|
1414
|
+
const walk = async (dir) => {
|
|
1415
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1416
|
+
for (const e of entries) {
|
|
1417
|
+
if (e.name === ".git" || e.name === "node_modules") continue;
|
|
1418
|
+
const full = path3.join(dir, e.name);
|
|
1419
|
+
if (e.isDirectory()) await walk(full);
|
|
1420
|
+
else {
|
|
1421
|
+
const rel = path3.relative(this.projectDir, full);
|
|
1422
|
+
if (re.test(rel) || re.test(e.name)) out.push(rel);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
await walk(base);
|
|
1427
|
+
return out;
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
function relativeAge(startedAt) {
|
|
1431
|
+
const diff = (Date.now() - startedAt) / 1e3;
|
|
1432
|
+
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
|
1433
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
1434
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
1435
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
1436
|
+
}
|
|
1437
|
+
function globToRegExp(glob) {
|
|
1438
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/ /g, ".*").replace(/\?/g, ".");
|
|
1439
|
+
return new RegExp(`^${escaped}$`);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// src/stream.ts
|
|
1443
|
+
var MessageStream = class {
|
|
1444
|
+
constructor(api, threadId, hooks) {
|
|
1445
|
+
this.api = api;
|
|
1446
|
+
this.threadId = threadId;
|
|
1447
|
+
this.hooks = hooks;
|
|
1448
|
+
}
|
|
1449
|
+
api;
|
|
1450
|
+
threadId;
|
|
1451
|
+
hooks;
|
|
1452
|
+
ws = null;
|
|
1453
|
+
closed = false;
|
|
1454
|
+
heartbeat = null;
|
|
1455
|
+
reconnectAttempt = 0;
|
|
1456
|
+
reconnectTimer = null;
|
|
1457
|
+
resolveConnected = null;
|
|
1458
|
+
/**
|
|
1459
|
+
* Connect and stay connected. Resolves on first open; reconnects on any drop
|
|
1460
|
+
* with exponential backoff. Silent — the bridge surfaces the user-facing
|
|
1461
|
+
* connection status; completion is detected via HTTP polling regardless, so a
|
|
1462
|
+
* dropped stream only affects live display, not correctness.
|
|
1463
|
+
*/
|
|
1464
|
+
connect() {
|
|
1465
|
+
return new Promise((resolve) => {
|
|
1466
|
+
let settled = false;
|
|
1467
|
+
this.resolveConnected = () => {
|
|
1468
|
+
if (!settled) {
|
|
1469
|
+
settled = true;
|
|
1470
|
+
resolve();
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
setTimeout(() => this.resolveConnected?.(), 8e3);
|
|
1474
|
+
this.openSocket();
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
openSocket() {
|
|
1478
|
+
if (this.closed) return;
|
|
1479
|
+
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}&reasoning=1`;
|
|
1480
|
+
let ws;
|
|
1481
|
+
try {
|
|
1482
|
+
ws = new WebSocket(url);
|
|
1483
|
+
} catch {
|
|
1484
|
+
this.scheduleReconnect();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
this.ws = ws;
|
|
1488
|
+
ws.addEventListener("open", () => {
|
|
1489
|
+
this.reconnectAttempt = 0;
|
|
1490
|
+
this.startHeartbeat(ws);
|
|
1491
|
+
this.resolveConnected?.();
|
|
1492
|
+
});
|
|
1493
|
+
ws.addEventListener("message", (ev) => {
|
|
1494
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1495
|
+
this.onMessage(String(ev.data));
|
|
1496
|
+
});
|
|
1497
|
+
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1498
|
+
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1499
|
+
}
|
|
1500
|
+
handleDrop(ws) {
|
|
1501
|
+
if (this.ws !== ws) return;
|
|
1502
|
+
this.ws = null;
|
|
1503
|
+
this.stopHeartbeat();
|
|
1504
|
+
this.scheduleReconnect();
|
|
1505
|
+
}
|
|
1506
|
+
startHeartbeat(ws) {
|
|
1507
|
+
this.stopHeartbeat();
|
|
1508
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1509
|
+
this.heartbeat.start();
|
|
1510
|
+
}
|
|
1511
|
+
stopHeartbeat() {
|
|
1512
|
+
if (this.heartbeat) {
|
|
1513
|
+
this.heartbeat.stop();
|
|
1514
|
+
this.heartbeat = null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
scheduleReconnect() {
|
|
1518
|
+
if (this.closed || this.reconnectTimer) return;
|
|
1519
|
+
this.reconnectAttempt++;
|
|
1520
|
+
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
1521
|
+
const delay = base + Math.floor(Math.random() * 400);
|
|
1522
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1523
|
+
this.reconnectTimer = null;
|
|
1524
|
+
this.openSocket();
|
|
1525
|
+
}, delay);
|
|
1526
|
+
}
|
|
1527
|
+
close() {
|
|
1528
|
+
this.closed = true;
|
|
1529
|
+
this.stopHeartbeat();
|
|
1530
|
+
if (this.reconnectTimer) {
|
|
1531
|
+
clearTimeout(this.reconnectTimer);
|
|
1532
|
+
this.reconnectTimer = null;
|
|
1533
|
+
}
|
|
1534
|
+
this.ws?.close();
|
|
1535
|
+
}
|
|
1536
|
+
onMessage(raw) {
|
|
1537
|
+
let msg;
|
|
1538
|
+
try {
|
|
1539
|
+
msg = JSON.parse(raw);
|
|
1540
|
+
} catch {
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (msg.type === "event" && typeof msg.eventType === "string") {
|
|
1544
|
+
this.hooks.onEvent?.(msg.eventType, msg.data);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
|
|
1548
|
+
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk, msg.message_id);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
if (msg.type === "reasoning_chunk" && (msg.depth ?? 0) === 0) {
|
|
1552
|
+
if (typeof msg.chunk === "string") this.hooks.onReasoningChunk?.(msg.chunk, msg.message_id);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
|
|
1556
|
+
const data = msg.data || {};
|
|
1557
|
+
if (data.role === "assistant" && typeof data.content === "string" && data.content.trim()) {
|
|
1558
|
+
const tc = data.tool_calls;
|
|
1559
|
+
const hasToolCalls2 = Array.isArray(tc) ? tc.length > 0 : typeof tc === "string" && tc.trim() !== "" && tc.trim() !== "null" && tc.trim() !== "[]";
|
|
1560
|
+
this.hooks.onAssistantText(data.content, hasToolCalls2);
|
|
1561
|
+
}
|
|
1562
|
+
if (data.role === "assistant" && data.status === "failed" && data.error) {
|
|
1563
|
+
this.hooks.onError(String(data.error));
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
// src/events-stream.ts
|
|
1570
|
+
var SystemEvents = class {
|
|
1571
|
+
constructor(api, hooks) {
|
|
1572
|
+
this.api = api;
|
|
1573
|
+
this.hooks = hooks;
|
|
1574
|
+
}
|
|
1575
|
+
api;
|
|
1576
|
+
hooks;
|
|
1577
|
+
ws = null;
|
|
1578
|
+
closed = false;
|
|
1579
|
+
heartbeat = null;
|
|
1580
|
+
reconnectAttempt = 0;
|
|
1581
|
+
reconnectTimer = null;
|
|
1582
|
+
connect() {
|
|
1583
|
+
this.openSocket();
|
|
1584
|
+
}
|
|
1585
|
+
openSocket() {
|
|
1586
|
+
if (this.closed) return;
|
|
1587
|
+
const url = `${this.api.wsEndpoint}/api/events?token=${encodeURIComponent(this.api.bearer)}`;
|
|
1588
|
+
let ws;
|
|
1589
|
+
try {
|
|
1590
|
+
ws = new WebSocket(url);
|
|
1591
|
+
} catch {
|
|
1592
|
+
this.scheduleReconnect();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
this.ws = ws;
|
|
1596
|
+
ws.addEventListener("open", () => {
|
|
1597
|
+
this.reconnectAttempt = 0;
|
|
1598
|
+
this.startHeartbeat(ws);
|
|
1599
|
+
this.hooks.onOpen?.();
|
|
1600
|
+
});
|
|
1601
|
+
ws.addEventListener("message", (ev) => {
|
|
1602
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1603
|
+
this.onMessage(String(ev.data));
|
|
1604
|
+
});
|
|
1605
|
+
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1606
|
+
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1607
|
+
}
|
|
1608
|
+
startHeartbeat(ws) {
|
|
1609
|
+
this.stopHeartbeat();
|
|
1610
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1611
|
+
this.heartbeat.start();
|
|
1612
|
+
}
|
|
1613
|
+
stopHeartbeat() {
|
|
1614
|
+
if (this.heartbeat) {
|
|
1615
|
+
this.heartbeat.stop();
|
|
1616
|
+
this.heartbeat = null;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
onMessage(raw) {
|
|
1620
|
+
let msg;
|
|
1621
|
+
try {
|
|
1622
|
+
msg = JSON.parse(raw);
|
|
1623
|
+
} catch {
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (msg?.type === "thread_created" && msg.thread) this.hooks.onThreadCreated(msg.thread);
|
|
1627
|
+
else if (msg?.type === "thread_updated" && msg.thread) this.hooks.onThreadUpdated(msg.thread);
|
|
1628
|
+
else if (msg?.type === "thread_deleted" && typeof msg.threadId === "string") {
|
|
1629
|
+
this.hooks.onThreadDeleted(msg.threadId);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
handleDrop(ws) {
|
|
1633
|
+
if (this.ws !== ws) return;
|
|
1634
|
+
this.ws = null;
|
|
1635
|
+
this.stopHeartbeat();
|
|
1636
|
+
this.scheduleReconnect();
|
|
1637
|
+
}
|
|
1638
|
+
scheduleReconnect() {
|
|
1639
|
+
if (this.closed || this.reconnectTimer) return;
|
|
1640
|
+
this.reconnectAttempt++;
|
|
1641
|
+
const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15e3);
|
|
1642
|
+
const delay = base + Math.floor(Math.random() * 400);
|
|
1643
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1644
|
+
this.reconnectTimer = null;
|
|
1645
|
+
this.openSocket();
|
|
1646
|
+
}, delay);
|
|
1647
|
+
}
|
|
1648
|
+
close() {
|
|
1649
|
+
this.closed = true;
|
|
1650
|
+
this.stopHeartbeat();
|
|
1651
|
+
if (this.reconnectTimer) {
|
|
1652
|
+
clearTimeout(this.reconnectTimer);
|
|
1653
|
+
this.reconnectTimer = null;
|
|
1654
|
+
}
|
|
1655
|
+
this.ws?.close();
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
// src/types.ts
|
|
1660
|
+
var LEVELS = [1, 2, 3, 4, 5];
|
|
1661
|
+
var LEVEL_DETAIL = {
|
|
1662
|
+
1: "only safe reads run automatically",
|
|
1663
|
+
2: "+ safe writes & builds",
|
|
1664
|
+
3: "+ installs & project edits",
|
|
1665
|
+
4: "+ outside-project & git-history",
|
|
1666
|
+
5: "everything \u2014 never ask"
|
|
1667
|
+
};
|
|
1668
|
+
function levelLabel(level) {
|
|
1669
|
+
return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
|
|
1670
|
+
}
|
|
1671
|
+
var FILE_MIMES = {
|
|
1672
|
+
".png": "image/png",
|
|
1673
|
+
".jpg": "image/jpeg",
|
|
1674
|
+
".jpeg": "image/jpeg",
|
|
1675
|
+
".gif": "image/gif",
|
|
1676
|
+
".webp": "image/webp"
|
|
1677
|
+
};
|
|
1678
|
+
var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
1679
|
+
function run(cmd, args, maxBuffer = MAX_IMAGE_BYTES * 2) {
|
|
1680
|
+
return new Promise((resolve) => {
|
|
1681
|
+
execFile(cmd, args, { encoding: "buffer", maxBuffer }, (err, stdout) => {
|
|
1682
|
+
resolve({ ok: !err, stdout: stdout ?? Buffer.alloc(0) });
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
function fromFile(filePath) {
|
|
1687
|
+
const mime = FILE_MIMES[path3.extname(filePath).toLowerCase()];
|
|
1688
|
+
if (!mime) return null;
|
|
1689
|
+
try {
|
|
1690
|
+
const stat = fs4.statSync(filePath);
|
|
1691
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null;
|
|
1692
|
+
return { data: fs4.readFileSync(filePath).toString("base64"), mime };
|
|
1693
|
+
} catch {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
async function readDarwin() {
|
|
1698
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1699
|
+
const script = [
|
|
1700
|
+
`set d to the clipboard as \xABclass PNGf\xBB`,
|
|
1701
|
+
`set f to open for access POSIX file "${tmp}" with write permission`,
|
|
1702
|
+
`set eof f to 0`,
|
|
1703
|
+
`write d to f`,
|
|
1704
|
+
`close access f`
|
|
1705
|
+
].join("\n");
|
|
1706
|
+
const png = await run("osascript", ["-e", script]);
|
|
1707
|
+
if (png.ok) {
|
|
1708
|
+
const img = fromFile(tmp);
|
|
1709
|
+
try {
|
|
1710
|
+
fs4.unlinkSync(tmp);
|
|
1711
|
+
} catch {
|
|
1712
|
+
}
|
|
1713
|
+
if (img) return img;
|
|
1714
|
+
}
|
|
1715
|
+
const furl = await run("osascript", ["-e", "POSIX path of (the clipboard as \xABclass furl\xBB)"]);
|
|
1716
|
+
if (furl.ok) {
|
|
1717
|
+
const p = furl.stdout.toString("utf8").trim();
|
|
1718
|
+
if (p) return fromFile(p);
|
|
1719
|
+
}
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
async function readLinux() {
|
|
1723
|
+
for (const [cmd, args] of [
|
|
1724
|
+
["wl-paste", ["--type", "image/png"]],
|
|
1725
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
1726
|
+
]) {
|
|
1727
|
+
const res = await run(cmd, args);
|
|
1728
|
+
if (res.ok && res.stdout.length > 8 && res.stdout.length <= MAX_IMAGE_BYTES && res.stdout[0] === 137 && res.stdout[1] === 80) {
|
|
1729
|
+
return { data: res.stdout.toString("base64"), mime: "image/png" };
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
async function readWindows() {
|
|
1735
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1736
|
+
const ps = [
|
|
1737
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
1738
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage();",
|
|
1739
|
+
`if ($img -ne $null) { $img.Save('${tmp.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) }`
|
|
1740
|
+
].join(" ");
|
|
1741
|
+
await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
1742
|
+
const img = fromFile(tmp);
|
|
1743
|
+
try {
|
|
1744
|
+
fs4.unlinkSync(tmp);
|
|
1745
|
+
} catch {
|
|
1746
|
+
}
|
|
1747
|
+
return img;
|
|
1748
|
+
}
|
|
1749
|
+
async function readClipboardImage() {
|
|
1750
|
+
try {
|
|
1751
|
+
if (process.platform === "darwin") return await readDarwin();
|
|
1752
|
+
if (process.platform === "win32") return await readWindows();
|
|
1753
|
+
return await readLinux();
|
|
1754
|
+
} catch {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/tui.ts
|
|
1760
|
+
function imagePlaceholder(seq) {
|
|
1761
|
+
return `[#Image ${seq}]`;
|
|
1762
|
+
}
|
|
1763
|
+
var C = {
|
|
1764
|
+
reset: "\x1B[0m",
|
|
1765
|
+
dim: "\x1B[2m",
|
|
1766
|
+
bold: "\x1B[1m",
|
|
1767
|
+
cyan: "\x1B[36m",
|
|
1768
|
+
green: "\x1B[32m",
|
|
1769
|
+
yellow: "\x1B[33m",
|
|
1770
|
+
red: "\x1B[31m",
|
|
1771
|
+
blue: "\x1B[34m",
|
|
1772
|
+
magenta: "\x1B[35m",
|
|
1773
|
+
gray: "\x1B[90m",
|
|
1774
|
+
teal: "\x1B[38;5;37m"
|
|
1775
|
+
};
|
|
1776
|
+
var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
|
|
1777
|
+
var SUBAGENT_COLORS = [
|
|
1778
|
+
"\x1B[35m",
|
|
1779
|
+
// magenta
|
|
1780
|
+
"\x1B[38;5;39m",
|
|
1781
|
+
// azure
|
|
1782
|
+
"\x1B[38;5;75m",
|
|
1783
|
+
// blue
|
|
1784
|
+
"\x1B[32m",
|
|
1785
|
+
// green
|
|
1786
|
+
"\x1B[38;5;141m",
|
|
1787
|
+
// violet
|
|
1788
|
+
"\x1B[38;5;177m"
|
|
1789
|
+
// orchid
|
|
1790
|
+
];
|
|
1791
|
+
var COMPACTION_COLOR = "\x1B[38;5;208m";
|
|
1792
|
+
var COMPACTION_AGENT = "compaction_agent";
|
|
1793
|
+
var Tui = class _Tui {
|
|
1794
|
+
constructor(level = 1) {
|
|
1795
|
+
this.level = level;
|
|
1796
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1797
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
1798
|
+
process.stdin.on("keypress", (str, key) => this.dispatch(str, key));
|
|
1799
|
+
process.stdin.resume();
|
|
1800
|
+
process.stdout.write("\x1B[?2004h");
|
|
1801
|
+
process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
|
|
1802
|
+
process.stdout.on("resize", () => this.renderBottom());
|
|
1803
|
+
}
|
|
1804
|
+
level;
|
|
1805
|
+
// input + indicators
|
|
1806
|
+
inputBuffer = "";
|
|
1807
|
+
cursorPos = 0;
|
|
1808
|
+
// caret index within inputBuffer (0..length)
|
|
1809
|
+
lastCursorRow = 0;
|
|
1810
|
+
// caret's row offset from the input region top, last render
|
|
1811
|
+
working = false;
|
|
1812
|
+
workingStart = 0;
|
|
1813
|
+
spinnerTimer = null;
|
|
1814
|
+
bgCount = 0;
|
|
1815
|
+
queuedCount = 0;
|
|
1816
|
+
subagents = [];
|
|
1817
|
+
// active subagents (one line each)
|
|
1818
|
+
subagentColorByID = /* @__PURE__ */ new Map();
|
|
1819
|
+
// subagent id → SUBAGENT_COLORS index
|
|
1820
|
+
tokensIn = 0;
|
|
1821
|
+
// cumulative input tokens
|
|
1822
|
+
tokensOut = 0;
|
|
1823
|
+
// cumulative output tokens (includes the in-progress live count)
|
|
1824
|
+
contextPct = null;
|
|
1825
|
+
// % of the model context window currently used
|
|
1826
|
+
// Live streaming preview, shown just above the status line while a turn runs:
|
|
1827
|
+
// the model's internal reasoning (dim italic) until the answer starts, then the
|
|
1828
|
+
// answer text (plain). Bounded to a tail; cleared when the message commits.
|
|
1829
|
+
streamThinking = "";
|
|
1830
|
+
streamResponse = "";
|
|
1831
|
+
streamMessageId = null;
|
|
1832
|
+
// the message currently previewing
|
|
1833
|
+
streamRedrawTimer = null;
|
|
1834
|
+
streamIdleTimer = null;
|
|
1835
|
+
// wipes a stale preview
|
|
1836
|
+
// Live goal checklist, fixed below the status bar. Driven by the goal_updated
|
|
1837
|
+
// thread event (+ an initial fetch). null = nothing to show.
|
|
1838
|
+
goal = null;
|
|
1839
|
+
// Set true the moment every step of a goal is done: the goal area is cleared
|
|
1840
|
+
// and the status line shows "Goal complete." until the next turn starts.
|
|
1841
|
+
goalComplete = false;
|
|
1842
|
+
// Inline slash-command palette: when the input starts with "/", the filtered
|
|
1843
|
+
// command list renders above the prompt and arrows/enter/tab drive it.
|
|
1844
|
+
commands = [];
|
|
1845
|
+
slashIdx = 0;
|
|
1846
|
+
// highlighted index within the FILTERED command list
|
|
1847
|
+
step = null;
|
|
1848
|
+
// current step label (e.g. "writing index.html")
|
|
1849
|
+
stepStart = 0;
|
|
1850
|
+
// when the current step began (for the step's own elapsed)
|
|
1851
|
+
stepOut = 0;
|
|
1852
|
+
// output tokens produced during the current step
|
|
1853
|
+
connected = true;
|
|
1854
|
+
bottomDrawn = false;
|
|
1855
|
+
started = false;
|
|
1856
|
+
// Resize bookkeeping: the width the region was last drawn at, and the visible
|
|
1857
|
+
// width of every HUD row written above the input. When the terminal is
|
|
1858
|
+
// resized, previously drawn rows re-wrap (a full-width ruler becomes 2+ rows
|
|
1859
|
+
// when narrowed), so the move-up count recorded at draw time is wrong — these
|
|
1860
|
+
// let moveToRegionTop recompute the region height under the NEW wrap instead
|
|
1861
|
+
// of leaving stale rulers behind.
|
|
1862
|
+
lastDrawnCols = 0;
|
|
1863
|
+
drawnHudWidths = [];
|
|
1864
|
+
// takeover (approval / menu) state
|
|
1865
|
+
takeoverHandler = null;
|
|
1866
|
+
bufferedPrints = [];
|
|
1867
|
+
// double-press-to-quit state: the first ctrl-c arms a brief window and shows a
|
|
1868
|
+
// transient hint; a second ctrl-c within the window actually quits.
|
|
1869
|
+
quitArmed = false;
|
|
1870
|
+
quitTimer = null;
|
|
1871
|
+
// bracketed-paste state
|
|
1872
|
+
pasting = false;
|
|
1873
|
+
pasteTimer = null;
|
|
1874
|
+
// Images pasted into the CURRENT input (Ctrl+V). Each got a `[#Image N]`
|
|
1875
|
+
// placeholder at the caret; on submit only images whose placeholder is still
|
|
1876
|
+
// present in the text are handed to onSubmit. Cleared with the input.
|
|
1877
|
+
pendingImages = [];
|
|
1878
|
+
imagePasteBusy = false;
|
|
1879
|
+
// one clipboard read at a time
|
|
1880
|
+
// Sent-message history for ↑/↓ recall (oldest → newest). `historyIdx` is the
|
|
1881
|
+
// entry currently shown (null = not browsing); the in-progress draft is
|
|
1882
|
+
// stashed so cycling past the newest entry restores it. Any edit exits
|
|
1883
|
+
// browsing and keeps the recalled text as the new draft.
|
|
1884
|
+
history = [];
|
|
1885
|
+
historyIdx = null;
|
|
1886
|
+
historyDraft = "";
|
|
1887
|
+
historyDraftImages = [];
|
|
1888
|
+
// event hooks (wired by index.ts)
|
|
1889
|
+
onSubmit = () => {
|
|
1890
|
+
};
|
|
1891
|
+
onInterrupt = () => {
|
|
1892
|
+
};
|
|
1893
|
+
/** Up on the top row: return true to consume it (e.g. pull a queued message)
|
|
1894
|
+
* before history recall gets a chance. */
|
|
1895
|
+
onUpArrow = () => false;
|
|
1896
|
+
onQuit = () => process.exit(0);
|
|
1897
|
+
levelListeners = [];
|
|
1898
|
+
get colors() {
|
|
1899
|
+
return C;
|
|
1900
|
+
}
|
|
1901
|
+
setQuitHandler(fn) {
|
|
1902
|
+
this.onQuit = fn;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Handle a ctrl-c. The first press arms a 2-second window and surfaces a
|
|
1906
|
+
* transient "Press Control-C again to exit" hint in the bottom region; a
|
|
1907
|
+
* second press within the window quits. After the window lapses the hint
|
|
1908
|
+
* clears and the next ctrl-c starts over — so it always takes two.
|
|
1909
|
+
*/
|
|
1910
|
+
requestQuit() {
|
|
1911
|
+
if (this.quitArmed) {
|
|
1912
|
+
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1913
|
+
this.quitTimer = null;
|
|
1914
|
+
this.quitArmed = false;
|
|
1915
|
+
this.onQuit();
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
this.quitArmed = true;
|
|
1919
|
+
this.renderBottom();
|
|
1920
|
+
this.quitTimer = setTimeout(() => {
|
|
1921
|
+
this.quitArmed = false;
|
|
1922
|
+
this.quitTimer = null;
|
|
1923
|
+
this.renderBottom();
|
|
1924
|
+
}, 2e3);
|
|
1925
|
+
}
|
|
1926
|
+
/** Tear down the bottom region and restore the terminal (called on quit). */
|
|
1927
|
+
end() {
|
|
1928
|
+
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1929
|
+
this.quitTimer = null;
|
|
1930
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
1931
|
+
this.streamIdleTimer = null;
|
|
1932
|
+
this.clearBottom();
|
|
1933
|
+
process.stdout.write("\x1B[?2004l\x1B[?25h");
|
|
1934
|
+
}
|
|
1935
|
+
onLevelChange(fn) {
|
|
1936
|
+
this.levelListeners.push(fn);
|
|
1937
|
+
}
|
|
1938
|
+
/** Carrot colour by level — cooler/safer (low) to warmer/permissive (high). */
|
|
1939
|
+
levelColor() {
|
|
1940
|
+
return { 1: C.cyan, 2: C.green, 3: C.yellow, 4: C.magenta, 5: C.red }[this.level];
|
|
1941
|
+
}
|
|
1942
|
+
// ─── key dispatch ──────────────────────────────────────────────────────────
|
|
1943
|
+
dispatch(str, key) {
|
|
1944
|
+
const seq = key && key.sequence || str || "";
|
|
1945
|
+
if (seq === "\n" || seq === "\x1B[13;2u" || seq === "\x1B[27;2;13~") {
|
|
1946
|
+
this.insertAtCursor("\n");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (key && key.ctrl && key.name === "c") {
|
|
1950
|
+
this.requestQuit();
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (key && key.name === "tab" && key.shift) {
|
|
1954
|
+
this.cycleLevel();
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
if (!this.pasting && seq.includes("\x1B[200~")) {
|
|
1958
|
+
this.pasting = true;
|
|
1959
|
+
this.armPasteSafety();
|
|
1960
|
+
this.handlePasteChunk(seq.slice(seq.indexOf("\x1B[200~") + 6));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
if (this.pasting) {
|
|
1964
|
+
this.armPasteSafety();
|
|
1965
|
+
this.handlePasteChunk(seq);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (this.takeoverHandler) {
|
|
1969
|
+
this.takeoverHandler(str, key);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (!key) return;
|
|
1973
|
+
if (this.paletteOpen()) {
|
|
1974
|
+
const matches = this.filteredCommands();
|
|
1975
|
+
const cur = matches.length ? Math.min(this.slashIdx, matches.length - 1) : 0;
|
|
1976
|
+
if (key.name === "up") {
|
|
1977
|
+
if (matches.length) {
|
|
1978
|
+
this.slashIdx = (cur - 1 + matches.length) % matches.length;
|
|
1979
|
+
this.renderBottom();
|
|
1980
|
+
}
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (key.name === "down") {
|
|
1984
|
+
if (matches.length) {
|
|
1985
|
+
this.slashIdx = (cur + 1) % matches.length;
|
|
1986
|
+
this.renderBottom();
|
|
1987
|
+
}
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (key.name === "tab") {
|
|
1991
|
+
if (matches.length) {
|
|
1992
|
+
this.inputBuffer = "/" + matches[cur].name;
|
|
1993
|
+
this.cursorPos = this.inputBuffer.length;
|
|
1994
|
+
this.slashIdx = 0;
|
|
1995
|
+
this.renderBottom();
|
|
1996
|
+
}
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
if (key.name === "return" || key.name === "enter") {
|
|
2000
|
+
if (key.shift) {
|
|
2001
|
+
this.insertAtCursor("\n");
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (matches.length) this.runCommand(matches[cur]);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (key.name === "escape") {
|
|
2008
|
+
this.inputBuffer = "";
|
|
2009
|
+
this.cursorPos = 0;
|
|
2010
|
+
this.slashIdx = 0;
|
|
2011
|
+
this.renderBottom();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (key.name === "escape") {
|
|
2016
|
+
this.onInterrupt();
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
if (key.name === "left") {
|
|
2020
|
+
if (this.cursorPos > 0) {
|
|
2021
|
+
this.cursorPos--;
|
|
2022
|
+
this.renderBottom();
|
|
2023
|
+
}
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (key.name === "right") {
|
|
2027
|
+
if (this.cursorPos < this.inputBuffer.length) {
|
|
2028
|
+
this.cursorPos++;
|
|
2029
|
+
this.renderBottom();
|
|
2030
|
+
}
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (key.name === "home" || key.ctrl && key.name === "a") {
|
|
2034
|
+
this.cursorPos = 0;
|
|
2035
|
+
this.renderBottom();
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (key.name === "end" || key.ctrl && key.name === "e") {
|
|
2039
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2040
|
+
this.renderBottom();
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (key.name === "up") {
|
|
2044
|
+
const { caretRow } = this.inputLayout();
|
|
2045
|
+
if (caretRow > 0) {
|
|
2046
|
+
this.moveCaretVertical(-1);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
if (this.onUpArrow()) return;
|
|
2050
|
+
this.historyPrev();
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (key.name === "down") {
|
|
2054
|
+
const { caretRow, rowCount } = this.inputLayout();
|
|
2055
|
+
if (caretRow < rowCount - 1) {
|
|
2056
|
+
this.moveCaretVertical(1);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
this.historyNext();
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (key.name === "return" || key.name === "enter") {
|
|
2063
|
+
if (key.shift) {
|
|
2064
|
+
this.insertAtCursor("\n");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const text = this.inputBuffer;
|
|
2068
|
+
const images = this.pendingImages.filter((img) => text.includes(imagePlaceholder(img.seq)));
|
|
2069
|
+
this.inputBuffer = "";
|
|
2070
|
+
this.cursorPos = 0;
|
|
2071
|
+
this.pendingImages = [];
|
|
2072
|
+
this.historyIdx = null;
|
|
2073
|
+
this.historyDraft = "";
|
|
2074
|
+
this.historyDraftImages = [];
|
|
2075
|
+
this.renderBottom();
|
|
2076
|
+
if (text.trim()) {
|
|
2077
|
+
this.addHistoryEntry(text.trim());
|
|
2078
|
+
this.onSubmit(text.trim(), images);
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (key.ctrl && key.name === "v") {
|
|
2083
|
+
void this.pasteClipboardImage();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (key.name === "backspace") {
|
|
2087
|
+
if (this.cursorPos > 0) {
|
|
2088
|
+
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
|
|
2089
|
+
this.cursorPos--;
|
|
2090
|
+
this.slashIdx = 0;
|
|
2091
|
+
this.historyIdx = null;
|
|
2092
|
+
this.renderBottom();
|
|
2093
|
+
}
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (key.name === "delete") {
|
|
2097
|
+
if (this.cursorPos < this.inputBuffer.length) {
|
|
2098
|
+
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
|
|
2099
|
+
this.slashIdx = 0;
|
|
2100
|
+
this.historyIdx = null;
|
|
2101
|
+
this.renderBottom();
|
|
2102
|
+
}
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (key.ctrl || key.meta || key.name === "tab") return;
|
|
2106
|
+
if (str && str >= " ") {
|
|
2107
|
+
this.insertAtCursor(str);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
insertAtCursor(text) {
|
|
2111
|
+
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
|
|
2112
|
+
this.cursorPos += text.length;
|
|
2113
|
+
this.slashIdx = 0;
|
|
2114
|
+
this.historyIdx = null;
|
|
2115
|
+
this.renderBottom();
|
|
2116
|
+
}
|
|
2117
|
+
/** Insert a paste fragment at the caret; collapse newlines (single-line input). */
|
|
2118
|
+
handlePasteChunk(chunk) {
|
|
2119
|
+
const end = chunk.indexOf("\x1B[201~");
|
|
2120
|
+
const content = (end >= 0 ? chunk.slice(0, end) : chunk).replace(/[\r\n]+/g, " ");
|
|
2121
|
+
if (content) {
|
|
2122
|
+
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
|
|
2123
|
+
this.cursorPos += content.length;
|
|
2124
|
+
this.historyIdx = null;
|
|
2125
|
+
}
|
|
2126
|
+
if (end >= 0) {
|
|
2127
|
+
this.pasting = false;
|
|
2128
|
+
if (this.pasteTimer) {
|
|
2129
|
+
clearTimeout(this.pasteTimer);
|
|
2130
|
+
this.pasteTimer = null;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
this.renderBottom();
|
|
2134
|
+
}
|
|
2135
|
+
/** Never let a missed end-marker wedge the input: clear paste mode shortly. */
|
|
2136
|
+
armPasteSafety() {
|
|
2137
|
+
if (this.pasteTimer) clearTimeout(this.pasteTimer);
|
|
2138
|
+
this.pasteTimer = setTimeout(() => {
|
|
2139
|
+
this.pasting = false;
|
|
2140
|
+
this.pasteTimer = null;
|
|
2141
|
+
this.renderBottom();
|
|
2142
|
+
}, 2e3);
|
|
2143
|
+
}
|
|
2144
|
+
// ─── input layout + vertical caret movement ────────────────────────────────
|
|
2145
|
+
/**
|
|
2146
|
+
* The input's physical rows (same wrapping math as renderBottom: logical
|
|
2147
|
+
* lines split on "\n", line 0 led by the prompt prefix, each wrapping at the
|
|
2148
|
+
* terminal width) plus where the caret sits among them. Each row records the
|
|
2149
|
+
* buffer index of its first character, its character count, and the visual
|
|
2150
|
+
* column its first character renders at (only row 0 is offset, by the
|
|
2151
|
+
* prompt). Drives ↑/↓: row 0 is "the top line" (history recall territory),
|
|
2152
|
+
* anything below moves the caret instead.
|
|
2153
|
+
*/
|
|
2154
|
+
inputLayout() {
|
|
2155
|
+
const cols2 = process.stdout.columns || 80;
|
|
2156
|
+
const pw = this.visibleWidth(this.promptPrefix());
|
|
2157
|
+
const lines = this.inputBuffer.split("\n");
|
|
2158
|
+
const rows = [];
|
|
2159
|
+
let offset = 0;
|
|
2160
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2161
|
+
const lead = i === 0 ? pw : 0;
|
|
2162
|
+
const len = lines[i].length;
|
|
2163
|
+
const nRows = Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2164
|
+
for (let j = 0; j < nRows; j++) {
|
|
2165
|
+
const charStart = Math.max(0, j * cols2 - lead);
|
|
2166
|
+
const charEnd = Math.min(len, (j + 1) * cols2 - lead);
|
|
2167
|
+
rows.push({ start: offset + charStart, len: Math.max(0, charEnd - charStart), colOffset: j === 0 ? lead : 0 });
|
|
2168
|
+
}
|
|
2169
|
+
offset += len + 1;
|
|
2170
|
+
}
|
|
2171
|
+
let pos = this.cursorPos;
|
|
2172
|
+
let caretLine = 0;
|
|
2173
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2174
|
+
pos -= lines[caretLine].length + 1;
|
|
2175
|
+
caretLine++;
|
|
2176
|
+
}
|
|
2177
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2178
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2179
|
+
for (let i = 0; i < caretLine; i++) {
|
|
2180
|
+
const lead = i === 0 ? pw : 0;
|
|
2181
|
+
caretRow += Math.max(1, Math.ceil((lead + lines[i].length) / cols2));
|
|
2182
|
+
}
|
|
2183
|
+
const caretCol = caretCell % cols2;
|
|
2184
|
+
while (caretRow >= rows.length) rows.push({ start: this.inputBuffer.length, len: 0, colOffset: 0 });
|
|
2185
|
+
return { rows, caretRow, caretCol, rowCount: rows.length };
|
|
2186
|
+
}
|
|
2187
|
+
/** Move the caret one visual row up/down, keeping the column when possible. */
|
|
2188
|
+
moveCaretVertical(delta) {
|
|
2189
|
+
const { rows, caretRow, caretCol } = this.inputLayout();
|
|
2190
|
+
const target = caretRow + delta;
|
|
2191
|
+
if (target < 0 || target >= rows.length) return;
|
|
2192
|
+
const row = rows[target];
|
|
2193
|
+
const within = Math.max(0, Math.min(caretCol - row.colOffset, row.len));
|
|
2194
|
+
this.cursorPos = Math.min(row.start + within, this.inputBuffer.length);
|
|
2195
|
+
this.renderBottom();
|
|
2196
|
+
}
|
|
2197
|
+
// ─── sent-message history (↑/↓ recall) ─────────────────────────────────────
|
|
2198
|
+
/** Seed the recall history (oldest → newest), e.g. from the on-disk file. */
|
|
2199
|
+
setHistory(entries) {
|
|
2200
|
+
this.history = entries.filter((e) => e.trim() !== "");
|
|
2201
|
+
}
|
|
2202
|
+
/** Record a sent message (skipping a consecutive duplicate). */
|
|
2203
|
+
addHistoryEntry(text) {
|
|
2204
|
+
if (this.history[this.history.length - 1] === text) return;
|
|
2205
|
+
this.history.push(text);
|
|
2206
|
+
}
|
|
2207
|
+
/** Recall the previous (older) history entry; stashes the draft first. */
|
|
2208
|
+
historyPrev() {
|
|
2209
|
+
if (!this.history.length) return;
|
|
2210
|
+
if (this.historyIdx === null) {
|
|
2211
|
+
this.historyDraft = this.inputBuffer;
|
|
2212
|
+
this.historyDraftImages = this.pendingImages;
|
|
2213
|
+
this.historyIdx = this.history.length - 1;
|
|
2214
|
+
} else if (this.historyIdx > 0) {
|
|
2215
|
+
this.historyIdx--;
|
|
2216
|
+
} else {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
this.pendingImages = [];
|
|
2220
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2221
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2222
|
+
this.slashIdx = 0;
|
|
2223
|
+
this.renderBottom();
|
|
2224
|
+
}
|
|
2225
|
+
/** Step toward the newest entry; past it, restore the stashed draft. */
|
|
2226
|
+
historyNext() {
|
|
2227
|
+
if (this.historyIdx === null) return;
|
|
2228
|
+
if (this.historyIdx < this.history.length - 1) {
|
|
2229
|
+
this.historyIdx++;
|
|
2230
|
+
this.pendingImages = [];
|
|
2231
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2232
|
+
} else {
|
|
2233
|
+
this.historyIdx = null;
|
|
2234
|
+
this.inputBuffer = this.historyDraft;
|
|
2235
|
+
this.pendingImages = this.historyDraftImages;
|
|
2236
|
+
this.historyDraft = "";
|
|
2237
|
+
this.historyDraftImages = [];
|
|
2238
|
+
}
|
|
2239
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2240
|
+
this.slashIdx = 0;
|
|
2241
|
+
this.renderBottom();
|
|
2242
|
+
}
|
|
2243
|
+
// ─── clipboard image paste (Ctrl+V) ────────────────────────────────────────
|
|
2244
|
+
/**
|
|
2245
|
+
* Read an image off the system clipboard and drop an `[#Image N]`
|
|
2246
|
+
* placeholder at the caret. The bytes ride along with the message on submit
|
|
2247
|
+
* (as a real attachment) as long as the placeholder is still in the text —
|
|
2248
|
+
* delete the placeholder and the image is dropped too.
|
|
2249
|
+
*/
|
|
2250
|
+
async pasteClipboardImage() {
|
|
2251
|
+
if (this.imagePasteBusy) return;
|
|
2252
|
+
this.imagePasteBusy = true;
|
|
2253
|
+
try {
|
|
2254
|
+
const img = await readClipboardImage();
|
|
2255
|
+
if (!img) {
|
|
2256
|
+
this.print(`${C.dim}No image on the clipboard.${C.reset}`);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const seq = this.pendingImages.reduce((m, i) => Math.max(m, i.seq), 0) + 1;
|
|
2260
|
+
this.pendingImages.push({ seq, data: img.data, mime: img.mime });
|
|
2261
|
+
this.insertAtCursor(imagePlaceholder(seq));
|
|
2262
|
+
} finally {
|
|
2263
|
+
this.imagePasteBusy = false;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
cycleLevel() {
|
|
2267
|
+
const idx = LEVELS.indexOf(this.level);
|
|
2268
|
+
this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
|
|
2269
|
+
}
|
|
2270
|
+
setLevel(level) {
|
|
2271
|
+
if (level === this.level) return;
|
|
2272
|
+
this.level = level;
|
|
2273
|
+
this.levelListeners.forEach((fn) => fn(this.level));
|
|
2274
|
+
this.print(`${this.levelColor()}${levelLabel(this.level)}${C.reset}`);
|
|
2275
|
+
}
|
|
2276
|
+
// ─── bottom input line ───────────────────────────────────────────────────
|
|
2277
|
+
/** Begin showing the persistent input line. */
|
|
2278
|
+
start() {
|
|
2279
|
+
this.started = true;
|
|
2280
|
+
this.renderBottom();
|
|
2281
|
+
}
|
|
2282
|
+
/** Format an elapsed millisecond span as 45s / 2m 05s / 1h 05m. */
|
|
2283
|
+
formatElapsed(ms) {
|
|
2284
|
+
const s = Math.floor(ms / 1e3);
|
|
2285
|
+
if (s < 60) return `${s}s`;
|
|
2286
|
+
const m = Math.floor(s / 60);
|
|
2287
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
2288
|
+
const h = Math.floor(m / 60);
|
|
2289
|
+
return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
2290
|
+
}
|
|
2291
|
+
/** Compact token count: 945 → "945", 12345 → "12.35k", 1_250_000 → "1.25M". */
|
|
2292
|
+
fmtTokens(n) {
|
|
2293
|
+
if (n < 1e3) return String(n);
|
|
2294
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(2)}k`;
|
|
2295
|
+
return `${(n / 1e6).toFixed(2)}M`;
|
|
2296
|
+
}
|
|
2297
|
+
spinnerFrame() {
|
|
2298
|
+
return `${C.bold}${C.cyan}${FRAMES[Math.floor(Date.now() / 100) % FRAMES.length]}${C.reset}`;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* "↑X ↓Y" cumulative token totals (greyed — low-priority), plus a context
|
|
2302
|
+
* window gauge "ctx N%" when known. The gauge colour ramps with fill (green →
|
|
2303
|
+
* yellow → red) so the user can see compaction approaching at a glance.
|
|
2304
|
+
*/
|
|
2305
|
+
tokensText() {
|
|
2306
|
+
const parts = [];
|
|
2307
|
+
if (this.tokensIn > 0) parts.push(`\u2191${this.fmtTokens(this.tokensIn)}`);
|
|
2308
|
+
if (this.tokensOut > 0) parts.push(`\u2193${this.fmtTokens(this.tokensOut)}`);
|
|
2309
|
+
let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
|
|
2310
|
+
if (this.contextPct != null) {
|
|
2311
|
+
const pct = this.contextPct;
|
|
2312
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2313
|
+
const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : calmGreen;
|
|
2314
|
+
const gauge = `${col}ctx ${pct}%${C.reset}`;
|
|
2315
|
+
out = out ? `${out} ${gauge}` : gauge;
|
|
2316
|
+
}
|
|
2317
|
+
return out;
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Set the context-window fill percentage (0–100), or null to hide it.
|
|
2321
|
+
* Driven by the runtime's `context_usage` KV (latest request input tokens ÷
|
|
2322
|
+
* model context window).
|
|
2323
|
+
*/
|
|
2324
|
+
setContextPct(pct) {
|
|
2325
|
+
const next = pct == null ? null : Math.max(0, Math.min(100, Math.round(pct)));
|
|
2326
|
+
if (next === this.contextPct) return;
|
|
2327
|
+
this.contextPct = next;
|
|
2328
|
+
this.renderBottom();
|
|
2329
|
+
}
|
|
2330
|
+
/** Plain "ctx N%" label (no ANSI) for menu hints, or "" when unknown. */
|
|
2331
|
+
contextPctLabel() {
|
|
2332
|
+
return this.contextPct == null ? "" : `ctx ${this.contextPct}%`;
|
|
2333
|
+
}
|
|
2334
|
+
/** Register the slash commands shown in the inline `/` palette. */
|
|
2335
|
+
setCommands(commands) {
|
|
2336
|
+
this.commands = commands;
|
|
2337
|
+
}
|
|
2338
|
+
/** Is the `/` command palette currently showing? (input starts with "/".) */
|
|
2339
|
+
paletteOpen() {
|
|
2340
|
+
return this.started && !this.takeoverHandler && this.commands.length > 0 && this.inputBuffer.startsWith("/");
|
|
2341
|
+
}
|
|
2342
|
+
/** Commands matching the text typed after "/", in declared order. */
|
|
2343
|
+
filteredCommands() {
|
|
2344
|
+
if (!this.inputBuffer.startsWith("/")) return [];
|
|
2345
|
+
const q = this.inputBuffer.slice(1).trim().toLowerCase();
|
|
2346
|
+
if (q === "") return this.commands;
|
|
2347
|
+
return this.commands.filter(
|
|
2348
|
+
(c2) => c2.name.startsWith(q) || c2.name.includes(q) || c2.label.toLowerCase().includes(q)
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
runCommand(cmd) {
|
|
2352
|
+
this.inputBuffer = "";
|
|
2353
|
+
this.cursorPos = 0;
|
|
2354
|
+
this.slashIdx = 0;
|
|
2355
|
+
this.renderBottom();
|
|
2356
|
+
void Promise.resolve(cmd.run()).catch(() => {
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* The summary line ABOVE the prompt. While working it reads
|
|
2361
|
+
* `⣷ Working <step> <elapsed> ↑in ↓out`; when idle it keeps the cumulative
|
|
2362
|
+
* token totals visible (`↑in ↓out`) so they live in the summary rather than
|
|
2363
|
+
* crowding the prompt. Null when idle with nothing counted yet.
|
|
2364
|
+
*/
|
|
2365
|
+
statusLineText(cols2) {
|
|
2366
|
+
const tk = this.tokensText();
|
|
2367
|
+
if (this.working) {
|
|
2368
|
+
const el = this.formatElapsed(Date.now() - this.workingStart);
|
|
2369
|
+
const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
|
|
2370
|
+
const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
|
|
2371
|
+
const avail = Math.max(0, cols2 - this.visibleWidth(head) - this.visibleWidth(right) - 2);
|
|
2372
|
+
let stepPart = "";
|
|
2373
|
+
if (this.step && avail > 1) {
|
|
2374
|
+
let s = this.step;
|
|
2375
|
+
if (s.length > avail) s = s.slice(0, avail - 1) + "\u2026";
|
|
2376
|
+
stepPart = ` ${C.dim}${s}${C.reset}`;
|
|
2377
|
+
}
|
|
2378
|
+
return `${head}${stepPart} ${right}`;
|
|
2379
|
+
}
|
|
2380
|
+
if (this.goalComplete) {
|
|
2381
|
+
const done = `${C.bold}${C.green}\u2713 Goal complete.${C.reset}`;
|
|
2382
|
+
return tk ? `${done} ${tk}` : done;
|
|
2383
|
+
}
|
|
2384
|
+
return tk ? tk : null;
|
|
2385
|
+
}
|
|
2386
|
+
/** The prompt line prefix (with ANSI colour) that precedes the typed text. */
|
|
2387
|
+
promptPrefix() {
|
|
2388
|
+
const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
|
|
2389
|
+
const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
|
|
2390
|
+
return `${q}${bg}${this.levelColor()}\u276F${C.reset} `;
|
|
2391
|
+
}
|
|
2392
|
+
visibleWidth(s) {
|
|
2393
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* The slash-palette block rendered BELOW the input. While the palette is open
|
|
2397
|
+
* we reserve a fixed number of rows — one per available command — padding with
|
|
2398
|
+
* blank rows so the region height never changes as the filter narrows. That
|
|
2399
|
+
* pins the input: the region scrolls into place once when the palette opens,
|
|
2400
|
+
* then nothing below the input resizes, so the text you're typing never jumps.
|
|
2401
|
+
* Returns `[]` when the palette is closed (no reservation, input sits at the
|
|
2402
|
+
* bottom as usual).
|
|
2403
|
+
*/
|
|
2404
|
+
paletteBlockLines(cols2) {
|
|
2405
|
+
if (!this.paletteOpen()) return [];
|
|
2406
|
+
const rows = this.paletteLines(cols2);
|
|
2407
|
+
const reserved = Math.max(this.commands.length, rows.length);
|
|
2408
|
+
while (rows.length < reserved) rows.push("");
|
|
2409
|
+
return rows;
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Build the slash-palette rows for the current filter. Each row is clamped to
|
|
2413
|
+
* ONE physical line (a wrapped row would desync the move-up redraw), with the
|
|
2414
|
+
* `/name` highlighted, the label dimmed, and the hint right-aligned.
|
|
2415
|
+
*/
|
|
2416
|
+
paletteLines(cols2) {
|
|
2417
|
+
if (!this.paletteOpen()) return [];
|
|
2418
|
+
const matches = this.filteredCommands();
|
|
2419
|
+
if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
|
|
2420
|
+
const cur = Math.min(this.slashIdx, matches.length - 1);
|
|
2421
|
+
const pointerW = 2;
|
|
2422
|
+
return matches.map((cmd, i) => {
|
|
2423
|
+
const sel = i === cur;
|
|
2424
|
+
const hint = (typeof cmd.hint === "function" ? cmd.hint() : cmd.hint) ?? "";
|
|
2425
|
+
const hintW = hint.length;
|
|
2426
|
+
const name = `/${cmd.name}`;
|
|
2427
|
+
let visible = `${name} ${cmd.label}`;
|
|
2428
|
+
const labelMax = Math.max(6, cols2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
2429
|
+
if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
|
|
2430
|
+
const desc = visible.slice(name.length);
|
|
2431
|
+
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
2432
|
+
const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
|
|
2433
|
+
let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
|
|
2434
|
+
if (hintW) {
|
|
2435
|
+
const gap = Math.max(2, cols2 - pointerW - visible.length - hintW);
|
|
2436
|
+
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
2437
|
+
}
|
|
2438
|
+
return line;
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Move the cursor to the top-left of the current bottom region.
|
|
2443
|
+
*
|
|
2444
|
+
* Same width as the last draw → the caret's recorded row offset is exact.
|
|
2445
|
+
* Width CHANGED (terminal resized) → previously drawn rows re-wrapped, so
|
|
2446
|
+
* that offset is stale; recompute it under the new wrap instead: each drawn
|
|
2447
|
+
* HUD row of visible width w now occupies ceil(w / cols) physical rows
|
|
2448
|
+
* (reflowing terminals re-wrap hard lines; the cursor follows its logical
|
|
2449
|
+
* position in the input text, which inputLayout locates at the new width).
|
|
2450
|
+
*/
|
|
2451
|
+
moveToRegionTop() {
|
|
2452
|
+
process.stdout.write("\r");
|
|
2453
|
+
if (!this.bottomDrawn) return;
|
|
2454
|
+
const cols2 = process.stdout.columns || 80;
|
|
2455
|
+
let up;
|
|
2456
|
+
if (cols2 !== this.lastDrawnCols && this.lastDrawnCols > 0) {
|
|
2457
|
+
let above = 0;
|
|
2458
|
+
for (const w of this.drawnHudWidths) above += Math.max(1, Math.ceil(Math.max(w, 1) / cols2));
|
|
2459
|
+
up = above + this.inputLayout().caretRow;
|
|
2460
|
+
} else {
|
|
2461
|
+
up = this.lastCursorRow;
|
|
2462
|
+
}
|
|
2463
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Render the bottom region: an optional step line, then the prompt + input
|
|
2467
|
+
* (wrapping across as many rows as needed), with the caret placed at cursorPos.
|
|
2468
|
+
* Uses only relative cursor moves so it survives terminal scrolling when the
|
|
2469
|
+
* region grows near the bottom of the screen.
|
|
2470
|
+
*/
|
|
2471
|
+
renderBottom() {
|
|
2472
|
+
if (!this.started || this.takeoverHandler) return;
|
|
2473
|
+
const cols2 = process.stdout.columns || 80;
|
|
2474
|
+
this.moveToRegionTop();
|
|
2475
|
+
process.stdout.write("\x1B[J");
|
|
2476
|
+
const hudWidths = [];
|
|
2477
|
+
const writeHudRow = (line) => {
|
|
2478
|
+
hudWidths.push(this.visibleWidth(line));
|
|
2479
|
+
process.stdout.write(line + "\r\n");
|
|
2480
|
+
};
|
|
2481
|
+
const previewLines = this.streamPreviewLines(cols2);
|
|
2482
|
+
for (const line of previewLines) writeHudRow(line);
|
|
2483
|
+
const rulerRows = 1;
|
|
2484
|
+
writeHudRow(`${C.dim}${"\u2500".repeat(cols2)}${C.reset}`);
|
|
2485
|
+
const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
|
|
2486
|
+
const noticeRows = noticeLine ? 1 : 0;
|
|
2487
|
+
if (noticeLine) writeHudRow(noticeLine);
|
|
2488
|
+
const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
|
|
2489
|
+
const quitRows = quitLine ? 1 : 0;
|
|
2490
|
+
if (quitLine) writeHudRow(quitLine);
|
|
2491
|
+
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
2492
|
+
for (const sub of this.subagents) {
|
|
2493
|
+
const color = sub.agentName === COMPACTION_AGENT ? COMPACTION_COLOR : SUBAGENT_COLORS[this.subagentColorByID.get(sub.id) ?? 0];
|
|
2494
|
+
const budget = cols2 - 10;
|
|
2495
|
+
let label = sub.label;
|
|
2496
|
+
if (budget < 1) label = "";
|
|
2497
|
+
else if (label.length > budget) label = label.slice(0, Math.max(0, budget - 1)) + "\u2026";
|
|
2498
|
+
const line = `${color}${frame}${C.reset} ${color}${label}${C.reset} ${C.dim}working${C.reset}`;
|
|
2499
|
+
writeHudRow(line);
|
|
2500
|
+
}
|
|
2501
|
+
const statusLine = this.statusLineText(cols2);
|
|
2502
|
+
const statusRows = statusLine ? 1 : 0;
|
|
2503
|
+
if (statusLine) writeHudRow(statusLine);
|
|
2504
|
+
const goalLines = this.goalLines(cols2);
|
|
2505
|
+
for (const line of goalLines) writeHudRow(line);
|
|
2506
|
+
const aboveRows = previewLines.length + rulerRows + noticeRows + quitRows + this.subagents.length + statusRows + goalLines.length;
|
|
2507
|
+
const prefix = this.promptPrefix();
|
|
2508
|
+
const pw = this.visibleWidth(prefix);
|
|
2509
|
+
const lines = this.inputBuffer.split("\n");
|
|
2510
|
+
process.stdout.write(prefix + lines[0]);
|
|
2511
|
+
for (let i = 1; i < lines.length; i++) process.stdout.write("\r\n" + lines[i]);
|
|
2512
|
+
const rowsOf = (len, lead) => Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2513
|
+
const lineRows = lines.map((l, i) => rowsOf(l.length, i === 0 ? pw : 0));
|
|
2514
|
+
const inputRows = lineRows.reduce((a, b) => a + b, 0);
|
|
2515
|
+
let pos = this.cursorPos;
|
|
2516
|
+
let caretLine = 0;
|
|
2517
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2518
|
+
pos -= lines[caretLine].length + 1;
|
|
2519
|
+
caretLine++;
|
|
2520
|
+
}
|
|
2521
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2522
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2523
|
+
for (let i = 0; i < caretLine; i++) caretRow += lineRows[i];
|
|
2524
|
+
const caretCol = caretCell % cols2;
|
|
2525
|
+
const paletteBlock = this.paletteBlockLines(cols2);
|
|
2526
|
+
for (const line of paletteBlock) process.stdout.write("\r\n" + line);
|
|
2527
|
+
if (paletteBlock.length > 0) {
|
|
2528
|
+
process.stdout.write("\r");
|
|
2529
|
+
const up = inputRows - 1 + paletteBlock.length - caretRow;
|
|
2530
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2531
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2532
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
2533
|
+
} else if (this.cursorPos < this.inputBuffer.length) {
|
|
2534
|
+
process.stdout.write("\r");
|
|
2535
|
+
const up = inputRows - 1 - caretRow;
|
|
2536
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2537
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2538
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
2539
|
+
} else {
|
|
2540
|
+
this.lastCursorRow = aboveRows + (inputRows - 1);
|
|
2541
|
+
}
|
|
2542
|
+
this.drawnHudWidths = hudWidths;
|
|
2543
|
+
this.lastDrawnCols = cols2;
|
|
2544
|
+
this.bottomDrawn = true;
|
|
2545
|
+
}
|
|
2546
|
+
clearBottom() {
|
|
2547
|
+
if (!this.bottomDrawn) return;
|
|
2548
|
+
this.moveToRegionTop();
|
|
2549
|
+
process.stdout.write("\x1B[J");
|
|
2550
|
+
this.bottomDrawn = false;
|
|
2551
|
+
}
|
|
2552
|
+
// ── live streaming preview ────────────────────────────────────────────────
|
|
2553
|
+
// An ephemeral tail of the model's output above the status line: reasoning in
|
|
2554
|
+
// dim italic until the answer begins, then the answer plain. clearStream()
|
|
2555
|
+
// wipes it right before the finished message is committed to the transcript
|
|
2556
|
+
// (which renders full markdown), so there's no double-render.
|
|
2557
|
+
static STREAM_TAIL = 20;
|
|
2558
|
+
// How long a preview may sit untouched before it's wiped. The model often
|
|
2559
|
+
// reasons and then calls a tool without ever emitting an answer, so without
|
|
2560
|
+
// this the reasoning tail would linger on screen until the *next* thought (or
|
|
2561
|
+
// message) arrives. Re-armed on every delta → fires this long after the last.
|
|
2562
|
+
static STREAM_IDLE_MS = 1e4;
|
|
2563
|
+
/** Append a fragment of streamed answer text (rendered plain). */
|
|
2564
|
+
streamResponseDelta(delta, messageId) {
|
|
2565
|
+
this.beginStreamMessage(messageId);
|
|
2566
|
+
this.streamResponse += delta;
|
|
2567
|
+
this.scheduleStreamRedraw();
|
|
2568
|
+
this.armStreamIdleExpiry();
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Append a fragment of streamed internal reasoning (rendered dim italic).
|
|
2572
|
+
* Ignored once the answer has started, since reasoning precedes the answer.
|
|
2573
|
+
*/
|
|
2574
|
+
streamThinkingDelta(delta, messageId) {
|
|
2575
|
+
this.beginStreamMessage(messageId);
|
|
2576
|
+
if (this.streamResponse) return;
|
|
2577
|
+
this.streamThinking += delta;
|
|
2578
|
+
this.scheduleStreamRedraw();
|
|
2579
|
+
this.armStreamIdleExpiry();
|
|
2580
|
+
}
|
|
2581
|
+
/** Reset the preview when a new message starts, so two messages' output (e.g.
|
|
2582
|
+
* one that only reasons then calls a tool, then the next) never blend. */
|
|
2583
|
+
beginStreamMessage(messageId) {
|
|
2584
|
+
if (messageId !== void 0 && messageId !== this.streamMessageId) {
|
|
2585
|
+
this.streamMessageId = messageId;
|
|
2586
|
+
this.streamThinking = "";
|
|
2587
|
+
this.streamResponse = "";
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
/** Wipe the live preview — call right before committing the final message. */
|
|
2591
|
+
clearStream() {
|
|
2592
|
+
if (this.streamRedrawTimer) {
|
|
2593
|
+
clearTimeout(this.streamRedrawTimer);
|
|
2594
|
+
this.streamRedrawTimer = null;
|
|
2595
|
+
}
|
|
2596
|
+
if (this.streamIdleTimer) {
|
|
2597
|
+
clearTimeout(this.streamIdleTimer);
|
|
2598
|
+
this.streamIdleTimer = null;
|
|
2599
|
+
}
|
|
2600
|
+
this.streamMessageId = null;
|
|
2601
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2602
|
+
this.streamThinking = "";
|
|
2603
|
+
this.streamResponse = "";
|
|
2604
|
+
this.renderBottom();
|
|
2605
|
+
}
|
|
2606
|
+
scheduleStreamRedraw() {
|
|
2607
|
+
if (this.streamRedrawTimer || this.takeoverHandler || !this.started) return;
|
|
2608
|
+
this.streamRedrawTimer = setTimeout(() => {
|
|
2609
|
+
this.streamRedrawTimer = null;
|
|
2610
|
+
this.renderBottom();
|
|
2611
|
+
}, 40);
|
|
2612
|
+
}
|
|
2613
|
+
/** Re-armed on every streamed delta: once the model goes quiet for a beat, the
|
|
2614
|
+
* preview is stale, so wipe it instead of letting it sit until the next
|
|
2615
|
+
* message. The committed message (if any) still renders in full via
|
|
2616
|
+
* clearStream(), so nothing is lost. */
|
|
2617
|
+
armStreamIdleExpiry() {
|
|
2618
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
2619
|
+
this.streamIdleTimer = setTimeout(() => {
|
|
2620
|
+
this.streamIdleTimer = null;
|
|
2621
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2622
|
+
this.streamThinking = "";
|
|
2623
|
+
this.streamResponse = "";
|
|
2624
|
+
this.renderBottom();
|
|
2625
|
+
}, _Tui.STREAM_IDLE_MS);
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The preview's physical rows: a tail of reasoning (dim italic) before the
|
|
2629
|
+
* answer starts, otherwise a tail of the answer (plain). Each row is clamped to
|
|
2630
|
+
* one terminal line so the bottom-region redraw math stays correct.
|
|
2631
|
+
*
|
|
2632
|
+
* Rows match the committed transcript formatting so a finished message doesn't
|
|
2633
|
+
* visibly "snap" into shape: the answer's first line carries the grey gutter
|
|
2634
|
+
* dot (as long as it hasn't scrolled out of the tail) and the body indents two
|
|
2635
|
+
* spaces beneath it; reasoning aligns at the same indent, dotless.
|
|
2636
|
+
*/
|
|
2637
|
+
streamPreviewLines(cols2) {
|
|
2638
|
+
const thinkStyle = "\x1B[3m\x1B[38;5;240m";
|
|
2639
|
+
const clamp2 = (s, wrap, lead) => {
|
|
2640
|
+
const max = cols2 - 2;
|
|
2641
|
+
const t = s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2642
|
+
return wrap ? `${lead}${wrap}${t}${C.reset}` : `${lead}${t}`;
|
|
2643
|
+
};
|
|
2644
|
+
const realLines = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
|
|
2645
|
+
if (this.streamResponse) {
|
|
2646
|
+
const all = realLines(this.streamResponse);
|
|
2647
|
+
const shown = all.slice(-20);
|
|
2648
|
+
const firstVisible = all.length <= _Tui.STREAM_TAIL;
|
|
2649
|
+
return shown.map(
|
|
2650
|
+
(l, i) => clamp2(l, "", i === 0 && firstVisible ? `${C.gray}\u2022${C.reset} ` : " ")
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
if (this.streamThinking) {
|
|
2654
|
+
return realLines(this.streamThinking).slice(-20).map((l) => clamp2(l, thinkStyle, " "));
|
|
2655
|
+
}
|
|
2656
|
+
return [];
|
|
2657
|
+
}
|
|
2658
|
+
// ── live goal checklist ───────────────────────────────────────────────────
|
|
2659
|
+
/**
|
|
2660
|
+
* Update the goal from the goal_updated event (or the initial fetch). When
|
|
2661
|
+
* every step is done, the goal is fully achieved: we clear the goal area and
|
|
2662
|
+
* flip on the "Goal complete." status badge instead of leaving a finished
|
|
2663
|
+
* checklist sitting there.
|
|
2664
|
+
*/
|
|
2665
|
+
setGoal(goal) {
|
|
2666
|
+
const steps = goal?.steps;
|
|
2667
|
+
if (steps && Array.isArray(steps) && steps.length) {
|
|
2668
|
+
const allDone = steps.every((s) => s.status === "done");
|
|
2669
|
+
if (allDone) {
|
|
2670
|
+
this.goal = null;
|
|
2671
|
+
this.goalComplete = true;
|
|
2672
|
+
} else {
|
|
2673
|
+
this.goal = goal;
|
|
2674
|
+
this.goalComplete = false;
|
|
2675
|
+
}
|
|
2676
|
+
} else {
|
|
2677
|
+
this.goal = null;
|
|
2678
|
+
}
|
|
2679
|
+
this.renderBottom();
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* The goal's physical rows: a header (the short summary + progress) then one
|
|
2683
|
+
* row per step, indented two spaces beneath it so the todos clearly belong to
|
|
2684
|
+
* the goal — ○ pending / ▸ in-progress / ✓ done — each clamped to a line.
|
|
2685
|
+
*/
|
|
2686
|
+
goalLines(cols2) {
|
|
2687
|
+
const steps = this.goal?.steps;
|
|
2688
|
+
if (!steps?.length) return [];
|
|
2689
|
+
const oneLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
2690
|
+
const clamp2 = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2691
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2692
|
+
const out = [];
|
|
2693
|
+
const done = steps.filter((s) => s.status === "done").length;
|
|
2694
|
+
const summary = oneLine(this.goal?.summary || this.goal?.description || "Goal");
|
|
2695
|
+
const prefix = `Goal ${done}/${steps.length} `;
|
|
2696
|
+
const sum = clamp2(summary, Math.max(1, cols2 - prefix.length));
|
|
2697
|
+
out.push(`${C.bold}Goal${C.reset} ${C.gray}${done}/${steps.length}${C.reset} ${sum}`);
|
|
2698
|
+
for (const s of steps) {
|
|
2699
|
+
const text = clamp2(oneLine(s.step || ""), Math.max(1, cols2 - 4));
|
|
2700
|
+
if (s.status === "done") out.push(` ${calmGreen}\u2713${C.reset} ${C.dim}${text}${C.reset}`);
|
|
2701
|
+
else if (s.status === "in_progress") out.push(` ${C.cyan}\u25B8${C.reset} ${text}`);
|
|
2702
|
+
else out.push(` ${C.gray}\u25CB ${text}${C.reset}`);
|
|
2703
|
+
}
|
|
2704
|
+
return out;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Transcript gutter: content starts at column 1, so the far-left column is a
|
|
2708
|
+
* clean strip where only status glyphs (✓ ✗ ⛔ …) land — scanning down the
|
|
2709
|
+
* left edge reads as a ledger of what happened. Lines already led by a
|
|
2710
|
+
* gutter glyph or whitespace (indented blocks, the user-message bar) pass
|
|
2711
|
+
* through untouched.
|
|
2712
|
+
*/
|
|
2713
|
+
gutterize(text) {
|
|
2714
|
+
return text.split("\n").map((line) => {
|
|
2715
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2716
|
+
if (plain === "" || /^[\s✓✗⛔⚠⚙⚡⏳↪›❯◇─•]/.test(plain)) return line;
|
|
2717
|
+
return " " + line;
|
|
2718
|
+
}).join("\n");
|
|
2719
|
+
}
|
|
2720
|
+
/** Print a line of transcript above the persistent input. */
|
|
2721
|
+
print(text) {
|
|
2722
|
+
const line = this.gutterize(text);
|
|
2723
|
+
if (this.takeoverHandler) {
|
|
2724
|
+
this.bufferedPrints.push(line);
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
this.clearBottom();
|
|
2728
|
+
process.stdout.write(line + "\n");
|
|
2729
|
+
this.renderBottom();
|
|
2730
|
+
}
|
|
2731
|
+
/** Multi-line convenience. */
|
|
2732
|
+
printLines(lines) {
|
|
2733
|
+
for (const l of lines) this.print(l);
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Print a user message as a highlighted block so it stands out in the
|
|
2737
|
+
* transcript (à la Codex). The bar is sized to the text (not full width, which
|
|
2738
|
+
* would wrap awkwardly), padded with a space on each side and a blank line
|
|
2739
|
+
* above and below, and a teal `›` marks the first row.
|
|
2740
|
+
*/
|
|
2741
|
+
printUserMessage(text) {
|
|
2742
|
+
const cols2 = Math.max(20, process.stdout.columns || 80);
|
|
2743
|
+
const bg = "\x1B[48;5;238m";
|
|
2744
|
+
const limit = Math.max(8, cols2 - 6);
|
|
2745
|
+
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
2746
|
+
const lines = [];
|
|
2747
|
+
let cur = "";
|
|
2748
|
+
for (let w of words) {
|
|
2749
|
+
while (w.length > limit) {
|
|
2750
|
+
if (cur) {
|
|
2751
|
+
lines.push(cur);
|
|
2752
|
+
cur = "";
|
|
2753
|
+
}
|
|
2754
|
+
lines.push(w.slice(0, limit));
|
|
2755
|
+
w = w.slice(limit);
|
|
2756
|
+
}
|
|
2757
|
+
if (!cur) cur = w;
|
|
2758
|
+
else if (cur.length + 1 + w.length <= limit) cur += " " + w;
|
|
2759
|
+
else {
|
|
2760
|
+
lines.push(cur);
|
|
2761
|
+
cur = w;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
if (cur || !lines.length) lines.push(cur);
|
|
2765
|
+
const innerW = 2 + Math.max(...lines.map((l) => l.length));
|
|
2766
|
+
this.print("");
|
|
2767
|
+
lines.forEach((line, i) => {
|
|
2768
|
+
const rowText = (i === 0 ? "\u276F " : " ") + line;
|
|
2769
|
+
const padded = rowText.padEnd(innerW);
|
|
2770
|
+
const inner = i === 0 ? `${C.teal}\u276F${C.reset}${bg}${padded.slice(1)}` : padded;
|
|
2771
|
+
this.print(`${bg} ${inner} ${C.reset}`);
|
|
2772
|
+
});
|
|
2773
|
+
this.print("");
|
|
2774
|
+
}
|
|
2775
|
+
// ─── working indicator (turn state) ───────────────────────────────────────
|
|
2776
|
+
setWorking(on) {
|
|
2777
|
+
if (on && !this.working) {
|
|
2778
|
+
this.working = true;
|
|
2779
|
+
this.workingStart = Date.now();
|
|
2780
|
+
this.goalComplete = false;
|
|
2781
|
+
} else if (!on) {
|
|
2782
|
+
this.working = false;
|
|
2783
|
+
}
|
|
2784
|
+
this.syncSpinner();
|
|
2785
|
+
this.renderBottom();
|
|
2786
|
+
}
|
|
2787
|
+
/** The subagents currently working, one persistent line each. Each keeps a
|
|
2788
|
+
* stable, distinct colour for as long as it's active; the compaction agent
|
|
2789
|
+
* is always orange (its colour never comes from the shared pool). */
|
|
2790
|
+
setSubagents(subagents) {
|
|
2791
|
+
this.subagents = subagents;
|
|
2792
|
+
const active = new Set(subagents.map((s) => s.id));
|
|
2793
|
+
for (const id of [...this.subagentColorByID.keys()]) {
|
|
2794
|
+
if (!active.has(id)) this.subagentColorByID.delete(id);
|
|
2795
|
+
}
|
|
2796
|
+
for (const s of subagents) {
|
|
2797
|
+
if (s.agentName === COMPACTION_AGENT) continue;
|
|
2798
|
+
if (this.subagentColorByID.has(s.id)) continue;
|
|
2799
|
+
const used = new Set(this.subagentColorByID.values());
|
|
2800
|
+
let idx = 0;
|
|
2801
|
+
while (used.has(idx) && idx < SUBAGENT_COLORS.length - 1) idx++;
|
|
2802
|
+
this.subagentColorByID.set(s.id, idx);
|
|
2803
|
+
}
|
|
2804
|
+
this.syncSpinner();
|
|
2805
|
+
this.renderBottom();
|
|
2806
|
+
}
|
|
2807
|
+
/** Run the spinner animation while anything (the agent or a subagent) is active. */
|
|
2808
|
+
syncSpinner() {
|
|
2809
|
+
const spinning = this.working || this.subagents.length > 0;
|
|
2810
|
+
if (spinning && !this.spinnerTimer) {
|
|
2811
|
+
this.spinnerTimer = setInterval(() => this.renderBottom(), 100);
|
|
2812
|
+
} else if (!spinning && this.spinnerTimer) {
|
|
2813
|
+
clearInterval(this.spinnerTimer);
|
|
2814
|
+
this.spinnerTimer = null;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
get isWorking() {
|
|
2818
|
+
return this.working;
|
|
2819
|
+
}
|
|
2820
|
+
setBackgroundCount(n) {
|
|
2821
|
+
this.bgCount = n;
|
|
2822
|
+
this.renderBottom();
|
|
2823
|
+
}
|
|
2824
|
+
setQueuedCount(n) {
|
|
2825
|
+
this.queuedCount = n;
|
|
2826
|
+
this.renderBottom();
|
|
2827
|
+
}
|
|
2828
|
+
setConnected(connected) {
|
|
2829
|
+
this.connected = connected;
|
|
2830
|
+
this.renderBottom();
|
|
2831
|
+
}
|
|
2832
|
+
// ─── input buffer access (for up-arrow editing of queued messages) ─────────
|
|
2833
|
+
getInput() {
|
|
2834
|
+
return this.inputBuffer;
|
|
2835
|
+
}
|
|
2836
|
+
/** Replace the input (and any pasted images tied to placeholders in it). */
|
|
2837
|
+
setInput(text, images = []) {
|
|
2838
|
+
this.inputBuffer = text;
|
|
2839
|
+
this.cursorPos = text.length;
|
|
2840
|
+
this.pendingImages = images;
|
|
2841
|
+
this.historyIdx = null;
|
|
2842
|
+
this.renderBottom();
|
|
2843
|
+
}
|
|
2844
|
+
/** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
|
|
2845
|
+
setTokens(inTokens, outTokens) {
|
|
2846
|
+
this.tokensIn = inTokens;
|
|
2847
|
+
this.tokensOut = outTokens;
|
|
2848
|
+
this.renderBottom();
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Current step the agent is working on, shown on the line above the prompt.
|
|
2852
|
+
* `outTokens` is the output produced during this step. The step's own timer
|
|
2853
|
+
* resets whenever the label changes.
|
|
2854
|
+
*/
|
|
2855
|
+
setStep(label, outTokens) {
|
|
2856
|
+
const next = label && label.trim() ? label.trim() : null;
|
|
2857
|
+
if (next !== this.step) {
|
|
2858
|
+
this.step = next;
|
|
2859
|
+
this.stepStart = Date.now();
|
|
2860
|
+
}
|
|
2861
|
+
this.stepOut = outTokens;
|
|
2862
|
+
this.renderBottom();
|
|
2863
|
+
}
|
|
2864
|
+
// ─── takeover helpers (approval / menus) ───────────────────────────────────
|
|
2865
|
+
beginTakeover() {
|
|
2866
|
+
this.clearBottom();
|
|
2867
|
+
process.stdout.write("\r\x1B[K");
|
|
2868
|
+
process.stdout.write("\x1B[?25l");
|
|
2869
|
+
this.bottomDrawn = false;
|
|
2870
|
+
}
|
|
2871
|
+
endTakeover() {
|
|
2872
|
+
this.takeoverHandler = null;
|
|
2873
|
+
process.stdout.write("\x1B[?25h");
|
|
2874
|
+
const buffered = this.bufferedPrints;
|
|
2875
|
+
this.bufferedPrints = [];
|
|
2876
|
+
for (const t of buffered) {
|
|
2877
|
+
process.stdout.write(t + "\n");
|
|
2878
|
+
}
|
|
2879
|
+
this.renderBottom();
|
|
2880
|
+
}
|
|
2881
|
+
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
|
|
2882
|
+
* Tab resolves "deny_with_reason" so the caller can collect a free-text reason. */
|
|
2883
|
+
approval(question, risk) {
|
|
2884
|
+
return new Promise((resolve) => {
|
|
2885
|
+
const options = [
|
|
2886
|
+
{ value: "allow", label: "Allow once", shortcut: "y", color: C.green },
|
|
2887
|
+
{ value: "always", label: "Always allow this tool", shortcut: "a", color: C.cyan },
|
|
2888
|
+
{ value: "always_risk", label: `Allow all level ${risk} this session`, shortcut: "l", color: C.cyan },
|
|
2889
|
+
{ value: "deny", label: "Deny", shortcut: "n", color: C.red }
|
|
2890
|
+
];
|
|
2891
|
+
let idx = 0;
|
|
2892
|
+
const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
|
|
2893
|
+
this.beginTakeover();
|
|
2894
|
+
const cols2 = Math.max(1, process.stdout.columns || 80);
|
|
2895
|
+
const physRows = (line) => Math.max(1, Math.ceil(this.visibleWidth(line) / cols2));
|
|
2896
|
+
const headerText = `${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}`;
|
|
2897
|
+
const questionLines = question.split("\n").map((line) => `${C.yellow}\u2503${C.reset} ${line}`);
|
|
2898
|
+
const hintLine = `${C.yellow}\u2503${C.reset} ${C.gray}\u21E5 tab \u2014 deny with a reason${C.reset}`;
|
|
2899
|
+
const blockLines = [headerText, ...questionLines, hintLine];
|
|
2900
|
+
const headerRows = 1 + blockLines.reduce((n, line) => n + physRows(line), 0);
|
|
2901
|
+
process.stdout.write(`
|
|
2902
|
+
` + blockLines.map((line) => `${line}
|
|
2903
|
+
`).join(""));
|
|
2904
|
+
const renderLine = (i) => {
|
|
2905
|
+
const o = options[i];
|
|
2906
|
+
const sel = i === idx;
|
|
2907
|
+
const pointer = sel ? `${o.color}\u276F${C.reset}` : " ";
|
|
2908
|
+
const label = sel ? `${C.bold}${o.label}${C.reset}` : o.label;
|
|
2909
|
+
return `${C.yellow}\u2503${C.reset} ${pointer} ${label} ${C.gray}(${o.shortcut})${C.reset}`;
|
|
2910
|
+
};
|
|
2911
|
+
const draw = (moveUp) => {
|
|
2912
|
+
if (moveUp) process.stdout.write(`\x1B[${options.length}A`);
|
|
2913
|
+
for (let i = 0; i < options.length; i++) process.stdout.write(`\r\x1B[K${renderLine(i)}
|
|
2914
|
+
`);
|
|
2915
|
+
};
|
|
2916
|
+
draw(false);
|
|
2917
|
+
const erase = () => {
|
|
2918
|
+
process.stdout.write("\r");
|
|
2919
|
+
const up = headerRows + options.length;
|
|
2920
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2921
|
+
process.stdout.write("\x1B[J");
|
|
2922
|
+
};
|
|
2923
|
+
const finish = (choice) => {
|
|
2924
|
+
erase();
|
|
2925
|
+
this.endTakeover();
|
|
2926
|
+
resolve(choice);
|
|
2927
|
+
};
|
|
2928
|
+
this.takeoverHandler = (str, key) => {
|
|
2929
|
+
if (key?.name === "up" || str === "k") {
|
|
2930
|
+
idx = (idx - 1 + options.length) % options.length;
|
|
2931
|
+
draw(true);
|
|
2932
|
+
} else if (key?.name === "down" || str === "j") {
|
|
2933
|
+
idx = (idx + 1) % options.length;
|
|
2934
|
+
draw(true);
|
|
2935
|
+
} else if (key?.name === "tab") {
|
|
2936
|
+
finish("deny_with_reason");
|
|
2937
|
+
} else if (key?.name === "return" || key?.name === "enter") {
|
|
2938
|
+
finish(options[idx].value);
|
|
2939
|
+
} else {
|
|
2940
|
+
const k = (str || "").toLowerCase();
|
|
2941
|
+
if (k === "y") finish("allow");
|
|
2942
|
+
else if (k === "a") finish("always");
|
|
2943
|
+
else if (k === "l") finish("always_risk");
|
|
2944
|
+
else if (k === "n" || key?.name === "escape") finish("deny");
|
|
2945
|
+
}
|
|
2946
|
+
};
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
/** Arrow-key selection menu (slash menu, process menu, resume). Pauses input. */
|
|
2950
|
+
select(title, items) {
|
|
2951
|
+
return new Promise((resolve) => {
|
|
2952
|
+
let idx = 0;
|
|
2953
|
+
this.beginTakeover();
|
|
2954
|
+
if (title) process.stdout.write(`
|
|
2955
|
+
${title}
|
|
2956
|
+
|
|
2957
|
+
`);
|
|
2958
|
+
const renderLine = (i) => {
|
|
2959
|
+
const w = process.stdout.columns || 80;
|
|
2960
|
+
const it = items[i];
|
|
2961
|
+
const sel = i === idx;
|
|
2962
|
+
const hint = it.hint ?? "";
|
|
2963
|
+
const hintW = hint.length;
|
|
2964
|
+
const pointerW = 2;
|
|
2965
|
+
const labelMax = Math.max(6, w - 2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
2966
|
+
let label = it.label;
|
|
2967
|
+
if (label.length > labelMax) label = label.slice(0, labelMax - 1) + "\u2026";
|
|
2968
|
+
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
2969
|
+
const styledLabel = sel ? `${C.bold}${C.cyan}${label}${C.reset}` : label;
|
|
2970
|
+
let line = `${pointer}${styledLabel}`;
|
|
2971
|
+
if (hintW) {
|
|
2972
|
+
const gap = Math.max(2, w - 2 - pointerW - label.length - hintW);
|
|
2973
|
+
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
2974
|
+
}
|
|
2975
|
+
return line;
|
|
2976
|
+
};
|
|
2977
|
+
const draw = (moveUp) => {
|
|
2978
|
+
if (moveUp) process.stdout.write(`\x1B[${items.length}A`);
|
|
2979
|
+
for (let i = 0; i < items.length; i++) process.stdout.write(`\r\x1B[K${renderLine(i)}
|
|
2980
|
+
`);
|
|
2981
|
+
};
|
|
2982
|
+
draw(false);
|
|
2983
|
+
const titleRows = title ? 3 : 0;
|
|
2984
|
+
const erase = () => {
|
|
2985
|
+
process.stdout.write("\r");
|
|
2986
|
+
const up = titleRows + items.length;
|
|
2987
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2988
|
+
process.stdout.write("\x1B[J");
|
|
2989
|
+
};
|
|
2990
|
+
const close = (value) => {
|
|
2991
|
+
erase();
|
|
2992
|
+
this.endTakeover();
|
|
2993
|
+
resolve(value);
|
|
2994
|
+
};
|
|
2995
|
+
this.takeoverHandler = (str, key) => {
|
|
2996
|
+
if (!key) return;
|
|
2997
|
+
if (key.name === "up" || str === "k") {
|
|
2998
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
2999
|
+
draw(true);
|
|
3000
|
+
} else if (key.name === "down" || str === "j") {
|
|
3001
|
+
idx = (idx + 1) % items.length;
|
|
3002
|
+
draw(true);
|
|
3003
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
3004
|
+
close(items[idx].value);
|
|
3005
|
+
} else if (key.name === "escape") {
|
|
3006
|
+
close(void 0);
|
|
3007
|
+
}
|
|
3008
|
+
};
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
/**
|
|
3012
|
+
* Free-text prompt (single line). Pauses the main input and reads a line —
|
|
3013
|
+
* used where a menu can't, e.g. entering an MCP server command. Enter submits,
|
|
3014
|
+
* Escape (or empty submit) cancels with null.
|
|
3015
|
+
*/
|
|
3016
|
+
prompt(question, placeholder = "") {
|
|
3017
|
+
return new Promise((resolve) => {
|
|
3018
|
+
let buf = "";
|
|
3019
|
+
this.beginTakeover();
|
|
3020
|
+
process.stdout.write("\x1B[?25h");
|
|
3021
|
+
process.stdout.write(`
|
|
3022
|
+
${C.cyan}\u2503${C.reset} ${question}
|
|
3023
|
+
`);
|
|
3024
|
+
if (placeholder) process.stdout.write(`${C.gray}\u2503 e.g. ${placeholder}${C.reset}
|
|
3025
|
+
`);
|
|
3026
|
+
const draw = () => {
|
|
3027
|
+
process.stdout.write(`\r\x1B[K${C.cyan}\u2503${C.reset} ${C.bold}\u203A${C.reset} ${buf}`);
|
|
3028
|
+
};
|
|
3029
|
+
draw();
|
|
3030
|
+
const finish = (value) => {
|
|
3031
|
+
process.stdout.write("\n");
|
|
3032
|
+
this.endTakeover();
|
|
3033
|
+
resolve(value);
|
|
3034
|
+
};
|
|
3035
|
+
this.takeoverHandler = (str, key) => {
|
|
3036
|
+
if (key?.name === "escape") return finish(null);
|
|
3037
|
+
if (key?.name === "return" || key?.name === "enter") return finish(buf.trim() || null);
|
|
3038
|
+
if (key?.name === "backspace") {
|
|
3039
|
+
buf = buf.slice(0, -1);
|
|
3040
|
+
draw();
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
if (str && !key?.ctrl && !key?.meta && str >= " ") {
|
|
3044
|
+
buf += str;
|
|
3045
|
+
draw();
|
|
3046
|
+
}
|
|
3047
|
+
};
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
banner(lines) {
|
|
3051
|
+
this.clearBottom();
|
|
3052
|
+
process.stdout.write("\n");
|
|
3053
|
+
for (const l of lines) process.stdout.write(l + "\n");
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
3056
|
+
|
|
3057
|
+
// src/history.ts
|
|
3058
|
+
var HISTORY_KEY = "input_history";
|
|
3059
|
+
var MAX_ENTRIES = 100;
|
|
3060
|
+
function clean(value) {
|
|
3061
|
+
if (!Array.isArray(value)) return [];
|
|
3062
|
+
return value.filter((e) => typeof e === "string" && e.trim() !== "").slice(-MAX_ENTRIES);
|
|
3063
|
+
}
|
|
3064
|
+
async function loadHistory(store, threadId, seedThreadId) {
|
|
3065
|
+
const own = clean(await store.kvGet(threadId, HISTORY_KEY));
|
|
3066
|
+
if (own.length) return own;
|
|
3067
|
+
if (seedThreadId && seedThreadId !== threadId) {
|
|
3068
|
+
const seeded = clean(await store.kvGet(seedThreadId, HISTORY_KEY));
|
|
3069
|
+
if (seeded.length) {
|
|
3070
|
+
void store.kvSet(threadId, HISTORY_KEY, seeded);
|
|
3071
|
+
return seeded;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
return [];
|
|
3075
|
+
}
|
|
3076
|
+
function appendHistory(store, threadId, history, text) {
|
|
3077
|
+
const t = text.trim();
|
|
3078
|
+
if (!t || history[history.length - 1] === t) return history;
|
|
3079
|
+
history.push(t);
|
|
3080
|
+
if (history.length > MAX_ENTRIES) history.splice(0, history.length - MAX_ENTRIES);
|
|
3081
|
+
void store.kvSet(threadId, HISTORY_KEY, [...history]);
|
|
3082
|
+
return history;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// src/markdown.ts
|
|
3086
|
+
var ESC = "\x1B[";
|
|
3087
|
+
var R = ESC + "0m";
|
|
3088
|
+
var BOLD = ESC + "1m";
|
|
3089
|
+
var DIM2 = ESC + "2m";
|
|
3090
|
+
var ITAL = ESC + "3m";
|
|
3091
|
+
var UNDER = ESC + "4m";
|
|
3092
|
+
var TEAL = ESC + "38;5;37m";
|
|
3093
|
+
var CYAN = ESC + "36m";
|
|
3094
|
+
var GRAY = ESC + "90m";
|
|
3095
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
3096
|
+
function visibleWidth(s) {
|
|
3097
|
+
return s.replace(ANSI, "").length;
|
|
3098
|
+
}
|
|
3099
|
+
function padEndVisible(s, width) {
|
|
3100
|
+
const pad = width - visibleWidth(s);
|
|
3101
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
3102
|
+
}
|
|
3103
|
+
function inline(s) {
|
|
3104
|
+
const codes = [];
|
|
3105
|
+
s = s.replace(/`([^`]+)`/g, (_, code) => {
|
|
3106
|
+
codes.push(code);
|
|
3107
|
+
return "\0" + (codes.length - 1) + "\0";
|
|
3108
|
+
});
|
|
3109
|
+
s = s.replace(
|
|
3110
|
+
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
|
3111
|
+
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM2}${url}${R}`
|
|
3112
|
+
);
|
|
3113
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
|
|
3114
|
+
s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
|
|
3115
|
+
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM2}${t}${R}`);
|
|
3116
|
+
s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
|
|
3117
|
+
return s;
|
|
3118
|
+
}
|
|
3119
|
+
function wrapStyled(text, width) {
|
|
3120
|
+
if (width < 4 || visibleWidth(text) <= width) return [text];
|
|
3121
|
+
const words = text.split(" ");
|
|
3122
|
+
const lines = [];
|
|
3123
|
+
let cur = "";
|
|
3124
|
+
let curLen = 0;
|
|
3125
|
+
for (const w of words) {
|
|
3126
|
+
const wLen = visibleWidth(w);
|
|
3127
|
+
if (cur === "") {
|
|
3128
|
+
cur = w;
|
|
3129
|
+
curLen = wLen;
|
|
3130
|
+
} else if (curLen + 1 + wLen <= width) {
|
|
3131
|
+
cur += " " + w;
|
|
3132
|
+
curLen += 1 + wLen;
|
|
3133
|
+
} else {
|
|
3134
|
+
lines.push(cur);
|
|
3135
|
+
cur = w;
|
|
3136
|
+
curLen = wLen;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
if (cur !== "" || lines.length === 0) lines.push(cur);
|
|
3140
|
+
return lines;
|
|
3141
|
+
}
|
|
3142
|
+
function wrapBlock(out, cols2, leadFirst, leadRest, leadWidth, text) {
|
|
3143
|
+
const wrapped = wrapStyled(text, Math.max(8, cols2 - leadWidth));
|
|
3144
|
+
wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
|
|
3145
|
+
}
|
|
3146
|
+
function tableCells(row) {
|
|
3147
|
+
let r = row.trim();
|
|
3148
|
+
if (r.startsWith("|")) r = r.slice(1);
|
|
3149
|
+
if (r.endsWith("|")) r = r.slice(0, -1);
|
|
3150
|
+
return r.split("|").map((c2) => c2.trim());
|
|
3151
|
+
}
|
|
3152
|
+
var SEPARATOR = /^[\s|:-]+$/;
|
|
3153
|
+
function isTableSeparator(line) {
|
|
3154
|
+
return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
|
|
3155
|
+
}
|
|
3156
|
+
function renderTable(rows) {
|
|
3157
|
+
const cols2 = Math.max(...rows.map((r) => r.length));
|
|
3158
|
+
const widths = [];
|
|
3159
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
3160
|
+
widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
|
|
3161
|
+
}
|
|
3162
|
+
const sep = `${GRAY} \u2502 ${R}`;
|
|
3163
|
+
const out = [];
|
|
3164
|
+
rows.forEach((r, ri) => {
|
|
3165
|
+
const cells = [];
|
|
3166
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
3167
|
+
const raw = r[c2] ?? "";
|
|
3168
|
+
const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
|
|
3169
|
+
cells.push(padEndVisible(styled, widths[c2]));
|
|
3170
|
+
}
|
|
3171
|
+
out.push((" " + cells.join(sep)).replace(/\s+$/, ""));
|
|
3172
|
+
if (ri === 0) {
|
|
3173
|
+
const rule = widths.map((w) => `${GRAY}${"\u2500".repeat(w)}${R}`).join(`${GRAY}\u2500\u253C\u2500${R}`);
|
|
3174
|
+
out.push(" " + rule);
|
|
3175
|
+
}
|
|
3176
|
+
});
|
|
3177
|
+
return out;
|
|
3178
|
+
}
|
|
3179
|
+
function renderMarkdown(src, cols2 = 80) {
|
|
3180
|
+
const lines = src.replace(/\r\n/g, "\n").split("\n");
|
|
3181
|
+
const out = [];
|
|
3182
|
+
let inFence = false;
|
|
3183
|
+
let i = 0;
|
|
3184
|
+
while (i < lines.length) {
|
|
3185
|
+
const line = lines[i];
|
|
3186
|
+
if (/^\s*```/.test(line)) {
|
|
3187
|
+
inFence = !inFence;
|
|
3188
|
+
i++;
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3191
|
+
if (inFence) {
|
|
3192
|
+
out.push(`${GRAY}\u2502${R} ${line}`);
|
|
3193
|
+
i++;
|
|
3194
|
+
continue;
|
|
3195
|
+
}
|
|
3196
|
+
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
3197
|
+
const block = [tableCells(line)];
|
|
3198
|
+
i += 2;
|
|
3199
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
|
|
3200
|
+
block.push(tableCells(lines[i]));
|
|
3201
|
+
i++;
|
|
3202
|
+
}
|
|
3203
|
+
out.push(...renderTable(block));
|
|
3204
|
+
continue;
|
|
3205
|
+
}
|
|
3206
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
3207
|
+
if (heading) {
|
|
3208
|
+
for (const ln of wrapStyled(heading[2].trim(), cols2)) out.push(`${BOLD}${TEAL}${ln}${R}`);
|
|
3209
|
+
i++;
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3212
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
3213
|
+
out.push(`${GRAY}\u2500\u2500\u2500\u2500\u2500\u2500${R}`);
|
|
3214
|
+
i++;
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3217
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
3218
|
+
if (quote) {
|
|
3219
|
+
for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols2 - 2))) {
|
|
3220
|
+
out.push(`${GRAY}\u2502${R} ${DIM2}${ln}${R}`);
|
|
3221
|
+
}
|
|
3222
|
+
i++;
|
|
3223
|
+
continue;
|
|
3224
|
+
}
|
|
3225
|
+
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
3226
|
+
if (bullet) {
|
|
3227
|
+
const leadWidth = bullet[1].length + 2;
|
|
3228
|
+
wrapBlock(out, cols2, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
|
|
3229
|
+
i++;
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
const numbered = line.match(/^(\s*)(\d+)([.)])\s+(.*)$/);
|
|
3233
|
+
if (numbered) {
|
|
3234
|
+
const marker = `${numbered[2]}${numbered[3]}`;
|
|
3235
|
+
const leadWidth = numbered[1].length + marker.length + 1;
|
|
3236
|
+
wrapBlock(out, cols2, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
|
|
3237
|
+
i++;
|
|
3238
|
+
continue;
|
|
3239
|
+
}
|
|
3240
|
+
if (line.trim()) wrapBlock(out, cols2, "", "", 0, inline(line));
|
|
3241
|
+
else out.push("");
|
|
3242
|
+
i++;
|
|
3243
|
+
}
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
var MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
3247
|
+
var SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
3248
|
+
var CLIENT_INFO = { name: "standard-code", version: "0.1.0" };
|
|
3249
|
+
var RPC_TIMEOUT_MS = 3e4;
|
|
3250
|
+
var INIT_TIMEOUT_MS = 9e4;
|
|
3251
|
+
var McpClient = class {
|
|
3252
|
+
constructor(config) {
|
|
3253
|
+
this.config = config;
|
|
3254
|
+
this.serverInfo = { name: config.name };
|
|
3255
|
+
}
|
|
3256
|
+
config;
|
|
3257
|
+
child = null;
|
|
3258
|
+
nextId = 1;
|
|
3259
|
+
pending = /* @__PURE__ */ new Map();
|
|
3260
|
+
buffer = "";
|
|
3261
|
+
closed = false;
|
|
3262
|
+
serverInfo;
|
|
3263
|
+
protocolVersion = MCP_PROTOCOL_VERSION;
|
|
3264
|
+
capabilities = {};
|
|
3265
|
+
instructions = "";
|
|
3266
|
+
tools = [];
|
|
3267
|
+
resources = [];
|
|
3268
|
+
lastError = null;
|
|
3269
|
+
/** Spawn the server, run the initialize handshake, and discover capabilities. */
|
|
3270
|
+
async connect(defaultCwd) {
|
|
3271
|
+
const child = spawn(this.config.command, this.config.args, {
|
|
3272
|
+
cwd: this.config.cwd || defaultCwd,
|
|
3273
|
+
env: { ...process.env, ...this.config.env || {} },
|
|
3274
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3275
|
+
});
|
|
3276
|
+
this.child = child;
|
|
3277
|
+
child.on("error", (err) => this.failAll(new Error(`MCP server '${this.config.name}' failed to start: ${err.message}`)));
|
|
3278
|
+
child.on("exit", (code) => {
|
|
3279
|
+
if (!this.closed) this.failAll(new Error(`MCP server '${this.config.name}' exited (code ${code ?? "unknown"}).`));
|
|
3280
|
+
});
|
|
3281
|
+
child.stdout.setEncoding("utf8");
|
|
3282
|
+
child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
3283
|
+
child.stderr.setEncoding("utf8");
|
|
3284
|
+
let stderrTail = "";
|
|
3285
|
+
child.stderr.on("data", (d) => {
|
|
3286
|
+
stderrTail = (stderrTail + d).slice(-2e3);
|
|
3287
|
+
});
|
|
3288
|
+
try {
|
|
3289
|
+
const initResult = await this.request(
|
|
3290
|
+
"initialize",
|
|
3291
|
+
{
|
|
3292
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
3293
|
+
capabilities: { tools: {}, resources: {} },
|
|
3294
|
+
clientInfo: CLIENT_INFO
|
|
3295
|
+
},
|
|
3296
|
+
INIT_TIMEOUT_MS
|
|
3297
|
+
);
|
|
3298
|
+
const negotiated = initResult.protocolVersion || MCP_PROTOCOL_VERSION;
|
|
3299
|
+
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
|
|
3300
|
+
throw new Error(
|
|
3301
|
+
`Server requires unsupported MCP protocol version '${negotiated}' (this client speaks ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}).`
|
|
3302
|
+
);
|
|
3303
|
+
}
|
|
3304
|
+
this.protocolVersion = negotiated;
|
|
3305
|
+
this.capabilities = initResult.capabilities || {};
|
|
3306
|
+
this.serverInfo = initResult.serverInfo || { name: this.config.name };
|
|
3307
|
+
this.instructions = initResult.instructions || "";
|
|
3308
|
+
this.notify("notifications/initialized");
|
|
3309
|
+
if (this.capabilities.tools) await this.refreshTools();
|
|
3310
|
+
if (this.capabilities.resources) await this.refreshResources();
|
|
3311
|
+
} catch (err) {
|
|
3312
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3313
|
+
this.lastError = stderrTail ? `${msg}
|
|
3314
|
+
${stderrTail.trim()}` : msg;
|
|
3315
|
+
this.close();
|
|
3316
|
+
throw new Error(this.lastError);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
async refreshTools() {
|
|
3320
|
+
const res = await this.request("tools/list", {});
|
|
3321
|
+
this.tools = Array.isArray(res?.tools) ? res.tools : [];
|
|
3322
|
+
}
|
|
3323
|
+
async refreshResources() {
|
|
3324
|
+
try {
|
|
3325
|
+
const res = await this.request("resources/list", {});
|
|
3326
|
+
this.resources = Array.isArray(res?.resources) ? res.resources : [];
|
|
3327
|
+
} catch {
|
|
3328
|
+
this.resources = [];
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
/** Call a tool and return its flattened text + a provenance attestation. */
|
|
3332
|
+
async callTool(name, args) {
|
|
3333
|
+
const res = await this.request("tools/call", { name, arguments: args });
|
|
3334
|
+
const text = flattenContent(res?.content, res?.structuredContent);
|
|
3335
|
+
return this.attest(name, args, text, !!res?.isError);
|
|
3336
|
+
}
|
|
3337
|
+
/** Read a resource and return its flattened text + attestation. */
|
|
3338
|
+
async readResource(uri) {
|
|
3339
|
+
const res = await this.request("resources/read", { uri });
|
|
3340
|
+
const text = flattenResourceContents(res?.contents);
|
|
3341
|
+
return this.attest(uri, { uri }, text, false);
|
|
3342
|
+
}
|
|
3343
|
+
attest(target, args, text, isError) {
|
|
3344
|
+
const attestation = {
|
|
3345
|
+
server: this.config.name,
|
|
3346
|
+
serverInfo: this.serverInfo,
|
|
3347
|
+
protocolVersion: this.protocolVersion,
|
|
3348
|
+
target,
|
|
3349
|
+
argsSha256: sha256(canonicalJson(args)),
|
|
3350
|
+
resultSha256: sha256(text),
|
|
3351
|
+
nonce: crypto.randomBytes(8).toString("hex"),
|
|
3352
|
+
isError,
|
|
3353
|
+
at: Date.now()
|
|
3354
|
+
};
|
|
3355
|
+
return { ok: !isError, text, attestation };
|
|
3356
|
+
}
|
|
3357
|
+
catalogEntry() {
|
|
3358
|
+
return {
|
|
3359
|
+
name: this.config.name,
|
|
3360
|
+
status: this.lastError ? "error" : "connected",
|
|
3361
|
+
serverInfo: this.serverInfo,
|
|
3362
|
+
protocolVersion: this.protocolVersion,
|
|
3363
|
+
instructions: this.instructions || void 0,
|
|
3364
|
+
capabilities: this.capabilities,
|
|
3365
|
+
tools: this.tools,
|
|
3366
|
+
resources: this.resources,
|
|
3367
|
+
error: this.lastError || void 0
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
close() {
|
|
3371
|
+
this.closed = true;
|
|
3372
|
+
this.failAll(new Error("connection closed"));
|
|
3373
|
+
try {
|
|
3374
|
+
this.child?.stdin.end();
|
|
3375
|
+
} catch {
|
|
3376
|
+
}
|
|
3377
|
+
try {
|
|
3378
|
+
this.child?.kill("SIGTERM");
|
|
3379
|
+
} catch {
|
|
3380
|
+
}
|
|
3381
|
+
this.child = null;
|
|
3382
|
+
}
|
|
3383
|
+
// ── JSON-RPC plumbing ─────────────────────────────────────────────────────
|
|
3384
|
+
request(method, params, timeoutMs = RPC_TIMEOUT_MS) {
|
|
3385
|
+
return new Promise((resolve, reject) => {
|
|
3386
|
+
if (!this.child || this.closed) {
|
|
3387
|
+
reject(new Error(`MCP server '${this.config.name}' is not connected.`));
|
|
3388
|
+
return;
|
|
3389
|
+
}
|
|
3390
|
+
const id = this.nextId++;
|
|
3391
|
+
const payload = { jsonrpc: "2.0", id, method, params };
|
|
3392
|
+
const timer = setTimeout(() => {
|
|
3393
|
+
this.pending.delete(id);
|
|
3394
|
+
reject(new Error(`MCP request '${method}' to '${this.config.name}' timed out after ${timeoutMs}ms.`));
|
|
3395
|
+
}, timeoutMs);
|
|
3396
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
3397
|
+
this.write(payload);
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
notify(method, params) {
|
|
3401
|
+
const payload = { jsonrpc: "2.0", method, params };
|
|
3402
|
+
this.write(payload);
|
|
3403
|
+
}
|
|
3404
|
+
write(payload) {
|
|
3405
|
+
if (!this.child) return;
|
|
3406
|
+
try {
|
|
3407
|
+
this.child.stdin.write(JSON.stringify(payload) + "\n");
|
|
3408
|
+
} catch (err) {
|
|
3409
|
+
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
onData(chunk) {
|
|
3413
|
+
this.buffer += chunk;
|
|
3414
|
+
let nl;
|
|
3415
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
3416
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
3417
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
3418
|
+
if (!line) continue;
|
|
3419
|
+
let msg;
|
|
3420
|
+
try {
|
|
3421
|
+
msg = JSON.parse(line);
|
|
3422
|
+
} catch {
|
|
3423
|
+
continue;
|
|
3424
|
+
}
|
|
3425
|
+
this.dispatch(msg);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
dispatch(msg) {
|
|
3429
|
+
if (typeof msg.id === "number" && ("result" in msg || "error" in msg) && msg.method === void 0) {
|
|
3430
|
+
const entry = this.pending.get(msg.id);
|
|
3431
|
+
if (!entry) return;
|
|
3432
|
+
this.pending.delete(msg.id);
|
|
3433
|
+
clearTimeout(entry.timer);
|
|
3434
|
+
if (msg.error) entry.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
|
|
3435
|
+
else entry.resolve(msg.result);
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
if (msg.method && typeof msg.id === "number") {
|
|
3439
|
+
if (msg.method === "ping") {
|
|
3440
|
+
this.write({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
3441
|
+
} else {
|
|
3442
|
+
this.write({
|
|
3443
|
+
jsonrpc: "2.0",
|
|
3444
|
+
id: msg.id,
|
|
3445
|
+
error: { code: -32601, message: `Method not supported by host: ${msg.method}` }
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
if (msg.method && msg.id === void 0) {
|
|
3451
|
+
if (msg.method === "notifications/tools/list_changed") void this.refreshTools().catch(() => {
|
|
3452
|
+
});
|
|
3453
|
+
if (msg.method === "notifications/resources/list_changed") void this.refreshResources().catch(() => {
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
failAll(err) {
|
|
3458
|
+
for (const [, entry] of this.pending) {
|
|
3459
|
+
clearTimeout(entry.timer);
|
|
3460
|
+
entry.reject(err);
|
|
3461
|
+
}
|
|
3462
|
+
this.pending.clear();
|
|
3463
|
+
}
|
|
3464
|
+
};
|
|
3465
|
+
var McpManager = class {
|
|
3466
|
+
constructor(defaultCwd) {
|
|
3467
|
+
this.defaultCwd = defaultCwd;
|
|
3468
|
+
}
|
|
3469
|
+
defaultCwd;
|
|
3470
|
+
clients = /* @__PURE__ */ new Map();
|
|
3471
|
+
/** Last attestations produced this session (most recent last). */
|
|
3472
|
+
attestations = [];
|
|
3473
|
+
/** Connect one server (replacing any existing client of the same name). */
|
|
3474
|
+
async connect(config) {
|
|
3475
|
+
this.disconnect(config.name);
|
|
3476
|
+
const client = new McpClient(config);
|
|
3477
|
+
this.clients.set(config.name, client);
|
|
3478
|
+
await client.connect(this.defaultCwd);
|
|
3479
|
+
return client;
|
|
3480
|
+
}
|
|
3481
|
+
disconnect(name) {
|
|
3482
|
+
const existing = this.clients.get(name);
|
|
3483
|
+
if (existing) {
|
|
3484
|
+
existing.close();
|
|
3485
|
+
this.clients.delete(name);
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
closeAll() {
|
|
3489
|
+
for (const [, c2] of this.clients) c2.close();
|
|
3490
|
+
this.clients.clear();
|
|
3491
|
+
}
|
|
3492
|
+
get(name) {
|
|
3493
|
+
return this.clients.get(name);
|
|
3494
|
+
}
|
|
3495
|
+
connectedNames() {
|
|
3496
|
+
return Array.from(this.clients.keys()).sort();
|
|
3497
|
+
}
|
|
3498
|
+
toolCount() {
|
|
3499
|
+
let n = 0;
|
|
3500
|
+
for (const [, c2] of this.clients) n += c2.tools.length;
|
|
3501
|
+
return n;
|
|
3502
|
+
}
|
|
3503
|
+
/** A JSON-serializable catalog of every connected server for the KV/context. */
|
|
3504
|
+
catalog() {
|
|
3505
|
+
return {
|
|
3506
|
+
servers: Array.from(this.clients.values()).map((c2) => c2.catalogEntry()),
|
|
3507
|
+
generatedAt: Date.now()
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
recentAttestations(n = 10) {
|
|
3511
|
+
return this.attestations.slice(-n);
|
|
3512
|
+
}
|
|
3513
|
+
/** Execute a forwarded `mcp` tool request and return host-shaped text. */
|
|
3514
|
+
async dispatch(args) {
|
|
3515
|
+
const action = String(args.action || "call");
|
|
3516
|
+
const serverName = typeof args.server === "string" ? args.server : "";
|
|
3517
|
+
if (action === "list") {
|
|
3518
|
+
const servers = this.catalog().servers.map((s) => ({
|
|
3519
|
+
name: s.name,
|
|
3520
|
+
tools: s.tools.length,
|
|
3521
|
+
resources: s.resources?.length ?? 0
|
|
3522
|
+
}));
|
|
3523
|
+
return {
|
|
3524
|
+
ok: true,
|
|
3525
|
+
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."
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
if (action === "list_tools") {
|
|
3529
|
+
const cat = this.catalog().servers.filter((s) => !serverName || s.name === serverName);
|
|
3530
|
+
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." };
|
|
3531
|
+
return { ok: true, result: JSON.stringify(cat, null, 2) };
|
|
3532
|
+
}
|
|
3533
|
+
const client = this.clients.get(serverName);
|
|
3534
|
+
if (!client) {
|
|
3535
|
+
const avail = this.connectedNames();
|
|
3536
|
+
return {
|
|
3537
|
+
ok: false,
|
|
3538
|
+
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.`
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
try {
|
|
3542
|
+
let res;
|
|
3543
|
+
if (action === "read_resource") {
|
|
3544
|
+
const uri = String(args.uri || "");
|
|
3545
|
+
if (!uri) return { ok: false, error: "read_resource requires a 'uri'." };
|
|
3546
|
+
res = await client.readResource(uri);
|
|
3547
|
+
} else {
|
|
3548
|
+
const toolName = String(args.tool || "");
|
|
3549
|
+
if (!toolName) return { ok: false, error: "call requires a 'tool' name." };
|
|
3550
|
+
const callArgs = parseArgs(args.arguments_json);
|
|
3551
|
+
if (callArgs instanceof Error) return { ok: false, error: callArgs.message };
|
|
3552
|
+
res = await client.callTool(toolName, callArgs);
|
|
3553
|
+
}
|
|
3554
|
+
this.attestations.push(res.attestation);
|
|
3555
|
+
const footer = formatAttestation(res.attestation);
|
|
3556
|
+
if (!res.ok) {
|
|
3557
|
+
return { ok: false, error: `${res.text || "The MCP tool reported an error."}
|
|
3558
|
+
${footer}` };
|
|
3559
|
+
}
|
|
3560
|
+
return { ok: true, result: `${res.text}
|
|
3561
|
+
${footer}` };
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
};
|
|
3567
|
+
function parseArgs(raw) {
|
|
3568
|
+
if (raw == null || raw === "") return {};
|
|
3569
|
+
if (typeof raw === "object") return raw;
|
|
3570
|
+
if (typeof raw !== "string") return new Error("arguments_json must be a JSON object string.");
|
|
3571
|
+
try {
|
|
3572
|
+
const parsed = JSON.parse(raw);
|
|
3573
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
3574
|
+
return new Error("arguments_json must encode a JSON object.");
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
return new Error(`arguments_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
function flattenContent(content, structured) {
|
|
3580
|
+
const parts = [];
|
|
3581
|
+
for (const block of content || []) {
|
|
3582
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
3583
|
+
else if (block.type === "resource" && block.resource && typeof block.resource === "object") {
|
|
3584
|
+
const r = block.resource;
|
|
3585
|
+
if (typeof r.text === "string") parts.push(r.text);
|
|
3586
|
+
else parts.push(`[resource ${String(r.uri ?? "")}]`);
|
|
3587
|
+
} else if (block.type === "image") parts.push(`[image ${String(block.mimeType ?? "")}]`);
|
|
3588
|
+
else if (block.type === "audio") parts.push(`[audio ${String(block.mimeType ?? "")}]`);
|
|
3589
|
+
else parts.push(JSON.stringify(block));
|
|
3590
|
+
}
|
|
3591
|
+
if (!parts.length && structured !== void 0) parts.push(JSON.stringify(structured, null, 2));
|
|
3592
|
+
return parts.join("\n").trim();
|
|
3593
|
+
}
|
|
3594
|
+
function flattenResourceContents(contents) {
|
|
3595
|
+
const parts = [];
|
|
3596
|
+
for (const c2 of contents || []) {
|
|
3597
|
+
if (typeof c2.text === "string") parts.push(c2.text);
|
|
3598
|
+
else if (typeof c2.blob === "string") parts.push(`[binary resource ${String(c2.uri ?? "")} (${c2.blob.length} b64 chars)]`);
|
|
3599
|
+
}
|
|
3600
|
+
return parts.join("\n").trim();
|
|
3601
|
+
}
|
|
3602
|
+
function formatAttestation(a) {
|
|
3603
|
+
const id = `${a.serverInfo.name}${a.serverInfo.version ? `@${a.serverInfo.version}` : ""}`;
|
|
3604
|
+
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}`;
|
|
3605
|
+
}
|
|
3606
|
+
function canonicalJson(value) {
|
|
3607
|
+
return JSON.stringify(sortKeys(value));
|
|
3608
|
+
}
|
|
3609
|
+
function sortKeys(value) {
|
|
3610
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
3611
|
+
if (value && typeof value === "object") {
|
|
3612
|
+
const out = {};
|
|
3613
|
+
for (const k of Object.keys(value).sort()) {
|
|
3614
|
+
out[k] = sortKeys(value[k]);
|
|
3615
|
+
}
|
|
3616
|
+
return out;
|
|
3617
|
+
}
|
|
3618
|
+
return value;
|
|
3619
|
+
}
|
|
3620
|
+
function sha256(input2) {
|
|
3621
|
+
return crypto.createHash("sha256").update(input2).digest("hex");
|
|
3622
|
+
}
|
|
3623
|
+
var DIR = path3.join(os6.homedir(), ".standardagents");
|
|
3624
|
+
var FILE = path3.join(DIR, "credentials");
|
|
3625
|
+
function normalizeEndpoint(endpoint) {
|
|
3626
|
+
let e = endpoint.trim();
|
|
3627
|
+
if (!/^https?:\/\//i.test(e)) e = "http://" + e;
|
|
3628
|
+
return e.replace(/\/+$/, "");
|
|
3629
|
+
}
|
|
3630
|
+
function loadCredentials() {
|
|
3631
|
+
try {
|
|
3632
|
+
const raw = fs4.readFileSync(FILE, "utf8");
|
|
3633
|
+
const parsed = JSON.parse(raw);
|
|
3634
|
+
if (!parsed.instances) parsed.instances = {};
|
|
3635
|
+
return parsed;
|
|
3636
|
+
} catch {
|
|
3637
|
+
return { instances: {} };
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
function getCredential(endpoint) {
|
|
3641
|
+
const creds = loadCredentials();
|
|
3642
|
+
return creds.instances[normalizeEndpoint(endpoint)] ?? null;
|
|
3643
|
+
}
|
|
3644
|
+
function saveCredential(cred, options = {}) {
|
|
3645
|
+
const creds = loadCredentials();
|
|
3646
|
+
const endpoint = normalizeEndpoint(cred.endpoint);
|
|
3647
|
+
creds.instances[endpoint] = { ...cred, endpoint };
|
|
3648
|
+
if (options.updateDefault ?? true) {
|
|
3649
|
+
creds.default_endpoint = endpoint;
|
|
3650
|
+
}
|
|
3651
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3652
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
3653
|
+
try {
|
|
3654
|
+
fs4.chmodSync(FILE, 384);
|
|
3655
|
+
} catch {
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
function defaultEndpoint() {
|
|
3659
|
+
return loadCredentials().default_endpoint ?? null;
|
|
3660
|
+
}
|
|
3661
|
+
function saveDefaultEndpoint(endpoint) {
|
|
3662
|
+
const creds = loadCredentials();
|
|
3663
|
+
creds.default_endpoint = normalizeEndpoint(endpoint);
|
|
3664
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3665
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
3666
|
+
try {
|
|
3667
|
+
fs4.chmodSync(FILE, 384);
|
|
3668
|
+
} catch {
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
// src/index.ts
|
|
3673
|
+
var AGENT_ID = "standard_code_agent";
|
|
3674
|
+
var c = {
|
|
3675
|
+
reset: "\x1B[0m",
|
|
3676
|
+
dim: "\x1B[2m",
|
|
3677
|
+
bold: "\x1B[1m",
|
|
3678
|
+
white: "\x1B[97m",
|
|
3679
|
+
cyan: "\x1B[36m",
|
|
3680
|
+
green: "\x1B[32m",
|
|
3681
|
+
gray: "\x1B[90m",
|
|
3682
|
+
magenta: "\x1B[35m",
|
|
3683
|
+
yellow: "\x1B[33m",
|
|
3684
|
+
red: "\x1B[31m",
|
|
3685
|
+
teal: "\x1B[38;5;37m"
|
|
3686
|
+
// brand teal (matches the marketing site's teal accent)
|
|
3687
|
+
};
|
|
3688
|
+
var LOGO_MARK = [
|
|
3689
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
|
3690
|
+
" \u2588\u2588\u2588 \u2588\u2588",
|
|
3691
|
+
" \u2588\u2588 \u2588",
|
|
3692
|
+
"\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
|
|
3693
|
+
"\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588",
|
|
3694
|
+
"\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588",
|
|
3695
|
+
"\u2588 \u2588\u2588",
|
|
3696
|
+
"\u2588\u2588 \u2588\u2588\u2588",
|
|
3697
|
+
"\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
|
|
3698
|
+
];
|
|
3699
|
+
function printUsage() {
|
|
3700
|
+
stdout.write(
|
|
3701
|
+
[
|
|
3702
|
+
"",
|
|
3703
|
+
`${c.bold}Usage${c.reset}`,
|
|
3704
|
+
" standardcode [options] [dir]",
|
|
3705
|
+
"",
|
|
3706
|
+
`${c.bold}Options${c.reset}`,
|
|
3707
|
+
" -e, --endpoint [url] Use a Standard Agents instance for this run only.",
|
|
3708
|
+
" If url is omitted, prompt for it.",
|
|
3709
|
+
" Credentials are remembered for that endpoint, but",
|
|
3710
|
+
" the saved default endpoint is not changed.",
|
|
3711
|
+
" -h, --help Show this help.",
|
|
3712
|
+
""
|
|
3713
|
+
].join("\n")
|
|
3714
|
+
);
|
|
3715
|
+
}
|
|
3716
|
+
function parseArgs2(args) {
|
|
3717
|
+
const parsed = { help: false, promptEndpoint: false };
|
|
3718
|
+
for (let i = 0; i < args.length; i++) {
|
|
3719
|
+
const arg = args[i];
|
|
3720
|
+
if (arg === "--help" || arg === "-h") {
|
|
3721
|
+
parsed.help = true;
|
|
3722
|
+
continue;
|
|
3723
|
+
}
|
|
3724
|
+
if (arg === "--endpoint" || arg === "-e") {
|
|
3725
|
+
const value = args[i + 1];
|
|
3726
|
+
if (value && !value.startsWith("-")) {
|
|
3727
|
+
parsed.endpoint = value;
|
|
3728
|
+
i++;
|
|
3729
|
+
} else {
|
|
3730
|
+
parsed.promptEndpoint = true;
|
|
3731
|
+
}
|
|
3732
|
+
continue;
|
|
3733
|
+
}
|
|
3734
|
+
if (arg.startsWith("--endpoint=")) {
|
|
3735
|
+
const value = arg.slice("--endpoint=".length);
|
|
3736
|
+
if (value) {
|
|
3737
|
+
parsed.endpoint = value;
|
|
3738
|
+
} else {
|
|
3739
|
+
parsed.promptEndpoint = true;
|
|
3740
|
+
}
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
if (arg === "--") {
|
|
3744
|
+
if (i === 0 && args[i + 1]?.startsWith("-")) continue;
|
|
3745
|
+
const rest = args.slice(i + 1);
|
|
3746
|
+
if (rest.length > 1) throw new Error("Expected at most one project directory.");
|
|
3747
|
+
if (rest[0]) parsed.dir = rest[0];
|
|
3748
|
+
break;
|
|
3749
|
+
}
|
|
3750
|
+
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
|
3751
|
+
if (parsed.dir) throw new Error("Expected at most one project directory.");
|
|
3752
|
+
parsed.dir = arg;
|
|
3753
|
+
}
|
|
3754
|
+
return parsed;
|
|
3755
|
+
}
|
|
3756
|
+
function printAssistant(tui, text) {
|
|
3757
|
+
const cols2 = Math.max(20, (process.stdout.columns || 80) - 3);
|
|
3758
|
+
tui.clearStream();
|
|
3759
|
+
tui.print("");
|
|
3760
|
+
let dotted = false;
|
|
3761
|
+
for (const line of renderMarkdown(text, cols2)) {
|
|
3762
|
+
if (!dotted && line.trim()) {
|
|
3763
|
+
tui.print(`${c.gray}\u2022${c.reset} ${line}`);
|
|
3764
|
+
dotted = true;
|
|
3765
|
+
} else {
|
|
3766
|
+
tui.print(` ${line}`);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
tui.print("");
|
|
3770
|
+
}
|
|
3771
|
+
function farewell(stoppedProcs = 0) {
|
|
3772
|
+
if (stoppedProcs > 0) {
|
|
3773
|
+
stdout.write(
|
|
3774
|
+
`
|
|
3775
|
+
${c.cyan}\u2699${c.reset} Stopped ${stoppedProcs} background process${stoppedProcs === 1 ? "" : "es"}.
|
|
3776
|
+
`
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
3779
|
+
stdout.write(`
|
|
3780
|
+
${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
|
|
3781
|
+
`);
|
|
3782
|
+
}
|
|
3783
|
+
function isLocalHost(host) {
|
|
3784
|
+
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);
|
|
3785
|
+
}
|
|
3786
|
+
function relaxTlsForLocalEndpoint(endpoint) {
|
|
3787
|
+
let host = "";
|
|
3788
|
+
try {
|
|
3789
|
+
host = new URL(endpoint).hostname;
|
|
3790
|
+
} catch {
|
|
3791
|
+
return false;
|
|
3792
|
+
}
|
|
3793
|
+
if (!endpoint.startsWith("https:") || !isLocalHost(host)) return false;
|
|
3794
|
+
const origEmit = process.emitWarning.bind(process);
|
|
3795
|
+
process.emitWarning = ((warning, ...args) => {
|
|
3796
|
+
const msg = typeof warning === "string" ? warning : warning?.message ?? "";
|
|
3797
|
+
if (msg.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
|
|
3798
|
+
return origEmit(warning, ...args);
|
|
3799
|
+
});
|
|
3800
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
3801
|
+
return true;
|
|
3802
|
+
}
|
|
3803
|
+
function readVersion() {
|
|
3804
|
+
try {
|
|
3805
|
+
const pkg = JSON.parse(fs4.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
3806
|
+
return typeof pkg.version === "string" ? pkg.version : "";
|
|
3807
|
+
} catch {
|
|
3808
|
+
return "";
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
function printWelcome(endpoint, projectDir) {
|
|
3812
|
+
const home = os6.homedir();
|
|
3813
|
+
const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
3814
|
+
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
3815
|
+
const version = readVersion();
|
|
3816
|
+
const pad = " ";
|
|
3817
|
+
const meta = [
|
|
3818
|
+
`${c.bold}${c.white}Standard Code${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
|
|
3819
|
+
`${c.dim}terminal coding agent${c.reset}`,
|
|
3820
|
+
`${c.teal}${host}${c.reset}`,
|
|
3821
|
+
`${c.dim}${dir}${c.reset}`
|
|
3822
|
+
];
|
|
3823
|
+
const markWidth = Math.max(...LOGO_MARK.map((l) => [...l].length));
|
|
3824
|
+
const metaTop = Math.floor((LOGO_MARK.length - meta.length) / 2);
|
|
3825
|
+
stdout.write("\n");
|
|
3826
|
+
for (let i = 0; i < LOGO_MARK.length; i++) {
|
|
3827
|
+
const glyph = LOGO_MARK[i].padEnd(markWidth);
|
|
3828
|
+
const line = meta[i - metaTop];
|
|
3829
|
+
stdout.write(`${pad}${glyph}${line ? ` ${line}` : ""}
|
|
3830
|
+
`);
|
|
3831
|
+
}
|
|
3832
|
+
stdout.write("\n");
|
|
3833
|
+
}
|
|
3834
|
+
function colorActivity(line) {
|
|
3835
|
+
const m = line.match(/^(\s*)([✓✗⛔])\s?([\s\S]*)$/);
|
|
3836
|
+
if (!m) return `${c.dim}${line}${c.reset}`;
|
|
3837
|
+
const [, indent, glyph, rest] = m;
|
|
3838
|
+
if (glyph === "\u2713") {
|
|
3839
|
+
const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
|
|
3840
|
+
return `${indent}${c.green}\u2713${c.reset} ${body}`;
|
|
3841
|
+
}
|
|
3842
|
+
if (glyph === "\u2717") {
|
|
3843
|
+
const ERR_MAX_LINES = 7;
|
|
3844
|
+
const lines = rest.split("\n");
|
|
3845
|
+
const shown = lines.slice(0, ERR_MAX_LINES);
|
|
3846
|
+
const hidden = lines.length - shown.length;
|
|
3847
|
+
const body = shown.map(
|
|
3848
|
+
(l, i) => i === 0 ? `${indent}${c.red}\u2717 ${l}${c.reset}` : `${indent}${c.red}${c.dim}${l}${c.reset}`
|
|
3849
|
+
).join("\n");
|
|
3850
|
+
if (hidden > 0) {
|
|
3851
|
+
return `${body}
|
|
3852
|
+
${indent}${c.dim}\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${c.reset}`;
|
|
3853
|
+
}
|
|
3854
|
+
return body;
|
|
3855
|
+
}
|
|
3856
|
+
return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
|
|
3857
|
+
}
|
|
3858
|
+
async function main() {
|
|
3859
|
+
let cliArgs;
|
|
3860
|
+
try {
|
|
3861
|
+
cliArgs = parseArgs2(process.argv.slice(2));
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
stdout.write(`${c.red}error:${c.reset} ${error instanceof Error ? error.message : String(error)}
|
|
3864
|
+
`);
|
|
3865
|
+
printUsage();
|
|
3866
|
+
process.exit(1);
|
|
3867
|
+
}
|
|
3868
|
+
if (cliArgs.help) {
|
|
3869
|
+
printUsage();
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
const endpointArg = cliArgs.endpoint;
|
|
3873
|
+
const endpointOverride = cliArgs.promptEndpoint || typeof endpointArg === "string" && endpointArg.trim() !== "";
|
|
3874
|
+
const dirArg = cliArgs.dir;
|
|
3875
|
+
const projectDir = path3.resolve(dirArg || process.cwd());
|
|
3876
|
+
const machine = os6.hostname();
|
|
3877
|
+
const reader = { rl: null };
|
|
3878
|
+
let handoffClosing = false;
|
|
3879
|
+
let preflightArmed = false;
|
|
3880
|
+
let preflightTimer = null;
|
|
3881
|
+
const onPreflightSigint = () => {
|
|
3882
|
+
if (preflightArmed) {
|
|
3883
|
+
if (preflightTimer) clearTimeout(preflightTimer);
|
|
3884
|
+
reader.rl?.close();
|
|
3885
|
+
farewell();
|
|
3886
|
+
process.exit(0);
|
|
3887
|
+
}
|
|
3888
|
+
preflightArmed = true;
|
|
3889
|
+
stdout.write(`
|
|
3890
|
+
${c.dim}Press Control-C again to exit${c.reset}
|
|
3891
|
+
`);
|
|
3892
|
+
preflightTimer = setTimeout(() => {
|
|
3893
|
+
preflightArmed = false;
|
|
3894
|
+
preflightTimer = null;
|
|
3895
|
+
}, 2e3);
|
|
3896
|
+
};
|
|
3897
|
+
const ask = async (question) => {
|
|
3898
|
+
if (!reader.rl) {
|
|
3899
|
+
reader.rl = readline2.createInterface({ input: stdin, output: stdout });
|
|
3900
|
+
reader.rl.on("SIGINT", onPreflightSigint);
|
|
3901
|
+
reader.rl.on("close", () => {
|
|
3902
|
+
if (handoffClosing) return;
|
|
3903
|
+
farewell();
|
|
3904
|
+
process.exit(0);
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
return reader.rl.question(question);
|
|
3908
|
+
};
|
|
3909
|
+
const askEndpoint = async () => {
|
|
3910
|
+
for (; ; ) {
|
|
3911
|
+
const answer = (await ask(
|
|
3912
|
+
`${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
|
|
3913
|
+
)).trim();
|
|
3914
|
+
if (answer) return answer;
|
|
3915
|
+
stdout.write(`${c.dim}An endpoint URL is required.${c.reset}
|
|
3916
|
+
`);
|
|
3917
|
+
}
|
|
3918
|
+
};
|
|
3919
|
+
process.on("SIGINT", onPreflightSigint);
|
|
3920
|
+
let endpointPrompted = false;
|
|
3921
|
+
let endpoint = endpointArg || (cliArgs.promptEndpoint ? "" : defaultEndpoint() || "");
|
|
3922
|
+
if (!endpoint) {
|
|
3923
|
+
endpoint = await askEndpoint();
|
|
3924
|
+
endpointPrompted = true;
|
|
3925
|
+
}
|
|
3926
|
+
endpoint = normalizeEndpoint(endpoint);
|
|
3927
|
+
const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
|
|
3928
|
+
printWelcome(endpoint, projectDir);
|
|
3929
|
+
if (tlsRelaxed) {
|
|
3930
|
+
stdout.write(`${c.dim} TLS verification relaxed for local endpoint.${c.reset}
|
|
3931
|
+
|
|
3932
|
+
`);
|
|
3933
|
+
}
|
|
3934
|
+
const stored = getCredential(endpoint);
|
|
3935
|
+
let api = stored ? new ApiClient(endpoint, stored.access_token) : null;
|
|
3936
|
+
if (!api || !await api.verify()) {
|
|
3937
|
+
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
3938
|
+
stdout.write(
|
|
3939
|
+
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 connect to${c.reset} ${c.teal}${host}${c.reset}
|
|
3940
|
+
`
|
|
3941
|
+
);
|
|
3942
|
+
stdout.write(
|
|
3943
|
+
`${c.dim}Press Enter to sign in with your browser, or paste an API token.${c.reset}
|
|
3944
|
+
|
|
3945
|
+
`
|
|
3946
|
+
);
|
|
3947
|
+
for (; ; ) {
|
|
3948
|
+
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token (or Enter for browser)${c.reset} `)).trim();
|
|
3949
|
+
if (!token) {
|
|
3950
|
+
const got = await deviceLogin(endpoint).catch((e) => {
|
|
3951
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}${e instanceof Error ? e.message : String(e)}${c.reset}
|
|
3952
|
+
`);
|
|
3953
|
+
return null;
|
|
3954
|
+
});
|
|
3955
|
+
if (!got) continue;
|
|
3956
|
+
api = new ApiClient(endpoint, got);
|
|
3957
|
+
if (await api.verify()) {
|
|
3958
|
+
saveCredential(
|
|
3959
|
+
{ endpoint, access_token: got, token_type: "Bearer", saved_at: Date.now() },
|
|
3960
|
+
{ updateDefault: !endpointOverride }
|
|
3961
|
+
);
|
|
3962
|
+
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
3963
|
+
`);
|
|
3964
|
+
break;
|
|
3965
|
+
}
|
|
3966
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}Browser sign-in didn't verify. Try again.${c.reset}
|
|
3967
|
+
`);
|
|
3968
|
+
continue;
|
|
3969
|
+
}
|
|
3970
|
+
api = new ApiClient(endpoint, token);
|
|
3971
|
+
if (await api.verify()) {
|
|
3972
|
+
saveCredential(
|
|
3973
|
+
{ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() },
|
|
3974
|
+
{ updateDefault: !endpointOverride }
|
|
3975
|
+
);
|
|
3976
|
+
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
3977
|
+
`);
|
|
3978
|
+
break;
|
|
3979
|
+
}
|
|
3980
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
|
|
3981
|
+
`);
|
|
3982
|
+
}
|
|
3983
|
+
} else if (endpointPrompted) {
|
|
3984
|
+
saveDefaultEndpoint(endpoint);
|
|
3985
|
+
}
|
|
3986
|
+
if (!api) process.exit(1);
|
|
3987
|
+
handoffClosing = true;
|
|
3988
|
+
reader.rl?.close();
|
|
3989
|
+
const tags = [`path:${projectDir}`, `machine:${machine}`];
|
|
3990
|
+
let existing = [];
|
|
3991
|
+
try {
|
|
3992
|
+
existing = await api.listThreads(AGENT_ID, tags);
|
|
3993
|
+
} catch {
|
|
3994
|
+
existing = [];
|
|
3995
|
+
}
|
|
3996
|
+
const tui = new Tui(1);
|
|
3997
|
+
let threadId;
|
|
3998
|
+
let resumed = false;
|
|
3999
|
+
let historySeed;
|
|
4000
|
+
if (existing.length > 0) {
|
|
4001
|
+
const summaries = await summarizeThreads(api, existing.slice(0, 8));
|
|
4002
|
+
const items = summaries.map((s) => ({
|
|
4003
|
+
label: s.label,
|
|
4004
|
+
hint: s.hint,
|
|
4005
|
+
value: s.id
|
|
4006
|
+
}));
|
|
4007
|
+
items.push({ label: "\uFF0B Start a new session", value: null });
|
|
4008
|
+
const home = os6.homedir();
|
|
4009
|
+
const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
4010
|
+
const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
|
|
4011
|
+
const picked = await tui.select(
|
|
4012
|
+
`${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}`,
|
|
4013
|
+
items
|
|
4014
|
+
);
|
|
4015
|
+
if (typeof picked === "string") {
|
|
4016
|
+
threadId = picked;
|
|
4017
|
+
resumed = true;
|
|
4018
|
+
} else {
|
|
4019
|
+
threadId = await api.createThread(AGENT_ID, tags);
|
|
4020
|
+
historySeed = existing[0]?.id;
|
|
4021
|
+
}
|
|
4022
|
+
} else {
|
|
4023
|
+
threadId = await api.createThread(AGENT_ID, tags);
|
|
4024
|
+
}
|
|
4025
|
+
await runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeed);
|
|
4026
|
+
}
|
|
4027
|
+
async function summarizeThreads(api, threads) {
|
|
4028
|
+
return Promise.all(
|
|
4029
|
+
threads.map(async (t) => {
|
|
4030
|
+
let preview = "";
|
|
4031
|
+
try {
|
|
4032
|
+
const msgs = await api.getMessages(t.id, 30);
|
|
4033
|
+
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));
|
|
4034
|
+
if (users[0]) preview = String(users[0].content).replace(/\s+/g, " ").trim();
|
|
4035
|
+
} catch {
|
|
4036
|
+
}
|
|
4037
|
+
const label = preview ? preview.length > 64 ? preview.slice(0, 63) + "\u2026" : preview : "(empty session)";
|
|
4038
|
+
const when = t.created_at ? relativeTime(t.created_at) : "";
|
|
4039
|
+
const hint = [t.id.slice(0, 8), when].filter(Boolean).join(" \xB7 ");
|
|
4040
|
+
return { id: t.id, label, hint };
|
|
4041
|
+
})
|
|
4042
|
+
);
|
|
4043
|
+
}
|
|
4044
|
+
function subagentLabel(s, titles) {
|
|
4045
|
+
const agentName = (s.agent_name || "").trim();
|
|
4046
|
+
const title = (s.title || "").trim() || titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
|
|
4047
|
+
const tagged = (s.threadName || "").trim();
|
|
4048
|
+
return tagged ? `${title} \xB7 ${tagged}` : title;
|
|
4049
|
+
}
|
|
4050
|
+
async function deviceLogin(endpoint) {
|
|
4051
|
+
const start = await fetch(`${endpoint}/api/auth/device/start`, { method: "POST" });
|
|
4052
|
+
if (!start.ok) throw new Error(`This instance does not support browser sign-in (HTTP ${start.status}). Paste an API token instead.`);
|
|
4053
|
+
const info = await start.json();
|
|
4054
|
+
stdout.write(`${c.dim}Opening your browser to approve this sign-in\u2026${c.reset}
|
|
4055
|
+
`);
|
|
4056
|
+
stdout.write(`${c.dim}If it doesn't open, visit:${c.reset} ${c.teal}${info.verify_url}${c.reset}
|
|
4057
|
+
`);
|
|
4058
|
+
openUrl(info.verify_url);
|
|
4059
|
+
const deadline = Date.now() + (info.expires_in ?? 600) * 1e3;
|
|
4060
|
+
const interval = Math.max(2, info.interval ?? 2) * 1e3;
|
|
4061
|
+
while (Date.now() < deadline) {
|
|
4062
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
4063
|
+
const res = await fetch(info.poll_url).catch(() => null);
|
|
4064
|
+
if (!res) continue;
|
|
4065
|
+
if (res.status === 404) throw new Error("The sign-in link expired. Try again.");
|
|
4066
|
+
const body = await res.json().catch(() => ({}));
|
|
4067
|
+
if (body.status === "approved" && body.token) return body.token;
|
|
4068
|
+
if (body.status === "denied") throw new Error("Sign-in was denied in the browser.");
|
|
4069
|
+
}
|
|
4070
|
+
throw new Error("Timed out waiting for browser approval. Try again.");
|
|
4071
|
+
}
|
|
4072
|
+
function openUrl(url) {
|
|
4073
|
+
const platform = process.platform;
|
|
4074
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
4075
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
4076
|
+
try {
|
|
4077
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
4078
|
+
child.unref();
|
|
4079
|
+
} catch {
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
function relativeTime(unixSeconds) {
|
|
4083
|
+
const diff = Date.now() / 1e3 - unixSeconds;
|
|
4084
|
+
if (diff < 60) return "just now";
|
|
4085
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
4086
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
4087
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
4088
|
+
}
|
|
4089
|
+
function hasToolCalls(m) {
|
|
4090
|
+
const tc = m?.tool_calls;
|
|
4091
|
+
if (Array.isArray(tc)) return tc.length > 0;
|
|
4092
|
+
if (typeof tc === "string") {
|
|
4093
|
+
const s = tc.trim();
|
|
4094
|
+
return s.length > 0 && s !== "null" && s !== "[]";
|
|
4095
|
+
}
|
|
4096
|
+
return false;
|
|
4097
|
+
}
|
|
4098
|
+
function messageText(content) {
|
|
4099
|
+
if (typeof content === "string") return content;
|
|
4100
|
+
if (Array.isArray(content)) {
|
|
4101
|
+
return content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
|
|
4102
|
+
}
|
|
4103
|
+
return "";
|
|
4104
|
+
}
|
|
4105
|
+
function threadBusy(msgs) {
|
|
4106
|
+
if (!msgs.length) return false;
|
|
4107
|
+
if (msgs.some((m) => m.status === "pending")) return true;
|
|
4108
|
+
const last = [...msgs].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
|
|
4109
|
+
if (!last) return false;
|
|
4110
|
+
if (last.role === "user" || last.role === "tool") return true;
|
|
4111
|
+
if (last.role === "assistant") return hasToolCalls(last);
|
|
4112
|
+
return false;
|
|
4113
|
+
}
|
|
4114
|
+
async function printHistory(api, threadId, tui) {
|
|
4115
|
+
let msgs;
|
|
4116
|
+
try {
|
|
4117
|
+
msgs = await api.getMessages(threadId, 200);
|
|
4118
|
+
} catch {
|
|
4119
|
+
return;
|
|
4120
|
+
}
|
|
4121
|
+
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));
|
|
4122
|
+
if (!convo.length) return;
|
|
4123
|
+
const shown = convo.slice(-24);
|
|
4124
|
+
tui.print(`${c.dim}\u2500\u2500 resuming session \xB7 ${convo.length} message${convo.length === 1 ? "" : "s"} \u2500\u2500${c.reset}`);
|
|
4125
|
+
if (shown.length < convo.length) tui.print(`${c.dim} \u2026 earlier messages omitted${c.reset}`);
|
|
4126
|
+
for (const m of shown) {
|
|
4127
|
+
const text = messageText(m.content).trim();
|
|
4128
|
+
if (!text) continue;
|
|
4129
|
+
if (m.role === "user") tui.printUserMessage(text);
|
|
4130
|
+
else printAssistant(tui, text);
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
async function runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeedThreadId) {
|
|
4134
|
+
const registry = new ProcessRegistry(api, threadId, machine);
|
|
4135
|
+
const mcp = new McpManager(projectDir);
|
|
4136
|
+
const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
|
|
4137
|
+
});
|
|
4138
|
+
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog, api);
|
|
4139
|
+
const refreshBgCount = () => {
|
|
4140
|
+
void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
|
|
4141
|
+
});
|
|
4142
|
+
};
|
|
4143
|
+
const perm = { level: tui.level, alwaysAllow: /* @__PURE__ */ new Set(), allowRisk: /* @__PURE__ */ new Set() };
|
|
4144
|
+
const savedApprovals = await loadApprovals(api, threadId);
|
|
4145
|
+
for (const t of savedApprovals.allowTools) perm.alwaysAllow.add(t);
|
|
4146
|
+
for (const r of savedApprovals.allowRisk) perm.allowRisk.add(r);
|
|
4147
|
+
if (savedApprovals.level) {
|
|
4148
|
+
perm.level = savedApprovals.level;
|
|
4149
|
+
tui.setLevel(savedApprovals.level);
|
|
4150
|
+
}
|
|
4151
|
+
tui.onLevelChange((l) => {
|
|
4152
|
+
perm.level = l;
|
|
4153
|
+
saveApprovals(api, threadId, perm);
|
|
4154
|
+
});
|
|
4155
|
+
saveApprovals(api, threadId, perm);
|
|
4156
|
+
let busy = false;
|
|
4157
|
+
let interrupting = false;
|
|
4158
|
+
const queued = [];
|
|
4159
|
+
let editingQueued = false;
|
|
4160
|
+
const shownIds = /* @__PURE__ */ new Set();
|
|
4161
|
+
const pendingSent = /* @__PURE__ */ new Map();
|
|
4162
|
+
let tokensIn = 0;
|
|
4163
|
+
let tokensOut = 0;
|
|
4164
|
+
let liveOut = 0;
|
|
4165
|
+
const countedLogs = /* @__PURE__ */ new Set();
|
|
4166
|
+
const activeSteps = /* @__PURE__ */ new Map();
|
|
4167
|
+
const refreshStatus = () => {
|
|
4168
|
+
tui.setTokens(tokensIn, tokensOut + liveOut);
|
|
4169
|
+
let label = null;
|
|
4170
|
+
for (const v of activeSteps.values()) label = v;
|
|
4171
|
+
tui.setStep(label, liveOut);
|
|
4172
|
+
};
|
|
4173
|
+
const bridge = new Bridge(api, threadId, host, perm, {
|
|
4174
|
+
onActivity: (line, detail) => {
|
|
4175
|
+
tui.print(colorActivity(line));
|
|
4176
|
+
if (detail) for (const d of detail) tui.print(d);
|
|
4177
|
+
refreshBgCount();
|
|
4178
|
+
},
|
|
4179
|
+
onStatus: (id, summary) => {
|
|
4180
|
+
if (summary) {
|
|
4181
|
+
if (!activeSteps.has(id)) activeSteps.set(id, summary);
|
|
4182
|
+
} else {
|
|
4183
|
+
activeSteps.delete(id);
|
|
4184
|
+
}
|
|
4185
|
+
refreshStatus();
|
|
4186
|
+
},
|
|
4187
|
+
onConnection: (state, attempt) => {
|
|
4188
|
+
if (state === "reconnecting") {
|
|
4189
|
+
if (attempt >= 4) tui.setConnected(false);
|
|
4190
|
+
} else {
|
|
4191
|
+
tui.setConnected(true);
|
|
4192
|
+
}
|
|
4193
|
+
},
|
|
4194
|
+
requestApproval: async (req, summary, risk) => {
|
|
4195
|
+
const choice = await tui.approval(
|
|
4196
|
+
`${summary}${req.requestPermission ? `
|
|
4197
|
+
${c.bold}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
4198
|
+
risk
|
|
4199
|
+
);
|
|
4200
|
+
if (choice === "deny_with_reason") {
|
|
4201
|
+
const reason = await tui.prompt(
|
|
4202
|
+
"Why are you denying this? (sent to the agent \u2014 enter to send, esc to skip)"
|
|
4203
|
+
);
|
|
4204
|
+
return { choice: "deny", reason: reason ?? void 0 };
|
|
4205
|
+
}
|
|
4206
|
+
return { choice };
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
const stream = new MessageStream(api, threadId, {
|
|
4210
|
+
// Live streaming preview: answer text and (opt-in) internal reasoning feed
|
|
4211
|
+
// the TUI's ephemeral preview; the committed message still renders from
|
|
4212
|
+
// polling, which calls tui.clearStream() first so there's no double-render.
|
|
4213
|
+
onChunk: (text, mid) => tui.streamResponseDelta(text, mid),
|
|
4214
|
+
onReasoningChunk: (text, mid) => tui.streamThinkingDelta(text, mid),
|
|
4215
|
+
onAssistantText: () => {
|
|
4216
|
+
},
|
|
4217
|
+
onEvent: (eventType, data) => {
|
|
4218
|
+
if (eventType === "generation" && typeof data?.outputTokens === "number") {
|
|
4219
|
+
liveOut = data.outputTokens;
|
|
4220
|
+
refreshStatus();
|
|
4221
|
+
} else if (eventType === "tool_call_started" && data?.id) {
|
|
4222
|
+
activeSteps.set(data.id, data.progress || data.name || "working");
|
|
4223
|
+
refreshStatus();
|
|
4224
|
+
} else if (eventType === "tool_call_done" && data?.id) {
|
|
4225
|
+
activeSteps.delete(data.id);
|
|
4226
|
+
refreshStatus();
|
|
4227
|
+
} else if (eventType === "goal_updated" && data) {
|
|
4228
|
+
tui.setGoal(data);
|
|
4229
|
+
}
|
|
4230
|
+
},
|
|
4231
|
+
onError: () => {
|
|
4232
|
+
}
|
|
4233
|
+
});
|
|
4234
|
+
const activeSubagents = /* @__PURE__ */ new Map();
|
|
4235
|
+
const agentTitles = /* @__PURE__ */ new Map();
|
|
4236
|
+
const agentTitlesReady = api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
|
|
4237
|
+
});
|
|
4238
|
+
const pushSubagents = () => tui.setSubagents(
|
|
4239
|
+
[...activeSubagents.entries()].map(([id, s]) => ({ id, label: s.label, agentName: s.agentName }))
|
|
4240
|
+
);
|
|
4241
|
+
const reconcileSubagents = async () => {
|
|
4242
|
+
try {
|
|
4243
|
+
await agentTitlesReady;
|
|
4244
|
+
const subs = await api.listSubagents(threadId);
|
|
4245
|
+
activeSubagents.clear();
|
|
4246
|
+
for (const s of subs) {
|
|
4247
|
+
if (s.status !== "running") continue;
|
|
4248
|
+
activeSubagents.set(s.id, {
|
|
4249
|
+
label: subagentLabel(s, agentTitles),
|
|
4250
|
+
agentName: s.agent_name ?? void 0
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
pushSubagents();
|
|
4254
|
+
} catch {
|
|
4255
|
+
}
|
|
4256
|
+
};
|
|
4257
|
+
let reconcileTimer = null;
|
|
4258
|
+
let reconcilePending = false;
|
|
4259
|
+
const scheduleReconcile = () => {
|
|
4260
|
+
if (reconcileTimer) {
|
|
4261
|
+
reconcilePending = true;
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
reconcileTimer = setTimeout(async () => {
|
|
4265
|
+
reconcileTimer = null;
|
|
4266
|
+
await reconcileSubagents();
|
|
4267
|
+
if (reconcilePending) {
|
|
4268
|
+
reconcilePending = false;
|
|
4269
|
+
scheduleReconcile();
|
|
4270
|
+
}
|
|
4271
|
+
}, 150);
|
|
4272
|
+
};
|
|
4273
|
+
const events = new SystemEvents(api, {
|
|
4274
|
+
onOpen: () => scheduleReconcile(),
|
|
4275
|
+
onThreadCreated: (t) => {
|
|
4276
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
4277
|
+
},
|
|
4278
|
+
onThreadUpdated: (t) => {
|
|
4279
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
4280
|
+
},
|
|
4281
|
+
onThreadDeleted: (id) => {
|
|
4282
|
+
if (activeSubagents.has(id)) scheduleReconcile();
|
|
4283
|
+
}
|
|
4284
|
+
});
|
|
4285
|
+
const quit = async () => {
|
|
4286
|
+
tui.end();
|
|
4287
|
+
const stopped = api.stop(threadId).catch(() => {
|
|
4288
|
+
});
|
|
4289
|
+
const procsStopped = host.stopAllLocalProcesses().catch(() => 0);
|
|
4290
|
+
bridge.close();
|
|
4291
|
+
stream.close();
|
|
4292
|
+
events.close();
|
|
4293
|
+
mcp.closeAll();
|
|
4294
|
+
const [, killed] = await Promise.race([
|
|
4295
|
+
Promise.all([stopped, procsStopped]),
|
|
4296
|
+
new Promise((r) => setTimeout(() => r([void 0, 0]), 1500))
|
|
4297
|
+
]);
|
|
4298
|
+
farewell(killed);
|
|
4299
|
+
process.exit(0);
|
|
4300
|
+
};
|
|
4301
|
+
tui.setQuitHandler(quit);
|
|
4302
|
+
const viewThread = () => {
|
|
4303
|
+
const url = `${api.origin}/threads/${threadId}`;
|
|
4304
|
+
openUrl(url);
|
|
4305
|
+
tui.print(`${c.gray}opened ${c.cyan}${url}${c.reset}`);
|
|
4306
|
+
};
|
|
4307
|
+
const bgMgr = {
|
|
4308
|
+
list: () => registry.list(),
|
|
4309
|
+
stop: async (id) => {
|
|
4310
|
+
await host.execute("background_process", { action: "stop", id });
|
|
4311
|
+
refreshBgCount();
|
|
4312
|
+
}
|
|
4313
|
+
};
|
|
4314
|
+
const mcpCtl = {
|
|
4315
|
+
configured: () => listMcpServers(),
|
|
4316
|
+
connectedNames: () => mcp.connectedNames(),
|
|
4317
|
+
catalog: () => mcp.catalog(),
|
|
4318
|
+
connect: async (cfg) => {
|
|
4319
|
+
try {
|
|
4320
|
+
const client = await mcp.connect(cfg);
|
|
4321
|
+
publishMcpCatalog();
|
|
4322
|
+
return { ok: true, tools: client.tools.length };
|
|
4323
|
+
} catch (e) {
|
|
4324
|
+
publishMcpCatalog();
|
|
4325
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
4326
|
+
}
|
|
4327
|
+
},
|
|
4328
|
+
disconnect: (name) => {
|
|
4329
|
+
mcp.disconnect(name);
|
|
4330
|
+
publishMcpCatalog();
|
|
4331
|
+
},
|
|
4332
|
+
add: async (cfg) => {
|
|
4333
|
+
saveMcpServer(cfg);
|
|
4334
|
+
return mcpCtl.connect(cfg);
|
|
4335
|
+
},
|
|
4336
|
+
// Seed the request into the main chat — the agent researches + installs it
|
|
4337
|
+
// there (using research_agent + install_mcp), visible in the transcript.
|
|
4338
|
+
requestInstall: (query) => {
|
|
4339
|
+
void sendNow(
|
|
4340
|
+
`Install an MCP server for me: ${query}. Research the best one and its exact launch command, then install it.`
|
|
4341
|
+
);
|
|
4342
|
+
},
|
|
4343
|
+
remove: (name) => {
|
|
4344
|
+
mcp.disconnect(name);
|
|
4345
|
+
removeMcpServer(name);
|
|
4346
|
+
publishMcpCatalog();
|
|
4347
|
+
},
|
|
4348
|
+
setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
|
|
4349
|
+
};
|
|
4350
|
+
const extFor = (mime) => ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" })[mime] ?? "bin";
|
|
4351
|
+
const toAttachments = (images) => images.map((img) => ({ name: `image-${img.seq}.${extFor(img.mime)}`, mimeType: img.mime, data: img.data }));
|
|
4352
|
+
const sendNow = async (text, images = []) => {
|
|
4353
|
+
tui.printUserMessage(text);
|
|
4354
|
+
const key = text.trim();
|
|
4355
|
+
pendingSent.set(key, (pendingSent.get(key) ?? 0) + 1);
|
|
4356
|
+
try {
|
|
4357
|
+
await api.sendMessage(threadId, text, toAttachments(images));
|
|
4358
|
+
} catch (e) {
|
|
4359
|
+
const n = (pendingSent.get(key) ?? 1) - 1;
|
|
4360
|
+
if (n > 0) pendingSent.set(key, n);
|
|
4361
|
+
else pendingSent.delete(key);
|
|
4362
|
+
tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
interrupting = false;
|
|
4366
|
+
busy = true;
|
|
4367
|
+
tui.setWorking(true);
|
|
4368
|
+
};
|
|
4369
|
+
const flushQueued = async () => {
|
|
4370
|
+
if (!queued.length) return;
|
|
4371
|
+
const toSend = queued.splice(0);
|
|
4372
|
+
tui.setQueuedCount(0);
|
|
4373
|
+
for (const q of toSend) await sendNow(q.text, q.images);
|
|
4374
|
+
};
|
|
4375
|
+
const requestCompaction = async () => {
|
|
4376
|
+
try {
|
|
4377
|
+
await api.compact(threadId);
|
|
4378
|
+
} catch (err) {
|
|
4379
|
+
tui.print(`${c.red}\u2717${c.reset} couldn't start compaction: ${err.message}`);
|
|
4380
|
+
}
|
|
4381
|
+
};
|
|
4382
|
+
const skillsCtl = {
|
|
4383
|
+
list: () => api.listSkills(),
|
|
4384
|
+
setEnabled: (name, enabled) => api.setSkillEnabled(name, enabled),
|
|
4385
|
+
remove: (name) => api.removeSkill(name),
|
|
4386
|
+
// Seed the request into the main chat — the agent researches or authors
|
|
4387
|
+
// the skill there (research_agent + install_skill), visible in the transcript.
|
|
4388
|
+
requestInstall: (query) => {
|
|
4389
|
+
void sendNow(
|
|
4390
|
+
`Install a skill for me: ${query}. Find the skill's published files (or author a proper SKILL.md from your research), install it with install_skill, then tell me what it can do.`
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
};
|
|
4394
|
+
tui.setCommands([
|
|
4395
|
+
{
|
|
4396
|
+
name: "compact",
|
|
4397
|
+
label: "Compact conversation now",
|
|
4398
|
+
hint: () => tui.contextPctLabel() || "free up context",
|
|
4399
|
+
run: requestCompaction
|
|
4400
|
+
},
|
|
4401
|
+
{ name: "level", label: "Auto-accept level", hint: () => `level ${tui.level}`, run: () => runLevelMenu(tui, perm) },
|
|
4402
|
+
{
|
|
4403
|
+
name: "permissions",
|
|
4404
|
+
label: "Approved commands",
|
|
4405
|
+
hint: () => {
|
|
4406
|
+
const n = perm.alwaysAllow.size + perm.allowRisk.size;
|
|
4407
|
+
return n ? `${n} approved` : "none";
|
|
4408
|
+
},
|
|
4409
|
+
run: () => runApprovalsMenu(tui, perm, () => saveApprovals(api, threadId, perm))
|
|
4410
|
+
},
|
|
4411
|
+
{
|
|
4412
|
+
name: "mcp",
|
|
4413
|
+
label: "MCP servers",
|
|
4414
|
+
hint: () => {
|
|
4415
|
+
const n = mcpCtl.connectedNames().length;
|
|
4416
|
+
return n ? `${n} connected` : "none";
|
|
4417
|
+
},
|
|
4418
|
+
run: () => runMcpMenu(tui, mcpCtl)
|
|
4419
|
+
},
|
|
4420
|
+
{
|
|
4421
|
+
name: "skills",
|
|
4422
|
+
label: "Agent skills",
|
|
4423
|
+
hint: "list / install / manage",
|
|
4424
|
+
run: () => runSkillsMenu(tui, skillsCtl)
|
|
4425
|
+
},
|
|
4426
|
+
{ name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
|
|
4427
|
+
{ name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
|
|
4428
|
+
{ name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
|
|
4429
|
+
{ name: "quit", label: "Quit", run: () => quit() }
|
|
4430
|
+
]);
|
|
4431
|
+
const history = await loadHistory(api, threadId, historySeedThreadId);
|
|
4432
|
+
tui.setHistory(history);
|
|
4433
|
+
tui.onSubmit = (text, images) => {
|
|
4434
|
+
appendHistory(api, threadId, history, text);
|
|
4435
|
+
if (editingQueued) {
|
|
4436
|
+
editingQueued = false;
|
|
4437
|
+
queued.push({ text, images });
|
|
4438
|
+
tui.setQueuedCount(queued.length);
|
|
4439
|
+
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4442
|
+
if (busy) {
|
|
4443
|
+
queued.push({ text, images });
|
|
4444
|
+
tui.setQueuedCount(queued.length);
|
|
4445
|
+
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
|
|
4446
|
+
} else {
|
|
4447
|
+
void sendNow(text, images);
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
tui.onInterrupt = () => {
|
|
4451
|
+
if (queued.length > 0) {
|
|
4452
|
+
tui.print(`${c.yellow}\u21AA steering \u2014 stopping current work and sending your message\u2026${c.reset}`);
|
|
4453
|
+
void api.stop(threadId).catch(() => {
|
|
4454
|
+
}).then(() => flushQueued());
|
|
4455
|
+
} else if (busy) {
|
|
4456
|
+
interrupting = true;
|
|
4457
|
+
busy = false;
|
|
4458
|
+
activeSteps.clear();
|
|
4459
|
+
liveOut = 0;
|
|
4460
|
+
tui.setWorking(false);
|
|
4461
|
+
refreshStatus();
|
|
4462
|
+
tui.print(`${c.yellow}[interrupted by user]${c.reset}`);
|
|
4463
|
+
void api.stop(threadId).catch(() => {
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
};
|
|
4467
|
+
tui.onUpArrow = () => {
|
|
4468
|
+
if (tui.getInput().trim() || queued.length === 0) return false;
|
|
4469
|
+
const q = queued.pop();
|
|
4470
|
+
tui.setQueuedCount(queued.length);
|
|
4471
|
+
editingQueued = true;
|
|
4472
|
+
tui.setInput(q.text, q.images);
|
|
4473
|
+
return true;
|
|
4474
|
+
};
|
|
4475
|
+
events.connect();
|
|
4476
|
+
await Promise.all([bridge.connect(), stream.connect()]);
|
|
4477
|
+
void api.getGoal(threadId).then((g) => tui.setGoal(g)).catch(() => {
|
|
4478
|
+
});
|
|
4479
|
+
tui.banner([
|
|
4480
|
+
`${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
|
|
4481
|
+
`${c.gray}project:${c.reset} ${projectDir}`,
|
|
4482
|
+
`${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`
|
|
4483
|
+
]);
|
|
4484
|
+
if (resumed) await printHistory(api, threadId, tui);
|
|
4485
|
+
try {
|
|
4486
|
+
(await api.getMessages(threadId, 200)).forEach((m) => shownIds.add(m.id));
|
|
4487
|
+
} catch {
|
|
4488
|
+
}
|
|
4489
|
+
const runningProcs = (await registry.list()).filter((p) => p.status === "running");
|
|
4490
|
+
if (runningProcs.length) {
|
|
4491
|
+
tui.print(
|
|
4492
|
+
`${c.cyan}\u2699 ${runningProcs.length} background process${runningProcs.length === 1 ? "" : "es"} running:${c.reset}`
|
|
4493
|
+
);
|
|
4494
|
+
for (const p of runningProcs) tui.print(`${c.gray} ${p.id} ${p.description || p.command}${c.reset}`);
|
|
4495
|
+
}
|
|
4496
|
+
refreshBgCount();
|
|
4497
|
+
const enabledServers = listMcpServers().filter((s) => s.enabled);
|
|
4498
|
+
for (const s of enabledServers) {
|
|
4499
|
+
const res = await mcpCtl.connect(s);
|
|
4500
|
+
if (res.ok) {
|
|
4501
|
+
tui.print(`${c.cyan}\u26A1 MCP "${s.name}" connected${c.reset} ${c.gray}(${res.tools} tool${res.tools === 1 ? "" : "s"})${c.reset}`);
|
|
4502
|
+
} else {
|
|
4503
|
+
tui.print(`${c.red}\u26A0 MCP "${s.name}" failed:${c.reset} ${c.gray}${res.error}${c.reset}`);
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4506
|
+
publishMcpCatalog();
|
|
4507
|
+
tui.start();
|
|
4508
|
+
const poll = async () => {
|
|
4509
|
+
let msgs;
|
|
4510
|
+
try {
|
|
4511
|
+
msgs = await api.getMessages(threadId, 60);
|
|
4512
|
+
} catch {
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
const sorted = [...msgs].sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
|
4516
|
+
for (const m of sorted) {
|
|
4517
|
+
if (shownIds.has(m.id) || m.status === "pending") continue;
|
|
4518
|
+
shownIds.add(m.id);
|
|
4519
|
+
const text = messageText(m.content).trim();
|
|
4520
|
+
if (m.role === "assistant" && text) printAssistant(tui, text);
|
|
4521
|
+
else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
|
|
4522
|
+
else if (m.role === "user" && text) {
|
|
4523
|
+
const pending = pendingSent.get(text) ?? 0;
|
|
4524
|
+
if (pending > 0) {
|
|
4525
|
+
if (pending === 1) pendingSent.delete(text);
|
|
4526
|
+
else pendingSent.set(text, pending - 1);
|
|
4527
|
+
} else {
|
|
4528
|
+
tui.printUserMessage(text);
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
const polledBusy = threadBusy(msgs);
|
|
4533
|
+
if (interrupting) {
|
|
4534
|
+
if (!polledBusy) interrupting = false;
|
|
4535
|
+
busy = false;
|
|
4536
|
+
} else {
|
|
4537
|
+
busy = polledBusy;
|
|
4538
|
+
}
|
|
4539
|
+
tui.setWorking(busy);
|
|
4540
|
+
if (!busy) {
|
|
4541
|
+
if (activeSteps.size) activeSteps.clear();
|
|
4542
|
+
liveOut = 0;
|
|
4543
|
+
refreshStatus();
|
|
4544
|
+
if (queued.length > 0 && !editingQueued) await flushQueued();
|
|
4545
|
+
}
|
|
4546
|
+
refreshBgCount();
|
|
4547
|
+
try {
|
|
4548
|
+
const logs = await api.getLogs(threadId, 100);
|
|
4549
|
+
let landed = 0;
|
|
4550
|
+
for (const l of logs) {
|
|
4551
|
+
if (!l.is_complete) continue;
|
|
4552
|
+
const id = l.id ?? `${l.created_at}:${l.total_tokens}`;
|
|
4553
|
+
if (countedLogs.has(id)) continue;
|
|
4554
|
+
countedLogs.add(id);
|
|
4555
|
+
const inT = Number(l.input_tokens) || 0;
|
|
4556
|
+
const outT = Number(l.output_tokens) || 0;
|
|
4557
|
+
tokensIn += inT;
|
|
4558
|
+
tokensOut += outT;
|
|
4559
|
+
landed += outT;
|
|
4560
|
+
}
|
|
4561
|
+
if (landed > 0) liveOut = 0;
|
|
4562
|
+
refreshStatus();
|
|
4563
|
+
} catch {
|
|
4564
|
+
}
|
|
4565
|
+
try {
|
|
4566
|
+
const cu = await api.kvGet(threadId, "context_usage");
|
|
4567
|
+
const used = Number(cu?.inputTokens) || 0;
|
|
4568
|
+
const max = Number(cu?.maxContextTokens) || 0;
|
|
4569
|
+
tui.setContextPct(max > 0 && used > 0 ? used / max * 100 : null);
|
|
4570
|
+
} catch {
|
|
4571
|
+
}
|
|
4572
|
+
};
|
|
4573
|
+
setInterval(() => void poll().catch(() => {
|
|
4574
|
+
}), 1200);
|
|
4575
|
+
await new Promise(() => {
|
|
4576
|
+
});
|
|
4577
|
+
}
|
|
4578
|
+
async function runSkillsMenu(tui, skills) {
|
|
4579
|
+
let list;
|
|
4580
|
+
try {
|
|
4581
|
+
list = await skills.list();
|
|
4582
|
+
} catch (e) {
|
|
4583
|
+
tui.print(`${c.red}\u2717 couldn't load skills:${c.reset} ${c.gray}${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
const INSTALL = "__install__";
|
|
4587
|
+
const items = list.map((s) => ({
|
|
4588
|
+
label: s.name,
|
|
4589
|
+
hint: `${s.enabled ? "enabled" : "disabled"} \xB7 ${s.files.length} file${s.files.length === 1 ? "" : "s"}`,
|
|
4590
|
+
value: s.name
|
|
4591
|
+
}));
|
|
4592
|
+
items.push({ label: "\uFF0B Install a skill\u2026", hint: "find & install", value: INSTALL });
|
|
4593
|
+
const picked = await tui.select(
|
|
4594
|
+
`${c.bold}Agent skills${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
|
|
4595
|
+
items
|
|
4596
|
+
);
|
|
4597
|
+
if (!picked) return;
|
|
4598
|
+
if (picked === INSTALL) {
|
|
4599
|
+
const query = await tui.prompt(
|
|
4600
|
+
"What skill do you want to install?",
|
|
4601
|
+
"the anthropic pdf skill / a skill for writing conventional commits"
|
|
4602
|
+
);
|
|
4603
|
+
if (query) skills.requestInstall(query);
|
|
4604
|
+
return;
|
|
4605
|
+
}
|
|
4606
|
+
const skill = list.find((s) => s.name === picked);
|
|
4607
|
+
tui.print(`${c.cyan}${skill.name}${c.reset}${skill.version ? ` ${c.dim}v${skill.version}${c.reset}` : ""} ${c.gray}\u2014 ${skill.description}${c.reset}`);
|
|
4608
|
+
const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
|
|
4609
|
+
skill.enabled ? { label: "Disable (hide from the agent)", value: "disable" } : { label: "Enable", value: "enable" },
|
|
4610
|
+
{ label: "View files", value: "files" },
|
|
4611
|
+
{ label: "Remove this skill", value: "remove" },
|
|
4612
|
+
{ label: "Back", value: "back" }
|
|
4613
|
+
]);
|
|
4614
|
+
try {
|
|
4615
|
+
if (action === "enable" || action === "disable") {
|
|
4616
|
+
await skills.setEnabled(picked, action === "enable");
|
|
4617
|
+
tui.print(`${c.gray}${action}d ${picked}${c.reset}`);
|
|
4618
|
+
} else if (action === "files") {
|
|
4619
|
+
for (const f of skill.files) tui.print(` ${c.gray}${f}${c.reset}`);
|
|
4620
|
+
} else if (action === "remove") {
|
|
4621
|
+
await skills.remove(picked);
|
|
4622
|
+
tui.print(`${c.gray}removed ${picked}${c.reset}`);
|
|
4623
|
+
}
|
|
4624
|
+
} catch (e) {
|
|
4625
|
+
tui.print(`${c.red}\u2717 ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
async function runLevelMenu(tui, perm) {
|
|
4629
|
+
const picked = await tui.select(
|
|
4630
|
+
`${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
|
|
4631
|
+
LEVELS.map((l) => ({
|
|
4632
|
+
label: levelLabel(l),
|
|
4633
|
+
hint: l === tui.level ? "current" : "",
|
|
4634
|
+
value: l
|
|
4635
|
+
}))
|
|
4636
|
+
);
|
|
4637
|
+
if (picked) {
|
|
4638
|
+
tui.setLevel(picked);
|
|
4639
|
+
perm.level = picked;
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
function showKeybindings(tui) {
|
|
4643
|
+
tui.print(`${c.gray}shortcuts:${c.reset}`);
|
|
4644
|
+
tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
|
|
4645
|
+
tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
|
|
4646
|
+
tui.print(`${c.gray} ctrl-v${c.reset} paste an image from the clipboard ([#Image 1])`);
|
|
4647
|
+
tui.print(`${c.gray} \u2191 / \u2193${c.reset} cycle past messages (on the input's top line)`);
|
|
4648
|
+
tui.print(`${c.gray} ctrl-c${c.reset} quit`);
|
|
4649
|
+
}
|
|
4650
|
+
async function runProcessMenu(tui, bg) {
|
|
4651
|
+
const procs = await bg.list();
|
|
4652
|
+
if (!procs.length) {
|
|
4653
|
+
tui.print(`${c.gray}No background processes for this session.${c.reset}`);
|
|
4654
|
+
return;
|
|
4655
|
+
}
|
|
4656
|
+
const items = procs.map((p) => {
|
|
4657
|
+
const status = p.status === "running" ? `${c.green}running${c.reset}` : `${c.gray}${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}${c.reset}`;
|
|
4658
|
+
return {
|
|
4659
|
+
label: `${p.description || p.command}`,
|
|
4660
|
+
hint: `${p.id} \xB7 ${status}`,
|
|
4661
|
+
value: p.id
|
|
4662
|
+
};
|
|
4663
|
+
});
|
|
4664
|
+
const picked = await tui.select(
|
|
4665
|
+
`${c.bold}Background processes${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter to manage \xB7 esc to close)${c.reset}`,
|
|
4666
|
+
items
|
|
4667
|
+
);
|
|
4668
|
+
if (!picked) return;
|
|
4669
|
+
const proc = procs.find((p) => p.id === picked);
|
|
4670
|
+
if (!proc || proc.status !== "running") {
|
|
4671
|
+
tui.print(`${c.gray}${picked} is not running.${c.reset}`);
|
|
4672
|
+
return;
|
|
4673
|
+
}
|
|
4674
|
+
const action = await tui.select(`${c.bold}${proc.description || proc.command}${c.reset}`, [
|
|
4675
|
+
{ label: "Stop this process", value: "stop" },
|
|
4676
|
+
{ label: "Leave it running", value: "leave" }
|
|
4677
|
+
]);
|
|
4678
|
+
if (action === "stop") {
|
|
4679
|
+
await bg.stop(picked);
|
|
4680
|
+
tui.print(`${c.gray}stopped ${picked}${c.reset}`);
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
async function runApprovalsMenu(tui, perm, save) {
|
|
4684
|
+
const tools = Array.from(perm.alwaysAllow).sort();
|
|
4685
|
+
const risks = Array.from(perm.allowRisk).sort((a, b) => a - b);
|
|
4686
|
+
if (!tools.length && !risks.length) {
|
|
4687
|
+
tui.print(
|
|
4688
|
+
`${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}`
|
|
4689
|
+
);
|
|
4690
|
+
return;
|
|
4691
|
+
}
|
|
4692
|
+
const items = [
|
|
4693
|
+
...tools.map((t) => ({ label: `Tool: ${t}`, hint: "always allowed", value: `tool:${t}` })),
|
|
4694
|
+
...risks.map((r) => ({ label: `All level ${r} risk`, hint: "always allowed", value: `risk:${r}` })),
|
|
4695
|
+
{ label: "Clear all approvals", hint: "", value: "clear" }
|
|
4696
|
+
];
|
|
4697
|
+
const picked = await tui.select(
|
|
4698
|
+
`${c.bold}Approved commands${c.reset} ${c.dim}(enter to revoke \xB7 esc to close)${c.reset}`,
|
|
4699
|
+
items
|
|
4700
|
+
);
|
|
4701
|
+
if (!picked) return;
|
|
4702
|
+
if (picked === "clear") {
|
|
4703
|
+
perm.alwaysAllow.clear();
|
|
4704
|
+
perm.allowRisk.clear();
|
|
4705
|
+
tui.print(`${c.gray}cleared all approvals${c.reset}`);
|
|
4706
|
+
} else if (picked.startsWith("tool:")) {
|
|
4707
|
+
const t = picked.slice(5);
|
|
4708
|
+
perm.alwaysAllow.delete(t);
|
|
4709
|
+
tui.print(`${c.gray}revoked tool ${t}${c.reset}`);
|
|
4710
|
+
} else if (picked.startsWith("risk:")) {
|
|
4711
|
+
const r = Number(picked.slice(5));
|
|
4712
|
+
perm.allowRisk.delete(r);
|
|
4713
|
+
tui.print(`${c.gray}revoked level ${r}${c.reset}`);
|
|
4714
|
+
}
|
|
4715
|
+
save();
|
|
4716
|
+
}
|
|
4717
|
+
async function runMcpMenu(tui, mcp) {
|
|
4718
|
+
const configured = mcp.configured();
|
|
4719
|
+
const connected = new Set(mcp.connectedNames());
|
|
4720
|
+
const cat = mcp.catalog();
|
|
4721
|
+
const INSTALL = "__install__";
|
|
4722
|
+
const ADD_MANUAL = "__manual__";
|
|
4723
|
+
const items = configured.map((s) => {
|
|
4724
|
+
const entry = cat.servers.find((e) => e.name === s.name);
|
|
4725
|
+
const status = !s.enabled ? "disabled" : connected.has(s.name) ? `connected \xB7 ${entry?.tools.length ?? 0} tools` : entry?.error ? "error" : "disconnected";
|
|
4726
|
+
return { label: s.name, hint: status, value: s.name };
|
|
4727
|
+
});
|
|
4728
|
+
items.push({ label: "\uFF0B Install a new MCP server\u2026", hint: "find & install", value: INSTALL });
|
|
4729
|
+
items.push({ label: "Add manually (name: command)\u2026", hint: "advanced", value: ADD_MANUAL });
|
|
4730
|
+
const picked = await tui.select(
|
|
4731
|
+
`${c.bold}MCP servers${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
|
|
4732
|
+
items
|
|
4733
|
+
);
|
|
4734
|
+
if (!picked) return;
|
|
4735
|
+
if (picked === INSTALL) {
|
|
4736
|
+
await installMcpServerFlow(tui, mcp);
|
|
4737
|
+
return;
|
|
4738
|
+
}
|
|
4739
|
+
if (picked === ADD_MANUAL) {
|
|
4740
|
+
await addMcpServer(tui, mcp);
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
const server = configured.find((s) => s.name === picked);
|
|
4744
|
+
const isConnected = connected.has(picked);
|
|
4745
|
+
const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
|
|
4746
|
+
{ label: "View tools", value: "tools" },
|
|
4747
|
+
isConnected ? { label: "Disconnect", value: "disconnect" } : { label: "Connect", value: "connect" },
|
|
4748
|
+
server.enabled ? { label: "Disable (don't auto-connect)", value: "disable" } : { label: "Enable (auto-connect on start)", value: "enable" },
|
|
4749
|
+
{ label: "Remove this server", value: "remove" },
|
|
4750
|
+
{ label: "Back", value: "back" }
|
|
4751
|
+
]);
|
|
4752
|
+
if (action === "tools") {
|
|
4753
|
+
const entry = mcp.catalog().servers.find((e) => e.name === picked);
|
|
4754
|
+
if (!entry || entry.status !== "connected") {
|
|
4755
|
+
tui.print(`${c.gray}${picked} is not connected \u2014 connect it to list tools.${c.reset}`);
|
|
4756
|
+
return;
|
|
4757
|
+
}
|
|
4758
|
+
if (!entry.tools.length) tui.print(`${c.gray}${picked} exposes no tools.${c.reset}`);
|
|
4759
|
+
for (const t of entry.tools) tui.print(` ${c.cyan}${t.name}${c.reset}${t.description ? ` ${c.gray}\u2014 ${t.description}${c.reset}` : ""}`);
|
|
4760
|
+
if (entry.resources.length) tui.print(` ${c.gray}${entry.resources.length} resource(s)${c.reset}`);
|
|
4761
|
+
} else if (action === "connect") {
|
|
4762
|
+
const res = await mcp.connect(server);
|
|
4763
|
+
tui.print(res.ok ? `${c.cyan}\u26A1 connected (${res.tools} tools)${c.reset}` : `${c.red}\u26A0 ${res.error}${c.reset}`);
|
|
4764
|
+
} else if (action === "disconnect") {
|
|
4765
|
+
mcp.disconnect(picked);
|
|
4766
|
+
tui.print(`${c.gray}disconnected ${picked}${c.reset}`);
|
|
4767
|
+
} else if (action === "enable") {
|
|
4768
|
+
mcp.setEnabled(picked, true);
|
|
4769
|
+
const res = await mcp.connect(server);
|
|
4770
|
+
tui.print(res.ok ? `${c.cyan}\u26A1 enabled + connected (${res.tools} tools)${c.reset}` : `${c.red}\u26A0 enabled but failed: ${res.error}${c.reset}`);
|
|
4771
|
+
} else if (action === "disable") {
|
|
4772
|
+
mcp.setEnabled(picked, false);
|
|
4773
|
+
mcp.disconnect(picked);
|
|
4774
|
+
tui.print(`${c.gray}disabled + disconnected ${picked}${c.reset}`);
|
|
4775
|
+
} else if (action === "remove") {
|
|
4776
|
+
mcp.remove(picked);
|
|
4777
|
+
tui.print(`${c.gray}removed ${picked}${c.reset}`);
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
async function addMcpServer(tui, mcp) {
|
|
4781
|
+
const spec = await tui.prompt(
|
|
4782
|
+
"Add an MCP server \u2014 enter name: command [args\u2026]",
|
|
4783
|
+
"fs: npx -y @modelcontextprotocol/server-filesystem ."
|
|
4784
|
+
);
|
|
4785
|
+
if (!spec) return;
|
|
4786
|
+
const cfg = parseServerSpec(spec);
|
|
4787
|
+
if (!cfg) {
|
|
4788
|
+
tui.print(`${c.yellow}couldn't parse that. Use name: command [args]${c.reset}`);
|
|
4789
|
+
return;
|
|
4790
|
+
}
|
|
4791
|
+
tui.print(`${c.gray}connecting MCP "${cfg.name}" (${cfg.command} ${cfg.args.join(" ")})\u2026${c.reset}`);
|
|
4792
|
+
const res = await mcp.add(cfg);
|
|
4793
|
+
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}`);
|
|
4794
|
+
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}`);
|
|
4795
|
+
}
|
|
4796
|
+
async function installMcpServerFlow(tui, mcp) {
|
|
4797
|
+
const query = await tui.prompt(
|
|
4798
|
+
"What MCP server do you want to install?",
|
|
4799
|
+
"the best computer-use mcp server for mac"
|
|
4800
|
+
);
|
|
4801
|
+
if (!query) return;
|
|
4802
|
+
mcp.requestInstall(query);
|
|
4803
|
+
}
|
|
4804
|
+
main().catch((err) => {
|
|
4805
|
+
process.stderr.write(`
|
|
4806
|
+
${err instanceof Error ? err.stack || err.message : String(err)}
|
|
4807
|
+
`);
|
|
4808
|
+
process.exit(1);
|
|
4809
|
+
});
|
|
4810
|
+
//# sourceMappingURL=index.js.map
|
|
4811
|
+
//# sourceMappingURL=index.js.map
|