@standardagents/code 0.1.0 → 0.1.2
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 +8 -0
- package/dist/index.js +1622 -220
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import os6 from 'os';
|
|
3
|
+
import fs4 from 'fs';
|
|
4
4
|
import path3 from 'path';
|
|
5
5
|
import readline2 from 'readline/promises';
|
|
6
|
-
import { spawn } from 'child_process';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
7
|
import { stdout, stdin } from 'process';
|
|
8
8
|
import fsp from 'fs/promises';
|
|
9
|
-
import
|
|
9
|
+
import crypto from 'crypto';
|
|
10
10
|
import readline from 'readline';
|
|
11
11
|
|
|
12
12
|
// src/api.ts
|
|
@@ -83,10 +83,38 @@ var ApiClient = class {
|
|
|
83
83
|
preview: t.preview || t.last_message
|
|
84
84
|
})).filter((t) => requireTags.every((tag) => t.tags.includes(tag)));
|
|
85
85
|
}
|
|
86
|
-
|
|
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;
|
|
87
115
|
await this.json(`/api/threads/${threadId}/messages`, {
|
|
88
116
|
method: "POST",
|
|
89
|
-
body: JSON.stringify(
|
|
117
|
+
body: JSON.stringify(body)
|
|
90
118
|
});
|
|
91
119
|
}
|
|
92
120
|
async getMessages(threadId, limit = 50) {
|
|
@@ -122,6 +150,22 @@ var ApiClient = class {
|
|
|
122
150
|
}
|
|
123
151
|
return false;
|
|
124
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
|
+
}
|
|
125
169
|
/** Read a value from the thread's durable KV store (null if absent). */
|
|
126
170
|
async kvGet(threadId, key) {
|
|
127
171
|
try {
|
|
@@ -144,12 +188,49 @@ var ApiClient = class {
|
|
|
144
188
|
} catch {
|
|
145
189
|
}
|
|
146
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
|
+
}
|
|
147
215
|
async stop(threadId) {
|
|
148
216
|
try {
|
|
149
217
|
await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
|
|
150
218
|
} catch {
|
|
151
219
|
}
|
|
152
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
|
+
}
|
|
153
234
|
};
|
|
154
235
|
|
|
155
236
|
// src/permissions.ts
|
|
@@ -202,8 +283,208 @@ function saveApprovals(api, threadId, perm) {
|
|
|
202
283
|
void api.kvSet(threadId, KEY, payload);
|
|
203
284
|
}
|
|
204
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
|
+
var GRAD_STOPS = [
|
|
433
|
+
[255, 179, 92],
|
|
434
|
+
// amber
|
|
435
|
+
[255, 92, 135],
|
|
436
|
+
// coral
|
|
437
|
+
[139, 108, 255]
|
|
438
|
+
// violet
|
|
439
|
+
];
|
|
440
|
+
var GRAD_256 = [215, 210, 204, 176, 141];
|
|
441
|
+
function truecolor() {
|
|
442
|
+
const ct = process.env.COLORTERM ?? "";
|
|
443
|
+
if (/truecolor|24bit/i.test(ct)) return true;
|
|
444
|
+
return /iterm|kitty|wezterm|ghostty|alacritty/i.test(process.env.TERM_PROGRAM ?? "");
|
|
445
|
+
}
|
|
446
|
+
function gradAt(t) {
|
|
447
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
448
|
+
const seg = clamped * (GRAD_STOPS.length - 1);
|
|
449
|
+
const i = Math.min(GRAD_STOPS.length - 2, Math.floor(seg));
|
|
450
|
+
const k = seg - i;
|
|
451
|
+
const [a, b] = [GRAD_STOPS[i], GRAD_STOPS[i + 1]];
|
|
452
|
+
return [
|
|
453
|
+
Math.round(a[0] + (b[0] - a[0]) * k),
|
|
454
|
+
Math.round(a[1] + (b[1] - a[1]) * k),
|
|
455
|
+
Math.round(a[2] + (b[2] - a[2]) * k)
|
|
456
|
+
];
|
|
457
|
+
}
|
|
458
|
+
function gradientText(text, phase = 0) {
|
|
459
|
+
const chars = [...text];
|
|
460
|
+
const visible = chars.filter((ch) => ch.trim().length > 0).length;
|
|
461
|
+
if (!visible) return text;
|
|
462
|
+
const tc = truecolor();
|
|
463
|
+
let seen = 0;
|
|
464
|
+
let out = "";
|
|
465
|
+
for (const ch of chars) {
|
|
466
|
+
if (ch.trim().length === 0) {
|
|
467
|
+
out += ch;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const t = Math.min(1, phase + seen / Math.max(1, visible - 1) * (1 - phase * 0.4));
|
|
471
|
+
seen++;
|
|
472
|
+
if (tc) {
|
|
473
|
+
const [r, g, b] = gradAt(t);
|
|
474
|
+
out += `\x1B[38;2;${r};${g};${b}m${ch}`;
|
|
475
|
+
} else {
|
|
476
|
+
out += `\x1B[38;5;${GRAD_256[Math.min(GRAD_256.length - 1, Math.floor(t * GRAD_256.length))]}m${ch}`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return out + "\x1B[0m";
|
|
480
|
+
}
|
|
481
|
+
function gradientArt(lines) {
|
|
482
|
+
const n = Math.max(1, lines.length - 1);
|
|
483
|
+
return lines.map((line, i) => gradientText(line, i / n * 0.55));
|
|
484
|
+
}
|
|
485
|
+
|
|
205
486
|
// src/bridge.ts
|
|
206
|
-
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "
|
|
487
|
+
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "grep", "glob", "write_file", "edit_file"]);
|
|
207
488
|
var Bridge = class {
|
|
208
489
|
constructor(api, threadId, host, perm, hooks) {
|
|
209
490
|
this.api = api;
|
|
@@ -264,7 +545,10 @@ var Bridge = class {
|
|
|
264
545
|
this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
|
|
265
546
|
this.resolveConnected?.();
|
|
266
547
|
});
|
|
267
|
-
ws.addEventListener("message", (ev) =>
|
|
548
|
+
ws.addEventListener("message", (ev) => {
|
|
549
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
550
|
+
this.onMessage(String(ev.data));
|
|
551
|
+
});
|
|
268
552
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
269
553
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
270
554
|
}
|
|
@@ -287,16 +571,12 @@ var Bridge = class {
|
|
|
287
571
|
}
|
|
288
572
|
startHeartbeat(ws) {
|
|
289
573
|
this.stopHeartbeat();
|
|
290
|
-
this.heartbeat =
|
|
291
|
-
|
|
292
|
-
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
|
|
293
|
-
} catch {
|
|
294
|
-
}
|
|
295
|
-
}, 5e3);
|
|
574
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
575
|
+
this.heartbeat.start();
|
|
296
576
|
}
|
|
297
577
|
stopHeartbeat() {
|
|
298
578
|
if (this.heartbeat) {
|
|
299
|
-
|
|
579
|
+
this.heartbeat.stop();
|
|
300
580
|
this.heartbeat = null;
|
|
301
581
|
}
|
|
302
582
|
}
|
|
@@ -361,10 +641,16 @@ var Bridge = class {
|
|
|
361
641
|
return;
|
|
362
642
|
}
|
|
363
643
|
if (decision === "ask") {
|
|
364
|
-
const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
644
|
+
const { choice, reason } = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
365
645
|
if (choice === "deny") {
|
|
366
|
-
|
|
367
|
-
this.
|
|
646
|
+
const why = reason?.trim();
|
|
647
|
+
this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined${why ? `: ${why}` : ""}`);
|
|
648
|
+
this.respond(
|
|
649
|
+
req,
|
|
650
|
+
false,
|
|
651
|
+
void 0,
|
|
652
|
+
why ? `The user declined to run this operation. Their reason: ${why}` : "The user declined to run this operation."
|
|
653
|
+
);
|
|
368
654
|
return;
|
|
369
655
|
}
|
|
370
656
|
if (choice === "always") this.perm.alwaysAllow.add(permKey);
|
|
@@ -373,11 +659,23 @@ var Bridge = class {
|
|
|
373
659
|
saveApprovals(this.api, this.threadId, this.perm);
|
|
374
660
|
}
|
|
375
661
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
662
|
+
const callKey = req.toolCallId ?? req.id ?? `${req.tool}:${summary}`;
|
|
663
|
+
this.hooks.onStatus?.(callKey, summary);
|
|
664
|
+
let result;
|
|
665
|
+
try {
|
|
666
|
+
result = await this.host.execute(req.tool, req.args);
|
|
667
|
+
} finally {
|
|
668
|
+
this.hooks.onStatus?.(callKey, null);
|
|
669
|
+
}
|
|
379
670
|
if (result.ok) {
|
|
380
|
-
|
|
671
|
+
const display = req.tool === "bash" ? `bash: ${highlightBash(String(req.args.command ?? "").slice(0, 200))}` : summary;
|
|
672
|
+
let detail;
|
|
673
|
+
if (req.tool === "edit_file") {
|
|
674
|
+
detail = diffLines(String(req.args.old_string ?? ""), String(req.args.new_string ?? ""));
|
|
675
|
+
} else if (req.tool === "write_file") {
|
|
676
|
+
detail = newFileLines(String(req.args.content ?? ""));
|
|
677
|
+
}
|
|
678
|
+
this.hooks.onActivity(`\u2713 ${display}${detailSuffix(req.tool, result.result)}`, detail);
|
|
381
679
|
this.respond(req, true, result.result ?? "");
|
|
382
680
|
} else {
|
|
383
681
|
this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
|
|
@@ -406,8 +704,6 @@ function describe(req) {
|
|
|
406
704
|
}
|
|
407
705
|
case "read_file":
|
|
408
706
|
return `read ${a.path}`;
|
|
409
|
-
case "list_dir":
|
|
410
|
-
return `list ${a.path || "."}`;
|
|
411
707
|
case "grep":
|
|
412
708
|
return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
|
|
413
709
|
case "glob":
|
|
@@ -416,8 +712,10 @@ function describe(req) {
|
|
|
416
712
|
return `write ${a.path}`;
|
|
417
713
|
case "edit_file":
|
|
418
714
|
return `edit ${a.path}`;
|
|
419
|
-
case "
|
|
420
|
-
return `
|
|
715
|
+
case "save_to_disk":
|
|
716
|
+
return `save ${a.source_path} \u2192 ${a.dest_path}`;
|
|
717
|
+
case "run_skill_script":
|
|
718
|
+
return `skill ${a.skill}: run ${a.entry}`;
|
|
421
719
|
case "bash":
|
|
422
720
|
return `bash: ${String(a.command).slice(0, 80)}`;
|
|
423
721
|
default:
|
|
@@ -426,7 +724,7 @@ function describe(req) {
|
|
|
426
724
|
}
|
|
427
725
|
function detailSuffix(tool, result) {
|
|
428
726
|
if (!result) return "";
|
|
429
|
-
if (tool === "write_file" || tool === "edit_file"
|
|
727
|
+
if (tool === "write_file" || tool === "edit_file") return "";
|
|
430
728
|
if (tool === "bash") {
|
|
431
729
|
const m = result.match(/\[exit code (\d+)\]\s*$/);
|
|
432
730
|
return m ? ` (exit ${m[1]})` : "";
|
|
@@ -434,7 +732,7 @@ function detailSuffix(tool, result) {
|
|
|
434
732
|
const lines = result.split("\n").length;
|
|
435
733
|
return ` (${lines} line${lines === 1 ? "" : "s"})`;
|
|
436
734
|
}
|
|
437
|
-
var LOG_DIR = path3.join(
|
|
735
|
+
var LOG_DIR = path3.join(os6.homedir(), ".standardagents", "process-logs");
|
|
438
736
|
var KEY2 = "bg_processes";
|
|
439
737
|
function isAlive(pid) {
|
|
440
738
|
try {
|
|
@@ -510,11 +808,12 @@ var ProcessRegistry = class {
|
|
|
510
808
|
}
|
|
511
809
|
}
|
|
512
810
|
};
|
|
513
|
-
|
|
514
|
-
|
|
811
|
+
function configFile() {
|
|
812
|
+
return process.env.STANDARDAGENTS_MCP_CONFIG || path3.join(os6.homedir(), ".standardagents", "mcp.json");
|
|
813
|
+
}
|
|
515
814
|
function loadMcpConfig() {
|
|
516
815
|
try {
|
|
517
|
-
const raw =
|
|
816
|
+
const raw = fs4.readFileSync(configFile(), "utf8");
|
|
518
817
|
const parsed = JSON.parse(raw);
|
|
519
818
|
if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
|
|
520
819
|
return parsed;
|
|
@@ -544,8 +843,9 @@ function setMcpServerEnabled(name, enabled) {
|
|
|
544
843
|
write(cfg);
|
|
545
844
|
}
|
|
546
845
|
function write(cfg) {
|
|
547
|
-
|
|
548
|
-
|
|
846
|
+
const file = configFile();
|
|
847
|
+
fs4.mkdirSync(path3.dirname(file), { recursive: true });
|
|
848
|
+
fs4.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
549
849
|
}
|
|
550
850
|
function parseServerSpec(spec) {
|
|
551
851
|
const trimmed = spec.trim();
|
|
@@ -609,13 +909,14 @@ async function readLogTail(logPath, n) {
|
|
|
609
909
|
}
|
|
610
910
|
}
|
|
611
911
|
var HostTools = class {
|
|
612
|
-
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange) {
|
|
912
|
+
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange, api) {
|
|
613
913
|
this.projectDir = projectDir;
|
|
614
914
|
this.registry = registry;
|
|
615
915
|
this.threadId = threadId;
|
|
616
916
|
this.machine = machine;
|
|
617
917
|
this.mcp = mcp;
|
|
618
918
|
this.onMcpCatalogChange = onMcpCatalogChange;
|
|
919
|
+
this.api = api;
|
|
619
920
|
}
|
|
620
921
|
projectDir;
|
|
621
922
|
registry;
|
|
@@ -623,6 +924,7 @@ var HostTools = class {
|
|
|
623
924
|
machine;
|
|
624
925
|
mcp;
|
|
625
926
|
onMcpCatalogChange;
|
|
927
|
+
api;
|
|
626
928
|
/** Resolve a user/model-supplied path against the project directory. */
|
|
627
929
|
resolve(p) {
|
|
628
930
|
if (!p || p === ".") return this.projectDir;
|
|
@@ -639,8 +941,6 @@ var HostTools = class {
|
|
|
639
941
|
switch (tool) {
|
|
640
942
|
case "read_file":
|
|
641
943
|
return await this.readFile(args);
|
|
642
|
-
case "list_dir":
|
|
643
|
-
return await this.listDir(args);
|
|
644
944
|
case "grep":
|
|
645
945
|
return await this.grep(args);
|
|
646
946
|
case "glob":
|
|
@@ -651,8 +951,10 @@ var HostTools = class {
|
|
|
651
951
|
return await this.editFile(args);
|
|
652
952
|
case "bash":
|
|
653
953
|
return await this.bash(args);
|
|
654
|
-
case "
|
|
655
|
-
return await this.
|
|
954
|
+
case "save_to_disk":
|
|
955
|
+
return await this.saveToDisk(args);
|
|
956
|
+
case "run_skill_script":
|
|
957
|
+
return await this.runSkillScript(args);
|
|
656
958
|
case "background_process": {
|
|
657
959
|
const action = String(args.action || "list");
|
|
658
960
|
if (action === "start") return await this.runBackground(args);
|
|
@@ -695,15 +997,6 @@ var HostTools = class {
|
|
|
695
997
|
const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
|
|
696
998
|
return { ok: true, result: numbered || "(empty file)" };
|
|
697
999
|
}
|
|
698
|
-
async listDir(args) {
|
|
699
|
-
const dir = this.resolve(args.path ? String(args.path) : void 0);
|
|
700
|
-
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
701
|
-
const sorted = entries.filter((e) => e.name !== ".git" && e.name !== "node_modules").sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
702
|
-
const lines = sorted.map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
703
|
-
const header = `${path3.relative(this.projectDir, dir) || "."} (${lines.length} entries)`;
|
|
704
|
-
return { ok: true, result: `${header}
|
|
705
|
-
${lines.join("\n")}` };
|
|
706
|
-
}
|
|
707
1000
|
async grep(args) {
|
|
708
1001
|
const pattern = String(args.pattern || "");
|
|
709
1002
|
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
@@ -735,7 +1028,7 @@ ${lines.join("\n")}` };
|
|
|
735
1028
|
const file = this.resolve(String(args.path || ""));
|
|
736
1029
|
const content = String(args.content ?? "");
|
|
737
1030
|
await fsp.mkdir(path3.dirname(file), { recursive: true });
|
|
738
|
-
const existed =
|
|
1031
|
+
const existed = fs4.existsSync(file);
|
|
739
1032
|
await fsp.writeFile(file, content, "utf8");
|
|
740
1033
|
return {
|
|
741
1034
|
ok: true,
|
|
@@ -760,16 +1053,125 @@ ${lines.join("\n")}` };
|
|
|
760
1053
|
await fsp.writeFile(file, updated, "utf8");
|
|
761
1054
|
return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
|
|
762
1055
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1056
|
+
/**
|
|
1057
|
+
* Copy a file from the THREAD filesystem (e.g. a generated /attachments/*
|
|
1058
|
+
* asset) onto the project disk: download the bytes from the instance API and
|
|
1059
|
+
* write them at the destination. The bytes never pass through the model.
|
|
1060
|
+
*/
|
|
1061
|
+
async saveToDisk(args) {
|
|
1062
|
+
const source = String(args.source_path || "");
|
|
1063
|
+
const destArg = String(args.dest_path || "");
|
|
1064
|
+
if (!source || !destArg) return { ok: false, error: "source_path and dest_path are required" };
|
|
1065
|
+
if (!this.api || !this.threadId) {
|
|
1066
|
+
return { ok: false, error: "No instance API available for thread file downloads." };
|
|
1067
|
+
}
|
|
1068
|
+
const bytes = await this.api.fetchFile(this.threadId, source);
|
|
1069
|
+
if (!bytes) return { ok: false, error: `Could not download ${source} from the thread filesystem.` };
|
|
1070
|
+
const dest = this.resolve(destArg);
|
|
1071
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1072
|
+
const existed = fs4.existsSync(dest);
|
|
1073
|
+
await fsp.writeFile(dest, bytes);
|
|
1074
|
+
return {
|
|
1075
|
+
ok: true,
|
|
1076
|
+
result: `${existed ? "Overwrote" : "Saved"} ${path3.relative(this.projectDir, dest)} (${bytes.length} bytes) from ${source}`
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Execute a SKILL script on the host. Skills live in the instance (cloud is
|
|
1081
|
+
* the source of truth); the server forwards the skill's files with the call
|
|
1082
|
+
* and we materialize them into a content-addressed temp dir — nothing about
|
|
1083
|
+
* a skill is stored on this machine beyond an ephemeral cache. The script
|
|
1084
|
+
* runs with cwd = the PROJECT (so it can operate on the user's files) and
|
|
1085
|
+
* SKILL_DIR pointing at the materialized folder (so it can read its own
|
|
1086
|
+
* references/assets).
|
|
1087
|
+
*/
|
|
1088
|
+
async runSkillScript(args) {
|
|
1089
|
+
const skill = String(args.skill || "skill");
|
|
1090
|
+
const entry = String(args.entry || "");
|
|
1091
|
+
if (!entry) return { ok: false, error: "entry (the script path within the skill) is required" };
|
|
1092
|
+
let files;
|
|
1093
|
+
try {
|
|
1094
|
+
const parsed = JSON.parse(String(args.files_json || "[]"));
|
|
1095
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1096
|
+
files = parsed.map((f) => ({ path: String(f.path), content: String(f.content ?? "") }));
|
|
1097
|
+
} catch {
|
|
1098
|
+
return { ok: false, error: "files_json must be a JSON array of {path, content}" };
|
|
1099
|
+
}
|
|
1100
|
+
if (!files.some((f) => f.path === entry)) {
|
|
1101
|
+
return { ok: false, error: `entry "${entry}" is not among the provided skill files` };
|
|
1102
|
+
}
|
|
1103
|
+
let scriptArgs = [];
|
|
1104
|
+
if (args.args_json) {
|
|
1105
|
+
try {
|
|
1106
|
+
const parsed = JSON.parse(String(args.args_json));
|
|
1107
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1108
|
+
scriptArgs = parsed.map(String);
|
|
1109
|
+
} catch {
|
|
1110
|
+
return { ok: false, error: "args_json must be a JSON array of strings" };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12);
|
|
1114
|
+
const skillDir = path3.join(os6.tmpdir(), "standardcode-skills", `${skill}-${hash}`);
|
|
1115
|
+
for (const f of files) {
|
|
1116
|
+
const dest = path3.resolve(skillDir, f.path);
|
|
1117
|
+
if (path3.relative(skillDir, dest).startsWith("..")) {
|
|
1118
|
+
return { ok: false, error: `Skill file escapes its directory: ${f.path}` };
|
|
1119
|
+
}
|
|
1120
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1121
|
+
await fsp.writeFile(dest, f.content, "utf8");
|
|
1122
|
+
}
|
|
1123
|
+
const entryPath = path3.resolve(skillDir, entry);
|
|
1124
|
+
const entryContent = files.find((f) => f.path === entry).content;
|
|
1125
|
+
let cmd;
|
|
1126
|
+
let argv;
|
|
1127
|
+
if (entryContent.startsWith("#!")) {
|
|
1128
|
+
await fsp.chmod(entryPath, 493);
|
|
1129
|
+
cmd = entryPath;
|
|
1130
|
+
argv = scriptArgs;
|
|
1131
|
+
} else {
|
|
1132
|
+
const ext = path3.extname(entry).toLowerCase();
|
|
1133
|
+
const interp = {
|
|
1134
|
+
".py": ["python3"],
|
|
1135
|
+
".sh": ["bash"],
|
|
1136
|
+
".js": ["node"],
|
|
1137
|
+
".mjs": ["node"],
|
|
1138
|
+
".ts": ["npx", "tsx"]
|
|
1139
|
+
};
|
|
1140
|
+
const found = interp[ext];
|
|
1141
|
+
if (!found) return { ok: false, error: `No interpreter for "${ext}" \u2014 add a shebang line to the script.` };
|
|
1142
|
+
cmd = found[0];
|
|
1143
|
+
argv = [...found.slice(1), entryPath, ...scriptArgs];
|
|
770
1144
|
}
|
|
771
|
-
|
|
772
|
-
return
|
|
1145
|
+
const timeoutMs = typeof args.timeout_ms === "number" ? Math.min(args.timeout_ms, 3e5) : 12e4;
|
|
1146
|
+
return await new Promise((resolvePromise) => {
|
|
1147
|
+
const child = spawn(cmd, argv, {
|
|
1148
|
+
cwd: this.projectDir,
|
|
1149
|
+
env: { ...process.env, SKILL_DIR: skillDir },
|
|
1150
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1151
|
+
});
|
|
1152
|
+
let out = "";
|
|
1153
|
+
const cap = (chunk) => {
|
|
1154
|
+
if (out.length < 2e5) out += chunk.toString("utf8");
|
|
1155
|
+
};
|
|
1156
|
+
child.stdout.on("data", cap);
|
|
1157
|
+
child.stderr.on("data", cap);
|
|
1158
|
+
const timer = setTimeout(() => {
|
|
1159
|
+
child.kill("SIGKILL");
|
|
1160
|
+
resolvePromise({ ok: false, error: `Skill script timed out after ${timeoutMs}ms.
|
|
1161
|
+
${out.slice(-4e3)}` });
|
|
1162
|
+
}, timeoutMs);
|
|
1163
|
+
child.on("error", (err) => {
|
|
1164
|
+
clearTimeout(timer);
|
|
1165
|
+
resolvePromise({ ok: false, error: `Could not launch ${cmd}: ${err.message}` });
|
|
1166
|
+
});
|
|
1167
|
+
child.on("exit", (code) => {
|
|
1168
|
+
clearTimeout(timer);
|
|
1169
|
+
const body = out.trim() || "(no output)";
|
|
1170
|
+
if (code === 0) resolvePromise({ ok: true, result: body });
|
|
1171
|
+
else resolvePromise({ ok: false, error: `Script exited with code ${code}:
|
|
1172
|
+
${body.slice(-6e3)}` });
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
773
1175
|
}
|
|
774
1176
|
async bash(args) {
|
|
775
1177
|
const command = String(args.command || "");
|
|
@@ -797,12 +1199,12 @@ ${truncated}`
|
|
|
797
1199
|
const command = String(args.command || "");
|
|
798
1200
|
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
799
1201
|
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
800
|
-
const id =
|
|
1202
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
801
1203
|
const logPath = path3.join(LOG_DIR, `${id}.log`);
|
|
802
1204
|
let out;
|
|
803
1205
|
try {
|
|
804
1206
|
await fsp.mkdir(LOG_DIR, { recursive: true });
|
|
805
|
-
out =
|
|
1207
|
+
out = fs4.openSync(logPath, "a");
|
|
806
1208
|
} catch (err) {
|
|
807
1209
|
return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
|
|
808
1210
|
}
|
|
@@ -810,10 +1212,10 @@ ${truncated}`
|
|
|
810
1212
|
try {
|
|
811
1213
|
child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
|
|
812
1214
|
} catch (err) {
|
|
813
|
-
|
|
1215
|
+
fs4.closeSync(out);
|
|
814
1216
|
return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
|
|
815
1217
|
}
|
|
816
|
-
|
|
1218
|
+
fs4.closeSync(out);
|
|
817
1219
|
const pid = child.pid;
|
|
818
1220
|
if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
|
|
819
1221
|
let earlyExit;
|
|
@@ -973,6 +1375,39 @@ ${tail}` : " No output was captured.")
|
|
|
973
1375
|
}
|
|
974
1376
|
return { ok: false, error: `Unknown action: ${action}` };
|
|
975
1377
|
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Terminate every still-running background process this machine started, so
|
|
1380
|
+
* detached children don't outlive the CLI when the session quits. Sends
|
|
1381
|
+
* SIGTERM to each process group, waits a brief grace, then SIGKILL any
|
|
1382
|
+
* straggler, and records them stopped. Best effort and bounded so quitting
|
|
1383
|
+
* stays snappy. Returns the number of running processes it signaled.
|
|
1384
|
+
*/
|
|
1385
|
+
async stopAllLocalProcesses() {
|
|
1386
|
+
if (!this.registry) return 0;
|
|
1387
|
+
let running = [];
|
|
1388
|
+
try {
|
|
1389
|
+
const procs = await this.registry.list();
|
|
1390
|
+
running = procs.filter((p) => p.status === "running" && p.machine === (this.machine ?? ""));
|
|
1391
|
+
} catch {
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
if (!running.length) return 0;
|
|
1395
|
+
const signal = (pid, sig) => {
|
|
1396
|
+
try {
|
|
1397
|
+
process.kill(-pid, sig);
|
|
1398
|
+
} catch {
|
|
1399
|
+
try {
|
|
1400
|
+
process.kill(pid, sig);
|
|
1401
|
+
} catch {
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
for (const proc of running) signal(proc.pid, "SIGTERM");
|
|
1406
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1407
|
+
for (const proc of running) signal(proc.pid, "SIGKILL");
|
|
1408
|
+
await Promise.allSettled(running.map((proc) => this.registry.markStopped(proc.id)));
|
|
1409
|
+
return running.length;
|
|
1410
|
+
}
|
|
976
1411
|
run(cmd, args, cwd, timeoutMs) {
|
|
977
1412
|
return new Promise((resolve) => {
|
|
978
1413
|
let stdout = "";
|
|
@@ -1069,6 +1504,7 @@ var MessageStream = class {
|
|
|
1069
1504
|
hooks;
|
|
1070
1505
|
ws = null;
|
|
1071
1506
|
closed = false;
|
|
1507
|
+
heartbeat = null;
|
|
1072
1508
|
reconnectAttempt = 0;
|
|
1073
1509
|
reconnectTimer = null;
|
|
1074
1510
|
resolveConnected = null;
|
|
@@ -1093,7 +1529,7 @@ var MessageStream = class {
|
|
|
1093
1529
|
}
|
|
1094
1530
|
openSocket() {
|
|
1095
1531
|
if (this.closed) return;
|
|
1096
|
-
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
|
|
1532
|
+
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}&reasoning=1`;
|
|
1097
1533
|
let ws;
|
|
1098
1534
|
try {
|
|
1099
1535
|
ws = new WebSocket(url);
|
|
@@ -1104,17 +1540,33 @@ var MessageStream = class {
|
|
|
1104
1540
|
this.ws = ws;
|
|
1105
1541
|
ws.addEventListener("open", () => {
|
|
1106
1542
|
this.reconnectAttempt = 0;
|
|
1543
|
+
this.startHeartbeat(ws);
|
|
1107
1544
|
this.resolveConnected?.();
|
|
1108
1545
|
});
|
|
1109
|
-
ws.addEventListener("message", (ev) =>
|
|
1546
|
+
ws.addEventListener("message", (ev) => {
|
|
1547
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1548
|
+
this.onMessage(String(ev.data));
|
|
1549
|
+
});
|
|
1110
1550
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1111
1551
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1112
1552
|
}
|
|
1113
1553
|
handleDrop(ws) {
|
|
1114
1554
|
if (this.ws !== ws) return;
|
|
1115
1555
|
this.ws = null;
|
|
1556
|
+
this.stopHeartbeat();
|
|
1116
1557
|
this.scheduleReconnect();
|
|
1117
1558
|
}
|
|
1559
|
+
startHeartbeat(ws) {
|
|
1560
|
+
this.stopHeartbeat();
|
|
1561
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1562
|
+
this.heartbeat.start();
|
|
1563
|
+
}
|
|
1564
|
+
stopHeartbeat() {
|
|
1565
|
+
if (this.heartbeat) {
|
|
1566
|
+
this.heartbeat.stop();
|
|
1567
|
+
this.heartbeat = null;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1118
1570
|
scheduleReconnect() {
|
|
1119
1571
|
if (this.closed || this.reconnectTimer) return;
|
|
1120
1572
|
this.reconnectAttempt++;
|
|
@@ -1127,6 +1579,7 @@ var MessageStream = class {
|
|
|
1127
1579
|
}
|
|
1128
1580
|
close() {
|
|
1129
1581
|
this.closed = true;
|
|
1582
|
+
this.stopHeartbeat();
|
|
1130
1583
|
if (this.reconnectTimer) {
|
|
1131
1584
|
clearTimeout(this.reconnectTimer);
|
|
1132
1585
|
this.reconnectTimer = null;
|
|
@@ -1145,7 +1598,11 @@ var MessageStream = class {
|
|
|
1145
1598
|
return;
|
|
1146
1599
|
}
|
|
1147
1600
|
if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
|
|
1148
|
-
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
|
|
1601
|
+
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk, msg.message_id);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
if (msg.type === "reasoning_chunk" && (msg.depth ?? 0) === 0) {
|
|
1605
|
+
if (typeof msg.chunk === "string") this.hooks.onReasoningChunk?.(msg.chunk, msg.message_id);
|
|
1149
1606
|
return;
|
|
1150
1607
|
}
|
|
1151
1608
|
if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
|
|
@@ -1172,6 +1629,7 @@ var SystemEvents = class {
|
|
|
1172
1629
|
hooks;
|
|
1173
1630
|
ws = null;
|
|
1174
1631
|
closed = false;
|
|
1632
|
+
heartbeat = null;
|
|
1175
1633
|
reconnectAttempt = 0;
|
|
1176
1634
|
reconnectTimer = null;
|
|
1177
1635
|
connect() {
|
|
@@ -1190,11 +1648,27 @@ var SystemEvents = class {
|
|
|
1190
1648
|
this.ws = ws;
|
|
1191
1649
|
ws.addEventListener("open", () => {
|
|
1192
1650
|
this.reconnectAttempt = 0;
|
|
1651
|
+
this.startHeartbeat(ws);
|
|
1652
|
+
this.hooks.onOpen?.();
|
|
1653
|
+
});
|
|
1654
|
+
ws.addEventListener("message", (ev) => {
|
|
1655
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1656
|
+
this.onMessage(String(ev.data));
|
|
1193
1657
|
});
|
|
1194
|
-
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
1195
1658
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1196
1659
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1197
1660
|
}
|
|
1661
|
+
startHeartbeat(ws) {
|
|
1662
|
+
this.stopHeartbeat();
|
|
1663
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1664
|
+
this.heartbeat.start();
|
|
1665
|
+
}
|
|
1666
|
+
stopHeartbeat() {
|
|
1667
|
+
if (this.heartbeat) {
|
|
1668
|
+
this.heartbeat.stop();
|
|
1669
|
+
this.heartbeat = null;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1198
1672
|
onMessage(raw) {
|
|
1199
1673
|
let msg;
|
|
1200
1674
|
try {
|
|
@@ -1211,6 +1685,7 @@ var SystemEvents = class {
|
|
|
1211
1685
|
handleDrop(ws) {
|
|
1212
1686
|
if (this.ws !== ws) return;
|
|
1213
1687
|
this.ws = null;
|
|
1688
|
+
this.stopHeartbeat();
|
|
1214
1689
|
this.scheduleReconnect();
|
|
1215
1690
|
}
|
|
1216
1691
|
scheduleReconnect() {
|
|
@@ -1225,6 +1700,7 @@ var SystemEvents = class {
|
|
|
1225
1700
|
}
|
|
1226
1701
|
close() {
|
|
1227
1702
|
this.closed = true;
|
|
1703
|
+
this.stopHeartbeat();
|
|
1228
1704
|
if (this.reconnectTimer) {
|
|
1229
1705
|
clearTimeout(this.reconnectTimer);
|
|
1230
1706
|
this.reconnectTimer = null;
|
|
@@ -1245,8 +1721,98 @@ var LEVEL_DETAIL = {
|
|
|
1245
1721
|
function levelLabel(level) {
|
|
1246
1722
|
return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
|
|
1247
1723
|
}
|
|
1724
|
+
var FILE_MIMES = {
|
|
1725
|
+
".png": "image/png",
|
|
1726
|
+
".jpg": "image/jpeg",
|
|
1727
|
+
".jpeg": "image/jpeg",
|
|
1728
|
+
".gif": "image/gif",
|
|
1729
|
+
".webp": "image/webp"
|
|
1730
|
+
};
|
|
1731
|
+
var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
1732
|
+
function run(cmd, args, maxBuffer = MAX_IMAGE_BYTES * 2) {
|
|
1733
|
+
return new Promise((resolve) => {
|
|
1734
|
+
execFile(cmd, args, { encoding: "buffer", maxBuffer }, (err, stdout) => {
|
|
1735
|
+
resolve({ ok: !err, stdout: stdout ?? Buffer.alloc(0) });
|
|
1736
|
+
});
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
function fromFile(filePath) {
|
|
1740
|
+
const mime = FILE_MIMES[path3.extname(filePath).toLowerCase()];
|
|
1741
|
+
if (!mime) return null;
|
|
1742
|
+
try {
|
|
1743
|
+
const stat = fs4.statSync(filePath);
|
|
1744
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null;
|
|
1745
|
+
return { data: fs4.readFileSync(filePath).toString("base64"), mime };
|
|
1746
|
+
} catch {
|
|
1747
|
+
return null;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async function readDarwin() {
|
|
1751
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1752
|
+
const script = [
|
|
1753
|
+
`set d to the clipboard as \xABclass PNGf\xBB`,
|
|
1754
|
+
`set f to open for access POSIX file "${tmp}" with write permission`,
|
|
1755
|
+
`set eof f to 0`,
|
|
1756
|
+
`write d to f`,
|
|
1757
|
+
`close access f`
|
|
1758
|
+
].join("\n");
|
|
1759
|
+
const png = await run("osascript", ["-e", script]);
|
|
1760
|
+
if (png.ok) {
|
|
1761
|
+
const img = fromFile(tmp);
|
|
1762
|
+
try {
|
|
1763
|
+
fs4.unlinkSync(tmp);
|
|
1764
|
+
} catch {
|
|
1765
|
+
}
|
|
1766
|
+
if (img) return img;
|
|
1767
|
+
}
|
|
1768
|
+
const furl = await run("osascript", ["-e", "POSIX path of (the clipboard as \xABclass furl\xBB)"]);
|
|
1769
|
+
if (furl.ok) {
|
|
1770
|
+
const p = furl.stdout.toString("utf8").trim();
|
|
1771
|
+
if (p) return fromFile(p);
|
|
1772
|
+
}
|
|
1773
|
+
return null;
|
|
1774
|
+
}
|
|
1775
|
+
async function readLinux() {
|
|
1776
|
+
for (const [cmd, args] of [
|
|
1777
|
+
["wl-paste", ["--type", "image/png"]],
|
|
1778
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
1779
|
+
]) {
|
|
1780
|
+
const res = await run(cmd, args);
|
|
1781
|
+
if (res.ok && res.stdout.length > 8 && res.stdout.length <= MAX_IMAGE_BYTES && res.stdout[0] === 137 && res.stdout[1] === 80) {
|
|
1782
|
+
return { data: res.stdout.toString("base64"), mime: "image/png" };
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
async function readWindows() {
|
|
1788
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1789
|
+
const ps = [
|
|
1790
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
1791
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage();",
|
|
1792
|
+
`if ($img -ne $null) { $img.Save('${tmp.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) }`
|
|
1793
|
+
].join(" ");
|
|
1794
|
+
await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
1795
|
+
const img = fromFile(tmp);
|
|
1796
|
+
try {
|
|
1797
|
+
fs4.unlinkSync(tmp);
|
|
1798
|
+
} catch {
|
|
1799
|
+
}
|
|
1800
|
+
return img;
|
|
1801
|
+
}
|
|
1802
|
+
async function readClipboardImage() {
|
|
1803
|
+
try {
|
|
1804
|
+
if (process.platform === "darwin") return await readDarwin();
|
|
1805
|
+
if (process.platform === "win32") return await readWindows();
|
|
1806
|
+
return await readLinux();
|
|
1807
|
+
} catch {
|
|
1808
|
+
return null;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1248
1811
|
|
|
1249
1812
|
// src/tui.ts
|
|
1813
|
+
function imagePlaceholder(seq) {
|
|
1814
|
+
return `[#Image ${seq}]`;
|
|
1815
|
+
}
|
|
1250
1816
|
var C = {
|
|
1251
1817
|
reset: "\x1B[0m",
|
|
1252
1818
|
dim: "\x1B[2m",
|
|
@@ -1261,7 +1827,23 @@ var C = {
|
|
|
1261
1827
|
teal: "\x1B[38;5;37m"
|
|
1262
1828
|
};
|
|
1263
1829
|
var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
|
|
1264
|
-
var
|
|
1830
|
+
var SUBAGENT_COLORS = [
|
|
1831
|
+
"\x1B[35m",
|
|
1832
|
+
// magenta
|
|
1833
|
+
"\x1B[38;5;39m",
|
|
1834
|
+
// azure
|
|
1835
|
+
"\x1B[38;5;75m",
|
|
1836
|
+
// blue
|
|
1837
|
+
"\x1B[32m",
|
|
1838
|
+
// green
|
|
1839
|
+
"\x1B[38;5;141m",
|
|
1840
|
+
// violet
|
|
1841
|
+
"\x1B[38;5;177m"
|
|
1842
|
+
// orchid
|
|
1843
|
+
];
|
|
1844
|
+
var COMPACTION_COLOR = "\x1B[38;5;208m";
|
|
1845
|
+
var COMPACTION_AGENT = "compaction_agent";
|
|
1846
|
+
var Tui = class _Tui {
|
|
1265
1847
|
constructor(level = 1) {
|
|
1266
1848
|
this.level = level;
|
|
1267
1849
|
readline.emitKeypressEvents(process.stdin);
|
|
@@ -1270,6 +1852,7 @@ var Tui = class {
|
|
|
1270
1852
|
process.stdin.resume();
|
|
1271
1853
|
process.stdout.write("\x1B[?2004h");
|
|
1272
1854
|
process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
|
|
1855
|
+
process.stdout.on("resize", () => this.renderBottom());
|
|
1273
1856
|
}
|
|
1274
1857
|
level;
|
|
1275
1858
|
// input + indicators
|
|
@@ -1284,13 +1867,31 @@ var Tui = class {
|
|
|
1284
1867
|
bgCount = 0;
|
|
1285
1868
|
queuedCount = 0;
|
|
1286
1869
|
subagents = [];
|
|
1287
|
-
//
|
|
1870
|
+
// active subagents (one line each)
|
|
1871
|
+
subagentColorByID = /* @__PURE__ */ new Map();
|
|
1872
|
+
// subagent id → SUBAGENT_COLORS index
|
|
1288
1873
|
tokensIn = 0;
|
|
1289
1874
|
// cumulative input tokens
|
|
1290
1875
|
tokensOut = 0;
|
|
1291
1876
|
// cumulative output tokens (includes the in-progress live count)
|
|
1292
1877
|
contextPct = null;
|
|
1293
1878
|
// % of the model context window currently used
|
|
1879
|
+
// Live streaming preview, shown just above the status line while a turn runs:
|
|
1880
|
+
// the model's internal reasoning (dim italic) until the answer starts, then the
|
|
1881
|
+
// answer text (plain). Bounded to a tail; cleared when the message commits.
|
|
1882
|
+
streamThinking = "";
|
|
1883
|
+
streamResponse = "";
|
|
1884
|
+
streamMessageId = null;
|
|
1885
|
+
// the message currently previewing
|
|
1886
|
+
streamRedrawTimer = null;
|
|
1887
|
+
streamIdleTimer = null;
|
|
1888
|
+
// wipes a stale preview
|
|
1889
|
+
// Live goal checklist, fixed below the status bar. Driven by the goal_updated
|
|
1890
|
+
// thread event (+ an initial fetch). null = nothing to show.
|
|
1891
|
+
goal = null;
|
|
1892
|
+
// Set true the moment every step of a goal is done: the goal area is cleared
|
|
1893
|
+
// and the status line shows "Goal complete." until the next turn starts.
|
|
1894
|
+
goalComplete = false;
|
|
1294
1895
|
// Inline slash-command palette: when the input starts with "/", the filtered
|
|
1295
1896
|
// command list renders above the prompt and arrows/enter/tab drive it.
|
|
1296
1897
|
commands = [];
|
|
@@ -1305,6 +1906,14 @@ var Tui = class {
|
|
|
1305
1906
|
connected = true;
|
|
1306
1907
|
bottomDrawn = false;
|
|
1307
1908
|
started = false;
|
|
1909
|
+
// Resize bookkeeping: the width the region was last drawn at, and the visible
|
|
1910
|
+
// width of every HUD row written above the input. When the terminal is
|
|
1911
|
+
// resized, previously drawn rows re-wrap (a full-width ruler becomes 2+ rows
|
|
1912
|
+
// when narrowed), so the move-up count recorded at draw time is wrong — these
|
|
1913
|
+
// let moveToRegionTop recompute the region height under the NEW wrap instead
|
|
1914
|
+
// of leaving stale rulers behind.
|
|
1915
|
+
lastDrawnCols = 0;
|
|
1916
|
+
drawnHudWidths = [];
|
|
1308
1917
|
// takeover (approval / menu) state
|
|
1309
1918
|
takeoverHandler = null;
|
|
1310
1919
|
bufferedPrints = [];
|
|
@@ -1315,13 +1924,28 @@ var Tui = class {
|
|
|
1315
1924
|
// bracketed-paste state
|
|
1316
1925
|
pasting = false;
|
|
1317
1926
|
pasteTimer = null;
|
|
1927
|
+
// Images pasted into the CURRENT input (Ctrl+V). Each got a `[#Image N]`
|
|
1928
|
+
// placeholder at the caret; on submit only images whose placeholder is still
|
|
1929
|
+
// present in the text are handed to onSubmit. Cleared with the input.
|
|
1930
|
+
pendingImages = [];
|
|
1931
|
+
imagePasteBusy = false;
|
|
1932
|
+
// one clipboard read at a time
|
|
1933
|
+
// Sent-message history for ↑/↓ recall (oldest → newest). `historyIdx` is the
|
|
1934
|
+
// entry currently shown (null = not browsing); the in-progress draft is
|
|
1935
|
+
// stashed so cycling past the newest entry restores it. Any edit exits
|
|
1936
|
+
// browsing and keeps the recalled text as the new draft.
|
|
1937
|
+
history = [];
|
|
1938
|
+
historyIdx = null;
|
|
1939
|
+
historyDraft = "";
|
|
1940
|
+
historyDraftImages = [];
|
|
1318
1941
|
// event hooks (wired by index.ts)
|
|
1319
1942
|
onSubmit = () => {
|
|
1320
1943
|
};
|
|
1321
1944
|
onInterrupt = () => {
|
|
1322
1945
|
};
|
|
1323
|
-
|
|
1324
|
-
|
|
1946
|
+
/** Up on the top row: return true to consume it (e.g. pull a queued message)
|
|
1947
|
+
* before history recall gets a chance. */
|
|
1948
|
+
onUpArrow = () => false;
|
|
1325
1949
|
onQuit = () => process.exit(0);
|
|
1326
1950
|
levelListeners = [];
|
|
1327
1951
|
get colors() {
|
|
@@ -1356,6 +1980,8 @@ var Tui = class {
|
|
|
1356
1980
|
end() {
|
|
1357
1981
|
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1358
1982
|
this.quitTimer = null;
|
|
1983
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
1984
|
+
this.streamIdleTimer = null;
|
|
1359
1985
|
this.clearBottom();
|
|
1360
1986
|
process.stdout.write("\x1B[?2004l\x1B[?25h");
|
|
1361
1987
|
}
|
|
@@ -1368,6 +1994,11 @@ var Tui = class {
|
|
|
1368
1994
|
}
|
|
1369
1995
|
// ─── key dispatch ──────────────────────────────────────────────────────────
|
|
1370
1996
|
dispatch(str, key) {
|
|
1997
|
+
const seq = key && key.sequence || str || "";
|
|
1998
|
+
if (seq === "\n" || seq === "\x1B[13;2u" || seq === "\x1B[27;2;13~") {
|
|
1999
|
+
this.insertAtCursor("\n");
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
1371
2002
|
if (key && key.ctrl && key.name === "c") {
|
|
1372
2003
|
this.requestQuit();
|
|
1373
2004
|
return;
|
|
@@ -1376,7 +2007,6 @@ var Tui = class {
|
|
|
1376
2007
|
this.cycleLevel();
|
|
1377
2008
|
return;
|
|
1378
2009
|
}
|
|
1379
|
-
const seq = key && key.sequence || str || "";
|
|
1380
2010
|
if (!this.pasting && seq.includes("\x1B[200~")) {
|
|
1381
2011
|
this.pasting = true;
|
|
1382
2012
|
this.armPasteSafety();
|
|
@@ -1420,6 +2050,10 @@ var Tui = class {
|
|
|
1420
2050
|
return;
|
|
1421
2051
|
}
|
|
1422
2052
|
if (key.name === "return" || key.name === "enter") {
|
|
2053
|
+
if (key.shift) {
|
|
2054
|
+
this.insertAtCursor("\n");
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
1423
2057
|
if (matches.length) this.runCommand(matches[cur]);
|
|
1424
2058
|
return;
|
|
1425
2059
|
}
|
|
@@ -1460,15 +2094,46 @@ var Tui = class {
|
|
|
1460
2094
|
return;
|
|
1461
2095
|
}
|
|
1462
2096
|
if (key.name === "up") {
|
|
1463
|
-
this.
|
|
2097
|
+
const { caretRow } = this.inputLayout();
|
|
2098
|
+
if (caretRow > 0) {
|
|
2099
|
+
this.moveCaretVertical(-1);
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
if (this.onUpArrow()) return;
|
|
2103
|
+
this.historyPrev();
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
if (key.name === "down") {
|
|
2107
|
+
const { caretRow, rowCount } = this.inputLayout();
|
|
2108
|
+
if (caretRow < rowCount - 1) {
|
|
2109
|
+
this.moveCaretVertical(1);
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
this.historyNext();
|
|
1464
2113
|
return;
|
|
1465
2114
|
}
|
|
1466
2115
|
if (key.name === "return" || key.name === "enter") {
|
|
2116
|
+
if (key.shift) {
|
|
2117
|
+
this.insertAtCursor("\n");
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
1467
2120
|
const text = this.inputBuffer;
|
|
2121
|
+
const images = this.pendingImages.filter((img) => text.includes(imagePlaceholder(img.seq)));
|
|
1468
2122
|
this.inputBuffer = "";
|
|
1469
2123
|
this.cursorPos = 0;
|
|
2124
|
+
this.pendingImages = [];
|
|
2125
|
+
this.historyIdx = null;
|
|
2126
|
+
this.historyDraft = "";
|
|
2127
|
+
this.historyDraftImages = [];
|
|
1470
2128
|
this.renderBottom();
|
|
1471
|
-
if (text.trim())
|
|
2129
|
+
if (text.trim()) {
|
|
2130
|
+
this.addHistoryEntry(text.trim());
|
|
2131
|
+
this.onSubmit(text.trim(), images);
|
|
2132
|
+
}
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
if (key.ctrl && key.name === "v") {
|
|
2136
|
+
void this.pasteClipboardImage();
|
|
1472
2137
|
return;
|
|
1473
2138
|
}
|
|
1474
2139
|
if (key.name === "backspace") {
|
|
@@ -1476,6 +2141,7 @@ var Tui = class {
|
|
|
1476
2141
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
|
|
1477
2142
|
this.cursorPos--;
|
|
1478
2143
|
this.slashIdx = 0;
|
|
2144
|
+
this.historyIdx = null;
|
|
1479
2145
|
this.renderBottom();
|
|
1480
2146
|
}
|
|
1481
2147
|
return;
|
|
@@ -1484,6 +2150,7 @@ var Tui = class {
|
|
|
1484
2150
|
if (this.cursorPos < this.inputBuffer.length) {
|
|
1485
2151
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
|
|
1486
2152
|
this.slashIdx = 0;
|
|
2153
|
+
this.historyIdx = null;
|
|
1487
2154
|
this.renderBottom();
|
|
1488
2155
|
}
|
|
1489
2156
|
return;
|
|
@@ -1497,6 +2164,7 @@ var Tui = class {
|
|
|
1497
2164
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
|
|
1498
2165
|
this.cursorPos += text.length;
|
|
1499
2166
|
this.slashIdx = 0;
|
|
2167
|
+
this.historyIdx = null;
|
|
1500
2168
|
this.renderBottom();
|
|
1501
2169
|
}
|
|
1502
2170
|
/** Insert a paste fragment at the caret; collapse newlines (single-line input). */
|
|
@@ -1506,6 +2174,7 @@ var Tui = class {
|
|
|
1506
2174
|
if (content) {
|
|
1507
2175
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
|
|
1508
2176
|
this.cursorPos += content.length;
|
|
2177
|
+
this.historyIdx = null;
|
|
1509
2178
|
}
|
|
1510
2179
|
if (end >= 0) {
|
|
1511
2180
|
this.pasting = false;
|
|
@@ -1525,6 +2194,128 @@ var Tui = class {
|
|
|
1525
2194
|
this.renderBottom();
|
|
1526
2195
|
}, 2e3);
|
|
1527
2196
|
}
|
|
2197
|
+
// ─── input layout + vertical caret movement ────────────────────────────────
|
|
2198
|
+
/**
|
|
2199
|
+
* The input's physical rows (same wrapping math as renderBottom: logical
|
|
2200
|
+
* lines split on "\n", line 0 led by the prompt prefix, each wrapping at the
|
|
2201
|
+
* terminal width) plus where the caret sits among them. Each row records the
|
|
2202
|
+
* buffer index of its first character, its character count, and the visual
|
|
2203
|
+
* column its first character renders at (only row 0 is offset, by the
|
|
2204
|
+
* prompt). Drives ↑/↓: row 0 is "the top line" (history recall territory),
|
|
2205
|
+
* anything below moves the caret instead.
|
|
2206
|
+
*/
|
|
2207
|
+
inputLayout() {
|
|
2208
|
+
const cols2 = process.stdout.columns || 80;
|
|
2209
|
+
const pw = this.visibleWidth(this.promptPrefix());
|
|
2210
|
+
const lines = this.inputBuffer.split("\n");
|
|
2211
|
+
const rows = [];
|
|
2212
|
+
let offset = 0;
|
|
2213
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2214
|
+
const lead = i === 0 ? pw : 0;
|
|
2215
|
+
const len = lines[i].length;
|
|
2216
|
+
const nRows = Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2217
|
+
for (let j = 0; j < nRows; j++) {
|
|
2218
|
+
const charStart = Math.max(0, j * cols2 - lead);
|
|
2219
|
+
const charEnd = Math.min(len, (j + 1) * cols2 - lead);
|
|
2220
|
+
rows.push({ start: offset + charStart, len: Math.max(0, charEnd - charStart), colOffset: j === 0 ? lead : 0 });
|
|
2221
|
+
}
|
|
2222
|
+
offset += len + 1;
|
|
2223
|
+
}
|
|
2224
|
+
let pos = this.cursorPos;
|
|
2225
|
+
let caretLine = 0;
|
|
2226
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2227
|
+
pos -= lines[caretLine].length + 1;
|
|
2228
|
+
caretLine++;
|
|
2229
|
+
}
|
|
2230
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2231
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2232
|
+
for (let i = 0; i < caretLine; i++) {
|
|
2233
|
+
const lead = i === 0 ? pw : 0;
|
|
2234
|
+
caretRow += Math.max(1, Math.ceil((lead + lines[i].length) / cols2));
|
|
2235
|
+
}
|
|
2236
|
+
const caretCol = caretCell % cols2;
|
|
2237
|
+
while (caretRow >= rows.length) rows.push({ start: this.inputBuffer.length, len: 0, colOffset: 0 });
|
|
2238
|
+
return { rows, caretRow, caretCol, rowCount: rows.length };
|
|
2239
|
+
}
|
|
2240
|
+
/** Move the caret one visual row up/down, keeping the column when possible. */
|
|
2241
|
+
moveCaretVertical(delta) {
|
|
2242
|
+
const { rows, caretRow, caretCol } = this.inputLayout();
|
|
2243
|
+
const target = caretRow + delta;
|
|
2244
|
+
if (target < 0 || target >= rows.length) return;
|
|
2245
|
+
const row = rows[target];
|
|
2246
|
+
const within = Math.max(0, Math.min(caretCol - row.colOffset, row.len));
|
|
2247
|
+
this.cursorPos = Math.min(row.start + within, this.inputBuffer.length);
|
|
2248
|
+
this.renderBottom();
|
|
2249
|
+
}
|
|
2250
|
+
// ─── sent-message history (↑/↓ recall) ─────────────────────────────────────
|
|
2251
|
+
/** Seed the recall history (oldest → newest), e.g. from the on-disk file. */
|
|
2252
|
+
setHistory(entries) {
|
|
2253
|
+
this.history = entries.filter((e) => e.trim() !== "");
|
|
2254
|
+
}
|
|
2255
|
+
/** Record a sent message (skipping a consecutive duplicate). */
|
|
2256
|
+
addHistoryEntry(text) {
|
|
2257
|
+
if (this.history[this.history.length - 1] === text) return;
|
|
2258
|
+
this.history.push(text);
|
|
2259
|
+
}
|
|
2260
|
+
/** Recall the previous (older) history entry; stashes the draft first. */
|
|
2261
|
+
historyPrev() {
|
|
2262
|
+
if (!this.history.length) return;
|
|
2263
|
+
if (this.historyIdx === null) {
|
|
2264
|
+
this.historyDraft = this.inputBuffer;
|
|
2265
|
+
this.historyDraftImages = this.pendingImages;
|
|
2266
|
+
this.historyIdx = this.history.length - 1;
|
|
2267
|
+
} else if (this.historyIdx > 0) {
|
|
2268
|
+
this.historyIdx--;
|
|
2269
|
+
} else {
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
this.pendingImages = [];
|
|
2273
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2274
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2275
|
+
this.slashIdx = 0;
|
|
2276
|
+
this.renderBottom();
|
|
2277
|
+
}
|
|
2278
|
+
/** Step toward the newest entry; past it, restore the stashed draft. */
|
|
2279
|
+
historyNext() {
|
|
2280
|
+
if (this.historyIdx === null) return;
|
|
2281
|
+
if (this.historyIdx < this.history.length - 1) {
|
|
2282
|
+
this.historyIdx++;
|
|
2283
|
+
this.pendingImages = [];
|
|
2284
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2285
|
+
} else {
|
|
2286
|
+
this.historyIdx = null;
|
|
2287
|
+
this.inputBuffer = this.historyDraft;
|
|
2288
|
+
this.pendingImages = this.historyDraftImages;
|
|
2289
|
+
this.historyDraft = "";
|
|
2290
|
+
this.historyDraftImages = [];
|
|
2291
|
+
}
|
|
2292
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2293
|
+
this.slashIdx = 0;
|
|
2294
|
+
this.renderBottom();
|
|
2295
|
+
}
|
|
2296
|
+
// ─── clipboard image paste (Ctrl+V) ────────────────────────────────────────
|
|
2297
|
+
/**
|
|
2298
|
+
* Read an image off the system clipboard and drop an `[#Image N]`
|
|
2299
|
+
* placeholder at the caret. The bytes ride along with the message on submit
|
|
2300
|
+
* (as a real attachment) as long as the placeholder is still in the text —
|
|
2301
|
+
* delete the placeholder and the image is dropped too.
|
|
2302
|
+
*/
|
|
2303
|
+
async pasteClipboardImage() {
|
|
2304
|
+
if (this.imagePasteBusy) return;
|
|
2305
|
+
this.imagePasteBusy = true;
|
|
2306
|
+
try {
|
|
2307
|
+
const img = await readClipboardImage();
|
|
2308
|
+
if (!img) {
|
|
2309
|
+
this.print(`${C.dim}No image on the clipboard.${C.reset}`);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
const seq = this.pendingImages.reduce((m, i) => Math.max(m, i.seq), 0) + 1;
|
|
2313
|
+
this.pendingImages.push({ seq, data: img.data, mime: img.mime });
|
|
2314
|
+
this.insertAtCursor(imagePlaceholder(seq));
|
|
2315
|
+
} finally {
|
|
2316
|
+
this.imagePasteBusy = false;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
1528
2319
|
cycleLevel() {
|
|
1529
2320
|
const idx = LEVELS.indexOf(this.level);
|
|
1530
2321
|
this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
|
|
@@ -1571,7 +2362,8 @@ var Tui = class {
|
|
|
1571
2362
|
let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
|
|
1572
2363
|
if (this.contextPct != null) {
|
|
1573
2364
|
const pct = this.contextPct;
|
|
1574
|
-
const
|
|
2365
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2366
|
+
const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : calmGreen;
|
|
1575
2367
|
const gauge = `${col}ctx ${pct}%${C.reset}`;
|
|
1576
2368
|
out = out ? `${out} ${gauge}` : gauge;
|
|
1577
2369
|
}
|
|
@@ -1623,13 +2415,13 @@ var Tui = class {
|
|
|
1623
2415
|
* token totals visible (`↑in ↓out`) so they live in the summary rather than
|
|
1624
2416
|
* crowding the prompt. Null when idle with nothing counted yet.
|
|
1625
2417
|
*/
|
|
1626
|
-
statusLineText(
|
|
2418
|
+
statusLineText(cols2) {
|
|
1627
2419
|
const tk = this.tokensText();
|
|
1628
2420
|
if (this.working) {
|
|
1629
2421
|
const el = this.formatElapsed(Date.now() - this.workingStart);
|
|
1630
2422
|
const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
|
|
1631
2423
|
const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
|
|
1632
|
-
const avail = Math.max(0,
|
|
2424
|
+
const avail = Math.max(0, cols2 - this.visibleWidth(head) - this.visibleWidth(right) - 2);
|
|
1633
2425
|
let stepPart = "";
|
|
1634
2426
|
if (this.step && avail > 1) {
|
|
1635
2427
|
let s = this.step;
|
|
@@ -1638,23 +2430,43 @@ var Tui = class {
|
|
|
1638
2430
|
}
|
|
1639
2431
|
return `${head}${stepPart} ${right}`;
|
|
1640
2432
|
}
|
|
2433
|
+
if (this.goalComplete) {
|
|
2434
|
+
const done = `${C.bold}${gradientText("\u2713 Goal complete.")}${C.reset}`;
|
|
2435
|
+
return tk ? `${done} ${tk}` : done;
|
|
2436
|
+
}
|
|
1641
2437
|
return tk ? tk : null;
|
|
1642
2438
|
}
|
|
1643
2439
|
/** The prompt line prefix (with ANSI colour) that precedes the typed text. */
|
|
1644
2440
|
promptPrefix() {
|
|
1645
2441
|
const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
|
|
1646
2442
|
const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
|
|
1647
|
-
return `${q}${bg}${this.levelColor()}\
|
|
2443
|
+
return `${q}${bg}${this.levelColor()}\u276F${C.reset} `;
|
|
1648
2444
|
}
|
|
1649
2445
|
visibleWidth(s) {
|
|
1650
2446
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1651
2447
|
}
|
|
2448
|
+
/**
|
|
2449
|
+
* The slash-palette block rendered BELOW the input. While the palette is open
|
|
2450
|
+
* we reserve a fixed number of rows — one per available command — padding with
|
|
2451
|
+
* blank rows so the region height never changes as the filter narrows. That
|
|
2452
|
+
* pins the input: the region scrolls into place once when the palette opens,
|
|
2453
|
+
* then nothing below the input resizes, so the text you're typing never jumps.
|
|
2454
|
+
* Returns `[]` when the palette is closed (no reservation, input sits at the
|
|
2455
|
+
* bottom as usual).
|
|
2456
|
+
*/
|
|
2457
|
+
paletteBlockLines(cols2) {
|
|
2458
|
+
if (!this.paletteOpen()) return [];
|
|
2459
|
+
const rows = this.paletteLines(cols2);
|
|
2460
|
+
const reserved = Math.max(this.commands.length, rows.length);
|
|
2461
|
+
while (rows.length < reserved) rows.push("");
|
|
2462
|
+
return rows;
|
|
2463
|
+
}
|
|
1652
2464
|
/**
|
|
1653
2465
|
* Build the slash-palette rows for the current filter. Each row is clamped to
|
|
1654
2466
|
* ONE physical line (a wrapped row would desync the move-up redraw), with the
|
|
1655
2467
|
* `/name` highlighted, the label dimmed, and the hint right-aligned.
|
|
1656
2468
|
*/
|
|
1657
|
-
paletteLines(
|
|
2469
|
+
paletteLines(cols2) {
|
|
1658
2470
|
if (!this.paletteOpen()) return [];
|
|
1659
2471
|
const matches = this.filteredCommands();
|
|
1660
2472
|
if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
|
|
@@ -1666,23 +2478,42 @@ var Tui = class {
|
|
|
1666
2478
|
const hintW = hint.length;
|
|
1667
2479
|
const name = `/${cmd.name}`;
|
|
1668
2480
|
let visible = `${name} ${cmd.label}`;
|
|
1669
|
-
const labelMax = Math.max(6,
|
|
2481
|
+
const labelMax = Math.max(6, cols2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
1670
2482
|
if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
|
|
1671
2483
|
const desc = visible.slice(name.length);
|
|
1672
2484
|
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
1673
2485
|
const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
|
|
1674
2486
|
let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
|
|
1675
2487
|
if (hintW) {
|
|
1676
|
-
const gap = Math.max(2,
|
|
2488
|
+
const gap = Math.max(2, cols2 - pointerW - visible.length - hintW);
|
|
1677
2489
|
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
1678
2490
|
}
|
|
1679
2491
|
return line;
|
|
1680
2492
|
});
|
|
1681
2493
|
}
|
|
1682
|
-
/**
|
|
2494
|
+
/**
|
|
2495
|
+
* Move the cursor to the top-left of the current bottom region.
|
|
2496
|
+
*
|
|
2497
|
+
* Same width as the last draw → the caret's recorded row offset is exact.
|
|
2498
|
+
* Width CHANGED (terminal resized) → previously drawn rows re-wrapped, so
|
|
2499
|
+
* that offset is stale; recompute it under the new wrap instead: each drawn
|
|
2500
|
+
* HUD row of visible width w now occupies ceil(w / cols) physical rows
|
|
2501
|
+
* (reflowing terminals re-wrap hard lines; the cursor follows its logical
|
|
2502
|
+
* position in the input text, which inputLayout locates at the new width).
|
|
2503
|
+
*/
|
|
1683
2504
|
moveToRegionTop() {
|
|
1684
2505
|
process.stdout.write("\r");
|
|
1685
|
-
if (this.bottomDrawn
|
|
2506
|
+
if (!this.bottomDrawn) return;
|
|
2507
|
+
const cols2 = process.stdout.columns || 80;
|
|
2508
|
+
let up;
|
|
2509
|
+
if (cols2 !== this.lastDrawnCols && this.lastDrawnCols > 0) {
|
|
2510
|
+
let above = 0;
|
|
2511
|
+
for (const w of this.drawnHudWidths) above += Math.max(1, Math.ceil(Math.max(w, 1) / cols2));
|
|
2512
|
+
up = above + this.inputLayout().caretRow;
|
|
2513
|
+
} else {
|
|
2514
|
+
up = this.lastCursorRow;
|
|
2515
|
+
}
|
|
2516
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1686
2517
|
}
|
|
1687
2518
|
/**
|
|
1688
2519
|
* Render the bottom region: an optional step line, then the prompt + input
|
|
@@ -1692,43 +2523,77 @@ var Tui = class {
|
|
|
1692
2523
|
*/
|
|
1693
2524
|
renderBottom() {
|
|
1694
2525
|
if (!this.started || this.takeoverHandler) return;
|
|
1695
|
-
const
|
|
2526
|
+
const cols2 = process.stdout.columns || 80;
|
|
1696
2527
|
this.moveToRegionTop();
|
|
1697
2528
|
process.stdout.write("\x1B[J");
|
|
2529
|
+
const hudWidths = [];
|
|
2530
|
+
const writeHudRow = (line) => {
|
|
2531
|
+
hudWidths.push(this.visibleWidth(line));
|
|
2532
|
+
process.stdout.write(line + "\r\n");
|
|
2533
|
+
};
|
|
2534
|
+
const previewLines = this.streamPreviewLines(cols2);
|
|
2535
|
+
for (const line of previewLines) writeHudRow(line);
|
|
2536
|
+
const rulerRows = 1;
|
|
2537
|
+
writeHudRow(`${C.dim}${"\u2500".repeat(cols2)}${C.reset}`);
|
|
1698
2538
|
const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
|
|
1699
2539
|
const noticeRows = noticeLine ? 1 : 0;
|
|
1700
|
-
if (noticeLine)
|
|
2540
|
+
if (noticeLine) writeHudRow(noticeLine);
|
|
1701
2541
|
const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
|
|
1702
2542
|
const quitRows = quitLine ? 1 : 0;
|
|
1703
|
-
if (quitLine)
|
|
2543
|
+
if (quitLine) writeHudRow(quitLine);
|
|
1704
2544
|
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
1705
|
-
for (const
|
|
1706
|
-
const
|
|
1707
|
-
|
|
2545
|
+
for (const sub of this.subagents) {
|
|
2546
|
+
const color = sub.agentName === COMPACTION_AGENT ? COMPACTION_COLOR : SUBAGENT_COLORS[this.subagentColorByID.get(sub.id) ?? 0];
|
|
2547
|
+
const budget = cols2 - 10;
|
|
2548
|
+
let label = sub.label;
|
|
2549
|
+
if (budget < 1) label = "";
|
|
2550
|
+
else if (label.length > budget) label = label.slice(0, Math.max(0, budget - 1)) + "\u2026";
|
|
2551
|
+
const line = `${color}${frame}${C.reset} ${color}${label}${C.reset} ${C.dim}working${C.reset}`;
|
|
2552
|
+
writeHudRow(line);
|
|
1708
2553
|
}
|
|
1709
|
-
const statusLine = this.statusLineText(
|
|
2554
|
+
const statusLine = this.statusLineText(cols2);
|
|
1710
2555
|
const statusRows = statusLine ? 1 : 0;
|
|
1711
|
-
if (statusLine)
|
|
1712
|
-
const
|
|
1713
|
-
for (const line of
|
|
1714
|
-
const aboveRows = noticeRows + quitRows + this.subagents.length + statusRows +
|
|
2556
|
+
if (statusLine) writeHudRow(statusLine);
|
|
2557
|
+
const goalLines = this.goalLines(cols2);
|
|
2558
|
+
for (const line of goalLines) writeHudRow(line);
|
|
2559
|
+
const aboveRows = previewLines.length + rulerRows + noticeRows + quitRows + this.subagents.length + statusRows + goalLines.length;
|
|
1715
2560
|
const prefix = this.promptPrefix();
|
|
1716
2561
|
const pw = this.visibleWidth(prefix);
|
|
1717
|
-
const
|
|
1718
|
-
process.stdout.write(prefix +
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
2562
|
+
const lines = this.inputBuffer.split("\n");
|
|
2563
|
+
process.stdout.write(prefix + lines[0]);
|
|
2564
|
+
for (let i = 1; i < lines.length; i++) process.stdout.write("\r\n" + lines[i]);
|
|
2565
|
+
const rowsOf = (len, lead) => Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2566
|
+
const lineRows = lines.map((l, i) => rowsOf(l.length, i === 0 ? pw : 0));
|
|
2567
|
+
const inputRows = lineRows.reduce((a, b) => a + b, 0);
|
|
2568
|
+
let pos = this.cursorPos;
|
|
2569
|
+
let caretLine = 0;
|
|
2570
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2571
|
+
pos -= lines[caretLine].length + 1;
|
|
2572
|
+
caretLine++;
|
|
2573
|
+
}
|
|
2574
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2575
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2576
|
+
for (let i = 0; i < caretLine; i++) caretRow += lineRows[i];
|
|
2577
|
+
const caretCol = caretCell % cols2;
|
|
2578
|
+
const paletteBlock = this.paletteBlockLines(cols2);
|
|
2579
|
+
for (const line of paletteBlock) process.stdout.write("\r\n" + line);
|
|
2580
|
+
if (paletteBlock.length > 0) {
|
|
2581
|
+
process.stdout.write("\r");
|
|
2582
|
+
const up = inputRows - 1 + paletteBlock.length - caretRow;
|
|
2583
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2584
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2585
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
2586
|
+
} else if (this.cursorPos < this.inputBuffer.length) {
|
|
1724
2587
|
process.stdout.write("\r");
|
|
1725
|
-
const up = inputRows - 1 -
|
|
2588
|
+
const up = inputRows - 1 - caretRow;
|
|
1726
2589
|
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1727
|
-
if (
|
|
1728
|
-
this.lastCursorRow = aboveRows +
|
|
2590
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2591
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
1729
2592
|
} else {
|
|
1730
2593
|
this.lastCursorRow = aboveRows + (inputRows - 1);
|
|
1731
2594
|
}
|
|
2595
|
+
this.drawnHudWidths = hudWidths;
|
|
2596
|
+
this.lastDrawnCols = cols2;
|
|
1732
2597
|
this.bottomDrawn = true;
|
|
1733
2598
|
}
|
|
1734
2599
|
clearBottom() {
|
|
@@ -1737,14 +2602,183 @@ var Tui = class {
|
|
|
1737
2602
|
process.stdout.write("\x1B[J");
|
|
1738
2603
|
this.bottomDrawn = false;
|
|
1739
2604
|
}
|
|
2605
|
+
// ── live streaming preview ────────────────────────────────────────────────
|
|
2606
|
+
// An ephemeral tail of the model's output above the status line: reasoning in
|
|
2607
|
+
// dim italic until the answer begins, then the answer plain. clearStream()
|
|
2608
|
+
// wipes it right before the finished message is committed to the transcript
|
|
2609
|
+
// (which renders full markdown), so there's no double-render.
|
|
2610
|
+
static STREAM_TAIL = 20;
|
|
2611
|
+
// How long a preview may sit untouched before it's wiped. The model often
|
|
2612
|
+
// reasons and then calls a tool without ever emitting an answer, so without
|
|
2613
|
+
// this the reasoning tail would linger on screen until the *next* thought (or
|
|
2614
|
+
// message) arrives. Re-armed on every delta → fires this long after the last.
|
|
2615
|
+
static STREAM_IDLE_MS = 1e4;
|
|
2616
|
+
/** Append a fragment of streamed answer text (rendered plain). */
|
|
2617
|
+
streamResponseDelta(delta, messageId) {
|
|
2618
|
+
this.beginStreamMessage(messageId);
|
|
2619
|
+
this.streamResponse += delta;
|
|
2620
|
+
this.scheduleStreamRedraw();
|
|
2621
|
+
this.armStreamIdleExpiry();
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Append a fragment of streamed internal reasoning (rendered dim italic).
|
|
2625
|
+
* Ignored once the answer has started, since reasoning precedes the answer.
|
|
2626
|
+
*/
|
|
2627
|
+
streamThinkingDelta(delta, messageId) {
|
|
2628
|
+
this.beginStreamMessage(messageId);
|
|
2629
|
+
if (this.streamResponse) return;
|
|
2630
|
+
this.streamThinking += delta;
|
|
2631
|
+
this.scheduleStreamRedraw();
|
|
2632
|
+
this.armStreamIdleExpiry();
|
|
2633
|
+
}
|
|
2634
|
+
/** Reset the preview when a new message starts, so two messages' output (e.g.
|
|
2635
|
+
* one that only reasons then calls a tool, then the next) never blend. */
|
|
2636
|
+
beginStreamMessage(messageId) {
|
|
2637
|
+
if (messageId !== void 0 && messageId !== this.streamMessageId) {
|
|
2638
|
+
this.streamMessageId = messageId;
|
|
2639
|
+
this.streamThinking = "";
|
|
2640
|
+
this.streamResponse = "";
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
/** Wipe the live preview — call right before committing the final message. */
|
|
2644
|
+
clearStream() {
|
|
2645
|
+
if (this.streamRedrawTimer) {
|
|
2646
|
+
clearTimeout(this.streamRedrawTimer);
|
|
2647
|
+
this.streamRedrawTimer = null;
|
|
2648
|
+
}
|
|
2649
|
+
if (this.streamIdleTimer) {
|
|
2650
|
+
clearTimeout(this.streamIdleTimer);
|
|
2651
|
+
this.streamIdleTimer = null;
|
|
2652
|
+
}
|
|
2653
|
+
this.streamMessageId = null;
|
|
2654
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2655
|
+
this.streamThinking = "";
|
|
2656
|
+
this.streamResponse = "";
|
|
2657
|
+
this.renderBottom();
|
|
2658
|
+
}
|
|
2659
|
+
scheduleStreamRedraw() {
|
|
2660
|
+
if (this.streamRedrawTimer || this.takeoverHandler || !this.started) return;
|
|
2661
|
+
this.streamRedrawTimer = setTimeout(() => {
|
|
2662
|
+
this.streamRedrawTimer = null;
|
|
2663
|
+
this.renderBottom();
|
|
2664
|
+
}, 40);
|
|
2665
|
+
}
|
|
2666
|
+
/** Re-armed on every streamed delta: once the model goes quiet for a beat, the
|
|
2667
|
+
* preview is stale, so wipe it instead of letting it sit until the next
|
|
2668
|
+
* message. The committed message (if any) still renders in full via
|
|
2669
|
+
* clearStream(), so nothing is lost. */
|
|
2670
|
+
armStreamIdleExpiry() {
|
|
2671
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
2672
|
+
this.streamIdleTimer = setTimeout(() => {
|
|
2673
|
+
this.streamIdleTimer = null;
|
|
2674
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2675
|
+
this.streamThinking = "";
|
|
2676
|
+
this.streamResponse = "";
|
|
2677
|
+
this.renderBottom();
|
|
2678
|
+
}, _Tui.STREAM_IDLE_MS);
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* The preview's physical rows: a tail of reasoning (dim italic) before the
|
|
2682
|
+
* answer starts, otherwise a tail of the answer (plain). Each row is clamped to
|
|
2683
|
+
* one terminal line so the bottom-region redraw math stays correct.
|
|
2684
|
+
*
|
|
2685
|
+
* Rows match the committed transcript formatting so a finished message doesn't
|
|
2686
|
+
* visibly "snap" into shape: the answer's first line carries the grey gutter
|
|
2687
|
+
* dot (as long as it hasn't scrolled out of the tail) and the body indents two
|
|
2688
|
+
* spaces beneath it; reasoning aligns at the same indent, dotless.
|
|
2689
|
+
*/
|
|
2690
|
+
streamPreviewLines(cols2) {
|
|
2691
|
+
const thinkStyle = "\x1B[3m\x1B[38;5;240m";
|
|
2692
|
+
const clamp2 = (s, wrap, lead) => {
|
|
2693
|
+
const max = cols2 - 2;
|
|
2694
|
+
const t = s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2695
|
+
return wrap ? `${lead}${wrap}${t}${C.reset}` : `${lead}${t}`;
|
|
2696
|
+
};
|
|
2697
|
+
const realLines = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
|
|
2698
|
+
if (this.streamResponse) {
|
|
2699
|
+
const all = realLines(this.streamResponse);
|
|
2700
|
+
const shown = all.slice(-20);
|
|
2701
|
+
const firstVisible = all.length <= _Tui.STREAM_TAIL;
|
|
2702
|
+
return shown.map(
|
|
2703
|
+
(l, i) => clamp2(l, "", i === 0 && firstVisible ? `${C.gray}\u2022${C.reset} ` : " ")
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
if (this.streamThinking) {
|
|
2707
|
+
return realLines(this.streamThinking).slice(-20).map((l) => clamp2(l, thinkStyle, " "));
|
|
2708
|
+
}
|
|
2709
|
+
return [];
|
|
2710
|
+
}
|
|
2711
|
+
// ── live goal checklist ───────────────────────────────────────────────────
|
|
2712
|
+
/**
|
|
2713
|
+
* Update the goal from the goal_updated event (or the initial fetch). When
|
|
2714
|
+
* every step is done, the goal is fully achieved: we clear the goal area and
|
|
2715
|
+
* flip on the "Goal complete." status badge instead of leaving a finished
|
|
2716
|
+
* checklist sitting there.
|
|
2717
|
+
*/
|
|
2718
|
+
setGoal(goal) {
|
|
2719
|
+
const steps = goal?.steps;
|
|
2720
|
+
if (steps && Array.isArray(steps) && steps.length) {
|
|
2721
|
+
const allDone = steps.every((s) => s.status === "done");
|
|
2722
|
+
if (allDone) {
|
|
2723
|
+
this.goal = null;
|
|
2724
|
+
this.goalComplete = true;
|
|
2725
|
+
} else {
|
|
2726
|
+
this.goal = goal;
|
|
2727
|
+
this.goalComplete = false;
|
|
2728
|
+
}
|
|
2729
|
+
} else {
|
|
2730
|
+
this.goal = null;
|
|
2731
|
+
}
|
|
2732
|
+
this.renderBottom();
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* The goal's physical rows: a header (the short summary + progress) then one
|
|
2736
|
+
* row per step, indented two spaces beneath it so the todos clearly belong to
|
|
2737
|
+
* the goal — ○ pending / ▸ in-progress / ✓ done — each clamped to a line.
|
|
2738
|
+
*/
|
|
2739
|
+
goalLines(cols2) {
|
|
2740
|
+
const steps = this.goal?.steps;
|
|
2741
|
+
if (!steps?.length) return [];
|
|
2742
|
+
const oneLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
2743
|
+
const clamp2 = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2744
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2745
|
+
const out = [];
|
|
2746
|
+
const done = steps.filter((s) => s.status === "done").length;
|
|
2747
|
+
const summary = oneLine(this.goal?.summary || this.goal?.description || "Goal");
|
|
2748
|
+
const prefix = `Goal ${done}/${steps.length} `;
|
|
2749
|
+
const sum = clamp2(summary, Math.max(1, cols2 - prefix.length));
|
|
2750
|
+
out.push(`${C.bold}Goal${C.reset} ${C.gray}${done}/${steps.length}${C.reset} ${sum}`);
|
|
2751
|
+
for (const s of steps) {
|
|
2752
|
+
const text = clamp2(oneLine(s.step || ""), Math.max(1, cols2 - 4));
|
|
2753
|
+
if (s.status === "done") out.push(` ${calmGreen}\u2713${C.reset} ${C.dim}${text}${C.reset}`);
|
|
2754
|
+
else if (s.status === "in_progress") out.push(` ${C.cyan}\u25B8${C.reset} ${text}`);
|
|
2755
|
+
else out.push(` ${C.gray}\u25CB ${text}${C.reset}`);
|
|
2756
|
+
}
|
|
2757
|
+
return out;
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Transcript gutter: content starts at column 1, so the far-left column is a
|
|
2761
|
+
* clean strip where only status glyphs (✓ ✗ ⛔ …) land — scanning down the
|
|
2762
|
+
* left edge reads as a ledger of what happened. Lines already led by a
|
|
2763
|
+
* gutter glyph or whitespace (indented blocks, the user-message bar) pass
|
|
2764
|
+
* through untouched.
|
|
2765
|
+
*/
|
|
2766
|
+
gutterize(text) {
|
|
2767
|
+
return text.split("\n").map((line) => {
|
|
2768
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2769
|
+
if (plain === "" || /^[\s✓✗⛔⚠⚙⚡⏳↪›❯◇─•]/.test(plain)) return line;
|
|
2770
|
+
return " " + line;
|
|
2771
|
+
}).join("\n");
|
|
2772
|
+
}
|
|
1740
2773
|
/** Print a line of transcript above the persistent input. */
|
|
1741
2774
|
print(text) {
|
|
2775
|
+
const line = this.gutterize(text);
|
|
1742
2776
|
if (this.takeoverHandler) {
|
|
1743
|
-
this.bufferedPrints.push(
|
|
2777
|
+
this.bufferedPrints.push(line);
|
|
1744
2778
|
return;
|
|
1745
2779
|
}
|
|
1746
2780
|
this.clearBottom();
|
|
1747
|
-
process.stdout.write(
|
|
2781
|
+
process.stdout.write(line + "\n");
|
|
1748
2782
|
this.renderBottom();
|
|
1749
2783
|
}
|
|
1750
2784
|
/** Multi-line convenience. */
|
|
@@ -1758,9 +2792,9 @@ var Tui = class {
|
|
|
1758
2792
|
* above and below, and a teal `›` marks the first row.
|
|
1759
2793
|
*/
|
|
1760
2794
|
printUserMessage(text) {
|
|
1761
|
-
const
|
|
2795
|
+
const cols2 = Math.max(20, process.stdout.columns || 80);
|
|
1762
2796
|
const bg = "\x1B[48;5;238m";
|
|
1763
|
-
const limit = Math.max(8,
|
|
2797
|
+
const limit = Math.max(8, cols2 - 6);
|
|
1764
2798
|
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
1765
2799
|
const lines = [];
|
|
1766
2800
|
let cur = "";
|
|
@@ -1784,9 +2818,9 @@ var Tui = class {
|
|
|
1784
2818
|
const innerW = 2 + Math.max(...lines.map((l) => l.length));
|
|
1785
2819
|
this.print("");
|
|
1786
2820
|
lines.forEach((line, i) => {
|
|
1787
|
-
const rowText = (i === 0 ? "\
|
|
2821
|
+
const rowText = (i === 0 ? "\u276F " : " ") + line;
|
|
1788
2822
|
const padded = rowText.padEnd(innerW);
|
|
1789
|
-
const inner = i === 0 ? `${C.teal}\
|
|
2823
|
+
const inner = i === 0 ? `${C.teal}\u276F${C.reset}${bg}${padded.slice(1)}` : padded;
|
|
1790
2824
|
this.print(`${bg} ${inner} ${C.reset}`);
|
|
1791
2825
|
});
|
|
1792
2826
|
this.print("");
|
|
@@ -1796,15 +2830,30 @@ var Tui = class {
|
|
|
1796
2830
|
if (on && !this.working) {
|
|
1797
2831
|
this.working = true;
|
|
1798
2832
|
this.workingStart = Date.now();
|
|
2833
|
+
this.goalComplete = false;
|
|
1799
2834
|
} else if (!on) {
|
|
1800
2835
|
this.working = false;
|
|
1801
2836
|
}
|
|
1802
2837
|
this.syncSpinner();
|
|
1803
2838
|
this.renderBottom();
|
|
1804
2839
|
}
|
|
1805
|
-
/**
|
|
1806
|
-
|
|
1807
|
-
|
|
2840
|
+
/** The subagents currently working, one persistent line each. Each keeps a
|
|
2841
|
+
* stable, distinct colour for as long as it's active; the compaction agent
|
|
2842
|
+
* is always orange (its colour never comes from the shared pool). */
|
|
2843
|
+
setSubagents(subagents) {
|
|
2844
|
+
this.subagents = subagents;
|
|
2845
|
+
const active = new Set(subagents.map((s) => s.id));
|
|
2846
|
+
for (const id of [...this.subagentColorByID.keys()]) {
|
|
2847
|
+
if (!active.has(id)) this.subagentColorByID.delete(id);
|
|
2848
|
+
}
|
|
2849
|
+
for (const s of subagents) {
|
|
2850
|
+
if (s.agentName === COMPACTION_AGENT) continue;
|
|
2851
|
+
if (this.subagentColorByID.has(s.id)) continue;
|
|
2852
|
+
const used = new Set(this.subagentColorByID.values());
|
|
2853
|
+
let idx = 0;
|
|
2854
|
+
while (used.has(idx) && idx < SUBAGENT_COLORS.length - 1) idx++;
|
|
2855
|
+
this.subagentColorByID.set(s.id, idx);
|
|
2856
|
+
}
|
|
1808
2857
|
this.syncSpinner();
|
|
1809
2858
|
this.renderBottom();
|
|
1810
2859
|
}
|
|
@@ -1837,9 +2886,12 @@ var Tui = class {
|
|
|
1837
2886
|
getInput() {
|
|
1838
2887
|
return this.inputBuffer;
|
|
1839
2888
|
}
|
|
1840
|
-
|
|
2889
|
+
/** Replace the input (and any pasted images tied to placeholders in it). */
|
|
2890
|
+
setInput(text, images = []) {
|
|
1841
2891
|
this.inputBuffer = text;
|
|
1842
2892
|
this.cursorPos = text.length;
|
|
2893
|
+
this.pendingImages = images;
|
|
2894
|
+
this.historyIdx = null;
|
|
1843
2895
|
this.renderBottom();
|
|
1844
2896
|
}
|
|
1845
2897
|
/** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
|
|
@@ -1879,7 +2931,8 @@ var Tui = class {
|
|
|
1879
2931
|
}
|
|
1880
2932
|
this.renderBottom();
|
|
1881
2933
|
}
|
|
1882
|
-
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
|
|
2934
|
+
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
|
|
2935
|
+
* Tab resolves "deny_with_reason" so the caller can collect a free-text reason. */
|
|
1883
2936
|
approval(question, risk) {
|
|
1884
2937
|
return new Promise((resolve) => {
|
|
1885
2938
|
const options = [
|
|
@@ -1891,12 +2944,16 @@ var Tui = class {
|
|
|
1891
2944
|
let idx = 0;
|
|
1892
2945
|
const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
|
|
1893
2946
|
this.beginTakeover();
|
|
1894
|
-
process.stdout.
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2947
|
+
const cols2 = Math.max(1, process.stdout.columns || 80);
|
|
2948
|
+
const physRows = (line) => Math.max(1, Math.ceil(this.visibleWidth(line) / cols2));
|
|
2949
|
+
const headerText = `${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}`;
|
|
2950
|
+
const questionLines = question.split("\n").map((line) => `${C.yellow}\u2503${C.reset} ${line}`);
|
|
2951
|
+
const hintLine = `${C.yellow}\u2503${C.reset} ${C.gray}\u21E5 tab \u2014 deny with a reason${C.reset}`;
|
|
2952
|
+
const blockLines = [headerText, ...questionLines, hintLine];
|
|
2953
|
+
const headerRows = 1 + blockLines.reduce((n, line) => n + physRows(line), 0);
|
|
2954
|
+
process.stdout.write(`
|
|
2955
|
+
` + blockLines.map((line) => `${line}
|
|
2956
|
+
`).join(""));
|
|
1900
2957
|
const renderLine = (i) => {
|
|
1901
2958
|
const o = options[i];
|
|
1902
2959
|
const sel = i === idx;
|
|
@@ -1910,7 +2967,14 @@ ${C.yellow}\u2503${C.reset} ${question}
|
|
|
1910
2967
|
`);
|
|
1911
2968
|
};
|
|
1912
2969
|
draw(false);
|
|
2970
|
+
const erase = () => {
|
|
2971
|
+
process.stdout.write("\r");
|
|
2972
|
+
const up = headerRows + options.length;
|
|
2973
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2974
|
+
process.stdout.write("\x1B[J");
|
|
2975
|
+
};
|
|
1913
2976
|
const finish = (choice) => {
|
|
2977
|
+
erase();
|
|
1914
2978
|
this.endTakeover();
|
|
1915
2979
|
resolve(choice);
|
|
1916
2980
|
};
|
|
@@ -1921,6 +2985,8 @@ ${C.yellow}\u2503${C.reset} ${question}
|
|
|
1921
2985
|
} else if (key?.name === "down" || str === "j") {
|
|
1922
2986
|
idx = (idx + 1) % options.length;
|
|
1923
2987
|
draw(true);
|
|
2988
|
+
} else if (key?.name === "tab") {
|
|
2989
|
+
finish("deny_with_reason");
|
|
1924
2990
|
} else if (key?.name === "return" || key?.name === "enter") {
|
|
1925
2991
|
finish(options[idx].value);
|
|
1926
2992
|
} else {
|
|
@@ -2041,11 +3107,39 @@ ${C.cyan}\u2503${C.reset} ${question}
|
|
|
2041
3107
|
}
|
|
2042
3108
|
};
|
|
2043
3109
|
|
|
3110
|
+
// src/history.ts
|
|
3111
|
+
var HISTORY_KEY = "input_history";
|
|
3112
|
+
var MAX_ENTRIES = 100;
|
|
3113
|
+
function clean(value) {
|
|
3114
|
+
if (!Array.isArray(value)) return [];
|
|
3115
|
+
return value.filter((e) => typeof e === "string" && e.trim() !== "").slice(-MAX_ENTRIES);
|
|
3116
|
+
}
|
|
3117
|
+
async function loadHistory(store, threadId, seedThreadId) {
|
|
3118
|
+
const own = clean(await store.kvGet(threadId, HISTORY_KEY));
|
|
3119
|
+
if (own.length) return own;
|
|
3120
|
+
if (seedThreadId && seedThreadId !== threadId) {
|
|
3121
|
+
const seeded = clean(await store.kvGet(seedThreadId, HISTORY_KEY));
|
|
3122
|
+
if (seeded.length) {
|
|
3123
|
+
void store.kvSet(threadId, HISTORY_KEY, seeded);
|
|
3124
|
+
return seeded;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
return [];
|
|
3128
|
+
}
|
|
3129
|
+
function appendHistory(store, threadId, history, text) {
|
|
3130
|
+
const t = text.trim();
|
|
3131
|
+
if (!t || history[history.length - 1] === t) return history;
|
|
3132
|
+
history.push(t);
|
|
3133
|
+
if (history.length > MAX_ENTRIES) history.splice(0, history.length - MAX_ENTRIES);
|
|
3134
|
+
void store.kvSet(threadId, HISTORY_KEY, [...history]);
|
|
3135
|
+
return history;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
2044
3138
|
// src/markdown.ts
|
|
2045
3139
|
var ESC = "\x1B[";
|
|
2046
3140
|
var R = ESC + "0m";
|
|
2047
3141
|
var BOLD = ESC + "1m";
|
|
2048
|
-
var
|
|
3142
|
+
var DIM2 = ESC + "2m";
|
|
2049
3143
|
var ITAL = ESC + "3m";
|
|
2050
3144
|
var UNDER = ESC + "4m";
|
|
2051
3145
|
var TEAL = ESC + "38;5;37m";
|
|
@@ -2067,11 +3161,11 @@ function inline(s) {
|
|
|
2067
3161
|
});
|
|
2068
3162
|
s = s.replace(
|
|
2069
3163
|
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
|
2070
|
-
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${
|
|
3164
|
+
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM2}${url}${R}`
|
|
2071
3165
|
);
|
|
2072
3166
|
s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
|
|
2073
3167
|
s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
|
|
2074
|
-
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${
|
|
3168
|
+
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM2}${t}${R}`);
|
|
2075
3169
|
s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
|
|
2076
3170
|
return s;
|
|
2077
3171
|
}
|
|
@@ -2098,8 +3192,8 @@ function wrapStyled(text, width) {
|
|
|
2098
3192
|
if (cur !== "" || lines.length === 0) lines.push(cur);
|
|
2099
3193
|
return lines;
|
|
2100
3194
|
}
|
|
2101
|
-
function wrapBlock(out,
|
|
2102
|
-
const wrapped = wrapStyled(text, Math.max(8,
|
|
3195
|
+
function wrapBlock(out, cols2, leadFirst, leadRest, leadWidth, text) {
|
|
3196
|
+
const wrapped = wrapStyled(text, Math.max(8, cols2 - leadWidth));
|
|
2103
3197
|
wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
|
|
2104
3198
|
}
|
|
2105
3199
|
function tableCells(row) {
|
|
@@ -2113,16 +3207,16 @@ function isTableSeparator(line) {
|
|
|
2113
3207
|
return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
|
|
2114
3208
|
}
|
|
2115
3209
|
function renderTable(rows) {
|
|
2116
|
-
const
|
|
3210
|
+
const cols2 = Math.max(...rows.map((r) => r.length));
|
|
2117
3211
|
const widths = [];
|
|
2118
|
-
for (let c2 = 0; c2 <
|
|
3212
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
2119
3213
|
widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
|
|
2120
3214
|
}
|
|
2121
3215
|
const sep = `${GRAY} \u2502 ${R}`;
|
|
2122
3216
|
const out = [];
|
|
2123
3217
|
rows.forEach((r, ri) => {
|
|
2124
3218
|
const cells = [];
|
|
2125
|
-
for (let c2 = 0; c2 <
|
|
3219
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
2126
3220
|
const raw = r[c2] ?? "";
|
|
2127
3221
|
const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
|
|
2128
3222
|
cells.push(padEndVisible(styled, widths[c2]));
|
|
@@ -2135,7 +3229,7 @@ function renderTable(rows) {
|
|
|
2135
3229
|
});
|
|
2136
3230
|
return out;
|
|
2137
3231
|
}
|
|
2138
|
-
function renderMarkdown(src,
|
|
3232
|
+
function renderMarkdown(src, cols2 = 80) {
|
|
2139
3233
|
const lines = src.replace(/\r\n/g, "\n").split("\n");
|
|
2140
3234
|
const out = [];
|
|
2141
3235
|
let inFence = false;
|
|
@@ -2164,7 +3258,7 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2164
3258
|
}
|
|
2165
3259
|
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
2166
3260
|
if (heading) {
|
|
2167
|
-
for (const ln of wrapStyled(heading[2].trim(),
|
|
3261
|
+
for (const ln of wrapStyled(heading[2].trim(), cols2)) out.push(`${BOLD}${TEAL}${ln}${R}`);
|
|
2168
3262
|
i++;
|
|
2169
3263
|
continue;
|
|
2170
3264
|
}
|
|
@@ -2175,8 +3269,8 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2175
3269
|
}
|
|
2176
3270
|
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
2177
3271
|
if (quote) {
|
|
2178
|
-
for (const ln of wrapStyled(inline(quote[1]), Math.max(8,
|
|
2179
|
-
out.push(`${GRAY}\u2502${R} ${
|
|
3272
|
+
for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols2 - 2))) {
|
|
3273
|
+
out.push(`${GRAY}\u2502${R} ${DIM2}${ln}${R}`);
|
|
2180
3274
|
}
|
|
2181
3275
|
i++;
|
|
2182
3276
|
continue;
|
|
@@ -2184,7 +3278,7 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2184
3278
|
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
2185
3279
|
if (bullet) {
|
|
2186
3280
|
const leadWidth = bullet[1].length + 2;
|
|
2187
|
-
wrapBlock(out,
|
|
3281
|
+
wrapBlock(out, cols2, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
|
|
2188
3282
|
i++;
|
|
2189
3283
|
continue;
|
|
2190
3284
|
}
|
|
@@ -2192,11 +3286,11 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2192
3286
|
if (numbered) {
|
|
2193
3287
|
const marker = `${numbered[2]}${numbered[3]}`;
|
|
2194
3288
|
const leadWidth = numbered[1].length + marker.length + 1;
|
|
2195
|
-
wrapBlock(out,
|
|
3289
|
+
wrapBlock(out, cols2, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
|
|
2196
3290
|
i++;
|
|
2197
3291
|
continue;
|
|
2198
3292
|
}
|
|
2199
|
-
if (line.trim()) wrapBlock(out,
|
|
3293
|
+
if (line.trim()) wrapBlock(out, cols2, "", "", 0, inline(line));
|
|
2200
3294
|
else out.push("");
|
|
2201
3295
|
i++;
|
|
2202
3296
|
}
|
|
@@ -2307,7 +3401,7 @@ ${stderrTail.trim()}` : msg;
|
|
|
2307
3401
|
target,
|
|
2308
3402
|
argsSha256: sha256(canonicalJson(args)),
|
|
2309
3403
|
resultSha256: sha256(text),
|
|
2310
|
-
nonce:
|
|
3404
|
+
nonce: crypto.randomBytes(8).toString("hex"),
|
|
2311
3405
|
isError,
|
|
2312
3406
|
at: Date.now()
|
|
2313
3407
|
};
|
|
@@ -2577,10 +3671,10 @@ function sortKeys(value) {
|
|
|
2577
3671
|
return value;
|
|
2578
3672
|
}
|
|
2579
3673
|
function sha256(input2) {
|
|
2580
|
-
return
|
|
3674
|
+
return crypto.createHash("sha256").update(input2).digest("hex");
|
|
2581
3675
|
}
|
|
2582
|
-
var
|
|
2583
|
-
var
|
|
3676
|
+
var DIR = path3.join(os6.homedir(), ".standardagents");
|
|
3677
|
+
var FILE = path3.join(DIR, "credentials");
|
|
2584
3678
|
function normalizeEndpoint(endpoint) {
|
|
2585
3679
|
let e = endpoint.trim();
|
|
2586
3680
|
if (!/^https?:\/\//i.test(e)) e = "http://" + e;
|
|
@@ -2588,7 +3682,7 @@ function normalizeEndpoint(endpoint) {
|
|
|
2588
3682
|
}
|
|
2589
3683
|
function loadCredentials() {
|
|
2590
3684
|
try {
|
|
2591
|
-
const raw =
|
|
3685
|
+
const raw = fs4.readFileSync(FILE, "utf8");
|
|
2592
3686
|
const parsed = JSON.parse(raw);
|
|
2593
3687
|
if (!parsed.instances) parsed.instances = {};
|
|
2594
3688
|
return parsed;
|
|
@@ -2600,21 +3694,33 @@ function getCredential(endpoint) {
|
|
|
2600
3694
|
const creds = loadCredentials();
|
|
2601
3695
|
return creds.instances[normalizeEndpoint(endpoint)] ?? null;
|
|
2602
3696
|
}
|
|
2603
|
-
function saveCredential(cred) {
|
|
3697
|
+
function saveCredential(cred, options = {}) {
|
|
2604
3698
|
const creds = loadCredentials();
|
|
2605
3699
|
const endpoint = normalizeEndpoint(cred.endpoint);
|
|
2606
3700
|
creds.instances[endpoint] = { ...cred, endpoint };
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
3701
|
+
if (options.updateDefault ?? true) {
|
|
3702
|
+
creds.default_endpoint = endpoint;
|
|
3703
|
+
}
|
|
3704
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3705
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
2610
3706
|
try {
|
|
2611
|
-
|
|
3707
|
+
fs4.chmodSync(FILE, 384);
|
|
2612
3708
|
} catch {
|
|
2613
3709
|
}
|
|
2614
3710
|
}
|
|
2615
3711
|
function defaultEndpoint() {
|
|
2616
3712
|
return loadCredentials().default_endpoint ?? null;
|
|
2617
3713
|
}
|
|
3714
|
+
function saveDefaultEndpoint(endpoint) {
|
|
3715
|
+
const creds = loadCredentials();
|
|
3716
|
+
creds.default_endpoint = normalizeEndpoint(endpoint);
|
|
3717
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3718
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
3719
|
+
try {
|
|
3720
|
+
fs4.chmodSync(FILE, 384);
|
|
3721
|
+
} catch {
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
2618
3724
|
|
|
2619
3725
|
// src/index.ts
|
|
2620
3726
|
var AGENT_ID = "standard_code_agent";
|
|
@@ -2643,13 +3749,86 @@ var LOGO_MARK = [
|
|
|
2643
3749
|
"\u2588\u2588 \u2588\u2588\u2588",
|
|
2644
3750
|
"\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
|
|
2645
3751
|
];
|
|
3752
|
+
function printUsage() {
|
|
3753
|
+
stdout.write(
|
|
3754
|
+
[
|
|
3755
|
+
"",
|
|
3756
|
+
`${c.bold}Usage${c.reset}`,
|
|
3757
|
+
" standardcode [options] [dir]",
|
|
3758
|
+
"",
|
|
3759
|
+
`${c.bold}Options${c.reset}`,
|
|
3760
|
+
" -e, --endpoint [url] Use a Standard Agents instance for this run only.",
|
|
3761
|
+
" If url is omitted, prompt for it.",
|
|
3762
|
+
" Credentials are remembered for that endpoint, but",
|
|
3763
|
+
" the saved default endpoint is not changed.",
|
|
3764
|
+
" -h, --help Show this help.",
|
|
3765
|
+
""
|
|
3766
|
+
].join("\n")
|
|
3767
|
+
);
|
|
3768
|
+
}
|
|
3769
|
+
function parseArgs2(args) {
|
|
3770
|
+
const parsed = { help: false, promptEndpoint: false };
|
|
3771
|
+
for (let i = 0; i < args.length; i++) {
|
|
3772
|
+
const arg = args[i];
|
|
3773
|
+
if (arg === "--help" || arg === "-h") {
|
|
3774
|
+
parsed.help = true;
|
|
3775
|
+
continue;
|
|
3776
|
+
}
|
|
3777
|
+
if (arg === "--endpoint" || arg === "-e") {
|
|
3778
|
+
const value = args[i + 1];
|
|
3779
|
+
if (value && !value.startsWith("-")) {
|
|
3780
|
+
parsed.endpoint = value;
|
|
3781
|
+
i++;
|
|
3782
|
+
} else {
|
|
3783
|
+
parsed.promptEndpoint = true;
|
|
3784
|
+
}
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
3787
|
+
if (arg.startsWith("--endpoint=")) {
|
|
3788
|
+
const value = arg.slice("--endpoint=".length);
|
|
3789
|
+
if (value) {
|
|
3790
|
+
parsed.endpoint = value;
|
|
3791
|
+
} else {
|
|
3792
|
+
parsed.promptEndpoint = true;
|
|
3793
|
+
}
|
|
3794
|
+
continue;
|
|
3795
|
+
}
|
|
3796
|
+
if (arg === "--") {
|
|
3797
|
+
if (i === 0 && args[i + 1]?.startsWith("-")) continue;
|
|
3798
|
+
const rest = args.slice(i + 1);
|
|
3799
|
+
if (rest.length > 1) throw new Error("Expected at most one project directory.");
|
|
3800
|
+
if (rest[0]) parsed.dir = rest[0];
|
|
3801
|
+
break;
|
|
3802
|
+
}
|
|
3803
|
+
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
|
3804
|
+
if (parsed.dir) throw new Error("Expected at most one project directory.");
|
|
3805
|
+
parsed.dir = arg;
|
|
3806
|
+
}
|
|
3807
|
+
return parsed;
|
|
3808
|
+
}
|
|
2646
3809
|
function printAssistant(tui, text) {
|
|
2647
|
-
const
|
|
3810
|
+
const cols2 = Math.max(20, (process.stdout.columns || 80) - 3);
|
|
3811
|
+
tui.clearStream();
|
|
2648
3812
|
tui.print("");
|
|
2649
|
-
|
|
3813
|
+
let dotted = false;
|
|
3814
|
+
for (const line of renderMarkdown(text, cols2)) {
|
|
3815
|
+
if (!dotted && line.trim()) {
|
|
3816
|
+
tui.print(`${c.gray}\u2022${c.reset} ${line}`);
|
|
3817
|
+
dotted = true;
|
|
3818
|
+
} else {
|
|
3819
|
+
tui.print(` ${line}`);
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
2650
3822
|
tui.print("");
|
|
2651
3823
|
}
|
|
2652
|
-
function farewell() {
|
|
3824
|
+
function farewell(stoppedProcs = 0) {
|
|
3825
|
+
if (stoppedProcs > 0) {
|
|
3826
|
+
stdout.write(
|
|
3827
|
+
`
|
|
3828
|
+
${c.cyan}\u2699${c.reset} Stopped ${stoppedProcs} background process${stoppedProcs === 1 ? "" : "es"}.
|
|
3829
|
+
`
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
2653
3832
|
stdout.write(`
|
|
2654
3833
|
${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
|
|
2655
3834
|
`);
|
|
@@ -2676,29 +3855,30 @@ function relaxTlsForLocalEndpoint(endpoint) {
|
|
|
2676
3855
|
}
|
|
2677
3856
|
function readVersion() {
|
|
2678
3857
|
try {
|
|
2679
|
-
const pkg = JSON.parse(
|
|
3858
|
+
const pkg = JSON.parse(fs4.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
2680
3859
|
return typeof pkg.version === "string" ? pkg.version : "";
|
|
2681
3860
|
} catch {
|
|
2682
3861
|
return "";
|
|
2683
3862
|
}
|
|
2684
3863
|
}
|
|
2685
3864
|
function printWelcome(endpoint, projectDir) {
|
|
2686
|
-
const home =
|
|
3865
|
+
const home = os6.homedir();
|
|
2687
3866
|
const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2688
3867
|
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2689
3868
|
const version = readVersion();
|
|
2690
3869
|
const pad = " ";
|
|
2691
3870
|
const meta = [
|
|
2692
|
-
`${c.bold}${
|
|
3871
|
+
`${c.bold}${gradientText("Standard Code")}${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
|
|
2693
3872
|
`${c.dim}terminal coding agent${c.reset}`,
|
|
2694
3873
|
`${c.teal}${host}${c.reset}`,
|
|
2695
3874
|
`${c.dim}${dir}${c.reset}`
|
|
2696
3875
|
];
|
|
2697
3876
|
const markWidth = Math.max(...LOGO_MARK.map((l) => [...l].length));
|
|
3877
|
+
const gradMark = gradientArt(LOGO_MARK);
|
|
2698
3878
|
const metaTop = Math.floor((LOGO_MARK.length - meta.length) / 2);
|
|
2699
3879
|
stdout.write("\n");
|
|
2700
3880
|
for (let i = 0; i < LOGO_MARK.length; i++) {
|
|
2701
|
-
const glyph =
|
|
3881
|
+
const glyph = gradMark[i].padEnd(markWidth + (gradMark[i].length - LOGO_MARK[i].length));
|
|
2702
3882
|
const line = meta[i - metaTop];
|
|
2703
3883
|
stdout.write(`${pad}${glyph}${line ? ` ${line}` : ""}
|
|
2704
3884
|
`);
|
|
@@ -2713,19 +3893,41 @@ function colorActivity(line) {
|
|
|
2713
3893
|
const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
|
|
2714
3894
|
return `${indent}${c.green}\u2713${c.reset} ${body}`;
|
|
2715
3895
|
}
|
|
2716
|
-
if (glyph === "\u2717")
|
|
3896
|
+
if (glyph === "\u2717") {
|
|
3897
|
+
const ERR_MAX_LINES = 7;
|
|
3898
|
+
const lines = rest.split("\n");
|
|
3899
|
+
const shown = lines.slice(0, ERR_MAX_LINES);
|
|
3900
|
+
const hidden = lines.length - shown.length;
|
|
3901
|
+
const body = shown.map(
|
|
3902
|
+
(l, i) => i === 0 ? `${indent}${c.red}\u2717 ${l}${c.reset}` : `${indent}${c.red}${c.dim}${l}${c.reset}`
|
|
3903
|
+
).join("\n");
|
|
3904
|
+
if (hidden > 0) {
|
|
3905
|
+
return `${body}
|
|
3906
|
+
${indent}${c.dim}\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${c.reset}`;
|
|
3907
|
+
}
|
|
3908
|
+
return body;
|
|
3909
|
+
}
|
|
2717
3910
|
return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
|
|
2718
3911
|
}
|
|
2719
3912
|
async function main() {
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
3913
|
+
let cliArgs;
|
|
3914
|
+
try {
|
|
3915
|
+
cliArgs = parseArgs2(process.argv.slice(2));
|
|
3916
|
+
} catch (error) {
|
|
3917
|
+
stdout.write(`${c.red}error:${c.reset} ${error instanceof Error ? error.message : String(error)}
|
|
3918
|
+
`);
|
|
3919
|
+
printUsage();
|
|
3920
|
+
process.exit(1);
|
|
3921
|
+
}
|
|
3922
|
+
if (cliArgs.help) {
|
|
3923
|
+
printUsage();
|
|
3924
|
+
return;
|
|
2726
3925
|
}
|
|
3926
|
+
const endpointArg = cliArgs.endpoint;
|
|
3927
|
+
const endpointOverride = cliArgs.promptEndpoint || typeof endpointArg === "string" && endpointArg.trim() !== "";
|
|
3928
|
+
const dirArg = cliArgs.dir;
|
|
2727
3929
|
const projectDir = path3.resolve(dirArg || process.cwd());
|
|
2728
|
-
const machine =
|
|
3930
|
+
const machine = os6.hostname();
|
|
2729
3931
|
const reader = { rl: null };
|
|
2730
3932
|
let handoffClosing = false;
|
|
2731
3933
|
let preflightArmed = false;
|
|
@@ -2758,13 +3960,22 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2758
3960
|
}
|
|
2759
3961
|
return reader.rl.question(question);
|
|
2760
3962
|
};
|
|
3963
|
+
const askEndpoint = async () => {
|
|
3964
|
+
for (; ; ) {
|
|
3965
|
+
const answer = (await ask(
|
|
3966
|
+
`${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
|
|
3967
|
+
)).trim();
|
|
3968
|
+
if (answer) return answer;
|
|
3969
|
+
stdout.write(`${c.dim}An endpoint URL is required.${c.reset}
|
|
3970
|
+
`);
|
|
3971
|
+
}
|
|
3972
|
+
};
|
|
2761
3973
|
process.on("SIGINT", onPreflightSigint);
|
|
2762
|
-
let
|
|
3974
|
+
let endpointPrompted = false;
|
|
3975
|
+
let endpoint = endpointArg || (cliArgs.promptEndpoint ? "" : defaultEndpoint() || "");
|
|
2763
3976
|
if (!endpoint) {
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
);
|
|
2767
|
-
endpoint = answer.trim();
|
|
3977
|
+
endpoint = await askEndpoint();
|
|
3978
|
+
endpointPrompted = true;
|
|
2768
3979
|
}
|
|
2769
3980
|
endpoint = normalizeEndpoint(endpoint);
|
|
2770
3981
|
const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
|
|
@@ -2779,22 +3990,43 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2779
3990
|
if (!api || !await api.verify()) {
|
|
2780
3991
|
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2781
3992
|
stdout.write(
|
|
2782
|
-
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014
|
|
3993
|
+
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 connect to${c.reset} ${c.teal}${host}${c.reset}
|
|
2783
3994
|
`
|
|
2784
3995
|
);
|
|
2785
|
-
stdout.write(
|
|
3996
|
+
stdout.write(
|
|
3997
|
+
`${c.dim}Press Enter to sign in with your browser, or paste an API token.${c.reset}
|
|
2786
3998
|
|
|
2787
|
-
`
|
|
3999
|
+
`
|
|
4000
|
+
);
|
|
2788
4001
|
for (; ; ) {
|
|
2789
|
-
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token${c.reset} `)).trim();
|
|
4002
|
+
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token (or Enter for browser)${c.reset} `)).trim();
|
|
2790
4003
|
if (!token) {
|
|
2791
|
-
|
|
4004
|
+
const got = await deviceLogin(endpoint).catch((e) => {
|
|
4005
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}${e instanceof Error ? e.message : String(e)}${c.reset}
|
|
4006
|
+
`);
|
|
4007
|
+
return null;
|
|
4008
|
+
});
|
|
4009
|
+
if (!got) continue;
|
|
4010
|
+
api = new ApiClient(endpoint, got);
|
|
4011
|
+
if (await api.verify()) {
|
|
4012
|
+
saveCredential(
|
|
4013
|
+
{ endpoint, access_token: got, token_type: "Bearer", saved_at: Date.now() },
|
|
4014
|
+
{ updateDefault: !endpointOverride }
|
|
4015
|
+
);
|
|
4016
|
+
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
4017
|
+
`);
|
|
4018
|
+
break;
|
|
4019
|
+
}
|
|
4020
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}Browser sign-in didn't verify. Try again.${c.reset}
|
|
2792
4021
|
`);
|
|
2793
4022
|
continue;
|
|
2794
4023
|
}
|
|
2795
4024
|
api = new ApiClient(endpoint, token);
|
|
2796
4025
|
if (await api.verify()) {
|
|
2797
|
-
saveCredential(
|
|
4026
|
+
saveCredential(
|
|
4027
|
+
{ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() },
|
|
4028
|
+
{ updateDefault: !endpointOverride }
|
|
4029
|
+
);
|
|
2798
4030
|
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
2799
4031
|
`);
|
|
2800
4032
|
break;
|
|
@@ -2802,6 +4034,8 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2802
4034
|
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
|
|
2803
4035
|
`);
|
|
2804
4036
|
}
|
|
4037
|
+
} else if (endpointPrompted) {
|
|
4038
|
+
saveDefaultEndpoint(endpoint);
|
|
2805
4039
|
}
|
|
2806
4040
|
if (!api) process.exit(1);
|
|
2807
4041
|
handoffClosing = true;
|
|
@@ -2816,6 +4050,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2816
4050
|
const tui = new Tui(1);
|
|
2817
4051
|
let threadId;
|
|
2818
4052
|
let resumed = false;
|
|
4053
|
+
let historySeed;
|
|
2819
4054
|
if (existing.length > 0) {
|
|
2820
4055
|
const summaries = await summarizeThreads(api, existing.slice(0, 8));
|
|
2821
4056
|
const items = summaries.map((s) => ({
|
|
@@ -2824,7 +4059,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2824
4059
|
value: s.id
|
|
2825
4060
|
}));
|
|
2826
4061
|
items.push({ label: "\uFF0B Start a new session", value: null });
|
|
2827
|
-
const home =
|
|
4062
|
+
const home = os6.homedir();
|
|
2828
4063
|
const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2829
4064
|
const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
|
|
2830
4065
|
const picked = await tui.select(
|
|
@@ -2836,11 +4071,12 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2836
4071
|
resumed = true;
|
|
2837
4072
|
} else {
|
|
2838
4073
|
threadId = await api.createThread(AGENT_ID, tags);
|
|
4074
|
+
historySeed = existing[0]?.id;
|
|
2839
4075
|
}
|
|
2840
4076
|
} else {
|
|
2841
4077
|
threadId = await api.createThread(AGENT_ID, tags);
|
|
2842
4078
|
}
|
|
2843
|
-
await runInteractive(tui, api, threadId, projectDir, machine, resumed);
|
|
4079
|
+
await runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeed);
|
|
2844
4080
|
}
|
|
2845
4081
|
async function summarizeThreads(api, threads) {
|
|
2846
4082
|
return Promise.all(
|
|
@@ -2859,13 +4095,34 @@ async function summarizeThreads(api, threads) {
|
|
|
2859
4095
|
})
|
|
2860
4096
|
);
|
|
2861
4097
|
}
|
|
2862
|
-
function subagentLabel(
|
|
2863
|
-
const agentName = (
|
|
2864
|
-
const title = titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
|
|
2865
|
-
const
|
|
2866
|
-
const tagged = nameTag?.slice("name:".length).trim();
|
|
4098
|
+
function subagentLabel(s, titles) {
|
|
4099
|
+
const agentName = (s.agent_name || "").trim();
|
|
4100
|
+
const title = (s.title || "").trim() || titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
|
|
4101
|
+
const tagged = (s.threadName || "").trim();
|
|
2867
4102
|
return tagged ? `${title} \xB7 ${tagged}` : title;
|
|
2868
4103
|
}
|
|
4104
|
+
async function deviceLogin(endpoint) {
|
|
4105
|
+
const start = await fetch(`${endpoint}/api/auth/device/start`, { method: "POST" });
|
|
4106
|
+
if (!start.ok) throw new Error(`This instance does not support browser sign-in (HTTP ${start.status}). Paste an API token instead.`);
|
|
4107
|
+
const info = await start.json();
|
|
4108
|
+
stdout.write(`${c.dim}Opening your browser to approve this sign-in\u2026${c.reset}
|
|
4109
|
+
`);
|
|
4110
|
+
stdout.write(`${c.dim}If it doesn't open, visit:${c.reset} ${c.teal}${info.verify_url}${c.reset}
|
|
4111
|
+
`);
|
|
4112
|
+
openUrl(info.verify_url);
|
|
4113
|
+
const deadline = Date.now() + (info.expires_in ?? 600) * 1e3;
|
|
4114
|
+
const interval = Math.max(2, info.interval ?? 2) * 1e3;
|
|
4115
|
+
while (Date.now() < deadline) {
|
|
4116
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
4117
|
+
const res = await fetch(info.poll_url).catch(() => null);
|
|
4118
|
+
if (!res) continue;
|
|
4119
|
+
if (res.status === 404) throw new Error("The sign-in link expired. Try again.");
|
|
4120
|
+
const body = await res.json().catch(() => ({}));
|
|
4121
|
+
if (body.status === "approved" && body.token) return body.token;
|
|
4122
|
+
if (body.status === "denied") throw new Error("Sign-in was denied in the browser.");
|
|
4123
|
+
}
|
|
4124
|
+
throw new Error("Timed out waiting for browser approval. Try again.");
|
|
4125
|
+
}
|
|
2869
4126
|
function openUrl(url) {
|
|
2870
4127
|
const platform = process.platform;
|
|
2871
4128
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
@@ -2926,14 +4183,13 @@ async function printHistory(api, threadId, tui) {
|
|
|
2926
4183
|
if (m.role === "user") tui.printUserMessage(text);
|
|
2927
4184
|
else printAssistant(tui, text);
|
|
2928
4185
|
}
|
|
2929
|
-
tui.print(`${c.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.reset}`);
|
|
2930
4186
|
}
|
|
2931
|
-
async function runInteractive(tui, api, threadId, projectDir, machine, resumed) {
|
|
4187
|
+
async function runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeedThreadId) {
|
|
2932
4188
|
const registry = new ProcessRegistry(api, threadId, machine);
|
|
2933
4189
|
const mcp = new McpManager(projectDir);
|
|
2934
4190
|
const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
|
|
2935
4191
|
});
|
|
2936
|
-
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
|
|
4192
|
+
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog, api);
|
|
2937
4193
|
const refreshBgCount = () => {
|
|
2938
4194
|
void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
|
|
2939
4195
|
});
|
|
@@ -2950,11 +4206,13 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2950
4206
|
perm.level = l;
|
|
2951
4207
|
saveApprovals(api, threadId, perm);
|
|
2952
4208
|
});
|
|
4209
|
+
saveApprovals(api, threadId, perm);
|
|
2953
4210
|
let busy = false;
|
|
2954
4211
|
let interrupting = false;
|
|
2955
4212
|
const queued = [];
|
|
2956
4213
|
let editingQueued = false;
|
|
2957
4214
|
const shownIds = /* @__PURE__ */ new Set();
|
|
4215
|
+
const pendingSent = /* @__PURE__ */ new Map();
|
|
2958
4216
|
let tokensIn = 0;
|
|
2959
4217
|
let tokensOut = 0;
|
|
2960
4218
|
let liveOut = 0;
|
|
@@ -2967,13 +4225,19 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2967
4225
|
tui.setStep(label, liveOut);
|
|
2968
4226
|
};
|
|
2969
4227
|
const bridge = new Bridge(api, threadId, host, perm, {
|
|
2970
|
-
onActivity: (line) => {
|
|
4228
|
+
onActivity: (line, detail) => {
|
|
2971
4229
|
tui.print(colorActivity(line));
|
|
4230
|
+
if (detail) for (const d of detail) tui.print(d);
|
|
2972
4231
|
refreshBgCount();
|
|
2973
4232
|
},
|
|
2974
|
-
onStatus: () => {
|
|
4233
|
+
onStatus: (id, summary) => {
|
|
4234
|
+
if (summary) {
|
|
4235
|
+
if (!activeSteps.has(id)) activeSteps.set(id, summary);
|
|
4236
|
+
} else {
|
|
4237
|
+
activeSteps.delete(id);
|
|
4238
|
+
}
|
|
4239
|
+
refreshStatus();
|
|
2975
4240
|
},
|
|
2976
|
-
// the working indicator is driven by the busy poller
|
|
2977
4241
|
onConnection: (state, attempt) => {
|
|
2978
4242
|
if (state === "reconnecting") {
|
|
2979
4243
|
if (attempt >= 4) tui.setConnected(false);
|
|
@@ -2981,15 +4245,27 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2981
4245
|
tui.setConnected(true);
|
|
2982
4246
|
}
|
|
2983
4247
|
},
|
|
2984
|
-
requestApproval: (req, summary, risk) =>
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
4248
|
+
requestApproval: async (req, summary, risk) => {
|
|
4249
|
+
const choice = await tui.approval(
|
|
4250
|
+
`${summary}${req.requestPermission ? `
|
|
4251
|
+
${c.bold}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
4252
|
+
risk
|
|
4253
|
+
);
|
|
4254
|
+
if (choice === "deny_with_reason") {
|
|
4255
|
+
const reason = await tui.prompt(
|
|
4256
|
+
"Why are you denying this? (sent to the agent \u2014 enter to send, esc to skip)"
|
|
4257
|
+
);
|
|
4258
|
+
return { choice: "deny", reason: reason ?? void 0 };
|
|
4259
|
+
}
|
|
4260
|
+
return { choice };
|
|
4261
|
+
}
|
|
2989
4262
|
});
|
|
2990
4263
|
const stream = new MessageStream(api, threadId, {
|
|
2991
|
-
|
|
2992
|
-
|
|
4264
|
+
// Live streaming preview: answer text and (opt-in) internal reasoning feed
|
|
4265
|
+
// the TUI's ephemeral preview; the committed message still renders from
|
|
4266
|
+
// polling, which calls tui.clearStream() first so there's no double-render.
|
|
4267
|
+
onChunk: (text, mid) => tui.streamResponseDelta(text, mid),
|
|
4268
|
+
onReasoningChunk: (text, mid) => tui.streamThinkingDelta(text, mid),
|
|
2993
4269
|
onAssistantText: () => {
|
|
2994
4270
|
},
|
|
2995
4271
|
onEvent: (eventType, data) => {
|
|
@@ -3002,6 +4278,8 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3002
4278
|
} else if (eventType === "tool_call_done" && data?.id) {
|
|
3003
4279
|
activeSteps.delete(data.id);
|
|
3004
4280
|
refreshStatus();
|
|
4281
|
+
} else if (eventType === "goal_updated" && data) {
|
|
4282
|
+
tui.setGoal(data);
|
|
3005
4283
|
}
|
|
3006
4284
|
},
|
|
3007
4285
|
onError: () => {
|
|
@@ -3009,33 +4287,69 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3009
4287
|
});
|
|
3010
4288
|
const activeSubagents = /* @__PURE__ */ new Map();
|
|
3011
4289
|
const agentTitles = /* @__PURE__ */ new Map();
|
|
3012
|
-
|
|
4290
|
+
const agentTitlesReady = api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
|
|
3013
4291
|
});
|
|
3014
|
-
const pushSubagents = () => tui.setSubagents(
|
|
4292
|
+
const pushSubagents = () => tui.setSubagents(
|
|
4293
|
+
[...activeSubagents.entries()].map(([id, s]) => ({ id, label: s.label, agentName: s.agentName }))
|
|
4294
|
+
);
|
|
4295
|
+
const reconcileSubagents = async () => {
|
|
4296
|
+
try {
|
|
4297
|
+
await agentTitlesReady;
|
|
4298
|
+
const subs = await api.listSubagents(threadId);
|
|
4299
|
+
activeSubagents.clear();
|
|
4300
|
+
for (const s of subs) {
|
|
4301
|
+
if (s.status !== "running") continue;
|
|
4302
|
+
activeSubagents.set(s.id, {
|
|
4303
|
+
label: subagentLabel(s, agentTitles),
|
|
4304
|
+
agentName: s.agent_name ?? void 0
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
pushSubagents();
|
|
4308
|
+
} catch {
|
|
4309
|
+
}
|
|
4310
|
+
};
|
|
4311
|
+
let reconcileTimer = null;
|
|
4312
|
+
let reconcilePending = false;
|
|
4313
|
+
const scheduleReconcile = () => {
|
|
4314
|
+
if (reconcileTimer) {
|
|
4315
|
+
reconcilePending = true;
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
reconcileTimer = setTimeout(async () => {
|
|
4319
|
+
reconcileTimer = null;
|
|
4320
|
+
await reconcileSubagents();
|
|
4321
|
+
if (reconcilePending) {
|
|
4322
|
+
reconcilePending = false;
|
|
4323
|
+
scheduleReconcile();
|
|
4324
|
+
}
|
|
4325
|
+
}, 150);
|
|
4326
|
+
};
|
|
3015
4327
|
const events = new SystemEvents(api, {
|
|
4328
|
+
onOpen: () => scheduleReconcile(),
|
|
3016
4329
|
onThreadCreated: (t) => {
|
|
3017
|
-
if (t.parent === threadId
|
|
3018
|
-
activeSubagents.set(t.id, subagentLabel(t, agentTitles));
|
|
3019
|
-
pushSubagents();
|
|
3020
|
-
}
|
|
4330
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
3021
4331
|
},
|
|
3022
4332
|
onThreadUpdated: (t) => {
|
|
3023
|
-
if (t.parent
|
|
3024
|
-
if (t.terminated) activeSubagents.delete(t.id);
|
|
3025
|
-
else activeSubagents.set(t.id, subagentLabel(t, agentTitles));
|
|
3026
|
-
pushSubagents();
|
|
4333
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
3027
4334
|
},
|
|
3028
4335
|
onThreadDeleted: (id) => {
|
|
3029
|
-
if (activeSubagents.
|
|
4336
|
+
if (activeSubagents.has(id)) scheduleReconcile();
|
|
3030
4337
|
}
|
|
3031
4338
|
});
|
|
3032
|
-
const quit = () => {
|
|
4339
|
+
const quit = async () => {
|
|
3033
4340
|
tui.end();
|
|
4341
|
+
const stopped = api.stop(threadId).catch(() => {
|
|
4342
|
+
});
|
|
4343
|
+
const procsStopped = host.stopAllLocalProcesses().catch(() => 0);
|
|
3034
4344
|
bridge.close();
|
|
3035
4345
|
stream.close();
|
|
3036
4346
|
events.close();
|
|
3037
4347
|
mcp.closeAll();
|
|
3038
|
-
|
|
4348
|
+
const [, killed] = await Promise.race([
|
|
4349
|
+
Promise.all([stopped, procsStopped]),
|
|
4350
|
+
new Promise((r) => setTimeout(() => r([void 0, 0]), 1500))
|
|
4351
|
+
]);
|
|
4352
|
+
farewell(killed);
|
|
3039
4353
|
process.exit(0);
|
|
3040
4354
|
};
|
|
3041
4355
|
tui.setQuitHandler(quit);
|
|
@@ -3087,11 +4401,18 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3087
4401
|
},
|
|
3088
4402
|
setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
|
|
3089
4403
|
};
|
|
3090
|
-
const
|
|
4404
|
+
const extFor = (mime) => ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" })[mime] ?? "bin";
|
|
4405
|
+
const toAttachments = (images) => images.map((img) => ({ name: `image-${img.seq}.${extFor(img.mime)}`, mimeType: img.mime, data: img.data }));
|
|
4406
|
+
const sendNow = async (text, images = []) => {
|
|
3091
4407
|
tui.printUserMessage(text);
|
|
4408
|
+
const key = text.trim();
|
|
4409
|
+
pendingSent.set(key, (pendingSent.get(key) ?? 0) + 1);
|
|
3092
4410
|
try {
|
|
3093
|
-
await api.sendMessage(threadId, text);
|
|
4411
|
+
await api.sendMessage(threadId, text, toAttachments(images));
|
|
3094
4412
|
} catch (e) {
|
|
4413
|
+
const n = (pendingSent.get(key) ?? 1) - 1;
|
|
4414
|
+
if (n > 0) pendingSent.set(key, n);
|
|
4415
|
+
else pendingSent.delete(key);
|
|
3095
4416
|
tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
3096
4417
|
return;
|
|
3097
4418
|
}
|
|
@@ -3103,16 +4424,25 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3103
4424
|
if (!queued.length) return;
|
|
3104
4425
|
const toSend = queued.splice(0);
|
|
3105
4426
|
tui.setQueuedCount(0);
|
|
3106
|
-
for (const
|
|
4427
|
+
for (const q of toSend) await sendNow(q.text, q.images);
|
|
3107
4428
|
};
|
|
3108
4429
|
const requestCompaction = async () => {
|
|
3109
4430
|
try {
|
|
3110
|
-
await api.
|
|
3111
|
-
tui.print(
|
|
3112
|
-
`${c.cyan}\u27F3${c.reset} compacting the conversation in the background \u2014 recent messages stay live.`
|
|
3113
|
-
);
|
|
4431
|
+
await api.compact(threadId);
|
|
3114
4432
|
} catch (err) {
|
|
3115
|
-
tui.print(`${c.red}\u2717${c.reset} couldn't
|
|
4433
|
+
tui.print(`${c.red}\u2717${c.reset} couldn't start compaction: ${err.message}`);
|
|
4434
|
+
}
|
|
4435
|
+
};
|
|
4436
|
+
const skillsCtl = {
|
|
4437
|
+
list: () => api.listSkills(),
|
|
4438
|
+
setEnabled: (name, enabled) => api.setSkillEnabled(name, enabled),
|
|
4439
|
+
remove: (name) => api.removeSkill(name),
|
|
4440
|
+
// Seed the request into the main chat — the agent researches or authors
|
|
4441
|
+
// the skill there (research_agent + install_skill), visible in the transcript.
|
|
4442
|
+
requestInstall: (query) => {
|
|
4443
|
+
void sendNow(
|
|
4444
|
+
`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.`
|
|
4445
|
+
);
|
|
3116
4446
|
}
|
|
3117
4447
|
};
|
|
3118
4448
|
tui.setCommands([
|
|
@@ -3141,25 +4471,34 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3141
4471
|
},
|
|
3142
4472
|
run: () => runMcpMenu(tui, mcpCtl)
|
|
3143
4473
|
},
|
|
4474
|
+
{
|
|
4475
|
+
name: "skills",
|
|
4476
|
+
label: "Agent skills",
|
|
4477
|
+
hint: "list / install / manage",
|
|
4478
|
+
run: () => runSkillsMenu(tui, skillsCtl)
|
|
4479
|
+
},
|
|
3144
4480
|
{ name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
|
|
3145
4481
|
{ name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
|
|
3146
4482
|
{ name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
|
|
3147
4483
|
{ name: "quit", label: "Quit", run: () => quit() }
|
|
3148
4484
|
]);
|
|
3149
|
-
|
|
4485
|
+
const history = await loadHistory(api, threadId, historySeedThreadId);
|
|
4486
|
+
tui.setHistory(history);
|
|
4487
|
+
tui.onSubmit = (text, images) => {
|
|
4488
|
+
appendHistory(api, threadId, history, text);
|
|
3150
4489
|
if (editingQueued) {
|
|
3151
4490
|
editingQueued = false;
|
|
3152
|
-
queued.push(text);
|
|
4491
|
+
queued.push({ text, images });
|
|
3153
4492
|
tui.setQueuedCount(queued.length);
|
|
3154
4493
|
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
|
|
3155
4494
|
return;
|
|
3156
4495
|
}
|
|
3157
4496
|
if (busy) {
|
|
3158
|
-
queued.push(text);
|
|
4497
|
+
queued.push({ text, images });
|
|
3159
4498
|
tui.setQueuedCount(queued.length);
|
|
3160
4499
|
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
|
|
3161
4500
|
} else {
|
|
3162
|
-
void sendNow(text);
|
|
4501
|
+
void sendNow(text, images);
|
|
3163
4502
|
}
|
|
3164
4503
|
};
|
|
3165
4504
|
tui.onInterrupt = () => {
|
|
@@ -3180,19 +4519,21 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3180
4519
|
}
|
|
3181
4520
|
};
|
|
3182
4521
|
tui.onUpArrow = () => {
|
|
3183
|
-
if (tui.getInput().trim() || queued.length === 0) return;
|
|
3184
|
-
const
|
|
4522
|
+
if (tui.getInput().trim() || queued.length === 0) return false;
|
|
4523
|
+
const q = queued.pop();
|
|
3185
4524
|
tui.setQueuedCount(queued.length);
|
|
3186
4525
|
editingQueued = true;
|
|
3187
|
-
tui.setInput(text);
|
|
4526
|
+
tui.setInput(q.text, q.images);
|
|
4527
|
+
return true;
|
|
3188
4528
|
};
|
|
3189
4529
|
events.connect();
|
|
3190
4530
|
await Promise.all([bridge.connect(), stream.connect()]);
|
|
4531
|
+
void api.getGoal(threadId).then((g) => tui.setGoal(g)).catch(() => {
|
|
4532
|
+
});
|
|
3191
4533
|
tui.banner([
|
|
3192
4534
|
`${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
|
|
3193
4535
|
`${c.gray}project:${c.reset} ${projectDir}`,
|
|
3194
|
-
`${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}
|
|
3195
|
-
`${c.dim}type anytime \xB7 shift-tab cycles auto-accept level \xB7 / for options \xB7 esc interrupts/steers \xB7 ctrl-c quits${c.reset}`
|
|
4536
|
+
`${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`
|
|
3196
4537
|
]);
|
|
3197
4538
|
if (resumed) await printHistory(api, threadId, tui);
|
|
3198
4539
|
try {
|
|
@@ -3232,6 +4573,15 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3232
4573
|
const text = messageText(m.content).trim();
|
|
3233
4574
|
if (m.role === "assistant" && text) printAssistant(tui, text);
|
|
3234
4575
|
else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
|
|
4576
|
+
else if (m.role === "user" && text) {
|
|
4577
|
+
const pending = pendingSent.get(text) ?? 0;
|
|
4578
|
+
if (pending > 0) {
|
|
4579
|
+
if (pending === 1) pendingSent.delete(text);
|
|
4580
|
+
else pendingSent.set(text, pending - 1);
|
|
4581
|
+
} else {
|
|
4582
|
+
tui.printUserMessage(text);
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
3235
4585
|
}
|
|
3236
4586
|
const polledBusy = threadBusy(msgs);
|
|
3237
4587
|
if (interrupting) {
|
|
@@ -3279,6 +4629,56 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3279
4629
|
await new Promise(() => {
|
|
3280
4630
|
});
|
|
3281
4631
|
}
|
|
4632
|
+
async function runSkillsMenu(tui, skills) {
|
|
4633
|
+
let list;
|
|
4634
|
+
try {
|
|
4635
|
+
list = await skills.list();
|
|
4636
|
+
} catch (e) {
|
|
4637
|
+
tui.print(`${c.red}\u2717 couldn't load skills:${c.reset} ${c.gray}${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
const INSTALL = "__install__";
|
|
4641
|
+
const items = list.map((s) => ({
|
|
4642
|
+
label: s.name,
|
|
4643
|
+
hint: `${s.enabled ? "enabled" : "disabled"} \xB7 ${s.files.length} file${s.files.length === 1 ? "" : "s"}`,
|
|
4644
|
+
value: s.name
|
|
4645
|
+
}));
|
|
4646
|
+
items.push({ label: "\uFF0B Install a skill\u2026", hint: "find & install", value: INSTALL });
|
|
4647
|
+
const picked = await tui.select(
|
|
4648
|
+
`${c.bold}Agent skills${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
|
|
4649
|
+
items
|
|
4650
|
+
);
|
|
4651
|
+
if (!picked) return;
|
|
4652
|
+
if (picked === INSTALL) {
|
|
4653
|
+
const query = await tui.prompt(
|
|
4654
|
+
"What skill do you want to install?",
|
|
4655
|
+
"the anthropic pdf skill / a skill for writing conventional commits"
|
|
4656
|
+
);
|
|
4657
|
+
if (query) skills.requestInstall(query);
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
4660
|
+
const skill = list.find((s) => s.name === picked);
|
|
4661
|
+
tui.print(`${c.cyan}${skill.name}${c.reset}${skill.version ? ` ${c.dim}v${skill.version}${c.reset}` : ""} ${c.gray}\u2014 ${skill.description}${c.reset}`);
|
|
4662
|
+
const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
|
|
4663
|
+
skill.enabled ? { label: "Disable (hide from the agent)", value: "disable" } : { label: "Enable", value: "enable" },
|
|
4664
|
+
{ label: "View files", value: "files" },
|
|
4665
|
+
{ label: "Remove this skill", value: "remove" },
|
|
4666
|
+
{ label: "Back", value: "back" }
|
|
4667
|
+
]);
|
|
4668
|
+
try {
|
|
4669
|
+
if (action === "enable" || action === "disable") {
|
|
4670
|
+
await skills.setEnabled(picked, action === "enable");
|
|
4671
|
+
tui.print(`${c.gray}${action}d ${picked}${c.reset}`);
|
|
4672
|
+
} else if (action === "files") {
|
|
4673
|
+
for (const f of skill.files) tui.print(` ${c.gray}${f}${c.reset}`);
|
|
4674
|
+
} else if (action === "remove") {
|
|
4675
|
+
await skills.remove(picked);
|
|
4676
|
+
tui.print(`${c.gray}removed ${picked}${c.reset}`);
|
|
4677
|
+
}
|
|
4678
|
+
} catch (e) {
|
|
4679
|
+
tui.print(`${c.red}\u2717 ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
3282
4682
|
async function runLevelMenu(tui, perm) {
|
|
3283
4683
|
const picked = await tui.select(
|
|
3284
4684
|
`${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
|
|
@@ -3297,6 +4697,8 @@ function showKeybindings(tui) {
|
|
|
3297
4697
|
tui.print(`${c.gray}shortcuts:${c.reset}`);
|
|
3298
4698
|
tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
|
|
3299
4699
|
tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
|
|
4700
|
+
tui.print(`${c.gray} ctrl-v${c.reset} paste an image from the clipboard ([#Image 1])`);
|
|
4701
|
+
tui.print(`${c.gray} \u2191 / \u2193${c.reset} cycle past messages (on the input's top line)`);
|
|
3300
4702
|
tui.print(`${c.gray} ctrl-c${c.reset} quit`);
|
|
3301
4703
|
}
|
|
3302
4704
|
async function runProcessMenu(tui, bg) {
|