@standardagents/code 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/index.js +1566 -218
- 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,155 @@ 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
|
+
|
|
205
433
|
// src/bridge.ts
|
|
206
|
-
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "
|
|
434
|
+
var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "grep", "glob", "write_file", "edit_file"]);
|
|
207
435
|
var Bridge = class {
|
|
208
436
|
constructor(api, threadId, host, perm, hooks) {
|
|
209
437
|
this.api = api;
|
|
@@ -264,7 +492,10 @@ var Bridge = class {
|
|
|
264
492
|
this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
|
|
265
493
|
this.resolveConnected?.();
|
|
266
494
|
});
|
|
267
|
-
ws.addEventListener("message", (ev) =>
|
|
495
|
+
ws.addEventListener("message", (ev) => {
|
|
496
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
497
|
+
this.onMessage(String(ev.data));
|
|
498
|
+
});
|
|
268
499
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
269
500
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
270
501
|
}
|
|
@@ -287,16 +518,12 @@ var Bridge = class {
|
|
|
287
518
|
}
|
|
288
519
|
startHeartbeat(ws) {
|
|
289
520
|
this.stopHeartbeat();
|
|
290
|
-
this.heartbeat =
|
|
291
|
-
|
|
292
|
-
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
|
|
293
|
-
} catch {
|
|
294
|
-
}
|
|
295
|
-
}, 5e3);
|
|
521
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
522
|
+
this.heartbeat.start();
|
|
296
523
|
}
|
|
297
524
|
stopHeartbeat() {
|
|
298
525
|
if (this.heartbeat) {
|
|
299
|
-
|
|
526
|
+
this.heartbeat.stop();
|
|
300
527
|
this.heartbeat = null;
|
|
301
528
|
}
|
|
302
529
|
}
|
|
@@ -361,10 +588,16 @@ var Bridge = class {
|
|
|
361
588
|
return;
|
|
362
589
|
}
|
|
363
590
|
if (decision === "ask") {
|
|
364
|
-
const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
591
|
+
const { choice, reason } = await this.hooks.requestApproval(req, summary, effectiveRisk);
|
|
365
592
|
if (choice === "deny") {
|
|
366
|
-
|
|
367
|
-
this.
|
|
593
|
+
const why = reason?.trim();
|
|
594
|
+
this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined${why ? `: ${why}` : ""}`);
|
|
595
|
+
this.respond(
|
|
596
|
+
req,
|
|
597
|
+
false,
|
|
598
|
+
void 0,
|
|
599
|
+
why ? `The user declined to run this operation. Their reason: ${why}` : "The user declined to run this operation."
|
|
600
|
+
);
|
|
368
601
|
return;
|
|
369
602
|
}
|
|
370
603
|
if (choice === "always") this.perm.alwaysAllow.add(permKey);
|
|
@@ -373,11 +606,23 @@ var Bridge = class {
|
|
|
373
606
|
saveApprovals(this.api, this.threadId, this.perm);
|
|
374
607
|
}
|
|
375
608
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
609
|
+
const callKey = req.toolCallId ?? req.id ?? `${req.tool}:${summary}`;
|
|
610
|
+
this.hooks.onStatus?.(callKey, summary);
|
|
611
|
+
let result;
|
|
612
|
+
try {
|
|
613
|
+
result = await this.host.execute(req.tool, req.args);
|
|
614
|
+
} finally {
|
|
615
|
+
this.hooks.onStatus?.(callKey, null);
|
|
616
|
+
}
|
|
379
617
|
if (result.ok) {
|
|
380
|
-
|
|
618
|
+
const display = req.tool === "bash" ? `bash: ${highlightBash(String(req.args.command ?? "").slice(0, 200))}` : summary;
|
|
619
|
+
let detail;
|
|
620
|
+
if (req.tool === "edit_file") {
|
|
621
|
+
detail = diffLines(String(req.args.old_string ?? ""), String(req.args.new_string ?? ""));
|
|
622
|
+
} else if (req.tool === "write_file") {
|
|
623
|
+
detail = newFileLines(String(req.args.content ?? ""));
|
|
624
|
+
}
|
|
625
|
+
this.hooks.onActivity(`\u2713 ${display}${detailSuffix(req.tool, result.result)}`, detail);
|
|
381
626
|
this.respond(req, true, result.result ?? "");
|
|
382
627
|
} else {
|
|
383
628
|
this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
|
|
@@ -406,8 +651,6 @@ function describe(req) {
|
|
|
406
651
|
}
|
|
407
652
|
case "read_file":
|
|
408
653
|
return `read ${a.path}`;
|
|
409
|
-
case "list_dir":
|
|
410
|
-
return `list ${a.path || "."}`;
|
|
411
654
|
case "grep":
|
|
412
655
|
return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
|
|
413
656
|
case "glob":
|
|
@@ -416,8 +659,10 @@ function describe(req) {
|
|
|
416
659
|
return `write ${a.path}`;
|
|
417
660
|
case "edit_file":
|
|
418
661
|
return `edit ${a.path}`;
|
|
419
|
-
case "
|
|
420
|
-
return `
|
|
662
|
+
case "save_to_disk":
|
|
663
|
+
return `save ${a.source_path} \u2192 ${a.dest_path}`;
|
|
664
|
+
case "run_skill_script":
|
|
665
|
+
return `skill ${a.skill}: run ${a.entry}`;
|
|
421
666
|
case "bash":
|
|
422
667
|
return `bash: ${String(a.command).slice(0, 80)}`;
|
|
423
668
|
default:
|
|
@@ -426,7 +671,7 @@ function describe(req) {
|
|
|
426
671
|
}
|
|
427
672
|
function detailSuffix(tool, result) {
|
|
428
673
|
if (!result) return "";
|
|
429
|
-
if (tool === "write_file" || tool === "edit_file"
|
|
674
|
+
if (tool === "write_file" || tool === "edit_file") return "";
|
|
430
675
|
if (tool === "bash") {
|
|
431
676
|
const m = result.match(/\[exit code (\d+)\]\s*$/);
|
|
432
677
|
return m ? ` (exit ${m[1]})` : "";
|
|
@@ -434,7 +679,7 @@ function detailSuffix(tool, result) {
|
|
|
434
679
|
const lines = result.split("\n").length;
|
|
435
680
|
return ` (${lines} line${lines === 1 ? "" : "s"})`;
|
|
436
681
|
}
|
|
437
|
-
var LOG_DIR = path3.join(
|
|
682
|
+
var LOG_DIR = path3.join(os6.homedir(), ".standardagents", "process-logs");
|
|
438
683
|
var KEY2 = "bg_processes";
|
|
439
684
|
function isAlive(pid) {
|
|
440
685
|
try {
|
|
@@ -510,11 +755,12 @@ var ProcessRegistry = class {
|
|
|
510
755
|
}
|
|
511
756
|
}
|
|
512
757
|
};
|
|
513
|
-
|
|
514
|
-
|
|
758
|
+
function configFile() {
|
|
759
|
+
return process.env.STANDARDAGENTS_MCP_CONFIG || path3.join(os6.homedir(), ".standardagents", "mcp.json");
|
|
760
|
+
}
|
|
515
761
|
function loadMcpConfig() {
|
|
516
762
|
try {
|
|
517
|
-
const raw =
|
|
763
|
+
const raw = fs4.readFileSync(configFile(), "utf8");
|
|
518
764
|
const parsed = JSON.parse(raw);
|
|
519
765
|
if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
|
|
520
766
|
return parsed;
|
|
@@ -544,8 +790,9 @@ function setMcpServerEnabled(name, enabled) {
|
|
|
544
790
|
write(cfg);
|
|
545
791
|
}
|
|
546
792
|
function write(cfg) {
|
|
547
|
-
|
|
548
|
-
|
|
793
|
+
const file = configFile();
|
|
794
|
+
fs4.mkdirSync(path3.dirname(file), { recursive: true });
|
|
795
|
+
fs4.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
549
796
|
}
|
|
550
797
|
function parseServerSpec(spec) {
|
|
551
798
|
const trimmed = spec.trim();
|
|
@@ -609,13 +856,14 @@ async function readLogTail(logPath, n) {
|
|
|
609
856
|
}
|
|
610
857
|
}
|
|
611
858
|
var HostTools = class {
|
|
612
|
-
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange) {
|
|
859
|
+
constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange, api) {
|
|
613
860
|
this.projectDir = projectDir;
|
|
614
861
|
this.registry = registry;
|
|
615
862
|
this.threadId = threadId;
|
|
616
863
|
this.machine = machine;
|
|
617
864
|
this.mcp = mcp;
|
|
618
865
|
this.onMcpCatalogChange = onMcpCatalogChange;
|
|
866
|
+
this.api = api;
|
|
619
867
|
}
|
|
620
868
|
projectDir;
|
|
621
869
|
registry;
|
|
@@ -623,6 +871,7 @@ var HostTools = class {
|
|
|
623
871
|
machine;
|
|
624
872
|
mcp;
|
|
625
873
|
onMcpCatalogChange;
|
|
874
|
+
api;
|
|
626
875
|
/** Resolve a user/model-supplied path against the project directory. */
|
|
627
876
|
resolve(p) {
|
|
628
877
|
if (!p || p === ".") return this.projectDir;
|
|
@@ -639,8 +888,6 @@ var HostTools = class {
|
|
|
639
888
|
switch (tool) {
|
|
640
889
|
case "read_file":
|
|
641
890
|
return await this.readFile(args);
|
|
642
|
-
case "list_dir":
|
|
643
|
-
return await this.listDir(args);
|
|
644
891
|
case "grep":
|
|
645
892
|
return await this.grep(args);
|
|
646
893
|
case "glob":
|
|
@@ -651,8 +898,10 @@ var HostTools = class {
|
|
|
651
898
|
return await this.editFile(args);
|
|
652
899
|
case "bash":
|
|
653
900
|
return await this.bash(args);
|
|
654
|
-
case "
|
|
655
|
-
return await this.
|
|
901
|
+
case "save_to_disk":
|
|
902
|
+
return await this.saveToDisk(args);
|
|
903
|
+
case "run_skill_script":
|
|
904
|
+
return await this.runSkillScript(args);
|
|
656
905
|
case "background_process": {
|
|
657
906
|
const action = String(args.action || "list");
|
|
658
907
|
if (action === "start") return await this.runBackground(args);
|
|
@@ -695,15 +944,6 @@ var HostTools = class {
|
|
|
695
944
|
const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
|
|
696
945
|
return { ok: true, result: numbered || "(empty file)" };
|
|
697
946
|
}
|
|
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
947
|
async grep(args) {
|
|
708
948
|
const pattern = String(args.pattern || "");
|
|
709
949
|
if (!pattern) return { ok: false, error: "pattern is required" };
|
|
@@ -735,7 +975,7 @@ ${lines.join("\n")}` };
|
|
|
735
975
|
const file = this.resolve(String(args.path || ""));
|
|
736
976
|
const content = String(args.content ?? "");
|
|
737
977
|
await fsp.mkdir(path3.dirname(file), { recursive: true });
|
|
738
|
-
const existed =
|
|
978
|
+
const existed = fs4.existsSync(file);
|
|
739
979
|
await fsp.writeFile(file, content, "utf8");
|
|
740
980
|
return {
|
|
741
981
|
ok: true,
|
|
@@ -760,16 +1000,125 @@ ${lines.join("\n")}` };
|
|
|
760
1000
|
await fsp.writeFile(file, updated, "utf8");
|
|
761
1001
|
return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
|
|
762
1002
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Copy a file from the THREAD filesystem (e.g. a generated /attachments/*
|
|
1005
|
+
* asset) onto the project disk: download the bytes from the instance API and
|
|
1006
|
+
* write them at the destination. The bytes never pass through the model.
|
|
1007
|
+
*/
|
|
1008
|
+
async saveToDisk(args) {
|
|
1009
|
+
const source = String(args.source_path || "");
|
|
1010
|
+
const destArg = String(args.dest_path || "");
|
|
1011
|
+
if (!source || !destArg) return { ok: false, error: "source_path and dest_path are required" };
|
|
1012
|
+
if (!this.api || !this.threadId) {
|
|
1013
|
+
return { ok: false, error: "No instance API available for thread file downloads." };
|
|
770
1014
|
}
|
|
771
|
-
await
|
|
772
|
-
return { ok:
|
|
1015
|
+
const bytes = await this.api.fetchFile(this.threadId, source);
|
|
1016
|
+
if (!bytes) return { ok: false, error: `Could not download ${source} from the thread filesystem.` };
|
|
1017
|
+
const dest = this.resolve(destArg);
|
|
1018
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1019
|
+
const existed = fs4.existsSync(dest);
|
|
1020
|
+
await fsp.writeFile(dest, bytes);
|
|
1021
|
+
return {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
result: `${existed ? "Overwrote" : "Saved"} ${path3.relative(this.projectDir, dest)} (${bytes.length} bytes) from ${source}`
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Execute a SKILL script on the host. Skills live in the instance (cloud is
|
|
1028
|
+
* the source of truth); the server forwards the skill's files with the call
|
|
1029
|
+
* and we materialize them into a content-addressed temp dir — nothing about
|
|
1030
|
+
* a skill is stored on this machine beyond an ephemeral cache. The script
|
|
1031
|
+
* runs with cwd = the PROJECT (so it can operate on the user's files) and
|
|
1032
|
+
* SKILL_DIR pointing at the materialized folder (so it can read its own
|
|
1033
|
+
* references/assets).
|
|
1034
|
+
*/
|
|
1035
|
+
async runSkillScript(args) {
|
|
1036
|
+
const skill = String(args.skill || "skill");
|
|
1037
|
+
const entry = String(args.entry || "");
|
|
1038
|
+
if (!entry) return { ok: false, error: "entry (the script path within the skill) is required" };
|
|
1039
|
+
let files;
|
|
1040
|
+
try {
|
|
1041
|
+
const parsed = JSON.parse(String(args.files_json || "[]"));
|
|
1042
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1043
|
+
files = parsed.map((f) => ({ path: String(f.path), content: String(f.content ?? "") }));
|
|
1044
|
+
} catch {
|
|
1045
|
+
return { ok: false, error: "files_json must be a JSON array of {path, content}" };
|
|
1046
|
+
}
|
|
1047
|
+
if (!files.some((f) => f.path === entry)) {
|
|
1048
|
+
return { ok: false, error: `entry "${entry}" is not among the provided skill files` };
|
|
1049
|
+
}
|
|
1050
|
+
let scriptArgs = [];
|
|
1051
|
+
if (args.args_json) {
|
|
1052
|
+
try {
|
|
1053
|
+
const parsed = JSON.parse(String(args.args_json));
|
|
1054
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1055
|
+
scriptArgs = parsed.map(String);
|
|
1056
|
+
} catch {
|
|
1057
|
+
return { ok: false, error: "args_json must be a JSON array of strings" };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12);
|
|
1061
|
+
const skillDir = path3.join(os6.tmpdir(), "standardcode-skills", `${skill}-${hash}`);
|
|
1062
|
+
for (const f of files) {
|
|
1063
|
+
const dest = path3.resolve(skillDir, f.path);
|
|
1064
|
+
if (path3.relative(skillDir, dest).startsWith("..")) {
|
|
1065
|
+
return { ok: false, error: `Skill file escapes its directory: ${f.path}` };
|
|
1066
|
+
}
|
|
1067
|
+
await fsp.mkdir(path3.dirname(dest), { recursive: true });
|
|
1068
|
+
await fsp.writeFile(dest, f.content, "utf8");
|
|
1069
|
+
}
|
|
1070
|
+
const entryPath = path3.resolve(skillDir, entry);
|
|
1071
|
+
const entryContent = files.find((f) => f.path === entry).content;
|
|
1072
|
+
let cmd;
|
|
1073
|
+
let argv;
|
|
1074
|
+
if (entryContent.startsWith("#!")) {
|
|
1075
|
+
await fsp.chmod(entryPath, 493);
|
|
1076
|
+
cmd = entryPath;
|
|
1077
|
+
argv = scriptArgs;
|
|
1078
|
+
} else {
|
|
1079
|
+
const ext = path3.extname(entry).toLowerCase();
|
|
1080
|
+
const interp = {
|
|
1081
|
+
".py": ["python3"],
|
|
1082
|
+
".sh": ["bash"],
|
|
1083
|
+
".js": ["node"],
|
|
1084
|
+
".mjs": ["node"],
|
|
1085
|
+
".ts": ["npx", "tsx"]
|
|
1086
|
+
};
|
|
1087
|
+
const found = interp[ext];
|
|
1088
|
+
if (!found) return { ok: false, error: `No interpreter for "${ext}" \u2014 add a shebang line to the script.` };
|
|
1089
|
+
cmd = found[0];
|
|
1090
|
+
argv = [...found.slice(1), entryPath, ...scriptArgs];
|
|
1091
|
+
}
|
|
1092
|
+
const timeoutMs = typeof args.timeout_ms === "number" ? Math.min(args.timeout_ms, 3e5) : 12e4;
|
|
1093
|
+
return await new Promise((resolvePromise) => {
|
|
1094
|
+
const child = spawn(cmd, argv, {
|
|
1095
|
+
cwd: this.projectDir,
|
|
1096
|
+
env: { ...process.env, SKILL_DIR: skillDir },
|
|
1097
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1098
|
+
});
|
|
1099
|
+
let out = "";
|
|
1100
|
+
const cap = (chunk) => {
|
|
1101
|
+
if (out.length < 2e5) out += chunk.toString("utf8");
|
|
1102
|
+
};
|
|
1103
|
+
child.stdout.on("data", cap);
|
|
1104
|
+
child.stderr.on("data", cap);
|
|
1105
|
+
const timer = setTimeout(() => {
|
|
1106
|
+
child.kill("SIGKILL");
|
|
1107
|
+
resolvePromise({ ok: false, error: `Skill script timed out after ${timeoutMs}ms.
|
|
1108
|
+
${out.slice(-4e3)}` });
|
|
1109
|
+
}, timeoutMs);
|
|
1110
|
+
child.on("error", (err) => {
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
resolvePromise({ ok: false, error: `Could not launch ${cmd}: ${err.message}` });
|
|
1113
|
+
});
|
|
1114
|
+
child.on("exit", (code) => {
|
|
1115
|
+
clearTimeout(timer);
|
|
1116
|
+
const body = out.trim() || "(no output)";
|
|
1117
|
+
if (code === 0) resolvePromise({ ok: true, result: body });
|
|
1118
|
+
else resolvePromise({ ok: false, error: `Script exited with code ${code}:
|
|
1119
|
+
${body.slice(-6e3)}` });
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
773
1122
|
}
|
|
774
1123
|
async bash(args) {
|
|
775
1124
|
const command = String(args.command || "");
|
|
@@ -797,12 +1146,12 @@ ${truncated}`
|
|
|
797
1146
|
const command = String(args.command || "");
|
|
798
1147
|
if (!command.trim()) return { ok: false, error: "command is required" };
|
|
799
1148
|
const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
|
|
800
|
-
const id =
|
|
1149
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
801
1150
|
const logPath = path3.join(LOG_DIR, `${id}.log`);
|
|
802
1151
|
let out;
|
|
803
1152
|
try {
|
|
804
1153
|
await fsp.mkdir(LOG_DIR, { recursive: true });
|
|
805
|
-
out =
|
|
1154
|
+
out = fs4.openSync(logPath, "a");
|
|
806
1155
|
} catch (err) {
|
|
807
1156
|
return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
|
|
808
1157
|
}
|
|
@@ -810,10 +1159,10 @@ ${truncated}`
|
|
|
810
1159
|
try {
|
|
811
1160
|
child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
|
|
812
1161
|
} catch (err) {
|
|
813
|
-
|
|
1162
|
+
fs4.closeSync(out);
|
|
814
1163
|
return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
|
|
815
1164
|
}
|
|
816
|
-
|
|
1165
|
+
fs4.closeSync(out);
|
|
817
1166
|
const pid = child.pid;
|
|
818
1167
|
if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
|
|
819
1168
|
let earlyExit;
|
|
@@ -973,6 +1322,39 @@ ${tail}` : " No output was captured.")
|
|
|
973
1322
|
}
|
|
974
1323
|
return { ok: false, error: `Unknown action: ${action}` };
|
|
975
1324
|
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Terminate every still-running background process this machine started, so
|
|
1327
|
+
* detached children don't outlive the CLI when the session quits. Sends
|
|
1328
|
+
* SIGTERM to each process group, waits a brief grace, then SIGKILL any
|
|
1329
|
+
* straggler, and records them stopped. Best effort and bounded so quitting
|
|
1330
|
+
* stays snappy. Returns the number of running processes it signaled.
|
|
1331
|
+
*/
|
|
1332
|
+
async stopAllLocalProcesses() {
|
|
1333
|
+
if (!this.registry) return 0;
|
|
1334
|
+
let running = [];
|
|
1335
|
+
try {
|
|
1336
|
+
const procs = await this.registry.list();
|
|
1337
|
+
running = procs.filter((p) => p.status === "running" && p.machine === (this.machine ?? ""));
|
|
1338
|
+
} catch {
|
|
1339
|
+
return 0;
|
|
1340
|
+
}
|
|
1341
|
+
if (!running.length) return 0;
|
|
1342
|
+
const signal = (pid, sig) => {
|
|
1343
|
+
try {
|
|
1344
|
+
process.kill(-pid, sig);
|
|
1345
|
+
} catch {
|
|
1346
|
+
try {
|
|
1347
|
+
process.kill(pid, sig);
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
for (const proc of running) signal(proc.pid, "SIGTERM");
|
|
1353
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1354
|
+
for (const proc of running) signal(proc.pid, "SIGKILL");
|
|
1355
|
+
await Promise.allSettled(running.map((proc) => this.registry.markStopped(proc.id)));
|
|
1356
|
+
return running.length;
|
|
1357
|
+
}
|
|
976
1358
|
run(cmd, args, cwd, timeoutMs) {
|
|
977
1359
|
return new Promise((resolve) => {
|
|
978
1360
|
let stdout = "";
|
|
@@ -1069,6 +1451,7 @@ var MessageStream = class {
|
|
|
1069
1451
|
hooks;
|
|
1070
1452
|
ws = null;
|
|
1071
1453
|
closed = false;
|
|
1454
|
+
heartbeat = null;
|
|
1072
1455
|
reconnectAttempt = 0;
|
|
1073
1456
|
reconnectTimer = null;
|
|
1074
1457
|
resolveConnected = null;
|
|
@@ -1093,7 +1476,7 @@ var MessageStream = class {
|
|
|
1093
1476
|
}
|
|
1094
1477
|
openSocket() {
|
|
1095
1478
|
if (this.closed) return;
|
|
1096
|
-
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
|
|
1479
|
+
const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}&reasoning=1`;
|
|
1097
1480
|
let ws;
|
|
1098
1481
|
try {
|
|
1099
1482
|
ws = new WebSocket(url);
|
|
@@ -1104,17 +1487,33 @@ var MessageStream = class {
|
|
|
1104
1487
|
this.ws = ws;
|
|
1105
1488
|
ws.addEventListener("open", () => {
|
|
1106
1489
|
this.reconnectAttempt = 0;
|
|
1490
|
+
this.startHeartbeat(ws);
|
|
1107
1491
|
this.resolveConnected?.();
|
|
1108
1492
|
});
|
|
1109
|
-
ws.addEventListener("message", (ev) =>
|
|
1493
|
+
ws.addEventListener("message", (ev) => {
|
|
1494
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1495
|
+
this.onMessage(String(ev.data));
|
|
1496
|
+
});
|
|
1110
1497
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1111
1498
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1112
1499
|
}
|
|
1113
1500
|
handleDrop(ws) {
|
|
1114
1501
|
if (this.ws !== ws) return;
|
|
1115
1502
|
this.ws = null;
|
|
1503
|
+
this.stopHeartbeat();
|
|
1116
1504
|
this.scheduleReconnect();
|
|
1117
1505
|
}
|
|
1506
|
+
startHeartbeat(ws) {
|
|
1507
|
+
this.stopHeartbeat();
|
|
1508
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1509
|
+
this.heartbeat.start();
|
|
1510
|
+
}
|
|
1511
|
+
stopHeartbeat() {
|
|
1512
|
+
if (this.heartbeat) {
|
|
1513
|
+
this.heartbeat.stop();
|
|
1514
|
+
this.heartbeat = null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1118
1517
|
scheduleReconnect() {
|
|
1119
1518
|
if (this.closed || this.reconnectTimer) return;
|
|
1120
1519
|
this.reconnectAttempt++;
|
|
@@ -1127,6 +1526,7 @@ var MessageStream = class {
|
|
|
1127
1526
|
}
|
|
1128
1527
|
close() {
|
|
1129
1528
|
this.closed = true;
|
|
1529
|
+
this.stopHeartbeat();
|
|
1130
1530
|
if (this.reconnectTimer) {
|
|
1131
1531
|
clearTimeout(this.reconnectTimer);
|
|
1132
1532
|
this.reconnectTimer = null;
|
|
@@ -1145,7 +1545,11 @@ var MessageStream = class {
|
|
|
1145
1545
|
return;
|
|
1146
1546
|
}
|
|
1147
1547
|
if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
|
|
1148
|
-
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
|
|
1548
|
+
if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk, msg.message_id);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
if (msg.type === "reasoning_chunk" && (msg.depth ?? 0) === 0) {
|
|
1552
|
+
if (typeof msg.chunk === "string") this.hooks.onReasoningChunk?.(msg.chunk, msg.message_id);
|
|
1149
1553
|
return;
|
|
1150
1554
|
}
|
|
1151
1555
|
if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
|
|
@@ -1172,6 +1576,7 @@ var SystemEvents = class {
|
|
|
1172
1576
|
hooks;
|
|
1173
1577
|
ws = null;
|
|
1174
1578
|
closed = false;
|
|
1579
|
+
heartbeat = null;
|
|
1175
1580
|
reconnectAttempt = 0;
|
|
1176
1581
|
reconnectTimer = null;
|
|
1177
1582
|
connect() {
|
|
@@ -1190,11 +1595,27 @@ var SystemEvents = class {
|
|
|
1190
1595
|
this.ws = ws;
|
|
1191
1596
|
ws.addEventListener("open", () => {
|
|
1192
1597
|
this.reconnectAttempt = 0;
|
|
1598
|
+
this.startHeartbeat(ws);
|
|
1599
|
+
this.hooks.onOpen?.();
|
|
1600
|
+
});
|
|
1601
|
+
ws.addEventListener("message", (ev) => {
|
|
1602
|
+
if (this.ws === ws) this.heartbeat?.markAlive();
|
|
1603
|
+
this.onMessage(String(ev.data));
|
|
1193
1604
|
});
|
|
1194
|
-
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
1195
1605
|
ws.addEventListener("error", () => this.handleDrop(ws));
|
|
1196
1606
|
ws.addEventListener("close", () => this.handleDrop(ws));
|
|
1197
1607
|
}
|
|
1608
|
+
startHeartbeat(ws) {
|
|
1609
|
+
this.stopHeartbeat();
|
|
1610
|
+
this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
|
|
1611
|
+
this.heartbeat.start();
|
|
1612
|
+
}
|
|
1613
|
+
stopHeartbeat() {
|
|
1614
|
+
if (this.heartbeat) {
|
|
1615
|
+
this.heartbeat.stop();
|
|
1616
|
+
this.heartbeat = null;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1198
1619
|
onMessage(raw) {
|
|
1199
1620
|
let msg;
|
|
1200
1621
|
try {
|
|
@@ -1211,6 +1632,7 @@ var SystemEvents = class {
|
|
|
1211
1632
|
handleDrop(ws) {
|
|
1212
1633
|
if (this.ws !== ws) return;
|
|
1213
1634
|
this.ws = null;
|
|
1635
|
+
this.stopHeartbeat();
|
|
1214
1636
|
this.scheduleReconnect();
|
|
1215
1637
|
}
|
|
1216
1638
|
scheduleReconnect() {
|
|
@@ -1225,6 +1647,7 @@ var SystemEvents = class {
|
|
|
1225
1647
|
}
|
|
1226
1648
|
close() {
|
|
1227
1649
|
this.closed = true;
|
|
1650
|
+
this.stopHeartbeat();
|
|
1228
1651
|
if (this.reconnectTimer) {
|
|
1229
1652
|
clearTimeout(this.reconnectTimer);
|
|
1230
1653
|
this.reconnectTimer = null;
|
|
@@ -1245,8 +1668,98 @@ var LEVEL_DETAIL = {
|
|
|
1245
1668
|
function levelLabel(level) {
|
|
1246
1669
|
return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
|
|
1247
1670
|
}
|
|
1671
|
+
var FILE_MIMES = {
|
|
1672
|
+
".png": "image/png",
|
|
1673
|
+
".jpg": "image/jpeg",
|
|
1674
|
+
".jpeg": "image/jpeg",
|
|
1675
|
+
".gif": "image/gif",
|
|
1676
|
+
".webp": "image/webp"
|
|
1677
|
+
};
|
|
1678
|
+
var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
1679
|
+
function run(cmd, args, maxBuffer = MAX_IMAGE_BYTES * 2) {
|
|
1680
|
+
return new Promise((resolve) => {
|
|
1681
|
+
execFile(cmd, args, { encoding: "buffer", maxBuffer }, (err, stdout) => {
|
|
1682
|
+
resolve({ ok: !err, stdout: stdout ?? Buffer.alloc(0) });
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
function fromFile(filePath) {
|
|
1687
|
+
const mime = FILE_MIMES[path3.extname(filePath).toLowerCase()];
|
|
1688
|
+
if (!mime) return null;
|
|
1689
|
+
try {
|
|
1690
|
+
const stat = fs4.statSync(filePath);
|
|
1691
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null;
|
|
1692
|
+
return { data: fs4.readFileSync(filePath).toString("base64"), mime };
|
|
1693
|
+
} catch {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
async function readDarwin() {
|
|
1698
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1699
|
+
const script = [
|
|
1700
|
+
`set d to the clipboard as \xABclass PNGf\xBB`,
|
|
1701
|
+
`set f to open for access POSIX file "${tmp}" with write permission`,
|
|
1702
|
+
`set eof f to 0`,
|
|
1703
|
+
`write d to f`,
|
|
1704
|
+
`close access f`
|
|
1705
|
+
].join("\n");
|
|
1706
|
+
const png = await run("osascript", ["-e", script]);
|
|
1707
|
+
if (png.ok) {
|
|
1708
|
+
const img = fromFile(tmp);
|
|
1709
|
+
try {
|
|
1710
|
+
fs4.unlinkSync(tmp);
|
|
1711
|
+
} catch {
|
|
1712
|
+
}
|
|
1713
|
+
if (img) return img;
|
|
1714
|
+
}
|
|
1715
|
+
const furl = await run("osascript", ["-e", "POSIX path of (the clipboard as \xABclass furl\xBB)"]);
|
|
1716
|
+
if (furl.ok) {
|
|
1717
|
+
const p = furl.stdout.toString("utf8").trim();
|
|
1718
|
+
if (p) return fromFile(p);
|
|
1719
|
+
}
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
async function readLinux() {
|
|
1723
|
+
for (const [cmd, args] of [
|
|
1724
|
+
["wl-paste", ["--type", "image/png"]],
|
|
1725
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
1726
|
+
]) {
|
|
1727
|
+
const res = await run(cmd, args);
|
|
1728
|
+
if (res.ok && res.stdout.length > 8 && res.stdout.length <= MAX_IMAGE_BYTES && res.stdout[0] === 137 && res.stdout[1] === 80) {
|
|
1729
|
+
return { data: res.stdout.toString("base64"), mime: "image/png" };
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
async function readWindows() {
|
|
1735
|
+
const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
|
|
1736
|
+
const ps = [
|
|
1737
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
1738
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage();",
|
|
1739
|
+
`if ($img -ne $null) { $img.Save('${tmp.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) }`
|
|
1740
|
+
].join(" ");
|
|
1741
|
+
await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
1742
|
+
const img = fromFile(tmp);
|
|
1743
|
+
try {
|
|
1744
|
+
fs4.unlinkSync(tmp);
|
|
1745
|
+
} catch {
|
|
1746
|
+
}
|
|
1747
|
+
return img;
|
|
1748
|
+
}
|
|
1749
|
+
async function readClipboardImage() {
|
|
1750
|
+
try {
|
|
1751
|
+
if (process.platform === "darwin") return await readDarwin();
|
|
1752
|
+
if (process.platform === "win32") return await readWindows();
|
|
1753
|
+
return await readLinux();
|
|
1754
|
+
} catch {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1248
1758
|
|
|
1249
1759
|
// src/tui.ts
|
|
1760
|
+
function imagePlaceholder(seq) {
|
|
1761
|
+
return `[#Image ${seq}]`;
|
|
1762
|
+
}
|
|
1250
1763
|
var C = {
|
|
1251
1764
|
reset: "\x1B[0m",
|
|
1252
1765
|
dim: "\x1B[2m",
|
|
@@ -1261,7 +1774,23 @@ var C = {
|
|
|
1261
1774
|
teal: "\x1B[38;5;37m"
|
|
1262
1775
|
};
|
|
1263
1776
|
var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
|
|
1264
|
-
var
|
|
1777
|
+
var SUBAGENT_COLORS = [
|
|
1778
|
+
"\x1B[35m",
|
|
1779
|
+
// magenta
|
|
1780
|
+
"\x1B[38;5;39m",
|
|
1781
|
+
// azure
|
|
1782
|
+
"\x1B[38;5;75m",
|
|
1783
|
+
// blue
|
|
1784
|
+
"\x1B[32m",
|
|
1785
|
+
// green
|
|
1786
|
+
"\x1B[38;5;141m",
|
|
1787
|
+
// violet
|
|
1788
|
+
"\x1B[38;5;177m"
|
|
1789
|
+
// orchid
|
|
1790
|
+
];
|
|
1791
|
+
var COMPACTION_COLOR = "\x1B[38;5;208m";
|
|
1792
|
+
var COMPACTION_AGENT = "compaction_agent";
|
|
1793
|
+
var Tui = class _Tui {
|
|
1265
1794
|
constructor(level = 1) {
|
|
1266
1795
|
this.level = level;
|
|
1267
1796
|
readline.emitKeypressEvents(process.stdin);
|
|
@@ -1270,6 +1799,7 @@ var Tui = class {
|
|
|
1270
1799
|
process.stdin.resume();
|
|
1271
1800
|
process.stdout.write("\x1B[?2004h");
|
|
1272
1801
|
process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
|
|
1802
|
+
process.stdout.on("resize", () => this.renderBottom());
|
|
1273
1803
|
}
|
|
1274
1804
|
level;
|
|
1275
1805
|
// input + indicators
|
|
@@ -1284,13 +1814,31 @@ var Tui = class {
|
|
|
1284
1814
|
bgCount = 0;
|
|
1285
1815
|
queuedCount = 0;
|
|
1286
1816
|
subagents = [];
|
|
1287
|
-
//
|
|
1817
|
+
// active subagents (one line each)
|
|
1818
|
+
subagentColorByID = /* @__PURE__ */ new Map();
|
|
1819
|
+
// subagent id → SUBAGENT_COLORS index
|
|
1288
1820
|
tokensIn = 0;
|
|
1289
1821
|
// cumulative input tokens
|
|
1290
1822
|
tokensOut = 0;
|
|
1291
1823
|
// cumulative output tokens (includes the in-progress live count)
|
|
1292
1824
|
contextPct = null;
|
|
1293
1825
|
// % of the model context window currently used
|
|
1826
|
+
// Live streaming preview, shown just above the status line while a turn runs:
|
|
1827
|
+
// the model's internal reasoning (dim italic) until the answer starts, then the
|
|
1828
|
+
// answer text (plain). Bounded to a tail; cleared when the message commits.
|
|
1829
|
+
streamThinking = "";
|
|
1830
|
+
streamResponse = "";
|
|
1831
|
+
streamMessageId = null;
|
|
1832
|
+
// the message currently previewing
|
|
1833
|
+
streamRedrawTimer = null;
|
|
1834
|
+
streamIdleTimer = null;
|
|
1835
|
+
// wipes a stale preview
|
|
1836
|
+
// Live goal checklist, fixed below the status bar. Driven by the goal_updated
|
|
1837
|
+
// thread event (+ an initial fetch). null = nothing to show.
|
|
1838
|
+
goal = null;
|
|
1839
|
+
// Set true the moment every step of a goal is done: the goal area is cleared
|
|
1840
|
+
// and the status line shows "Goal complete." until the next turn starts.
|
|
1841
|
+
goalComplete = false;
|
|
1294
1842
|
// Inline slash-command palette: when the input starts with "/", the filtered
|
|
1295
1843
|
// command list renders above the prompt and arrows/enter/tab drive it.
|
|
1296
1844
|
commands = [];
|
|
@@ -1305,6 +1853,14 @@ var Tui = class {
|
|
|
1305
1853
|
connected = true;
|
|
1306
1854
|
bottomDrawn = false;
|
|
1307
1855
|
started = false;
|
|
1856
|
+
// Resize bookkeeping: the width the region was last drawn at, and the visible
|
|
1857
|
+
// width of every HUD row written above the input. When the terminal is
|
|
1858
|
+
// resized, previously drawn rows re-wrap (a full-width ruler becomes 2+ rows
|
|
1859
|
+
// when narrowed), so the move-up count recorded at draw time is wrong — these
|
|
1860
|
+
// let moveToRegionTop recompute the region height under the NEW wrap instead
|
|
1861
|
+
// of leaving stale rulers behind.
|
|
1862
|
+
lastDrawnCols = 0;
|
|
1863
|
+
drawnHudWidths = [];
|
|
1308
1864
|
// takeover (approval / menu) state
|
|
1309
1865
|
takeoverHandler = null;
|
|
1310
1866
|
bufferedPrints = [];
|
|
@@ -1315,13 +1871,28 @@ var Tui = class {
|
|
|
1315
1871
|
// bracketed-paste state
|
|
1316
1872
|
pasting = false;
|
|
1317
1873
|
pasteTimer = null;
|
|
1874
|
+
// Images pasted into the CURRENT input (Ctrl+V). Each got a `[#Image N]`
|
|
1875
|
+
// placeholder at the caret; on submit only images whose placeholder is still
|
|
1876
|
+
// present in the text are handed to onSubmit. Cleared with the input.
|
|
1877
|
+
pendingImages = [];
|
|
1878
|
+
imagePasteBusy = false;
|
|
1879
|
+
// one clipboard read at a time
|
|
1880
|
+
// Sent-message history for ↑/↓ recall (oldest → newest). `historyIdx` is the
|
|
1881
|
+
// entry currently shown (null = not browsing); the in-progress draft is
|
|
1882
|
+
// stashed so cycling past the newest entry restores it. Any edit exits
|
|
1883
|
+
// browsing and keeps the recalled text as the new draft.
|
|
1884
|
+
history = [];
|
|
1885
|
+
historyIdx = null;
|
|
1886
|
+
historyDraft = "";
|
|
1887
|
+
historyDraftImages = [];
|
|
1318
1888
|
// event hooks (wired by index.ts)
|
|
1319
1889
|
onSubmit = () => {
|
|
1320
1890
|
};
|
|
1321
1891
|
onInterrupt = () => {
|
|
1322
1892
|
};
|
|
1323
|
-
|
|
1324
|
-
|
|
1893
|
+
/** Up on the top row: return true to consume it (e.g. pull a queued message)
|
|
1894
|
+
* before history recall gets a chance. */
|
|
1895
|
+
onUpArrow = () => false;
|
|
1325
1896
|
onQuit = () => process.exit(0);
|
|
1326
1897
|
levelListeners = [];
|
|
1327
1898
|
get colors() {
|
|
@@ -1356,6 +1927,8 @@ var Tui = class {
|
|
|
1356
1927
|
end() {
|
|
1357
1928
|
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
1358
1929
|
this.quitTimer = null;
|
|
1930
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
1931
|
+
this.streamIdleTimer = null;
|
|
1359
1932
|
this.clearBottom();
|
|
1360
1933
|
process.stdout.write("\x1B[?2004l\x1B[?25h");
|
|
1361
1934
|
}
|
|
@@ -1368,6 +1941,11 @@ var Tui = class {
|
|
|
1368
1941
|
}
|
|
1369
1942
|
// ─── key dispatch ──────────────────────────────────────────────────────────
|
|
1370
1943
|
dispatch(str, key) {
|
|
1944
|
+
const seq = key && key.sequence || str || "";
|
|
1945
|
+
if (seq === "\n" || seq === "\x1B[13;2u" || seq === "\x1B[27;2;13~") {
|
|
1946
|
+
this.insertAtCursor("\n");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1371
1949
|
if (key && key.ctrl && key.name === "c") {
|
|
1372
1950
|
this.requestQuit();
|
|
1373
1951
|
return;
|
|
@@ -1376,7 +1954,6 @@ var Tui = class {
|
|
|
1376
1954
|
this.cycleLevel();
|
|
1377
1955
|
return;
|
|
1378
1956
|
}
|
|
1379
|
-
const seq = key && key.sequence || str || "";
|
|
1380
1957
|
if (!this.pasting && seq.includes("\x1B[200~")) {
|
|
1381
1958
|
this.pasting = true;
|
|
1382
1959
|
this.armPasteSafety();
|
|
@@ -1420,6 +1997,10 @@ var Tui = class {
|
|
|
1420
1997
|
return;
|
|
1421
1998
|
}
|
|
1422
1999
|
if (key.name === "return" || key.name === "enter") {
|
|
2000
|
+
if (key.shift) {
|
|
2001
|
+
this.insertAtCursor("\n");
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
1423
2004
|
if (matches.length) this.runCommand(matches[cur]);
|
|
1424
2005
|
return;
|
|
1425
2006
|
}
|
|
@@ -1460,15 +2041,46 @@ var Tui = class {
|
|
|
1460
2041
|
return;
|
|
1461
2042
|
}
|
|
1462
2043
|
if (key.name === "up") {
|
|
1463
|
-
this.
|
|
2044
|
+
const { caretRow } = this.inputLayout();
|
|
2045
|
+
if (caretRow > 0) {
|
|
2046
|
+
this.moveCaretVertical(-1);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
if (this.onUpArrow()) return;
|
|
2050
|
+
this.historyPrev();
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (key.name === "down") {
|
|
2054
|
+
const { caretRow, rowCount } = this.inputLayout();
|
|
2055
|
+
if (caretRow < rowCount - 1) {
|
|
2056
|
+
this.moveCaretVertical(1);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
this.historyNext();
|
|
1464
2060
|
return;
|
|
1465
2061
|
}
|
|
1466
2062
|
if (key.name === "return" || key.name === "enter") {
|
|
2063
|
+
if (key.shift) {
|
|
2064
|
+
this.insertAtCursor("\n");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
1467
2067
|
const text = this.inputBuffer;
|
|
2068
|
+
const images = this.pendingImages.filter((img) => text.includes(imagePlaceholder(img.seq)));
|
|
1468
2069
|
this.inputBuffer = "";
|
|
1469
2070
|
this.cursorPos = 0;
|
|
2071
|
+
this.pendingImages = [];
|
|
2072
|
+
this.historyIdx = null;
|
|
2073
|
+
this.historyDraft = "";
|
|
2074
|
+
this.historyDraftImages = [];
|
|
1470
2075
|
this.renderBottom();
|
|
1471
|
-
if (text.trim())
|
|
2076
|
+
if (text.trim()) {
|
|
2077
|
+
this.addHistoryEntry(text.trim());
|
|
2078
|
+
this.onSubmit(text.trim(), images);
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (key.ctrl && key.name === "v") {
|
|
2083
|
+
void this.pasteClipboardImage();
|
|
1472
2084
|
return;
|
|
1473
2085
|
}
|
|
1474
2086
|
if (key.name === "backspace") {
|
|
@@ -1476,6 +2088,7 @@ var Tui = class {
|
|
|
1476
2088
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
|
|
1477
2089
|
this.cursorPos--;
|
|
1478
2090
|
this.slashIdx = 0;
|
|
2091
|
+
this.historyIdx = null;
|
|
1479
2092
|
this.renderBottom();
|
|
1480
2093
|
}
|
|
1481
2094
|
return;
|
|
@@ -1484,6 +2097,7 @@ var Tui = class {
|
|
|
1484
2097
|
if (this.cursorPos < this.inputBuffer.length) {
|
|
1485
2098
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
|
|
1486
2099
|
this.slashIdx = 0;
|
|
2100
|
+
this.historyIdx = null;
|
|
1487
2101
|
this.renderBottom();
|
|
1488
2102
|
}
|
|
1489
2103
|
return;
|
|
@@ -1497,6 +2111,7 @@ var Tui = class {
|
|
|
1497
2111
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
|
|
1498
2112
|
this.cursorPos += text.length;
|
|
1499
2113
|
this.slashIdx = 0;
|
|
2114
|
+
this.historyIdx = null;
|
|
1500
2115
|
this.renderBottom();
|
|
1501
2116
|
}
|
|
1502
2117
|
/** Insert a paste fragment at the caret; collapse newlines (single-line input). */
|
|
@@ -1506,6 +2121,7 @@ var Tui = class {
|
|
|
1506
2121
|
if (content) {
|
|
1507
2122
|
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
|
|
1508
2123
|
this.cursorPos += content.length;
|
|
2124
|
+
this.historyIdx = null;
|
|
1509
2125
|
}
|
|
1510
2126
|
if (end >= 0) {
|
|
1511
2127
|
this.pasting = false;
|
|
@@ -1525,6 +2141,128 @@ var Tui = class {
|
|
|
1525
2141
|
this.renderBottom();
|
|
1526
2142
|
}, 2e3);
|
|
1527
2143
|
}
|
|
2144
|
+
// ─── input layout + vertical caret movement ────────────────────────────────
|
|
2145
|
+
/**
|
|
2146
|
+
* The input's physical rows (same wrapping math as renderBottom: logical
|
|
2147
|
+
* lines split on "\n", line 0 led by the prompt prefix, each wrapping at the
|
|
2148
|
+
* terminal width) plus where the caret sits among them. Each row records the
|
|
2149
|
+
* buffer index of its first character, its character count, and the visual
|
|
2150
|
+
* column its first character renders at (only row 0 is offset, by the
|
|
2151
|
+
* prompt). Drives ↑/↓: row 0 is "the top line" (history recall territory),
|
|
2152
|
+
* anything below moves the caret instead.
|
|
2153
|
+
*/
|
|
2154
|
+
inputLayout() {
|
|
2155
|
+
const cols2 = process.stdout.columns || 80;
|
|
2156
|
+
const pw = this.visibleWidth(this.promptPrefix());
|
|
2157
|
+
const lines = this.inputBuffer.split("\n");
|
|
2158
|
+
const rows = [];
|
|
2159
|
+
let offset = 0;
|
|
2160
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2161
|
+
const lead = i === 0 ? pw : 0;
|
|
2162
|
+
const len = lines[i].length;
|
|
2163
|
+
const nRows = Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2164
|
+
for (let j = 0; j < nRows; j++) {
|
|
2165
|
+
const charStart = Math.max(0, j * cols2 - lead);
|
|
2166
|
+
const charEnd = Math.min(len, (j + 1) * cols2 - lead);
|
|
2167
|
+
rows.push({ start: offset + charStart, len: Math.max(0, charEnd - charStart), colOffset: j === 0 ? lead : 0 });
|
|
2168
|
+
}
|
|
2169
|
+
offset += len + 1;
|
|
2170
|
+
}
|
|
2171
|
+
let pos = this.cursorPos;
|
|
2172
|
+
let caretLine = 0;
|
|
2173
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2174
|
+
pos -= lines[caretLine].length + 1;
|
|
2175
|
+
caretLine++;
|
|
2176
|
+
}
|
|
2177
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2178
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2179
|
+
for (let i = 0; i < caretLine; i++) {
|
|
2180
|
+
const lead = i === 0 ? pw : 0;
|
|
2181
|
+
caretRow += Math.max(1, Math.ceil((lead + lines[i].length) / cols2));
|
|
2182
|
+
}
|
|
2183
|
+
const caretCol = caretCell % cols2;
|
|
2184
|
+
while (caretRow >= rows.length) rows.push({ start: this.inputBuffer.length, len: 0, colOffset: 0 });
|
|
2185
|
+
return { rows, caretRow, caretCol, rowCount: rows.length };
|
|
2186
|
+
}
|
|
2187
|
+
/** Move the caret one visual row up/down, keeping the column when possible. */
|
|
2188
|
+
moveCaretVertical(delta) {
|
|
2189
|
+
const { rows, caretRow, caretCol } = this.inputLayout();
|
|
2190
|
+
const target = caretRow + delta;
|
|
2191
|
+
if (target < 0 || target >= rows.length) return;
|
|
2192
|
+
const row = rows[target];
|
|
2193
|
+
const within = Math.max(0, Math.min(caretCol - row.colOffset, row.len));
|
|
2194
|
+
this.cursorPos = Math.min(row.start + within, this.inputBuffer.length);
|
|
2195
|
+
this.renderBottom();
|
|
2196
|
+
}
|
|
2197
|
+
// ─── sent-message history (↑/↓ recall) ─────────────────────────────────────
|
|
2198
|
+
/** Seed the recall history (oldest → newest), e.g. from the on-disk file. */
|
|
2199
|
+
setHistory(entries) {
|
|
2200
|
+
this.history = entries.filter((e) => e.trim() !== "");
|
|
2201
|
+
}
|
|
2202
|
+
/** Record a sent message (skipping a consecutive duplicate). */
|
|
2203
|
+
addHistoryEntry(text) {
|
|
2204
|
+
if (this.history[this.history.length - 1] === text) return;
|
|
2205
|
+
this.history.push(text);
|
|
2206
|
+
}
|
|
2207
|
+
/** Recall the previous (older) history entry; stashes the draft first. */
|
|
2208
|
+
historyPrev() {
|
|
2209
|
+
if (!this.history.length) return;
|
|
2210
|
+
if (this.historyIdx === null) {
|
|
2211
|
+
this.historyDraft = this.inputBuffer;
|
|
2212
|
+
this.historyDraftImages = this.pendingImages;
|
|
2213
|
+
this.historyIdx = this.history.length - 1;
|
|
2214
|
+
} else if (this.historyIdx > 0) {
|
|
2215
|
+
this.historyIdx--;
|
|
2216
|
+
} else {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
this.pendingImages = [];
|
|
2220
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2221
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2222
|
+
this.slashIdx = 0;
|
|
2223
|
+
this.renderBottom();
|
|
2224
|
+
}
|
|
2225
|
+
/** Step toward the newest entry; past it, restore the stashed draft. */
|
|
2226
|
+
historyNext() {
|
|
2227
|
+
if (this.historyIdx === null) return;
|
|
2228
|
+
if (this.historyIdx < this.history.length - 1) {
|
|
2229
|
+
this.historyIdx++;
|
|
2230
|
+
this.pendingImages = [];
|
|
2231
|
+
this.inputBuffer = this.history[this.historyIdx];
|
|
2232
|
+
} else {
|
|
2233
|
+
this.historyIdx = null;
|
|
2234
|
+
this.inputBuffer = this.historyDraft;
|
|
2235
|
+
this.pendingImages = this.historyDraftImages;
|
|
2236
|
+
this.historyDraft = "";
|
|
2237
|
+
this.historyDraftImages = [];
|
|
2238
|
+
}
|
|
2239
|
+
this.cursorPos = this.inputBuffer.length;
|
|
2240
|
+
this.slashIdx = 0;
|
|
2241
|
+
this.renderBottom();
|
|
2242
|
+
}
|
|
2243
|
+
// ─── clipboard image paste (Ctrl+V) ────────────────────────────────────────
|
|
2244
|
+
/**
|
|
2245
|
+
* Read an image off the system clipboard and drop an `[#Image N]`
|
|
2246
|
+
* placeholder at the caret. The bytes ride along with the message on submit
|
|
2247
|
+
* (as a real attachment) as long as the placeholder is still in the text —
|
|
2248
|
+
* delete the placeholder and the image is dropped too.
|
|
2249
|
+
*/
|
|
2250
|
+
async pasteClipboardImage() {
|
|
2251
|
+
if (this.imagePasteBusy) return;
|
|
2252
|
+
this.imagePasteBusy = true;
|
|
2253
|
+
try {
|
|
2254
|
+
const img = await readClipboardImage();
|
|
2255
|
+
if (!img) {
|
|
2256
|
+
this.print(`${C.dim}No image on the clipboard.${C.reset}`);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const seq = this.pendingImages.reduce((m, i) => Math.max(m, i.seq), 0) + 1;
|
|
2260
|
+
this.pendingImages.push({ seq, data: img.data, mime: img.mime });
|
|
2261
|
+
this.insertAtCursor(imagePlaceholder(seq));
|
|
2262
|
+
} finally {
|
|
2263
|
+
this.imagePasteBusy = false;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
1528
2266
|
cycleLevel() {
|
|
1529
2267
|
const idx = LEVELS.indexOf(this.level);
|
|
1530
2268
|
this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
|
|
@@ -1571,7 +2309,8 @@ var Tui = class {
|
|
|
1571
2309
|
let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
|
|
1572
2310
|
if (this.contextPct != null) {
|
|
1573
2311
|
const pct = this.contextPct;
|
|
1574
|
-
const
|
|
2312
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2313
|
+
const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : calmGreen;
|
|
1575
2314
|
const gauge = `${col}ctx ${pct}%${C.reset}`;
|
|
1576
2315
|
out = out ? `${out} ${gauge}` : gauge;
|
|
1577
2316
|
}
|
|
@@ -1623,13 +2362,13 @@ var Tui = class {
|
|
|
1623
2362
|
* token totals visible (`↑in ↓out`) so they live in the summary rather than
|
|
1624
2363
|
* crowding the prompt. Null when idle with nothing counted yet.
|
|
1625
2364
|
*/
|
|
1626
|
-
statusLineText(
|
|
2365
|
+
statusLineText(cols2) {
|
|
1627
2366
|
const tk = this.tokensText();
|
|
1628
2367
|
if (this.working) {
|
|
1629
2368
|
const el = this.formatElapsed(Date.now() - this.workingStart);
|
|
1630
2369
|
const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
|
|
1631
2370
|
const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
|
|
1632
|
-
const avail = Math.max(0,
|
|
2371
|
+
const avail = Math.max(0, cols2 - this.visibleWidth(head) - this.visibleWidth(right) - 2);
|
|
1633
2372
|
let stepPart = "";
|
|
1634
2373
|
if (this.step && avail > 1) {
|
|
1635
2374
|
let s = this.step;
|
|
@@ -1638,23 +2377,43 @@ var Tui = class {
|
|
|
1638
2377
|
}
|
|
1639
2378
|
return `${head}${stepPart} ${right}`;
|
|
1640
2379
|
}
|
|
2380
|
+
if (this.goalComplete) {
|
|
2381
|
+
const done = `${C.bold}${C.green}\u2713 Goal complete.${C.reset}`;
|
|
2382
|
+
return tk ? `${done} ${tk}` : done;
|
|
2383
|
+
}
|
|
1641
2384
|
return tk ? tk : null;
|
|
1642
2385
|
}
|
|
1643
2386
|
/** The prompt line prefix (with ANSI colour) that precedes the typed text. */
|
|
1644
2387
|
promptPrefix() {
|
|
1645
2388
|
const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
|
|
1646
2389
|
const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
|
|
1647
|
-
return `${q}${bg}${this.levelColor()}\
|
|
2390
|
+
return `${q}${bg}${this.levelColor()}\u276F${C.reset} `;
|
|
1648
2391
|
}
|
|
1649
2392
|
visibleWidth(s) {
|
|
1650
2393
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1651
2394
|
}
|
|
2395
|
+
/**
|
|
2396
|
+
* The slash-palette block rendered BELOW the input. While the palette is open
|
|
2397
|
+
* we reserve a fixed number of rows — one per available command — padding with
|
|
2398
|
+
* blank rows so the region height never changes as the filter narrows. That
|
|
2399
|
+
* pins the input: the region scrolls into place once when the palette opens,
|
|
2400
|
+
* then nothing below the input resizes, so the text you're typing never jumps.
|
|
2401
|
+
* Returns `[]` when the palette is closed (no reservation, input sits at the
|
|
2402
|
+
* bottom as usual).
|
|
2403
|
+
*/
|
|
2404
|
+
paletteBlockLines(cols2) {
|
|
2405
|
+
if (!this.paletteOpen()) return [];
|
|
2406
|
+
const rows = this.paletteLines(cols2);
|
|
2407
|
+
const reserved = Math.max(this.commands.length, rows.length);
|
|
2408
|
+
while (rows.length < reserved) rows.push("");
|
|
2409
|
+
return rows;
|
|
2410
|
+
}
|
|
1652
2411
|
/**
|
|
1653
2412
|
* Build the slash-palette rows for the current filter. Each row is clamped to
|
|
1654
2413
|
* ONE physical line (a wrapped row would desync the move-up redraw), with the
|
|
1655
2414
|
* `/name` highlighted, the label dimmed, and the hint right-aligned.
|
|
1656
2415
|
*/
|
|
1657
|
-
paletteLines(
|
|
2416
|
+
paletteLines(cols2) {
|
|
1658
2417
|
if (!this.paletteOpen()) return [];
|
|
1659
2418
|
const matches = this.filteredCommands();
|
|
1660
2419
|
if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
|
|
@@ -1666,23 +2425,42 @@ var Tui = class {
|
|
|
1666
2425
|
const hintW = hint.length;
|
|
1667
2426
|
const name = `/${cmd.name}`;
|
|
1668
2427
|
let visible = `${name} ${cmd.label}`;
|
|
1669
|
-
const labelMax = Math.max(6,
|
|
2428
|
+
const labelMax = Math.max(6, cols2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
1670
2429
|
if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
|
|
1671
2430
|
const desc = visible.slice(name.length);
|
|
1672
2431
|
const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
|
|
1673
2432
|
const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
|
|
1674
2433
|
let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
|
|
1675
2434
|
if (hintW) {
|
|
1676
|
-
const gap = Math.max(2,
|
|
2435
|
+
const gap = Math.max(2, cols2 - pointerW - visible.length - hintW);
|
|
1677
2436
|
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
1678
2437
|
}
|
|
1679
2438
|
return line;
|
|
1680
2439
|
});
|
|
1681
2440
|
}
|
|
1682
|
-
/**
|
|
2441
|
+
/**
|
|
2442
|
+
* Move the cursor to the top-left of the current bottom region.
|
|
2443
|
+
*
|
|
2444
|
+
* Same width as the last draw → the caret's recorded row offset is exact.
|
|
2445
|
+
* Width CHANGED (terminal resized) → previously drawn rows re-wrapped, so
|
|
2446
|
+
* that offset is stale; recompute it under the new wrap instead: each drawn
|
|
2447
|
+
* HUD row of visible width w now occupies ceil(w / cols) physical rows
|
|
2448
|
+
* (reflowing terminals re-wrap hard lines; the cursor follows its logical
|
|
2449
|
+
* position in the input text, which inputLayout locates at the new width).
|
|
2450
|
+
*/
|
|
1683
2451
|
moveToRegionTop() {
|
|
1684
2452
|
process.stdout.write("\r");
|
|
1685
|
-
if (this.bottomDrawn
|
|
2453
|
+
if (!this.bottomDrawn) return;
|
|
2454
|
+
const cols2 = process.stdout.columns || 80;
|
|
2455
|
+
let up;
|
|
2456
|
+
if (cols2 !== this.lastDrawnCols && this.lastDrawnCols > 0) {
|
|
2457
|
+
let above = 0;
|
|
2458
|
+
for (const w of this.drawnHudWidths) above += Math.max(1, Math.ceil(Math.max(w, 1) / cols2));
|
|
2459
|
+
up = above + this.inputLayout().caretRow;
|
|
2460
|
+
} else {
|
|
2461
|
+
up = this.lastCursorRow;
|
|
2462
|
+
}
|
|
2463
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1686
2464
|
}
|
|
1687
2465
|
/**
|
|
1688
2466
|
* Render the bottom region: an optional step line, then the prompt + input
|
|
@@ -1692,43 +2470,77 @@ var Tui = class {
|
|
|
1692
2470
|
*/
|
|
1693
2471
|
renderBottom() {
|
|
1694
2472
|
if (!this.started || this.takeoverHandler) return;
|
|
1695
|
-
const
|
|
2473
|
+
const cols2 = process.stdout.columns || 80;
|
|
1696
2474
|
this.moveToRegionTop();
|
|
1697
2475
|
process.stdout.write("\x1B[J");
|
|
2476
|
+
const hudWidths = [];
|
|
2477
|
+
const writeHudRow = (line) => {
|
|
2478
|
+
hudWidths.push(this.visibleWidth(line));
|
|
2479
|
+
process.stdout.write(line + "\r\n");
|
|
2480
|
+
};
|
|
2481
|
+
const previewLines = this.streamPreviewLines(cols2);
|
|
2482
|
+
for (const line of previewLines) writeHudRow(line);
|
|
2483
|
+
const rulerRows = 1;
|
|
2484
|
+
writeHudRow(`${C.dim}${"\u2500".repeat(cols2)}${C.reset}`);
|
|
1698
2485
|
const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
|
|
1699
2486
|
const noticeRows = noticeLine ? 1 : 0;
|
|
1700
|
-
if (noticeLine)
|
|
2487
|
+
if (noticeLine) writeHudRow(noticeLine);
|
|
1701
2488
|
const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
|
|
1702
2489
|
const quitRows = quitLine ? 1 : 0;
|
|
1703
|
-
if (quitLine)
|
|
2490
|
+
if (quitLine) writeHudRow(quitLine);
|
|
1704
2491
|
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
1705
|
-
for (const
|
|
1706
|
-
const
|
|
1707
|
-
|
|
2492
|
+
for (const sub of this.subagents) {
|
|
2493
|
+
const color = sub.agentName === COMPACTION_AGENT ? COMPACTION_COLOR : SUBAGENT_COLORS[this.subagentColorByID.get(sub.id) ?? 0];
|
|
2494
|
+
const budget = cols2 - 10;
|
|
2495
|
+
let label = sub.label;
|
|
2496
|
+
if (budget < 1) label = "";
|
|
2497
|
+
else if (label.length > budget) label = label.slice(0, Math.max(0, budget - 1)) + "\u2026";
|
|
2498
|
+
const line = `${color}${frame}${C.reset} ${color}${label}${C.reset} ${C.dim}working${C.reset}`;
|
|
2499
|
+
writeHudRow(line);
|
|
1708
2500
|
}
|
|
1709
|
-
const statusLine = this.statusLineText(
|
|
2501
|
+
const statusLine = this.statusLineText(cols2);
|
|
1710
2502
|
const statusRows = statusLine ? 1 : 0;
|
|
1711
|
-
if (statusLine)
|
|
1712
|
-
const
|
|
1713
|
-
for (const line of
|
|
1714
|
-
const aboveRows = noticeRows + quitRows + this.subagents.length + statusRows +
|
|
2503
|
+
if (statusLine) writeHudRow(statusLine);
|
|
2504
|
+
const goalLines = this.goalLines(cols2);
|
|
2505
|
+
for (const line of goalLines) writeHudRow(line);
|
|
2506
|
+
const aboveRows = previewLines.length + rulerRows + noticeRows + quitRows + this.subagents.length + statusRows + goalLines.length;
|
|
1715
2507
|
const prefix = this.promptPrefix();
|
|
1716
2508
|
const pw = this.visibleWidth(prefix);
|
|
1717
|
-
const
|
|
1718
|
-
process.stdout.write(prefix +
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
2509
|
+
const lines = this.inputBuffer.split("\n");
|
|
2510
|
+
process.stdout.write(prefix + lines[0]);
|
|
2511
|
+
for (let i = 1; i < lines.length; i++) process.stdout.write("\r\n" + lines[i]);
|
|
2512
|
+
const rowsOf = (len, lead) => Math.max(1, Math.ceil((lead + len) / cols2));
|
|
2513
|
+
const lineRows = lines.map((l, i) => rowsOf(l.length, i === 0 ? pw : 0));
|
|
2514
|
+
const inputRows = lineRows.reduce((a, b) => a + b, 0);
|
|
2515
|
+
let pos = this.cursorPos;
|
|
2516
|
+
let caretLine = 0;
|
|
2517
|
+
while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
|
|
2518
|
+
pos -= lines[caretLine].length + 1;
|
|
2519
|
+
caretLine++;
|
|
2520
|
+
}
|
|
2521
|
+
const caretCell = (caretLine === 0 ? pw : 0) + pos;
|
|
2522
|
+
let caretRow = Math.floor(caretCell / cols2);
|
|
2523
|
+
for (let i = 0; i < caretLine; i++) caretRow += lineRows[i];
|
|
2524
|
+
const caretCol = caretCell % cols2;
|
|
2525
|
+
const paletteBlock = this.paletteBlockLines(cols2);
|
|
2526
|
+
for (const line of paletteBlock) process.stdout.write("\r\n" + line);
|
|
2527
|
+
if (paletteBlock.length > 0) {
|
|
2528
|
+
process.stdout.write("\r");
|
|
2529
|
+
const up = inputRows - 1 + paletteBlock.length - caretRow;
|
|
2530
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2531
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2532
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
2533
|
+
} else if (this.cursorPos < this.inputBuffer.length) {
|
|
1724
2534
|
process.stdout.write("\r");
|
|
1725
|
-
const up = inputRows - 1 -
|
|
2535
|
+
const up = inputRows - 1 - caretRow;
|
|
1726
2536
|
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
1727
|
-
if (
|
|
1728
|
-
this.lastCursorRow = aboveRows +
|
|
2537
|
+
if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
|
|
2538
|
+
this.lastCursorRow = aboveRows + caretRow;
|
|
1729
2539
|
} else {
|
|
1730
2540
|
this.lastCursorRow = aboveRows + (inputRows - 1);
|
|
1731
2541
|
}
|
|
2542
|
+
this.drawnHudWidths = hudWidths;
|
|
2543
|
+
this.lastDrawnCols = cols2;
|
|
1732
2544
|
this.bottomDrawn = true;
|
|
1733
2545
|
}
|
|
1734
2546
|
clearBottom() {
|
|
@@ -1737,14 +2549,183 @@ var Tui = class {
|
|
|
1737
2549
|
process.stdout.write("\x1B[J");
|
|
1738
2550
|
this.bottomDrawn = false;
|
|
1739
2551
|
}
|
|
2552
|
+
// ── live streaming preview ────────────────────────────────────────────────
|
|
2553
|
+
// An ephemeral tail of the model's output above the status line: reasoning in
|
|
2554
|
+
// dim italic until the answer begins, then the answer plain. clearStream()
|
|
2555
|
+
// wipes it right before the finished message is committed to the transcript
|
|
2556
|
+
// (which renders full markdown), so there's no double-render.
|
|
2557
|
+
static STREAM_TAIL = 20;
|
|
2558
|
+
// How long a preview may sit untouched before it's wiped. The model often
|
|
2559
|
+
// reasons and then calls a tool without ever emitting an answer, so without
|
|
2560
|
+
// this the reasoning tail would linger on screen until the *next* thought (or
|
|
2561
|
+
// message) arrives. Re-armed on every delta → fires this long after the last.
|
|
2562
|
+
static STREAM_IDLE_MS = 1e4;
|
|
2563
|
+
/** Append a fragment of streamed answer text (rendered plain). */
|
|
2564
|
+
streamResponseDelta(delta, messageId) {
|
|
2565
|
+
this.beginStreamMessage(messageId);
|
|
2566
|
+
this.streamResponse += delta;
|
|
2567
|
+
this.scheduleStreamRedraw();
|
|
2568
|
+
this.armStreamIdleExpiry();
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Append a fragment of streamed internal reasoning (rendered dim italic).
|
|
2572
|
+
* Ignored once the answer has started, since reasoning precedes the answer.
|
|
2573
|
+
*/
|
|
2574
|
+
streamThinkingDelta(delta, messageId) {
|
|
2575
|
+
this.beginStreamMessage(messageId);
|
|
2576
|
+
if (this.streamResponse) return;
|
|
2577
|
+
this.streamThinking += delta;
|
|
2578
|
+
this.scheduleStreamRedraw();
|
|
2579
|
+
this.armStreamIdleExpiry();
|
|
2580
|
+
}
|
|
2581
|
+
/** Reset the preview when a new message starts, so two messages' output (e.g.
|
|
2582
|
+
* one that only reasons then calls a tool, then the next) never blend. */
|
|
2583
|
+
beginStreamMessage(messageId) {
|
|
2584
|
+
if (messageId !== void 0 && messageId !== this.streamMessageId) {
|
|
2585
|
+
this.streamMessageId = messageId;
|
|
2586
|
+
this.streamThinking = "";
|
|
2587
|
+
this.streamResponse = "";
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
/** Wipe the live preview — call right before committing the final message. */
|
|
2591
|
+
clearStream() {
|
|
2592
|
+
if (this.streamRedrawTimer) {
|
|
2593
|
+
clearTimeout(this.streamRedrawTimer);
|
|
2594
|
+
this.streamRedrawTimer = null;
|
|
2595
|
+
}
|
|
2596
|
+
if (this.streamIdleTimer) {
|
|
2597
|
+
clearTimeout(this.streamIdleTimer);
|
|
2598
|
+
this.streamIdleTimer = null;
|
|
2599
|
+
}
|
|
2600
|
+
this.streamMessageId = null;
|
|
2601
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2602
|
+
this.streamThinking = "";
|
|
2603
|
+
this.streamResponse = "";
|
|
2604
|
+
this.renderBottom();
|
|
2605
|
+
}
|
|
2606
|
+
scheduleStreamRedraw() {
|
|
2607
|
+
if (this.streamRedrawTimer || this.takeoverHandler || !this.started) return;
|
|
2608
|
+
this.streamRedrawTimer = setTimeout(() => {
|
|
2609
|
+
this.streamRedrawTimer = null;
|
|
2610
|
+
this.renderBottom();
|
|
2611
|
+
}, 40);
|
|
2612
|
+
}
|
|
2613
|
+
/** Re-armed on every streamed delta: once the model goes quiet for a beat, the
|
|
2614
|
+
* preview is stale, so wipe it instead of letting it sit until the next
|
|
2615
|
+
* message. The committed message (if any) still renders in full via
|
|
2616
|
+
* clearStream(), so nothing is lost. */
|
|
2617
|
+
armStreamIdleExpiry() {
|
|
2618
|
+
if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
|
|
2619
|
+
this.streamIdleTimer = setTimeout(() => {
|
|
2620
|
+
this.streamIdleTimer = null;
|
|
2621
|
+
if (!this.streamThinking && !this.streamResponse) return;
|
|
2622
|
+
this.streamThinking = "";
|
|
2623
|
+
this.streamResponse = "";
|
|
2624
|
+
this.renderBottom();
|
|
2625
|
+
}, _Tui.STREAM_IDLE_MS);
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The preview's physical rows: a tail of reasoning (dim italic) before the
|
|
2629
|
+
* answer starts, otherwise a tail of the answer (plain). Each row is clamped to
|
|
2630
|
+
* one terminal line so the bottom-region redraw math stays correct.
|
|
2631
|
+
*
|
|
2632
|
+
* Rows match the committed transcript formatting so a finished message doesn't
|
|
2633
|
+
* visibly "snap" into shape: the answer's first line carries the grey gutter
|
|
2634
|
+
* dot (as long as it hasn't scrolled out of the tail) and the body indents two
|
|
2635
|
+
* spaces beneath it; reasoning aligns at the same indent, dotless.
|
|
2636
|
+
*/
|
|
2637
|
+
streamPreviewLines(cols2) {
|
|
2638
|
+
const thinkStyle = "\x1B[3m\x1B[38;5;240m";
|
|
2639
|
+
const clamp2 = (s, wrap, lead) => {
|
|
2640
|
+
const max = cols2 - 2;
|
|
2641
|
+
const t = s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2642
|
+
return wrap ? `${lead}${wrap}${t}${C.reset}` : `${lead}${t}`;
|
|
2643
|
+
};
|
|
2644
|
+
const realLines = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
|
|
2645
|
+
if (this.streamResponse) {
|
|
2646
|
+
const all = realLines(this.streamResponse);
|
|
2647
|
+
const shown = all.slice(-20);
|
|
2648
|
+
const firstVisible = all.length <= _Tui.STREAM_TAIL;
|
|
2649
|
+
return shown.map(
|
|
2650
|
+
(l, i) => clamp2(l, "", i === 0 && firstVisible ? `${C.gray}\u2022${C.reset} ` : " ")
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
if (this.streamThinking) {
|
|
2654
|
+
return realLines(this.streamThinking).slice(-20).map((l) => clamp2(l, thinkStyle, " "));
|
|
2655
|
+
}
|
|
2656
|
+
return [];
|
|
2657
|
+
}
|
|
2658
|
+
// ── live goal checklist ───────────────────────────────────────────────────
|
|
2659
|
+
/**
|
|
2660
|
+
* Update the goal from the goal_updated event (or the initial fetch). When
|
|
2661
|
+
* every step is done, the goal is fully achieved: we clear the goal area and
|
|
2662
|
+
* flip on the "Goal complete." status badge instead of leaving a finished
|
|
2663
|
+
* checklist sitting there.
|
|
2664
|
+
*/
|
|
2665
|
+
setGoal(goal) {
|
|
2666
|
+
const steps = goal?.steps;
|
|
2667
|
+
if (steps && Array.isArray(steps) && steps.length) {
|
|
2668
|
+
const allDone = steps.every((s) => s.status === "done");
|
|
2669
|
+
if (allDone) {
|
|
2670
|
+
this.goal = null;
|
|
2671
|
+
this.goalComplete = true;
|
|
2672
|
+
} else {
|
|
2673
|
+
this.goal = goal;
|
|
2674
|
+
this.goalComplete = false;
|
|
2675
|
+
}
|
|
2676
|
+
} else {
|
|
2677
|
+
this.goal = null;
|
|
2678
|
+
}
|
|
2679
|
+
this.renderBottom();
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* The goal's physical rows: a header (the short summary + progress) then one
|
|
2683
|
+
* row per step, indented two spaces beneath it so the todos clearly belong to
|
|
2684
|
+
* the goal — ○ pending / ▸ in-progress / ✓ done — each clamped to a line.
|
|
2685
|
+
*/
|
|
2686
|
+
goalLines(cols2) {
|
|
2687
|
+
const steps = this.goal?.steps;
|
|
2688
|
+
if (!steps?.length) return [];
|
|
2689
|
+
const oneLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
2690
|
+
const clamp2 = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
|
|
2691
|
+
const calmGreen = "\x1B[38;5;65m";
|
|
2692
|
+
const out = [];
|
|
2693
|
+
const done = steps.filter((s) => s.status === "done").length;
|
|
2694
|
+
const summary = oneLine(this.goal?.summary || this.goal?.description || "Goal");
|
|
2695
|
+
const prefix = `Goal ${done}/${steps.length} `;
|
|
2696
|
+
const sum = clamp2(summary, Math.max(1, cols2 - prefix.length));
|
|
2697
|
+
out.push(`${C.bold}Goal${C.reset} ${C.gray}${done}/${steps.length}${C.reset} ${sum}`);
|
|
2698
|
+
for (const s of steps) {
|
|
2699
|
+
const text = clamp2(oneLine(s.step || ""), Math.max(1, cols2 - 4));
|
|
2700
|
+
if (s.status === "done") out.push(` ${calmGreen}\u2713${C.reset} ${C.dim}${text}${C.reset}`);
|
|
2701
|
+
else if (s.status === "in_progress") out.push(` ${C.cyan}\u25B8${C.reset} ${text}`);
|
|
2702
|
+
else out.push(` ${C.gray}\u25CB ${text}${C.reset}`);
|
|
2703
|
+
}
|
|
2704
|
+
return out;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Transcript gutter: content starts at column 1, so the far-left column is a
|
|
2708
|
+
* clean strip where only status glyphs (✓ ✗ ⛔ …) land — scanning down the
|
|
2709
|
+
* left edge reads as a ledger of what happened. Lines already led by a
|
|
2710
|
+
* gutter glyph or whitespace (indented blocks, the user-message bar) pass
|
|
2711
|
+
* through untouched.
|
|
2712
|
+
*/
|
|
2713
|
+
gutterize(text) {
|
|
2714
|
+
return text.split("\n").map((line) => {
|
|
2715
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2716
|
+
if (plain === "" || /^[\s✓✗⛔⚠⚙⚡⏳↪›❯◇─•]/.test(plain)) return line;
|
|
2717
|
+
return " " + line;
|
|
2718
|
+
}).join("\n");
|
|
2719
|
+
}
|
|
1740
2720
|
/** Print a line of transcript above the persistent input. */
|
|
1741
2721
|
print(text) {
|
|
2722
|
+
const line = this.gutterize(text);
|
|
1742
2723
|
if (this.takeoverHandler) {
|
|
1743
|
-
this.bufferedPrints.push(
|
|
2724
|
+
this.bufferedPrints.push(line);
|
|
1744
2725
|
return;
|
|
1745
2726
|
}
|
|
1746
2727
|
this.clearBottom();
|
|
1747
|
-
process.stdout.write(
|
|
2728
|
+
process.stdout.write(line + "\n");
|
|
1748
2729
|
this.renderBottom();
|
|
1749
2730
|
}
|
|
1750
2731
|
/** Multi-line convenience. */
|
|
@@ -1758,9 +2739,9 @@ var Tui = class {
|
|
|
1758
2739
|
* above and below, and a teal `›` marks the first row.
|
|
1759
2740
|
*/
|
|
1760
2741
|
printUserMessage(text) {
|
|
1761
|
-
const
|
|
2742
|
+
const cols2 = Math.max(20, process.stdout.columns || 80);
|
|
1762
2743
|
const bg = "\x1B[48;5;238m";
|
|
1763
|
-
const limit = Math.max(8,
|
|
2744
|
+
const limit = Math.max(8, cols2 - 6);
|
|
1764
2745
|
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
1765
2746
|
const lines = [];
|
|
1766
2747
|
let cur = "";
|
|
@@ -1784,9 +2765,9 @@ var Tui = class {
|
|
|
1784
2765
|
const innerW = 2 + Math.max(...lines.map((l) => l.length));
|
|
1785
2766
|
this.print("");
|
|
1786
2767
|
lines.forEach((line, i) => {
|
|
1787
|
-
const rowText = (i === 0 ? "\
|
|
2768
|
+
const rowText = (i === 0 ? "\u276F " : " ") + line;
|
|
1788
2769
|
const padded = rowText.padEnd(innerW);
|
|
1789
|
-
const inner = i === 0 ? `${C.teal}\
|
|
2770
|
+
const inner = i === 0 ? `${C.teal}\u276F${C.reset}${bg}${padded.slice(1)}` : padded;
|
|
1790
2771
|
this.print(`${bg} ${inner} ${C.reset}`);
|
|
1791
2772
|
});
|
|
1792
2773
|
this.print("");
|
|
@@ -1796,15 +2777,30 @@ var Tui = class {
|
|
|
1796
2777
|
if (on && !this.working) {
|
|
1797
2778
|
this.working = true;
|
|
1798
2779
|
this.workingStart = Date.now();
|
|
2780
|
+
this.goalComplete = false;
|
|
1799
2781
|
} else if (!on) {
|
|
1800
2782
|
this.working = false;
|
|
1801
2783
|
}
|
|
1802
2784
|
this.syncSpinner();
|
|
1803
2785
|
this.renderBottom();
|
|
1804
2786
|
}
|
|
1805
|
-
/**
|
|
1806
|
-
|
|
1807
|
-
|
|
2787
|
+
/** The subagents currently working, one persistent line each. Each keeps a
|
|
2788
|
+
* stable, distinct colour for as long as it's active; the compaction agent
|
|
2789
|
+
* is always orange (its colour never comes from the shared pool). */
|
|
2790
|
+
setSubagents(subagents) {
|
|
2791
|
+
this.subagents = subagents;
|
|
2792
|
+
const active = new Set(subagents.map((s) => s.id));
|
|
2793
|
+
for (const id of [...this.subagentColorByID.keys()]) {
|
|
2794
|
+
if (!active.has(id)) this.subagentColorByID.delete(id);
|
|
2795
|
+
}
|
|
2796
|
+
for (const s of subagents) {
|
|
2797
|
+
if (s.agentName === COMPACTION_AGENT) continue;
|
|
2798
|
+
if (this.subagentColorByID.has(s.id)) continue;
|
|
2799
|
+
const used = new Set(this.subagentColorByID.values());
|
|
2800
|
+
let idx = 0;
|
|
2801
|
+
while (used.has(idx) && idx < SUBAGENT_COLORS.length - 1) idx++;
|
|
2802
|
+
this.subagentColorByID.set(s.id, idx);
|
|
2803
|
+
}
|
|
1808
2804
|
this.syncSpinner();
|
|
1809
2805
|
this.renderBottom();
|
|
1810
2806
|
}
|
|
@@ -1837,9 +2833,12 @@ var Tui = class {
|
|
|
1837
2833
|
getInput() {
|
|
1838
2834
|
return this.inputBuffer;
|
|
1839
2835
|
}
|
|
1840
|
-
|
|
2836
|
+
/** Replace the input (and any pasted images tied to placeholders in it). */
|
|
2837
|
+
setInput(text, images = []) {
|
|
1841
2838
|
this.inputBuffer = text;
|
|
1842
2839
|
this.cursorPos = text.length;
|
|
2840
|
+
this.pendingImages = images;
|
|
2841
|
+
this.historyIdx = null;
|
|
1843
2842
|
this.renderBottom();
|
|
1844
2843
|
}
|
|
1845
2844
|
/** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
|
|
@@ -1879,7 +2878,8 @@ var Tui = class {
|
|
|
1879
2878
|
}
|
|
1880
2879
|
this.renderBottom();
|
|
1881
2880
|
}
|
|
1882
|
-
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
|
|
2881
|
+
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
|
|
2882
|
+
* Tab resolves "deny_with_reason" so the caller can collect a free-text reason. */
|
|
1883
2883
|
approval(question, risk) {
|
|
1884
2884
|
return new Promise((resolve) => {
|
|
1885
2885
|
const options = [
|
|
@@ -1891,12 +2891,16 @@ var Tui = class {
|
|
|
1891
2891
|
let idx = 0;
|
|
1892
2892
|
const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
|
|
1893
2893
|
this.beginTakeover();
|
|
1894
|
-
process.stdout.
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2894
|
+
const cols2 = Math.max(1, process.stdout.columns || 80);
|
|
2895
|
+
const physRows = (line) => Math.max(1, Math.ceil(this.visibleWidth(line) / cols2));
|
|
2896
|
+
const headerText = `${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}`;
|
|
2897
|
+
const questionLines = question.split("\n").map((line) => `${C.yellow}\u2503${C.reset} ${line}`);
|
|
2898
|
+
const hintLine = `${C.yellow}\u2503${C.reset} ${C.gray}\u21E5 tab \u2014 deny with a reason${C.reset}`;
|
|
2899
|
+
const blockLines = [headerText, ...questionLines, hintLine];
|
|
2900
|
+
const headerRows = 1 + blockLines.reduce((n, line) => n + physRows(line), 0);
|
|
2901
|
+
process.stdout.write(`
|
|
2902
|
+
` + blockLines.map((line) => `${line}
|
|
2903
|
+
`).join(""));
|
|
1900
2904
|
const renderLine = (i) => {
|
|
1901
2905
|
const o = options[i];
|
|
1902
2906
|
const sel = i === idx;
|
|
@@ -1910,7 +2914,14 @@ ${C.yellow}\u2503${C.reset} ${question}
|
|
|
1910
2914
|
`);
|
|
1911
2915
|
};
|
|
1912
2916
|
draw(false);
|
|
2917
|
+
const erase = () => {
|
|
2918
|
+
process.stdout.write("\r");
|
|
2919
|
+
const up = headerRows + options.length;
|
|
2920
|
+
if (up > 0) process.stdout.write(`\x1B[${up}A`);
|
|
2921
|
+
process.stdout.write("\x1B[J");
|
|
2922
|
+
};
|
|
1913
2923
|
const finish = (choice) => {
|
|
2924
|
+
erase();
|
|
1914
2925
|
this.endTakeover();
|
|
1915
2926
|
resolve(choice);
|
|
1916
2927
|
};
|
|
@@ -1921,6 +2932,8 @@ ${C.yellow}\u2503${C.reset} ${question}
|
|
|
1921
2932
|
} else if (key?.name === "down" || str === "j") {
|
|
1922
2933
|
idx = (idx + 1) % options.length;
|
|
1923
2934
|
draw(true);
|
|
2935
|
+
} else if (key?.name === "tab") {
|
|
2936
|
+
finish("deny_with_reason");
|
|
1924
2937
|
} else if (key?.name === "return" || key?.name === "enter") {
|
|
1925
2938
|
finish(options[idx].value);
|
|
1926
2939
|
} else {
|
|
@@ -2041,11 +3054,39 @@ ${C.cyan}\u2503${C.reset} ${question}
|
|
|
2041
3054
|
}
|
|
2042
3055
|
};
|
|
2043
3056
|
|
|
3057
|
+
// src/history.ts
|
|
3058
|
+
var HISTORY_KEY = "input_history";
|
|
3059
|
+
var MAX_ENTRIES = 100;
|
|
3060
|
+
function clean(value) {
|
|
3061
|
+
if (!Array.isArray(value)) return [];
|
|
3062
|
+
return value.filter((e) => typeof e === "string" && e.trim() !== "").slice(-MAX_ENTRIES);
|
|
3063
|
+
}
|
|
3064
|
+
async function loadHistory(store, threadId, seedThreadId) {
|
|
3065
|
+
const own = clean(await store.kvGet(threadId, HISTORY_KEY));
|
|
3066
|
+
if (own.length) return own;
|
|
3067
|
+
if (seedThreadId && seedThreadId !== threadId) {
|
|
3068
|
+
const seeded = clean(await store.kvGet(seedThreadId, HISTORY_KEY));
|
|
3069
|
+
if (seeded.length) {
|
|
3070
|
+
void store.kvSet(threadId, HISTORY_KEY, seeded);
|
|
3071
|
+
return seeded;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
return [];
|
|
3075
|
+
}
|
|
3076
|
+
function appendHistory(store, threadId, history, text) {
|
|
3077
|
+
const t = text.trim();
|
|
3078
|
+
if (!t || history[history.length - 1] === t) return history;
|
|
3079
|
+
history.push(t);
|
|
3080
|
+
if (history.length > MAX_ENTRIES) history.splice(0, history.length - MAX_ENTRIES);
|
|
3081
|
+
void store.kvSet(threadId, HISTORY_KEY, [...history]);
|
|
3082
|
+
return history;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
2044
3085
|
// src/markdown.ts
|
|
2045
3086
|
var ESC = "\x1B[";
|
|
2046
3087
|
var R = ESC + "0m";
|
|
2047
3088
|
var BOLD = ESC + "1m";
|
|
2048
|
-
var
|
|
3089
|
+
var DIM2 = ESC + "2m";
|
|
2049
3090
|
var ITAL = ESC + "3m";
|
|
2050
3091
|
var UNDER = ESC + "4m";
|
|
2051
3092
|
var TEAL = ESC + "38;5;37m";
|
|
@@ -2067,11 +3108,11 @@ function inline(s) {
|
|
|
2067
3108
|
});
|
|
2068
3109
|
s = s.replace(
|
|
2069
3110
|
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
|
2070
|
-
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${
|
|
3111
|
+
(_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM2}${url}${R}`
|
|
2071
3112
|
);
|
|
2072
3113
|
s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
|
|
2073
3114
|
s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
|
|
2074
|
-
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${
|
|
3115
|
+
s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM2}${t}${R}`);
|
|
2075
3116
|
s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
|
|
2076
3117
|
return s;
|
|
2077
3118
|
}
|
|
@@ -2098,8 +3139,8 @@ function wrapStyled(text, width) {
|
|
|
2098
3139
|
if (cur !== "" || lines.length === 0) lines.push(cur);
|
|
2099
3140
|
return lines;
|
|
2100
3141
|
}
|
|
2101
|
-
function wrapBlock(out,
|
|
2102
|
-
const wrapped = wrapStyled(text, Math.max(8,
|
|
3142
|
+
function wrapBlock(out, cols2, leadFirst, leadRest, leadWidth, text) {
|
|
3143
|
+
const wrapped = wrapStyled(text, Math.max(8, cols2 - leadWidth));
|
|
2103
3144
|
wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
|
|
2104
3145
|
}
|
|
2105
3146
|
function tableCells(row) {
|
|
@@ -2113,16 +3154,16 @@ function isTableSeparator(line) {
|
|
|
2113
3154
|
return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
|
|
2114
3155
|
}
|
|
2115
3156
|
function renderTable(rows) {
|
|
2116
|
-
const
|
|
3157
|
+
const cols2 = Math.max(...rows.map((r) => r.length));
|
|
2117
3158
|
const widths = [];
|
|
2118
|
-
for (let c2 = 0; c2 <
|
|
3159
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
2119
3160
|
widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
|
|
2120
3161
|
}
|
|
2121
3162
|
const sep = `${GRAY} \u2502 ${R}`;
|
|
2122
3163
|
const out = [];
|
|
2123
3164
|
rows.forEach((r, ri) => {
|
|
2124
3165
|
const cells = [];
|
|
2125
|
-
for (let c2 = 0; c2 <
|
|
3166
|
+
for (let c2 = 0; c2 < cols2; c2++) {
|
|
2126
3167
|
const raw = r[c2] ?? "";
|
|
2127
3168
|
const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
|
|
2128
3169
|
cells.push(padEndVisible(styled, widths[c2]));
|
|
@@ -2135,7 +3176,7 @@ function renderTable(rows) {
|
|
|
2135
3176
|
});
|
|
2136
3177
|
return out;
|
|
2137
3178
|
}
|
|
2138
|
-
function renderMarkdown(src,
|
|
3179
|
+
function renderMarkdown(src, cols2 = 80) {
|
|
2139
3180
|
const lines = src.replace(/\r\n/g, "\n").split("\n");
|
|
2140
3181
|
const out = [];
|
|
2141
3182
|
let inFence = false;
|
|
@@ -2164,7 +3205,7 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2164
3205
|
}
|
|
2165
3206
|
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
2166
3207
|
if (heading) {
|
|
2167
|
-
for (const ln of wrapStyled(heading[2].trim(),
|
|
3208
|
+
for (const ln of wrapStyled(heading[2].trim(), cols2)) out.push(`${BOLD}${TEAL}${ln}${R}`);
|
|
2168
3209
|
i++;
|
|
2169
3210
|
continue;
|
|
2170
3211
|
}
|
|
@@ -2175,8 +3216,8 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2175
3216
|
}
|
|
2176
3217
|
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
2177
3218
|
if (quote) {
|
|
2178
|
-
for (const ln of wrapStyled(inline(quote[1]), Math.max(8,
|
|
2179
|
-
out.push(`${GRAY}\u2502${R} ${
|
|
3219
|
+
for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols2 - 2))) {
|
|
3220
|
+
out.push(`${GRAY}\u2502${R} ${DIM2}${ln}${R}`);
|
|
2180
3221
|
}
|
|
2181
3222
|
i++;
|
|
2182
3223
|
continue;
|
|
@@ -2184,7 +3225,7 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2184
3225
|
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
2185
3226
|
if (bullet) {
|
|
2186
3227
|
const leadWidth = bullet[1].length + 2;
|
|
2187
|
-
wrapBlock(out,
|
|
3228
|
+
wrapBlock(out, cols2, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
|
|
2188
3229
|
i++;
|
|
2189
3230
|
continue;
|
|
2190
3231
|
}
|
|
@@ -2192,11 +3233,11 @@ function renderMarkdown(src, cols = 80) {
|
|
|
2192
3233
|
if (numbered) {
|
|
2193
3234
|
const marker = `${numbered[2]}${numbered[3]}`;
|
|
2194
3235
|
const leadWidth = numbered[1].length + marker.length + 1;
|
|
2195
|
-
wrapBlock(out,
|
|
3236
|
+
wrapBlock(out, cols2, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
|
|
2196
3237
|
i++;
|
|
2197
3238
|
continue;
|
|
2198
3239
|
}
|
|
2199
|
-
if (line.trim()) wrapBlock(out,
|
|
3240
|
+
if (line.trim()) wrapBlock(out, cols2, "", "", 0, inline(line));
|
|
2200
3241
|
else out.push("");
|
|
2201
3242
|
i++;
|
|
2202
3243
|
}
|
|
@@ -2307,7 +3348,7 @@ ${stderrTail.trim()}` : msg;
|
|
|
2307
3348
|
target,
|
|
2308
3349
|
argsSha256: sha256(canonicalJson(args)),
|
|
2309
3350
|
resultSha256: sha256(text),
|
|
2310
|
-
nonce:
|
|
3351
|
+
nonce: crypto.randomBytes(8).toString("hex"),
|
|
2311
3352
|
isError,
|
|
2312
3353
|
at: Date.now()
|
|
2313
3354
|
};
|
|
@@ -2577,10 +3618,10 @@ function sortKeys(value) {
|
|
|
2577
3618
|
return value;
|
|
2578
3619
|
}
|
|
2579
3620
|
function sha256(input2) {
|
|
2580
|
-
return
|
|
3621
|
+
return crypto.createHash("sha256").update(input2).digest("hex");
|
|
2581
3622
|
}
|
|
2582
|
-
var
|
|
2583
|
-
var
|
|
3623
|
+
var DIR = path3.join(os6.homedir(), ".standardagents");
|
|
3624
|
+
var FILE = path3.join(DIR, "credentials");
|
|
2584
3625
|
function normalizeEndpoint(endpoint) {
|
|
2585
3626
|
let e = endpoint.trim();
|
|
2586
3627
|
if (!/^https?:\/\//i.test(e)) e = "http://" + e;
|
|
@@ -2588,7 +3629,7 @@ function normalizeEndpoint(endpoint) {
|
|
|
2588
3629
|
}
|
|
2589
3630
|
function loadCredentials() {
|
|
2590
3631
|
try {
|
|
2591
|
-
const raw =
|
|
3632
|
+
const raw = fs4.readFileSync(FILE, "utf8");
|
|
2592
3633
|
const parsed = JSON.parse(raw);
|
|
2593
3634
|
if (!parsed.instances) parsed.instances = {};
|
|
2594
3635
|
return parsed;
|
|
@@ -2600,21 +3641,33 @@ function getCredential(endpoint) {
|
|
|
2600
3641
|
const creds = loadCredentials();
|
|
2601
3642
|
return creds.instances[normalizeEndpoint(endpoint)] ?? null;
|
|
2602
3643
|
}
|
|
2603
|
-
function saveCredential(cred) {
|
|
3644
|
+
function saveCredential(cred, options = {}) {
|
|
2604
3645
|
const creds = loadCredentials();
|
|
2605
3646
|
const endpoint = normalizeEndpoint(cred.endpoint);
|
|
2606
3647
|
creds.instances[endpoint] = { ...cred, endpoint };
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
3648
|
+
if (options.updateDefault ?? true) {
|
|
3649
|
+
creds.default_endpoint = endpoint;
|
|
3650
|
+
}
|
|
3651
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3652
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
2610
3653
|
try {
|
|
2611
|
-
|
|
3654
|
+
fs4.chmodSync(FILE, 384);
|
|
2612
3655
|
} catch {
|
|
2613
3656
|
}
|
|
2614
3657
|
}
|
|
2615
3658
|
function defaultEndpoint() {
|
|
2616
3659
|
return loadCredentials().default_endpoint ?? null;
|
|
2617
3660
|
}
|
|
3661
|
+
function saveDefaultEndpoint(endpoint) {
|
|
3662
|
+
const creds = loadCredentials();
|
|
3663
|
+
creds.default_endpoint = normalizeEndpoint(endpoint);
|
|
3664
|
+
fs4.mkdirSync(DIR, { recursive: true });
|
|
3665
|
+
fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
3666
|
+
try {
|
|
3667
|
+
fs4.chmodSync(FILE, 384);
|
|
3668
|
+
} catch {
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
2618
3671
|
|
|
2619
3672
|
// src/index.ts
|
|
2620
3673
|
var AGENT_ID = "standard_code_agent";
|
|
@@ -2643,13 +3696,86 @@ var LOGO_MARK = [
|
|
|
2643
3696
|
"\u2588\u2588 \u2588\u2588\u2588",
|
|
2644
3697
|
"\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
|
|
2645
3698
|
];
|
|
3699
|
+
function printUsage() {
|
|
3700
|
+
stdout.write(
|
|
3701
|
+
[
|
|
3702
|
+
"",
|
|
3703
|
+
`${c.bold}Usage${c.reset}`,
|
|
3704
|
+
" standardcode [options] [dir]",
|
|
3705
|
+
"",
|
|
3706
|
+
`${c.bold}Options${c.reset}`,
|
|
3707
|
+
" -e, --endpoint [url] Use a Standard Agents instance for this run only.",
|
|
3708
|
+
" If url is omitted, prompt for it.",
|
|
3709
|
+
" Credentials are remembered for that endpoint, but",
|
|
3710
|
+
" the saved default endpoint is not changed.",
|
|
3711
|
+
" -h, --help Show this help.",
|
|
3712
|
+
""
|
|
3713
|
+
].join("\n")
|
|
3714
|
+
);
|
|
3715
|
+
}
|
|
3716
|
+
function parseArgs2(args) {
|
|
3717
|
+
const parsed = { help: false, promptEndpoint: false };
|
|
3718
|
+
for (let i = 0; i < args.length; i++) {
|
|
3719
|
+
const arg = args[i];
|
|
3720
|
+
if (arg === "--help" || arg === "-h") {
|
|
3721
|
+
parsed.help = true;
|
|
3722
|
+
continue;
|
|
3723
|
+
}
|
|
3724
|
+
if (arg === "--endpoint" || arg === "-e") {
|
|
3725
|
+
const value = args[i + 1];
|
|
3726
|
+
if (value && !value.startsWith("-")) {
|
|
3727
|
+
parsed.endpoint = value;
|
|
3728
|
+
i++;
|
|
3729
|
+
} else {
|
|
3730
|
+
parsed.promptEndpoint = true;
|
|
3731
|
+
}
|
|
3732
|
+
continue;
|
|
3733
|
+
}
|
|
3734
|
+
if (arg.startsWith("--endpoint=")) {
|
|
3735
|
+
const value = arg.slice("--endpoint=".length);
|
|
3736
|
+
if (value) {
|
|
3737
|
+
parsed.endpoint = value;
|
|
3738
|
+
} else {
|
|
3739
|
+
parsed.promptEndpoint = true;
|
|
3740
|
+
}
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
if (arg === "--") {
|
|
3744
|
+
if (i === 0 && args[i + 1]?.startsWith("-")) continue;
|
|
3745
|
+
const rest = args.slice(i + 1);
|
|
3746
|
+
if (rest.length > 1) throw new Error("Expected at most one project directory.");
|
|
3747
|
+
if (rest[0]) parsed.dir = rest[0];
|
|
3748
|
+
break;
|
|
3749
|
+
}
|
|
3750
|
+
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
|
3751
|
+
if (parsed.dir) throw new Error("Expected at most one project directory.");
|
|
3752
|
+
parsed.dir = arg;
|
|
3753
|
+
}
|
|
3754
|
+
return parsed;
|
|
3755
|
+
}
|
|
2646
3756
|
function printAssistant(tui, text) {
|
|
2647
|
-
const
|
|
3757
|
+
const cols2 = Math.max(20, (process.stdout.columns || 80) - 3);
|
|
3758
|
+
tui.clearStream();
|
|
2648
3759
|
tui.print("");
|
|
2649
|
-
|
|
3760
|
+
let dotted = false;
|
|
3761
|
+
for (const line of renderMarkdown(text, cols2)) {
|
|
3762
|
+
if (!dotted && line.trim()) {
|
|
3763
|
+
tui.print(`${c.gray}\u2022${c.reset} ${line}`);
|
|
3764
|
+
dotted = true;
|
|
3765
|
+
} else {
|
|
3766
|
+
tui.print(` ${line}`);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
2650
3769
|
tui.print("");
|
|
2651
3770
|
}
|
|
2652
|
-
function farewell() {
|
|
3771
|
+
function farewell(stoppedProcs = 0) {
|
|
3772
|
+
if (stoppedProcs > 0) {
|
|
3773
|
+
stdout.write(
|
|
3774
|
+
`
|
|
3775
|
+
${c.cyan}\u2699${c.reset} Stopped ${stoppedProcs} background process${stoppedProcs === 1 ? "" : "es"}.
|
|
3776
|
+
`
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
2653
3779
|
stdout.write(`
|
|
2654
3780
|
${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
|
|
2655
3781
|
`);
|
|
@@ -2676,14 +3802,14 @@ function relaxTlsForLocalEndpoint(endpoint) {
|
|
|
2676
3802
|
}
|
|
2677
3803
|
function readVersion() {
|
|
2678
3804
|
try {
|
|
2679
|
-
const pkg = JSON.parse(
|
|
3805
|
+
const pkg = JSON.parse(fs4.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
2680
3806
|
return typeof pkg.version === "string" ? pkg.version : "";
|
|
2681
3807
|
} catch {
|
|
2682
3808
|
return "";
|
|
2683
3809
|
}
|
|
2684
3810
|
}
|
|
2685
3811
|
function printWelcome(endpoint, projectDir) {
|
|
2686
|
-
const home =
|
|
3812
|
+
const home = os6.homedir();
|
|
2687
3813
|
const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2688
3814
|
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2689
3815
|
const version = readVersion();
|
|
@@ -2713,19 +3839,41 @@ function colorActivity(line) {
|
|
|
2713
3839
|
const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
|
|
2714
3840
|
return `${indent}${c.green}\u2713${c.reset} ${body}`;
|
|
2715
3841
|
}
|
|
2716
|
-
if (glyph === "\u2717")
|
|
3842
|
+
if (glyph === "\u2717") {
|
|
3843
|
+
const ERR_MAX_LINES = 7;
|
|
3844
|
+
const lines = rest.split("\n");
|
|
3845
|
+
const shown = lines.slice(0, ERR_MAX_LINES);
|
|
3846
|
+
const hidden = lines.length - shown.length;
|
|
3847
|
+
const body = shown.map(
|
|
3848
|
+
(l, i) => i === 0 ? `${indent}${c.red}\u2717 ${l}${c.reset}` : `${indent}${c.red}${c.dim}${l}${c.reset}`
|
|
3849
|
+
).join("\n");
|
|
3850
|
+
if (hidden > 0) {
|
|
3851
|
+
return `${body}
|
|
3852
|
+
${indent}${c.dim}\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${c.reset}`;
|
|
3853
|
+
}
|
|
3854
|
+
return body;
|
|
3855
|
+
}
|
|
2717
3856
|
return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
|
|
2718
3857
|
}
|
|
2719
3858
|
async function main() {
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
3859
|
+
let cliArgs;
|
|
3860
|
+
try {
|
|
3861
|
+
cliArgs = parseArgs2(process.argv.slice(2));
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
stdout.write(`${c.red}error:${c.reset} ${error instanceof Error ? error.message : String(error)}
|
|
3864
|
+
`);
|
|
3865
|
+
printUsage();
|
|
3866
|
+
process.exit(1);
|
|
2726
3867
|
}
|
|
3868
|
+
if (cliArgs.help) {
|
|
3869
|
+
printUsage();
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
const endpointArg = cliArgs.endpoint;
|
|
3873
|
+
const endpointOverride = cliArgs.promptEndpoint || typeof endpointArg === "string" && endpointArg.trim() !== "";
|
|
3874
|
+
const dirArg = cliArgs.dir;
|
|
2727
3875
|
const projectDir = path3.resolve(dirArg || process.cwd());
|
|
2728
|
-
const machine =
|
|
3876
|
+
const machine = os6.hostname();
|
|
2729
3877
|
const reader = { rl: null };
|
|
2730
3878
|
let handoffClosing = false;
|
|
2731
3879
|
let preflightArmed = false;
|
|
@@ -2758,13 +3906,22 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2758
3906
|
}
|
|
2759
3907
|
return reader.rl.question(question);
|
|
2760
3908
|
};
|
|
3909
|
+
const askEndpoint = async () => {
|
|
3910
|
+
for (; ; ) {
|
|
3911
|
+
const answer = (await ask(
|
|
3912
|
+
`${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
|
|
3913
|
+
)).trim();
|
|
3914
|
+
if (answer) return answer;
|
|
3915
|
+
stdout.write(`${c.dim}An endpoint URL is required.${c.reset}
|
|
3916
|
+
`);
|
|
3917
|
+
}
|
|
3918
|
+
};
|
|
2761
3919
|
process.on("SIGINT", onPreflightSigint);
|
|
2762
|
-
let
|
|
3920
|
+
let endpointPrompted = false;
|
|
3921
|
+
let endpoint = endpointArg || (cliArgs.promptEndpoint ? "" : defaultEndpoint() || "");
|
|
2763
3922
|
if (!endpoint) {
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
);
|
|
2767
|
-
endpoint = answer.trim();
|
|
3923
|
+
endpoint = await askEndpoint();
|
|
3924
|
+
endpointPrompted = true;
|
|
2768
3925
|
}
|
|
2769
3926
|
endpoint = normalizeEndpoint(endpoint);
|
|
2770
3927
|
const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
|
|
@@ -2779,22 +3936,43 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2779
3936
|
if (!api || !await api.verify()) {
|
|
2780
3937
|
const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
2781
3938
|
stdout.write(
|
|
2782
|
-
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014
|
|
3939
|
+
`${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 connect to${c.reset} ${c.teal}${host}${c.reset}
|
|
2783
3940
|
`
|
|
2784
3941
|
);
|
|
2785
|
-
stdout.write(
|
|
3942
|
+
stdout.write(
|
|
3943
|
+
`${c.dim}Press Enter to sign in with your browser, or paste an API token.${c.reset}
|
|
2786
3944
|
|
|
2787
|
-
`
|
|
3945
|
+
`
|
|
3946
|
+
);
|
|
2788
3947
|
for (; ; ) {
|
|
2789
|
-
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token${c.reset} `)).trim();
|
|
3948
|
+
const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token (or Enter for browser)${c.reset} `)).trim();
|
|
2790
3949
|
if (!token) {
|
|
2791
|
-
|
|
3950
|
+
const got = await deviceLogin(endpoint).catch((e) => {
|
|
3951
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}${e instanceof Error ? e.message : String(e)}${c.reset}
|
|
3952
|
+
`);
|
|
3953
|
+
return null;
|
|
3954
|
+
});
|
|
3955
|
+
if (!got) continue;
|
|
3956
|
+
api = new ApiClient(endpoint, got);
|
|
3957
|
+
if (await api.verify()) {
|
|
3958
|
+
saveCredential(
|
|
3959
|
+
{ endpoint, access_token: got, token_type: "Bearer", saved_at: Date.now() },
|
|
3960
|
+
{ updateDefault: !endpointOverride }
|
|
3961
|
+
);
|
|
3962
|
+
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
3963
|
+
`);
|
|
3964
|
+
break;
|
|
3965
|
+
}
|
|
3966
|
+
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}Browser sign-in didn't verify. Try again.${c.reset}
|
|
2792
3967
|
`);
|
|
2793
3968
|
continue;
|
|
2794
3969
|
}
|
|
2795
3970
|
api = new ApiClient(endpoint, token);
|
|
2796
3971
|
if (await api.verify()) {
|
|
2797
|
-
saveCredential(
|
|
3972
|
+
saveCredential(
|
|
3973
|
+
{ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() },
|
|
3974
|
+
{ updateDefault: !endpointOverride }
|
|
3975
|
+
);
|
|
2798
3976
|
stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
|
|
2799
3977
|
`);
|
|
2800
3978
|
break;
|
|
@@ -2802,6 +3980,8 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2802
3980
|
stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
|
|
2803
3981
|
`);
|
|
2804
3982
|
}
|
|
3983
|
+
} else if (endpointPrompted) {
|
|
3984
|
+
saveDefaultEndpoint(endpoint);
|
|
2805
3985
|
}
|
|
2806
3986
|
if (!api) process.exit(1);
|
|
2807
3987
|
handoffClosing = true;
|
|
@@ -2816,6 +3996,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2816
3996
|
const tui = new Tui(1);
|
|
2817
3997
|
let threadId;
|
|
2818
3998
|
let resumed = false;
|
|
3999
|
+
let historySeed;
|
|
2819
4000
|
if (existing.length > 0) {
|
|
2820
4001
|
const summaries = await summarizeThreads(api, existing.slice(0, 8));
|
|
2821
4002
|
const items = summaries.map((s) => ({
|
|
@@ -2824,7 +4005,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2824
4005
|
value: s.id
|
|
2825
4006
|
}));
|
|
2826
4007
|
items.push({ label: "\uFF0B Start a new session", value: null });
|
|
2827
|
-
const home =
|
|
4008
|
+
const home = os6.homedir();
|
|
2828
4009
|
const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
|
|
2829
4010
|
const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
|
|
2830
4011
|
const picked = await tui.select(
|
|
@@ -2836,11 +4017,12 @@ ${c.dim}Press Control-C again to exit${c.reset}
|
|
|
2836
4017
|
resumed = true;
|
|
2837
4018
|
} else {
|
|
2838
4019
|
threadId = await api.createThread(AGENT_ID, tags);
|
|
4020
|
+
historySeed = existing[0]?.id;
|
|
2839
4021
|
}
|
|
2840
4022
|
} else {
|
|
2841
4023
|
threadId = await api.createThread(AGENT_ID, tags);
|
|
2842
4024
|
}
|
|
2843
|
-
await runInteractive(tui, api, threadId, projectDir, machine, resumed);
|
|
4025
|
+
await runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeed);
|
|
2844
4026
|
}
|
|
2845
4027
|
async function summarizeThreads(api, threads) {
|
|
2846
4028
|
return Promise.all(
|
|
@@ -2859,13 +4041,34 @@ async function summarizeThreads(api, threads) {
|
|
|
2859
4041
|
})
|
|
2860
4042
|
);
|
|
2861
4043
|
}
|
|
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();
|
|
4044
|
+
function subagentLabel(s, titles) {
|
|
4045
|
+
const agentName = (s.agent_name || "").trim();
|
|
4046
|
+
const title = (s.title || "").trim() || titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
|
|
4047
|
+
const tagged = (s.threadName || "").trim();
|
|
2867
4048
|
return tagged ? `${title} \xB7 ${tagged}` : title;
|
|
2868
4049
|
}
|
|
4050
|
+
async function deviceLogin(endpoint) {
|
|
4051
|
+
const start = await fetch(`${endpoint}/api/auth/device/start`, { method: "POST" });
|
|
4052
|
+
if (!start.ok) throw new Error(`This instance does not support browser sign-in (HTTP ${start.status}). Paste an API token instead.`);
|
|
4053
|
+
const info = await start.json();
|
|
4054
|
+
stdout.write(`${c.dim}Opening your browser to approve this sign-in\u2026${c.reset}
|
|
4055
|
+
`);
|
|
4056
|
+
stdout.write(`${c.dim}If it doesn't open, visit:${c.reset} ${c.teal}${info.verify_url}${c.reset}
|
|
4057
|
+
`);
|
|
4058
|
+
openUrl(info.verify_url);
|
|
4059
|
+
const deadline = Date.now() + (info.expires_in ?? 600) * 1e3;
|
|
4060
|
+
const interval = Math.max(2, info.interval ?? 2) * 1e3;
|
|
4061
|
+
while (Date.now() < deadline) {
|
|
4062
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
4063
|
+
const res = await fetch(info.poll_url).catch(() => null);
|
|
4064
|
+
if (!res) continue;
|
|
4065
|
+
if (res.status === 404) throw new Error("The sign-in link expired. Try again.");
|
|
4066
|
+
const body = await res.json().catch(() => ({}));
|
|
4067
|
+
if (body.status === "approved" && body.token) return body.token;
|
|
4068
|
+
if (body.status === "denied") throw new Error("Sign-in was denied in the browser.");
|
|
4069
|
+
}
|
|
4070
|
+
throw new Error("Timed out waiting for browser approval. Try again.");
|
|
4071
|
+
}
|
|
2869
4072
|
function openUrl(url) {
|
|
2870
4073
|
const platform = process.platform;
|
|
2871
4074
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
@@ -2926,14 +4129,13 @@ async function printHistory(api, threadId, tui) {
|
|
|
2926
4129
|
if (m.role === "user") tui.printUserMessage(text);
|
|
2927
4130
|
else printAssistant(tui, text);
|
|
2928
4131
|
}
|
|
2929
|
-
tui.print(`${c.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.reset}`);
|
|
2930
4132
|
}
|
|
2931
|
-
async function runInteractive(tui, api, threadId, projectDir, machine, resumed) {
|
|
4133
|
+
async function runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeedThreadId) {
|
|
2932
4134
|
const registry = new ProcessRegistry(api, threadId, machine);
|
|
2933
4135
|
const mcp = new McpManager(projectDir);
|
|
2934
4136
|
const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
|
|
2935
4137
|
});
|
|
2936
|
-
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
|
|
4138
|
+
const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog, api);
|
|
2937
4139
|
const refreshBgCount = () => {
|
|
2938
4140
|
void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
|
|
2939
4141
|
});
|
|
@@ -2950,11 +4152,13 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2950
4152
|
perm.level = l;
|
|
2951
4153
|
saveApprovals(api, threadId, perm);
|
|
2952
4154
|
});
|
|
4155
|
+
saveApprovals(api, threadId, perm);
|
|
2953
4156
|
let busy = false;
|
|
2954
4157
|
let interrupting = false;
|
|
2955
4158
|
const queued = [];
|
|
2956
4159
|
let editingQueued = false;
|
|
2957
4160
|
const shownIds = /* @__PURE__ */ new Set();
|
|
4161
|
+
const pendingSent = /* @__PURE__ */ new Map();
|
|
2958
4162
|
let tokensIn = 0;
|
|
2959
4163
|
let tokensOut = 0;
|
|
2960
4164
|
let liveOut = 0;
|
|
@@ -2967,13 +4171,19 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2967
4171
|
tui.setStep(label, liveOut);
|
|
2968
4172
|
};
|
|
2969
4173
|
const bridge = new Bridge(api, threadId, host, perm, {
|
|
2970
|
-
onActivity: (line) => {
|
|
4174
|
+
onActivity: (line, detail) => {
|
|
2971
4175
|
tui.print(colorActivity(line));
|
|
4176
|
+
if (detail) for (const d of detail) tui.print(d);
|
|
2972
4177
|
refreshBgCount();
|
|
2973
4178
|
},
|
|
2974
|
-
onStatus: () => {
|
|
4179
|
+
onStatus: (id, summary) => {
|
|
4180
|
+
if (summary) {
|
|
4181
|
+
if (!activeSteps.has(id)) activeSteps.set(id, summary);
|
|
4182
|
+
} else {
|
|
4183
|
+
activeSteps.delete(id);
|
|
4184
|
+
}
|
|
4185
|
+
refreshStatus();
|
|
2975
4186
|
},
|
|
2976
|
-
// the working indicator is driven by the busy poller
|
|
2977
4187
|
onConnection: (state, attempt) => {
|
|
2978
4188
|
if (state === "reconnecting") {
|
|
2979
4189
|
if (attempt >= 4) tui.setConnected(false);
|
|
@@ -2981,15 +4191,27 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
|
|
|
2981
4191
|
tui.setConnected(true);
|
|
2982
4192
|
}
|
|
2983
4193
|
},
|
|
2984
|
-
requestApproval: (req, summary, risk) =>
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
4194
|
+
requestApproval: async (req, summary, risk) => {
|
|
4195
|
+
const choice = await tui.approval(
|
|
4196
|
+
`${summary}${req.requestPermission ? `
|
|
4197
|
+
${c.bold}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
4198
|
+
risk
|
|
4199
|
+
);
|
|
4200
|
+
if (choice === "deny_with_reason") {
|
|
4201
|
+
const reason = await tui.prompt(
|
|
4202
|
+
"Why are you denying this? (sent to the agent \u2014 enter to send, esc to skip)"
|
|
4203
|
+
);
|
|
4204
|
+
return { choice: "deny", reason: reason ?? void 0 };
|
|
4205
|
+
}
|
|
4206
|
+
return { choice };
|
|
4207
|
+
}
|
|
2989
4208
|
});
|
|
2990
4209
|
const stream = new MessageStream(api, threadId, {
|
|
2991
|
-
|
|
2992
|
-
|
|
4210
|
+
// Live streaming preview: answer text and (opt-in) internal reasoning feed
|
|
4211
|
+
// the TUI's ephemeral preview; the committed message still renders from
|
|
4212
|
+
// polling, which calls tui.clearStream() first so there's no double-render.
|
|
4213
|
+
onChunk: (text, mid) => tui.streamResponseDelta(text, mid),
|
|
4214
|
+
onReasoningChunk: (text, mid) => tui.streamThinkingDelta(text, mid),
|
|
2993
4215
|
onAssistantText: () => {
|
|
2994
4216
|
},
|
|
2995
4217
|
onEvent: (eventType, data) => {
|
|
@@ -3002,6 +4224,8 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3002
4224
|
} else if (eventType === "tool_call_done" && data?.id) {
|
|
3003
4225
|
activeSteps.delete(data.id);
|
|
3004
4226
|
refreshStatus();
|
|
4227
|
+
} else if (eventType === "goal_updated" && data) {
|
|
4228
|
+
tui.setGoal(data);
|
|
3005
4229
|
}
|
|
3006
4230
|
},
|
|
3007
4231
|
onError: () => {
|
|
@@ -3009,33 +4233,69 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3009
4233
|
});
|
|
3010
4234
|
const activeSubagents = /* @__PURE__ */ new Map();
|
|
3011
4235
|
const agentTitles = /* @__PURE__ */ new Map();
|
|
3012
|
-
|
|
4236
|
+
const agentTitlesReady = api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
|
|
3013
4237
|
});
|
|
3014
|
-
const pushSubagents = () => tui.setSubagents(
|
|
4238
|
+
const pushSubagents = () => tui.setSubagents(
|
|
4239
|
+
[...activeSubagents.entries()].map(([id, s]) => ({ id, label: s.label, agentName: s.agentName }))
|
|
4240
|
+
);
|
|
4241
|
+
const reconcileSubagents = async () => {
|
|
4242
|
+
try {
|
|
4243
|
+
await agentTitlesReady;
|
|
4244
|
+
const subs = await api.listSubagents(threadId);
|
|
4245
|
+
activeSubagents.clear();
|
|
4246
|
+
for (const s of subs) {
|
|
4247
|
+
if (s.status !== "running") continue;
|
|
4248
|
+
activeSubagents.set(s.id, {
|
|
4249
|
+
label: subagentLabel(s, agentTitles),
|
|
4250
|
+
agentName: s.agent_name ?? void 0
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
pushSubagents();
|
|
4254
|
+
} catch {
|
|
4255
|
+
}
|
|
4256
|
+
};
|
|
4257
|
+
let reconcileTimer = null;
|
|
4258
|
+
let reconcilePending = false;
|
|
4259
|
+
const scheduleReconcile = () => {
|
|
4260
|
+
if (reconcileTimer) {
|
|
4261
|
+
reconcilePending = true;
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
reconcileTimer = setTimeout(async () => {
|
|
4265
|
+
reconcileTimer = null;
|
|
4266
|
+
await reconcileSubagents();
|
|
4267
|
+
if (reconcilePending) {
|
|
4268
|
+
reconcilePending = false;
|
|
4269
|
+
scheduleReconcile();
|
|
4270
|
+
}
|
|
4271
|
+
}, 150);
|
|
4272
|
+
};
|
|
3015
4273
|
const events = new SystemEvents(api, {
|
|
4274
|
+
onOpen: () => scheduleReconcile(),
|
|
3016
4275
|
onThreadCreated: (t) => {
|
|
3017
|
-
if (t.parent === threadId
|
|
3018
|
-
activeSubagents.set(t.id, subagentLabel(t, agentTitles));
|
|
3019
|
-
pushSubagents();
|
|
3020
|
-
}
|
|
4276
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
3021
4277
|
},
|
|
3022
4278
|
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();
|
|
4279
|
+
if (t.parent === threadId) scheduleReconcile();
|
|
3027
4280
|
},
|
|
3028
4281
|
onThreadDeleted: (id) => {
|
|
3029
|
-
if (activeSubagents.
|
|
4282
|
+
if (activeSubagents.has(id)) scheduleReconcile();
|
|
3030
4283
|
}
|
|
3031
4284
|
});
|
|
3032
|
-
const quit = () => {
|
|
4285
|
+
const quit = async () => {
|
|
3033
4286
|
tui.end();
|
|
4287
|
+
const stopped = api.stop(threadId).catch(() => {
|
|
4288
|
+
});
|
|
4289
|
+
const procsStopped = host.stopAllLocalProcesses().catch(() => 0);
|
|
3034
4290
|
bridge.close();
|
|
3035
4291
|
stream.close();
|
|
3036
4292
|
events.close();
|
|
3037
4293
|
mcp.closeAll();
|
|
3038
|
-
|
|
4294
|
+
const [, killed] = await Promise.race([
|
|
4295
|
+
Promise.all([stopped, procsStopped]),
|
|
4296
|
+
new Promise((r) => setTimeout(() => r([void 0, 0]), 1500))
|
|
4297
|
+
]);
|
|
4298
|
+
farewell(killed);
|
|
3039
4299
|
process.exit(0);
|
|
3040
4300
|
};
|
|
3041
4301
|
tui.setQuitHandler(quit);
|
|
@@ -3087,11 +4347,18 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3087
4347
|
},
|
|
3088
4348
|
setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
|
|
3089
4349
|
};
|
|
3090
|
-
const
|
|
4350
|
+
const extFor = (mime) => ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" })[mime] ?? "bin";
|
|
4351
|
+
const toAttachments = (images) => images.map((img) => ({ name: `image-${img.seq}.${extFor(img.mime)}`, mimeType: img.mime, data: img.data }));
|
|
4352
|
+
const sendNow = async (text, images = []) => {
|
|
3091
4353
|
tui.printUserMessage(text);
|
|
4354
|
+
const key = text.trim();
|
|
4355
|
+
pendingSent.set(key, (pendingSent.get(key) ?? 0) + 1);
|
|
3092
4356
|
try {
|
|
3093
|
-
await api.sendMessage(threadId, text);
|
|
4357
|
+
await api.sendMessage(threadId, text, toAttachments(images));
|
|
3094
4358
|
} catch (e) {
|
|
4359
|
+
const n = (pendingSent.get(key) ?? 1) - 1;
|
|
4360
|
+
if (n > 0) pendingSent.set(key, n);
|
|
4361
|
+
else pendingSent.delete(key);
|
|
3095
4362
|
tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
3096
4363
|
return;
|
|
3097
4364
|
}
|
|
@@ -3103,16 +4370,25 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3103
4370
|
if (!queued.length) return;
|
|
3104
4371
|
const toSend = queued.splice(0);
|
|
3105
4372
|
tui.setQueuedCount(0);
|
|
3106
|
-
for (const
|
|
4373
|
+
for (const q of toSend) await sendNow(q.text, q.images);
|
|
3107
4374
|
};
|
|
3108
4375
|
const requestCompaction = async () => {
|
|
3109
4376
|
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
|
-
);
|
|
4377
|
+
await api.compact(threadId);
|
|
3114
4378
|
} catch (err) {
|
|
3115
|
-
tui.print(`${c.red}\u2717${c.reset} couldn't
|
|
4379
|
+
tui.print(`${c.red}\u2717${c.reset} couldn't start compaction: ${err.message}`);
|
|
4380
|
+
}
|
|
4381
|
+
};
|
|
4382
|
+
const skillsCtl = {
|
|
4383
|
+
list: () => api.listSkills(),
|
|
4384
|
+
setEnabled: (name, enabled) => api.setSkillEnabled(name, enabled),
|
|
4385
|
+
remove: (name) => api.removeSkill(name),
|
|
4386
|
+
// Seed the request into the main chat — the agent researches or authors
|
|
4387
|
+
// the skill there (research_agent + install_skill), visible in the transcript.
|
|
4388
|
+
requestInstall: (query) => {
|
|
4389
|
+
void sendNow(
|
|
4390
|
+
`Install a skill for me: ${query}. Find the skill's published files (or author a proper SKILL.md from your research), install it with install_skill, then tell me what it can do.`
|
|
4391
|
+
);
|
|
3116
4392
|
}
|
|
3117
4393
|
};
|
|
3118
4394
|
tui.setCommands([
|
|
@@ -3141,25 +4417,34 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3141
4417
|
},
|
|
3142
4418
|
run: () => runMcpMenu(tui, mcpCtl)
|
|
3143
4419
|
},
|
|
4420
|
+
{
|
|
4421
|
+
name: "skills",
|
|
4422
|
+
label: "Agent skills",
|
|
4423
|
+
hint: "list / install / manage",
|
|
4424
|
+
run: () => runSkillsMenu(tui, skillsCtl)
|
|
4425
|
+
},
|
|
3144
4426
|
{ name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
|
|
3145
4427
|
{ name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
|
|
3146
4428
|
{ name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
|
|
3147
4429
|
{ name: "quit", label: "Quit", run: () => quit() }
|
|
3148
4430
|
]);
|
|
3149
|
-
|
|
4431
|
+
const history = await loadHistory(api, threadId, historySeedThreadId);
|
|
4432
|
+
tui.setHistory(history);
|
|
4433
|
+
tui.onSubmit = (text, images) => {
|
|
4434
|
+
appendHistory(api, threadId, history, text);
|
|
3150
4435
|
if (editingQueued) {
|
|
3151
4436
|
editingQueued = false;
|
|
3152
|
-
queued.push(text);
|
|
4437
|
+
queued.push({ text, images });
|
|
3153
4438
|
tui.setQueuedCount(queued.length);
|
|
3154
4439
|
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
|
|
3155
4440
|
return;
|
|
3156
4441
|
}
|
|
3157
4442
|
if (busy) {
|
|
3158
|
-
queued.push(text);
|
|
4443
|
+
queued.push({ text, images });
|
|
3159
4444
|
tui.setQueuedCount(queued.length);
|
|
3160
4445
|
tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
|
|
3161
4446
|
} else {
|
|
3162
|
-
void sendNow(text);
|
|
4447
|
+
void sendNow(text, images);
|
|
3163
4448
|
}
|
|
3164
4449
|
};
|
|
3165
4450
|
tui.onInterrupt = () => {
|
|
@@ -3180,19 +4465,21 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3180
4465
|
}
|
|
3181
4466
|
};
|
|
3182
4467
|
tui.onUpArrow = () => {
|
|
3183
|
-
if (tui.getInput().trim() || queued.length === 0) return;
|
|
3184
|
-
const
|
|
4468
|
+
if (tui.getInput().trim() || queued.length === 0) return false;
|
|
4469
|
+
const q = queued.pop();
|
|
3185
4470
|
tui.setQueuedCount(queued.length);
|
|
3186
4471
|
editingQueued = true;
|
|
3187
|
-
tui.setInput(text);
|
|
4472
|
+
tui.setInput(q.text, q.images);
|
|
4473
|
+
return true;
|
|
3188
4474
|
};
|
|
3189
4475
|
events.connect();
|
|
3190
4476
|
await Promise.all([bridge.connect(), stream.connect()]);
|
|
4477
|
+
void api.getGoal(threadId).then((g) => tui.setGoal(g)).catch(() => {
|
|
4478
|
+
});
|
|
3191
4479
|
tui.banner([
|
|
3192
4480
|
`${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
|
|
3193
4481
|
`${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}`
|
|
4482
|
+
`${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`
|
|
3196
4483
|
]);
|
|
3197
4484
|
if (resumed) await printHistory(api, threadId, tui);
|
|
3198
4485
|
try {
|
|
@@ -3232,6 +4519,15 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3232
4519
|
const text = messageText(m.content).trim();
|
|
3233
4520
|
if (m.role === "assistant" && text) printAssistant(tui, text);
|
|
3234
4521
|
else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
|
|
4522
|
+
else if (m.role === "user" && text) {
|
|
4523
|
+
const pending = pendingSent.get(text) ?? 0;
|
|
4524
|
+
if (pending > 0) {
|
|
4525
|
+
if (pending === 1) pendingSent.delete(text);
|
|
4526
|
+
else pendingSent.set(text, pending - 1);
|
|
4527
|
+
} else {
|
|
4528
|
+
tui.printUserMessage(text);
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
3235
4531
|
}
|
|
3236
4532
|
const polledBusy = threadBusy(msgs);
|
|
3237
4533
|
if (interrupting) {
|
|
@@ -3279,6 +4575,56 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
|
|
|
3279
4575
|
await new Promise(() => {
|
|
3280
4576
|
});
|
|
3281
4577
|
}
|
|
4578
|
+
async function runSkillsMenu(tui, skills) {
|
|
4579
|
+
let list;
|
|
4580
|
+
try {
|
|
4581
|
+
list = await skills.list();
|
|
4582
|
+
} catch (e) {
|
|
4583
|
+
tui.print(`${c.red}\u2717 couldn't load skills:${c.reset} ${c.gray}${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
const INSTALL = "__install__";
|
|
4587
|
+
const items = list.map((s) => ({
|
|
4588
|
+
label: s.name,
|
|
4589
|
+
hint: `${s.enabled ? "enabled" : "disabled"} \xB7 ${s.files.length} file${s.files.length === 1 ? "" : "s"}`,
|
|
4590
|
+
value: s.name
|
|
4591
|
+
}));
|
|
4592
|
+
items.push({ label: "\uFF0B Install a skill\u2026", hint: "find & install", value: INSTALL });
|
|
4593
|
+
const picked = await tui.select(
|
|
4594
|
+
`${c.bold}Agent skills${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
|
|
4595
|
+
items
|
|
4596
|
+
);
|
|
4597
|
+
if (!picked) return;
|
|
4598
|
+
if (picked === INSTALL) {
|
|
4599
|
+
const query = await tui.prompt(
|
|
4600
|
+
"What skill do you want to install?",
|
|
4601
|
+
"the anthropic pdf skill / a skill for writing conventional commits"
|
|
4602
|
+
);
|
|
4603
|
+
if (query) skills.requestInstall(query);
|
|
4604
|
+
return;
|
|
4605
|
+
}
|
|
4606
|
+
const skill = list.find((s) => s.name === picked);
|
|
4607
|
+
tui.print(`${c.cyan}${skill.name}${c.reset}${skill.version ? ` ${c.dim}v${skill.version}${c.reset}` : ""} ${c.gray}\u2014 ${skill.description}${c.reset}`);
|
|
4608
|
+
const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
|
|
4609
|
+
skill.enabled ? { label: "Disable (hide from the agent)", value: "disable" } : { label: "Enable", value: "enable" },
|
|
4610
|
+
{ label: "View files", value: "files" },
|
|
4611
|
+
{ label: "Remove this skill", value: "remove" },
|
|
4612
|
+
{ label: "Back", value: "back" }
|
|
4613
|
+
]);
|
|
4614
|
+
try {
|
|
4615
|
+
if (action === "enable" || action === "disable") {
|
|
4616
|
+
await skills.setEnabled(picked, action === "enable");
|
|
4617
|
+
tui.print(`${c.gray}${action}d ${picked}${c.reset}`);
|
|
4618
|
+
} else if (action === "files") {
|
|
4619
|
+
for (const f of skill.files) tui.print(` ${c.gray}${f}${c.reset}`);
|
|
4620
|
+
} else if (action === "remove") {
|
|
4621
|
+
await skills.remove(picked);
|
|
4622
|
+
tui.print(`${c.gray}removed ${picked}${c.reset}`);
|
|
4623
|
+
}
|
|
4624
|
+
} catch (e) {
|
|
4625
|
+
tui.print(`${c.red}\u2717 ${e instanceof Error ? e.message : String(e)}${c.reset}`);
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
3282
4628
|
async function runLevelMenu(tui, perm) {
|
|
3283
4629
|
const picked = await tui.select(
|
|
3284
4630
|
`${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
|
|
@@ -3297,6 +4643,8 @@ function showKeybindings(tui) {
|
|
|
3297
4643
|
tui.print(`${c.gray}shortcuts:${c.reset}`);
|
|
3298
4644
|
tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
|
|
3299
4645
|
tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
|
|
4646
|
+
tui.print(`${c.gray} ctrl-v${c.reset} paste an image from the clipboard ([#Image 1])`);
|
|
4647
|
+
tui.print(`${c.gray} \u2191 / \u2193${c.reset} cycle past messages (on the input's top line)`);
|
|
3300
4648
|
tui.print(`${c.gray} ctrl-c${c.reset} quit`);
|
|
3301
4649
|
}
|
|
3302
4650
|
async function runProcessMenu(tui, bg) {
|