@tiens.nguyen/gu-cli 1.0.787 → 1.0.788
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/clear-logs.mjs +352 -0
- package/gonext_agent_chat.py +252 -14
- package/gu-repl.mjs +207 -7
- package/package.json +2 -1
- package/terminal-layout.mjs +59 -0
package/clear-logs.mjs
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "clear logs" (#329) — what is taking the disk under ~/.gonext, and a way to give it back.
|
|
3
|
+
*
|
|
4
|
+
* Asked for after this Mac's disk filled three times in one day and took the local MongoDB down
|
|
5
|
+
* with it (#328). A click on the notice row's "clear logs" (or /clear-logs) DELETES NOTHING at
|
|
6
|
+
* first: it measures, shows the logs beside the two things that are actually big — the RAG indexes
|
|
7
|
+
* and the downloaded API versions — with a total, and then asks about each of the three.
|
|
8
|
+
*
|
|
9
|
+
* THREE DIFFERENT KINDS OF "CLEAR", because losing each costs something different:
|
|
10
|
+
*
|
|
11
|
+
* logs nothing. But the worker and the local API hold their log OPEN: a deleted open file frees
|
|
12
|
+
* no space on macOS/Linux until the holder closes it (and the holder keeps writing into
|
|
13
|
+
* the unlinked file), and on Windows the delete fails outright. So the live "<name>.log"
|
|
14
|
+
* is TRUNCATED IN PLACE and only rotations and finished run-logs are deleted — the same
|
|
15
|
+
* rule rotateIfLarge (log-policy.mjs) already lives by.
|
|
16
|
+
* rag minutes of embedding per folder: an index is rebuilt the next time its folder is used.
|
|
17
|
+
* api nothing, PROVIDED the version in use stays. One folder per installed version, none ever
|
|
18
|
+
* removed; deleting the running one would break every open terminal's turn. So "clear
|
|
19
|
+
* api" only ever means the OLDER versions.
|
|
20
|
+
*
|
|
21
|
+
* SELECTED BY AN EXPLICIT LIST, never by a "*.log*" / "*.jsonl" sweep of the home: sessions/archive
|
|
22
|
+
* is conversation history (#278) and rag/ shards are also .jsonl. Anything not named here is not
|
|
23
|
+
* touched, whatever the answers.
|
|
24
|
+
*
|
|
25
|
+
* Everything takes `home` so the tests run against a real temp folder, not this machine's.
|
|
26
|
+
*/
|
|
27
|
+
import { readdir, readFile, rm, rmdir, stat, truncate, unlink } from "node:fs/promises";
|
|
28
|
+
import { existsSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { pickUsableVersion, compareVersions } from "./version-check.mjs";
|
|
32
|
+
|
|
33
|
+
/** The logs gu writes at the top of its home. `<name>` is live; `<name>.N` are its rotations. */
|
|
34
|
+
export const LIVE_LOGS = ["worker.log", "local-api.log", "model.log", "embed.log"];
|
|
35
|
+
/** Folders that hold nothing but finished log files. */
|
|
36
|
+
export const LOG_DIRS = ["run-logs", "logs"];
|
|
37
|
+
/** The link appears once the logs pass this — below it the row stays quiet. */
|
|
38
|
+
export const LOGS_NOTICE_BYTES = 50 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
const guHome = (home = homedir()) => join(home, ".gonext");
|
|
41
|
+
|
|
42
|
+
/** "740 KB", "15 MB", "4.9 GB" — two significant figures, the way `du -h` reads. */
|
|
43
|
+
export function humanSize(bytes) {
|
|
44
|
+
const n = Math.max(0, Number(bytes) || 0);
|
|
45
|
+
const units = [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]];
|
|
46
|
+
for (const [unit, size] of units) {
|
|
47
|
+
if (n < size) continue;
|
|
48
|
+
const v = n / size;
|
|
49
|
+
// 9.96 would print as "10.0" with one decimal — let it round up to the integer form instead.
|
|
50
|
+
return `${v < 9.95 ? v.toFixed(1) : Math.round(v)} ${unit}`;
|
|
51
|
+
}
|
|
52
|
+
return `${Math.round(n)} B`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a file takes ON DISK — its blocks, which is what clearing gives back and what `du` reports.
|
|
59
|
+
* The byte length undercounts thousands of small files (an API version read as 85 MB here against
|
|
60
|
+
* du's 120 MB). Windows reports no blocks; the length is the best there is.
|
|
61
|
+
*/
|
|
62
|
+
const onDisk = (s) => (s.blocks > 0 ? s.blocks * 512 : s.size);
|
|
63
|
+
|
|
64
|
+
async function fileSize(p) {
|
|
65
|
+
try {
|
|
66
|
+
const s = await stat(p);
|
|
67
|
+
return s.isFile() ? onDisk(s) : -1;
|
|
68
|
+
} catch {
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bytes under a folder. Children are walked together: an API version is thousands of files. */
|
|
74
|
+
async function dirSize(p) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = await readdir(p, { withFileTypes: true });
|
|
78
|
+
} catch {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const sizes = await Promise.all(
|
|
82
|
+
entries.map(async (e) => {
|
|
83
|
+
const full = join(p, e.name);
|
|
84
|
+
if (e.isDirectory()) return dirSize(full);
|
|
85
|
+
if (e.isSymbolicLink()) return 0; // never followed: a link out of the home is not ours to count
|
|
86
|
+
try {
|
|
87
|
+
return onDisk(await stat(full));
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
return sizes.reduce((a, b) => a + b, 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The logs: each live file and its rotations by name, each log folder as a count.
|
|
98
|
+
* → { items: [{ name, bytes, kind: "live" | "rotation" | "dir", files?, paths? }], bytes, files }
|
|
99
|
+
*/
|
|
100
|
+
export async function surveyLogs({ home = homedir() } = {}) {
|
|
101
|
+
const root = guHome(home);
|
|
102
|
+
let names = [];
|
|
103
|
+
try {
|
|
104
|
+
names = await readdir(root);
|
|
105
|
+
} catch {
|
|
106
|
+
/* no home yet — nothing to report */
|
|
107
|
+
}
|
|
108
|
+
const items = [];
|
|
109
|
+
for (const live of LIVE_LOGS) {
|
|
110
|
+
const bytes = await fileSize(join(root, live));
|
|
111
|
+
if (bytes >= 0) items.push({ name: live, bytes, kind: "live", path: join(root, live) });
|
|
112
|
+
const rotations = names
|
|
113
|
+
.filter((n) => n.startsWith(live + ".") && /^\d+$/.test(n.slice(live.length + 1)))
|
|
114
|
+
.sort((a, b) => Number(a.slice(live.length + 1)) - Number(b.slice(live.length + 1)));
|
|
115
|
+
for (const r of rotations) {
|
|
116
|
+
const b = await fileSize(join(root, r));
|
|
117
|
+
if (b >= 0) items.push({ name: r, bytes: b, kind: "rotation", path: join(root, r) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const dir of LOG_DIRS) {
|
|
121
|
+
let files = [];
|
|
122
|
+
try {
|
|
123
|
+
files = (await readdir(join(root, dir), { withFileTypes: true })).filter(
|
|
124
|
+
(e) => e.isFile() && /\.log(\.\d+)?$/.test(e.name)
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const paths = files.map((e) => join(root, dir, e.name));
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
for (const p of paths) bytes += Math.max(0, await fileSize(p));
|
|
132
|
+
if (paths.length) items.push({ name: dir, bytes, kind: "dir", files: paths.length, paths });
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
items,
|
|
136
|
+
bytes: items.reduce((n, i) => n + i.bytes, 0),
|
|
137
|
+
files: items.reduce((n, i) => n + (i.kind === "dir" ? i.files : 1), 0),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every local index: ~/.gonext/rag/<folder hash>/<source hash>/. The hashes say nothing to a
|
|
143
|
+
* person, so the name comes from the manifest the agent wrote (`sourceUrl`). `missing` = that
|
|
144
|
+
* source was a folder on this machine and is gone — the index can never be used again.
|
|
145
|
+
*/
|
|
146
|
+
export async function surveyRag({ home = homedir() } = {}) {
|
|
147
|
+
const root = join(guHome(home), "rag");
|
|
148
|
+
const indexes = [];
|
|
149
|
+
let folders = [];
|
|
150
|
+
try {
|
|
151
|
+
folders = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
152
|
+
} catch {
|
|
153
|
+
return { indexes, bytes: 0 };
|
|
154
|
+
}
|
|
155
|
+
for (const f of folders) {
|
|
156
|
+
let sources = [];
|
|
157
|
+
try {
|
|
158
|
+
sources = (await readdir(join(root, f.name), { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (const s of sources) {
|
|
163
|
+
const dir = join(root, f.name, s.name);
|
|
164
|
+
let source = "";
|
|
165
|
+
try {
|
|
166
|
+
source = String(JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"))?.sourceUrl ?? "");
|
|
167
|
+
} catch {
|
|
168
|
+
/* no manifest — an interrupted index; still counted, still clearable */
|
|
169
|
+
}
|
|
170
|
+
const isPath = source.startsWith("/") || /^[A-Za-z]:[\\/]/.test(source);
|
|
171
|
+
indexes.push({
|
|
172
|
+
dir,
|
|
173
|
+
parent: join(root, f.name),
|
|
174
|
+
source,
|
|
175
|
+
label: source ? source.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || source : `${s.name.slice(0, 8)}…`,
|
|
176
|
+
missing: isPath && !existsSync(source),
|
|
177
|
+
bytes: await dirSize(dir),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
indexes.sort((a, b) => b.bytes - a.bytes);
|
|
182
|
+
return { indexes, bytes: indexes.reduce((n, i) => n + i.bytes, 0) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The installed API versions, and which must stay.
|
|
187
|
+
*
|
|
188
|
+
* KEPT: the version answering on the port right now (`runningVersion`, from /api/health), the one
|
|
189
|
+
* `gu-cli api start` would pick (newest that has its dependencies — #181), and the newest folder
|
|
190
|
+
* even when it is not runnable yet (an upgrade in progress). Everything else is "old".
|
|
191
|
+
*/
|
|
192
|
+
export async function surveyApi({ home = homedir(), runningVersion = "" } = {}) {
|
|
193
|
+
const root = join(guHome(home), "api");
|
|
194
|
+
let names = [];
|
|
195
|
+
try {
|
|
196
|
+
names = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
197
|
+
} catch {
|
|
198
|
+
return { versions: [], bytes: 0, oldBytes: 0, old: [], inUse: "" };
|
|
199
|
+
}
|
|
200
|
+
names.sort((a, b) => compareVersions(a, b));
|
|
201
|
+
const usable = pickUsableVersion(names, (v) => existsSync(join(root, v, "node_modules", "mongodb", "package.json")));
|
|
202
|
+
const running = names.includes(runningVersion) ? runningVersion : "";
|
|
203
|
+
const keep = new Set([running, usable, names[names.length - 1]].filter(Boolean));
|
|
204
|
+
const sizes = await Promise.all(names.map((v) => dirSize(join(root, v))));
|
|
205
|
+
const versions = names.map((version, i) => ({ version, dir: join(root, version), bytes: sizes[i], keep: keep.has(version) }));
|
|
206
|
+
const old = versions.filter((v) => !v.keep);
|
|
207
|
+
return {
|
|
208
|
+
versions,
|
|
209
|
+
old,
|
|
210
|
+
bytes: versions.reduce((n, v) => n + v.bytes, 0),
|
|
211
|
+
oldBytes: old.reduce((n, v) => n + v.bytes, 0),
|
|
212
|
+
inUse: running || usable || "",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** All three, measured together. */
|
|
217
|
+
export async function surveyDisk({ home = homedir(), runningVersion = "" } = {}) {
|
|
218
|
+
const [logs, rag, api] = await Promise.all([surveyLogs({ home }), surveyRag({ home }), surveyApi({ home, runningVersion })]);
|
|
219
|
+
return { logs, rag, api, total: logs.bytes + rag.bytes + api.bytes };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Lay `parts` out after `lead`, breaking only between parts; later rows start at `indent`. */
|
|
223
|
+
function wrapParts(lead, parts, width, { sep = " · ", indent = lead.length } = {}) {
|
|
224
|
+
const pad = " ".repeat(indent);
|
|
225
|
+
const rows = [];
|
|
226
|
+
let cur = lead;
|
|
227
|
+
let empty = true;
|
|
228
|
+
for (const part of parts) {
|
|
229
|
+
if (!empty && cur.length + sep.length + part.length > width) {
|
|
230
|
+
rows.push(cur);
|
|
231
|
+
cur = pad + part;
|
|
232
|
+
} else {
|
|
233
|
+
cur += (empty ? "" : sep) + part;
|
|
234
|
+
}
|
|
235
|
+
empty = false;
|
|
236
|
+
}
|
|
237
|
+
rows.push(cur.trimEnd());
|
|
238
|
+
// a single part longer than the row is cut, never wrapped — a wrapped row moves everything under it
|
|
239
|
+
return rows.map((r) => (r.length > width ? r.slice(0, Math.max(1, width - 1)) + "…" : r));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What the click shows before it asks anything: the logs with their files, RAG and API with what
|
|
244
|
+
* they are, and the total. Plain text rows that fit `width` — the caller paints and indents them.
|
|
245
|
+
* A part that is not on this machine (no api/ outside Local mode) has no row.
|
|
246
|
+
*/
|
|
247
|
+
export function summaryRows(survey, { width = 76, top = 3 } = {}) {
|
|
248
|
+
const w = Math.max(30, width | 0);
|
|
249
|
+
const { logs, rag, api } = survey;
|
|
250
|
+
const parts = [
|
|
251
|
+
{ name: "Logs", bytes: logs.bytes, detail: logs.items.map((i) => i.kind === "dir"
|
|
252
|
+
? `${i.name} ${plural(i.files, "file")} ${humanSize(i.bytes)}`
|
|
253
|
+
: `${i.name} ${humanSize(i.bytes)}`), none: "none" },
|
|
254
|
+
];
|
|
255
|
+
if (rag.indexes.length) {
|
|
256
|
+
const shown = rag.indexes.slice(0, top);
|
|
257
|
+
const rest = rag.indexes.slice(top);
|
|
258
|
+
const detail = shown.map((i) => `${i.label} ${humanSize(i.bytes)}`);
|
|
259
|
+
if (rest.length) detail.push(`${rest.length} more ${humanSize(rest.reduce((n, i) => n + i.bytes, 0))}`);
|
|
260
|
+
parts.push({ name: "RAG", bytes: rag.bytes, intro: plural(rag.indexes.length, "folder index", "folder indexes") + ":", detail });
|
|
261
|
+
}
|
|
262
|
+
if (api.versions.length) {
|
|
263
|
+
const detail = [plural(api.versions.length, "version")];
|
|
264
|
+
if (api.inUse) detail.push(`${api.inUse} in use`);
|
|
265
|
+
if (api.old.length) detail.push(`${plural(api.old.length, "older one")} ${humanSize(api.oldBytes)}`);
|
|
266
|
+
parts.push({ name: "API", bytes: api.bytes, detail });
|
|
267
|
+
}
|
|
268
|
+
const nameW = Math.max(5, ...parts.map((p) => p.name.length));
|
|
269
|
+
const sizeW = Math.max(humanSize(survey.total).length, ...parts.map((p) => humanSize(p.bytes).length));
|
|
270
|
+
const lead = (name, bytes) => `${name.padEnd(nameW)} ${humanSize(bytes).padStart(sizeW)} `;
|
|
271
|
+
const rows = [];
|
|
272
|
+
for (const p of parts) {
|
|
273
|
+
const head = lead(p.name, p.bytes);
|
|
274
|
+
// "22 folder indexes:" introduces the list — the list wraps under the first index, the
|
|
275
|
+
// other parts under their own first word
|
|
276
|
+
rows.push(...wrapParts(head + (p.intro ? p.intro + " " : ""), p.detail.length ? p.detail : [p.none ?? ""], w, { indent: head.length }));
|
|
277
|
+
}
|
|
278
|
+
rows.push(lead("Total", survey.total).trimEnd());
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Clear the logs `surveyLogs` found: truncate the live ones, delete rotations and log folders'
|
|
284
|
+
* files. → { freed, truncated, deleted, failed: [name] } — `freed` is what really went, so a file
|
|
285
|
+
* Windows refused to let go of is reported, not counted.
|
|
286
|
+
*/
|
|
287
|
+
export async function clearLogs(logs) {
|
|
288
|
+
const out = { freed: 0, truncated: 0, deleted: 0, failed: [] };
|
|
289
|
+
for (const item of logs?.items ?? []) {
|
|
290
|
+
if (item.kind === "live") {
|
|
291
|
+
try {
|
|
292
|
+
const before = Math.max(0, await fileSize(item.path));
|
|
293
|
+
await truncate(item.path, 0);
|
|
294
|
+
out.freed += before;
|
|
295
|
+
out.truncated += 1;
|
|
296
|
+
} catch {
|
|
297
|
+
out.failed.push(item.name);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
for (const p of item.kind === "dir" ? item.paths : [item.path]) {
|
|
302
|
+
try {
|
|
303
|
+
const before = Math.max(0, await fileSize(p));
|
|
304
|
+
await unlink(p);
|
|
305
|
+
out.freed += before;
|
|
306
|
+
out.deleted += 1;
|
|
307
|
+
} catch (e) {
|
|
308
|
+
if (e?.code === "ENOENT") continue; // pruned meanwhile — gone is what was wanted
|
|
309
|
+
if (!out.failed.includes(item.name)) out.failed.push(item.name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Remove the given indexes (surveyRag's rows). An emptied folder-hash directory goes with them. */
|
|
317
|
+
export async function clearRagIndexes(indexes) {
|
|
318
|
+
const out = { freed: 0, removed: 0, failed: [] };
|
|
319
|
+
for (const idx of indexes ?? []) {
|
|
320
|
+
try {
|
|
321
|
+
await rm(idx.dir, { recursive: true, force: true });
|
|
322
|
+
out.freed += idx.bytes;
|
|
323
|
+
out.removed += 1;
|
|
324
|
+
await rmdir(idx.parent).catch(() => {}); // only succeeds when nothing else lives there
|
|
325
|
+
} catch {
|
|
326
|
+
out.failed.push(idx.label);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Remove the OLD API versions (surveyApi's `old`). A row marked keep is refused, whoever asks. */
|
|
333
|
+
export async function clearOldApiVersions(versions) {
|
|
334
|
+
const out = { freed: 0, removed: 0, failed: [] };
|
|
335
|
+
for (const v of versions ?? []) {
|
|
336
|
+
if (v.keep) continue;
|
|
337
|
+
try {
|
|
338
|
+
await rm(v.dir, { recursive: true, force: true });
|
|
339
|
+
out.freed += v.bytes;
|
|
340
|
+
out.removed += 1;
|
|
341
|
+
} catch {
|
|
342
|
+
out.failed.push(v.version);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The notice-row item's text, or "" while the logs are too small to be worth a row. */
|
|
349
|
+
export function clearLogsNoticeText(logBytes, { threshold = LOGS_NOTICE_BYTES } = {}) {
|
|
350
|
+
const n = Number(logBytes) || 0;
|
|
351
|
+
return n >= threshold ? `clear logs · ${humanSize(n)} (click)` : "";
|
|
352
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -5183,6 +5183,90 @@ def _same_embed_model(a: str, b: str) -> bool:
|
|
|
5183
5183
|
_RUNTOOL_PREFIX = "__RUNTOOL__::"
|
|
5184
5184
|
|
|
5185
5185
|
|
|
5186
|
+
# ---- the command that is running, WHOLE (task #331) ------------------------------------------
|
|
5187
|
+
# "Running → <cmd>" carries the first 70 characters, which is right for a history line and wrong
|
|
5188
|
+
# for the one moment someone is watching: a heredoc or a long pipeline was cut at the source, so
|
|
5189
|
+
# the terminal could not show the rest whatever its layout. Sent beside that line (which stays,
|
|
5190
|
+
# for the web app and for older terminals) as ONE line of JSON — a command can span lines.
|
|
5191
|
+
_RUNCMD_PREFIX = "__RUNCMD__::"
|
|
5192
|
+
# How much it has printed so far: "__RUNOUT__::<lines>". From the wait loop, only when it changed.
|
|
5193
|
+
_RUNOUT_PREFIX = "__RUNOUT__::"
|
|
5194
|
+
_RUNCMD_MAX_CHARS = 4000
|
|
5195
|
+
|
|
5196
|
+
# SECRETS. The 70-character cut hid most credentials by accident; showing the whole command must
|
|
5197
|
+
# not undo that. Conservative and by SHAPE — a value after a name that says it is a secret, a
|
|
5198
|
+
# bearer header, user:password@ in a URL, sshpass -p, and the key formats gu itself hands out.
|
|
5199
|
+
# This is what goes to the SCREEN and the job stream; the command that runs is untouched.
|
|
5200
|
+
# "pass" and "token" only as whole words: --passes=3, bypass=true and --max-tokens 100 are not secrets.
|
|
5201
|
+
_SECRET_NAME = (r"(?:password|passwd|passphrase|pwd|(?<![a-z])pass(?![a-z])|secret|token(?![a-z])"
|
|
5202
|
+
r"|api[_-]?key|access[_-]?key)")
|
|
5203
|
+
_CMD_SECRET_RES = (
|
|
5204
|
+
(re.compile(r"(?i)(\b[\w.-]*" + _SECRET_NAME + r"[\w.-]*\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5205
|
+
(re.compile(r"(?i)(--?[\w-]*" + _SECRET_NAME + r"[\w-]*[ =])(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5206
|
+
(re.compile(r"(?i)(\bsshpass\s+-p\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5207
|
+
(re.compile(r"(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s\"']+"), r"\1•••"),
|
|
5208
|
+
(re.compile(r"(?i)(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)[^\s/@]+(@)"), r"\1•••\2"),
|
|
5209
|
+
(re.compile(r"\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|wk_[A-Za-z0-9_-]{16,}|gu1\.[A-Za-z0-9_.-]{20,})"), "•••"),
|
|
5210
|
+
)
|
|
5211
|
+
|
|
5212
|
+
|
|
5213
|
+
def _mask_command_secrets(command: str) -> str:
|
|
5214
|
+
"""The command as it may be SHOWN: credentials replaced by •••, everything else as written."""
|
|
5215
|
+
text = str(command or "")
|
|
5216
|
+
for rx, sub in _CMD_SECRET_RES:
|
|
5217
|
+
text = rx.sub(sub, text)
|
|
5218
|
+
return text
|
|
5219
|
+
|
|
5220
|
+
|
|
5221
|
+
def _command_label(command: str, limit: int = 70) -> str:
|
|
5222
|
+
"""A command as ONE LINE of a step label: masked, its first line only, cut to `limit`.
|
|
5223
|
+
|
|
5224
|
+
A step is a line of the stream. A heredoc's newlines made "Command finished → python3 - <<'EOF'"
|
|
5225
|
+
followed by every line of the script as a bullet of its own — and, on the way in, ended the
|
|
5226
|
+
live status row the moment the "Running →" line was read (#332). " …" says there is more.
|
|
5227
|
+
"""
|
|
5228
|
+
lines = [ln.strip() for ln in _mask_command_secrets(command).replace("\r", "").split("\n") if ln.strip()]
|
|
5229
|
+
if not lines:
|
|
5230
|
+
return ""
|
|
5231
|
+
return lines[0][:limit] + (" …" if len(lines) > 1 else "")
|
|
5232
|
+
|
|
5233
|
+
|
|
5234
|
+
def _running_command_line(command: str) -> str:
|
|
5235
|
+
"""The stream line that carries the whole command to the terminal (masked, capped)."""
|
|
5236
|
+
shown = _mask_command_secrets(command)
|
|
5237
|
+
if len(shown) > _RUNCMD_MAX_CHARS:
|
|
5238
|
+
shown = shown[:_RUNCMD_MAX_CHARS] + "…"
|
|
5239
|
+
return _RUNCMD_PREFIX + json.dumps({"cmd": shown}, ensure_ascii=False)
|
|
5240
|
+
|
|
5241
|
+
|
|
5242
|
+
def _count_output_lines(path: str, state: dict) -> int:
|
|
5243
|
+
"""Lines a running command has written to its log so far. Reads only what is NEW since the
|
|
5244
|
+
last call (`state` keeps the offset), so a chatty ten-minute build is not re-read every tick."""
|
|
5245
|
+
try:
|
|
5246
|
+
with open(path, "rb") as fh:
|
|
5247
|
+
fh.seek(state.get("off", 0))
|
|
5248
|
+
chunk = fh.read()
|
|
5249
|
+
except OSError:
|
|
5250
|
+
return state.get("lines", 0)
|
|
5251
|
+
if chunk:
|
|
5252
|
+
state["off"] = state.get("off", 0) + len(chunk)
|
|
5253
|
+
state["lines"] = state.get("lines", 0) + chunk.count(b"\n")
|
|
5254
|
+
state["partial"] = not chunk.endswith(b"\n")
|
|
5255
|
+
# a last line with no newline yet is still a line someone could read
|
|
5256
|
+
return state.get("lines", 0) + (1 if state.get("partial") else 0)
|
|
5257
|
+
|
|
5258
|
+
|
|
5259
|
+
def _emit_command_progress(path: str, state: dict) -> None:
|
|
5260
|
+
"""Tell the terminal how much the running command has printed — only when that changed."""
|
|
5261
|
+
try:
|
|
5262
|
+
n = _count_output_lines(path, state)
|
|
5263
|
+
if n != state.get("sent", 0):
|
|
5264
|
+
state["sent"] = n
|
|
5265
|
+
_emit({"type": "step", "text": f"{_RUNOUT_PREFIX}{n}"})
|
|
5266
|
+
except Exception: # noqa: BLE001 — a progress figure must never fail a command
|
|
5267
|
+
pass
|
|
5268
|
+
|
|
5269
|
+
|
|
5186
5270
|
def _fmt_call(name: str, **kw) -> str:
|
|
5187
5271
|
"""A tool call rendered the way the model writes it. Skips empty/default arguments."""
|
|
5188
5272
|
parts = []
|
|
@@ -6391,6 +6475,114 @@ def _and_chain_parts(command):
|
|
|
6391
6475
|
return parts
|
|
6392
6476
|
|
|
6393
6477
|
|
|
6478
|
+
def _parse_heredoc(command):
|
|
6479
|
+
"""A heredoc or here-string in `command` → what to run and what to feed it. (task #332)
|
|
6480
|
+
|
|
6481
|
+
THE BUG THIS EXISTS FOR. run_command has no shell, so
|
|
6482
|
+
|
|
6483
|
+
python3 - <<'EOF'
|
|
6484
|
+
print("hello")
|
|
6485
|
+
EOF
|
|
6486
|
+
|
|
6487
|
+
became argv ["python3", "-", "<<EOF", "print(hello)", "EOF"]: python read its script from an
|
|
6488
|
+
EMPTY stdin and exited 0 with no output. Nothing ran and the step looked like a success —
|
|
6489
|
+
the #204 failure again, in the one shape #204's check did not cover (its redirect pattern
|
|
6490
|
+
matches "<" and ">", not "<<"). Seen live: the model lost two steps rewriting the work as
|
|
6491
|
+
`python3 -c`. Heredocs are how a strong model runs a multi-line script without a file, so
|
|
6492
|
+
this is the common case, not an edge.
|
|
6493
|
+
|
|
6494
|
+
SUPPORTED, still with no shell: ONE program fed a body.
|
|
6495
|
+
PROG ARGS <<'TAG' … TAG the body becomes the program's stdin
|
|
6496
|
+
PROG ARGS <<-TAG … TAG same, leading tabs stripped
|
|
6497
|
+
PROG ARGS <<< 'text' a here-string: text + newline on stdin
|
|
6498
|
+
REFUSED, each with the one change that fixes it — a guess costs a model step:
|
|
6499
|
+
an unquoted TAG (or a double-quoted here-string) whose text has $ or ` — a shell would
|
|
6500
|
+
expand those and we cannot; anything after the closing TAG; no closing TAG; two heredocs.
|
|
6501
|
+
|
|
6502
|
+
Returns None when there is no heredoc (`echo "a << b"` and `python3 -c "print(1 << 3)"` are
|
|
6503
|
+
not: the operator only counts OUTSIDE quotes), else {"command", "stdin"} or {"error"}.
|
|
6504
|
+
Pure, so every shape is testable without spawning anything.
|
|
6505
|
+
"""
|
|
6506
|
+
s = command or ""
|
|
6507
|
+
q = None
|
|
6508
|
+
i = 0
|
|
6509
|
+
at = -1
|
|
6510
|
+
while i < len(s):
|
|
6511
|
+
c = s[i]
|
|
6512
|
+
if q:
|
|
6513
|
+
if c == q:
|
|
6514
|
+
q = None
|
|
6515
|
+
elif c == "\\" and q == '"':
|
|
6516
|
+
i += 1
|
|
6517
|
+
elif c in "'\"":
|
|
6518
|
+
q = c
|
|
6519
|
+
elif c == "\\":
|
|
6520
|
+
i += 1
|
|
6521
|
+
elif s.startswith("<<", i):
|
|
6522
|
+
at = i
|
|
6523
|
+
break
|
|
6524
|
+
i += 1
|
|
6525
|
+
if at < 0:
|
|
6526
|
+
return None
|
|
6527
|
+
_why = "Error: that command did NOT run — nothing was executed. "
|
|
6528
|
+
before = s[:at]
|
|
6529
|
+
if s.startswith("<<<", at):
|
|
6530
|
+
# HERE-STRING: the next word is the input; anything after it stays an argument.
|
|
6531
|
+
rest = s[at + 3:]
|
|
6532
|
+
import shlex as _shlex
|
|
6533
|
+
try:
|
|
6534
|
+
words = _shlex.split(rest)
|
|
6535
|
+
except ValueError as e:
|
|
6536
|
+
return {"error": _why + f"The here-string is not closed: {e}."}
|
|
6537
|
+
if not words:
|
|
6538
|
+
return {"error": _why + "'<<<' needs the text to feed the program: PROG <<< 'text'."}
|
|
6539
|
+
lead = rest.lstrip()
|
|
6540
|
+
if not lead.startswith("'") and re.search(r"[$`]", words[0]):
|
|
6541
|
+
return {"error": _why + "run_command has NO shell, so $variables and `commands` in a "
|
|
6542
|
+
"here-string are not expanded. Put the text in SINGLE quotes to pass it "
|
|
6543
|
+
"exactly as written, or write the final text yourself."}
|
|
6544
|
+
tail = " ".join(_shlex.quote(w) for w in words[1:])
|
|
6545
|
+
return {"command": (before.rstrip() + (" " + tail if tail else "")).strip(),
|
|
6546
|
+
"stdin": words[0] + "\n"}
|
|
6547
|
+
m = re.match(r"<<(-?)[ \t]*(?:'([^'\n]+)'|\"([^\"\n]+)\"|\\?([A-Za-z_][\w.-]*))", s[at:])
|
|
6548
|
+
if not m:
|
|
6549
|
+
return {"error": _why + "'<<' must be followed by a tag: PROG <<'EOF', the text, then "
|
|
6550
|
+
"EOF on a line of its own."}
|
|
6551
|
+
strip_tabs = bool(m.group(1))
|
|
6552
|
+
tag = m.group(2) or m.group(3) or m.group(4)
|
|
6553
|
+
quoted = bool(m.group(2) or m.group(3)) or s[at + m.end() - len(tag) - 1] == "\\"
|
|
6554
|
+
after = s[at + m.end():]
|
|
6555
|
+
line_rest, nl, body_and_more = after.partition("\n")
|
|
6556
|
+
if "<<" in line_rest and _parse_heredoc(line_rest) is not None:
|
|
6557
|
+
return {"error": _why + "Only one heredoc per command is supported. Write the second "
|
|
6558
|
+
"text with create_file, or run the two commands in separate calls."}
|
|
6559
|
+
lines = body_and_more.split("\n") if nl else []
|
|
6560
|
+
end = -1
|
|
6561
|
+
for k, ln in enumerate(lines):
|
|
6562
|
+
if (ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") == tag:
|
|
6563
|
+
end = k
|
|
6564
|
+
break
|
|
6565
|
+
if end < 0:
|
|
6566
|
+
return {"error": _why + f"The heredoc is never closed: put {tag} on a line of its own "
|
|
6567
|
+
"after the text."}
|
|
6568
|
+
body_lines = [(ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") for ln in lines[:end]]
|
|
6569
|
+
trailing = "\n".join(lines[end + 1:]).strip()
|
|
6570
|
+
if trailing:
|
|
6571
|
+
return {"error": _why + f"There is more after the closing {tag}, and run_command runs "
|
|
6572
|
+
"ONE command per call (only && chains continue). Run what follows in a "
|
|
6573
|
+
"separate run_command call."}
|
|
6574
|
+
cmdline = (before.rstrip() + " " + line_rest.strip()).strip()
|
|
6575
|
+
body = "\n".join(body_lines) + ("\n" if body_lines else "")
|
|
6576
|
+
if not quoted and re.search(r"[$`]", body):
|
|
6577
|
+
return {"error": _why + "run_command has NO shell, so the $variables and `commands` in "
|
|
6578
|
+
f"this heredoc would not be expanded. Quote the tag — <<'{tag}' — to pass the "
|
|
6579
|
+
"text exactly as written (that is what a script wants), or write the final "
|
|
6580
|
+
"text yourself."}
|
|
6581
|
+
if not cmdline:
|
|
6582
|
+
return {"error": _why + "The heredoc has no program to feed: PROG <<'EOF' … EOF."}
|
|
6583
|
+
return {"command": cmdline, "stdin": body}
|
|
6584
|
+
|
|
6585
|
+
|
|
6394
6586
|
def _shell_syntax_error(command, argv):
|
|
6395
6587
|
"""Why this command cannot do what it says, or None. (task #204)
|
|
6396
6588
|
|
|
@@ -6963,7 +7155,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
6963
7155
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
6964
7156
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
6965
7157
|
else:
|
|
6966
|
-
_emit({"type": "step", "text": f"Awaiting your approval to run: {command
|
|
7158
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {_command_label(command, 70)}"})
|
|
6967
7159
|
deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
|
|
6968
7160
|
while _t.time() < deadline:
|
|
6969
7161
|
_t.sleep(1.2)
|
|
@@ -7341,6 +7533,12 @@ def _ws_scrubbed_env() -> dict:
|
|
|
7341
7533
|
# CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
|
|
7342
7534
|
# exit instead of sitting in interactive watch mode until the timeout kills them.
|
|
7343
7535
|
env["CI"] = "1"
|
|
7536
|
+
# WINDOWS: a python child reads and writes in the ANSI code page unless told otherwise, so a
|
|
7537
|
+
# script fed on stdin (a heredoc, #332) with "é" or "→" in it is mis-decoded, and printing
|
|
7538
|
+
# one raises UnicodeEncodeError. The worker's own python is started with PYTHONUTF8=1 for
|
|
7539
|
+
# exactly this reason; the whitelist above dropped it for the commands it runs.
|
|
7540
|
+
if _IS_WINDOWS:
|
|
7541
|
+
env["PYTHONUTF8"] = "1"
|
|
7344
7542
|
return env
|
|
7345
7543
|
|
|
7346
7544
|
|
|
@@ -12501,7 +12699,7 @@ def run_agent_chat(cfg):
|
|
|
12501
12699
|
"""Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING and its URL is reported. If it is a DESKTOP/GUI app (opens a window — tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Also allows DEPLOY commands (ssh/scp/rsync) — use non-interactive KEY auth (-o BatchMode=yes); never put a password in the command (there is no stdin). Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
|
|
12502
12700
|
|
|
12503
12701
|
Args:
|
|
12504
|
-
command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
|
|
12702
|
+
command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. To run a multi-line script without a file, feed ONE program a heredoc, tag quoted: "python3 - <<'EOF'\n...\nEOF". No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
|
|
12505
12703
|
workdir: directory to run in. Empty = first registered workspace.
|
|
12506
12704
|
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
12507
12705
|
"""
|
|
@@ -12546,7 +12744,24 @@ def run_agent_chat(cfg):
|
|
|
12546
12744
|
"gu-local-worker workspace add <path> --allow-run)")
|
|
12547
12745
|
_last_obs["text"] = msg
|
|
12548
12746
|
return msg
|
|
12549
|
-
|
|
12747
|
+
# A HEREDOC / HERE-STRING (task #332): the text is the program's stdin, and the
|
|
12748
|
+
# command line is what is left. `command` stays the whole text for the risk gate,
|
|
12749
|
+
# the status row and the log; `_cmdline` is what is split and judged as argv.
|
|
12750
|
+
_here = _parse_heredoc(command)
|
|
12751
|
+
if _here and _here.get("error"):
|
|
12752
|
+
_log(f"run_command refused heredoc: {_c(_clip(command, 120))}")
|
|
12753
|
+
_last_obs["text"] = _here["error"]
|
|
12754
|
+
return _here["error"]
|
|
12755
|
+
_cmdline = _here["command"] if _here else (command or "")
|
|
12756
|
+
_stdin_text = _here["stdin"] if _here else None
|
|
12757
|
+
if _here and _and_chain_parts(_cmdline):
|
|
12758
|
+
msg = ("Error: that command did NOT run — nothing was executed. A heredoc "
|
|
12759
|
+
"cannot be part of an && chain here. Run the program that reads it in "
|
|
12760
|
+
"its own run_command call, and pass the folder as the cwd argument "
|
|
12761
|
+
"instead of `cd X &&`.")
|
|
12762
|
+
_last_obs["text"] = msg
|
|
12763
|
+
return msg
|
|
12764
|
+
argv = shlex.split(_cmdline)
|
|
12550
12765
|
if not argv:
|
|
12551
12766
|
msg = "Error: empty command."
|
|
12552
12767
|
_last_obs["text"] = msg
|
|
@@ -12554,7 +12769,7 @@ def run_agent_chat(cfg):
|
|
|
12554
12769
|
# AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
|
|
12555
12770
|
# allowlist, risk gate, approval, server detection, all of it — in order, and
|
|
12556
12771
|
# the first failure stops the rest. `cd DIR` sets the folder for what follows.
|
|
12557
|
-
_chain = _and_chain_parts(command)
|
|
12772
|
+
_chain = None if _here else _and_chain_parts(command)
|
|
12558
12773
|
if _chain:
|
|
12559
12774
|
_chain_start = _t.time()
|
|
12560
12775
|
_cur_wd = wd
|
|
@@ -12604,7 +12819,7 @@ def run_agent_chat(cfg):
|
|
|
12604
12819
|
# executes the `cd` PROGRAM — which exits 0 with no output — and the step is
|
|
12605
12820
|
# reported as a success that did nothing. Refusing costs one step; the observed
|
|
12606
12821
|
# alternative cost two hour-long turns and a false report to the user.
|
|
12607
|
-
_shell_err = _shell_syntax_error(
|
|
12822
|
+
_shell_err = _shell_syntax_error(_cmdline, argv)
|
|
12608
12823
|
if _shell_err:
|
|
12609
12824
|
_log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
|
|
12610
12825
|
_last_obs["text"] = _shell_err
|
|
@@ -12622,7 +12837,10 @@ def run_agent_chat(cfg):
|
|
|
12622
12837
|
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
12623
12838
|
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
12624
12839
|
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
12625
|
-
|
|
12840
|
+
# Judged on the COMMAND LINE, not on a heredoc's text: the text is data for the
|
|
12841
|
+
# program (a script that prints "sudo …" is not a sudo), and what a script does
|
|
12842
|
+
# is no more visible here than it is for `python3 -c`.
|
|
12843
|
+
_hard_reason, _ask_reason = _ws_command_risk(_cmdline, argv, _WS_RUN_DENYLIST, wd)
|
|
12626
12844
|
# Shift+Tab may have switched it since the turn started (#47).
|
|
12627
12845
|
global _accept_edits
|
|
12628
12846
|
if _interactive_approval and _job_id:
|
|
@@ -12638,7 +12856,7 @@ def run_agent_chat(cfg):
|
|
|
12638
12856
|
# states it. "this needs root and sudo needs a password we cannot type" is a
|
|
12639
12857
|
# different question from "sudo is passwordless here", and the user can only
|
|
12640
12858
|
# answer the one they are shown.
|
|
12641
|
-
_priv = _ws_priv_reason(
|
|
12859
|
+
_priv = _ws_priv_reason(_cmdline)
|
|
12642
12860
|
_facts = _ws_access_facts() if _priv else None
|
|
12643
12861
|
if _priv and _facts and not _facts["can_elevate"]:
|
|
12644
12862
|
# REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
|
|
@@ -12689,7 +12907,8 @@ def run_agent_chat(cfg):
|
|
|
12689
12907
|
_emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
|
|
12690
12908
|
_last_obs["text"] = msg
|
|
12691
12909
|
return msg
|
|
12692
|
-
_emit({"type": "step", "text": f"Running → {command
|
|
12910
|
+
_emit({"type": "step", "text": f"Running → {_command_label(command, 70)}"})
|
|
12911
|
+
_emit({"type": "step", "text": _running_command_line(command)}) # whole (#331)
|
|
12693
12912
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
12694
12913
|
# Own process group (start_new_session) + output to a log file. This is
|
|
12695
12914
|
# what lets a server OUTLIVE this job process, and what lets a timeout
|
|
@@ -12705,13 +12924,30 @@ def run_agent_chat(cfg):
|
|
|
12705
12924
|
if _attrib_note:
|
|
12706
12925
|
_log(f"run_command: {_attrib_note}")
|
|
12707
12926
|
# Scrubbed env: repo scripts must never see worker secrets.
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
12712
|
-
|
|
12927
|
+
# The heredoc's text as stdin, from an unnamed temp file: no pipe to keep fed (a
|
|
12928
|
+
# pipe needs a writer thread or it deadlocks past 64 KB), and nothing left on
|
|
12929
|
+
# disk — the file goes when the last handle on it closes.
|
|
12930
|
+
_stdin_fh = None
|
|
12931
|
+
if _stdin_text is not None:
|
|
12932
|
+
import tempfile
|
|
12933
|
+
_stdin_fh = tempfile.TemporaryFile()
|
|
12934
|
+
_stdin_fh.write(_stdin_text.encode("utf-8"))
|
|
12935
|
+
_stdin_fh.seek(0)
|
|
12936
|
+
try:
|
|
12937
|
+
# stdin is passed ONLY for a heredoc — every other command keeps exactly
|
|
12938
|
+
# the stdin it has always had.
|
|
12939
|
+
proc = subprocess.Popen(
|
|
12940
|
+
argv, cwd=wd, env=_ws_scrubbed_env(),
|
|
12941
|
+
stdout=log_fh, stderr=subprocess.STDOUT,
|
|
12942
|
+
**({"stdin": _stdin_fh} if _stdin_fh is not None else {}),
|
|
12943
|
+
**_ws_spawn_kwargs(),
|
|
12944
|
+
)
|
|
12945
|
+
finally:
|
|
12946
|
+
if _stdin_fh is not None:
|
|
12947
|
+
_stdin_fh.close() # the child holds its own copy
|
|
12713
12948
|
_INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
|
|
12714
12949
|
start = _t.time()
|
|
12950
|
+
_out_seen: dict = {} # how much of its output the terminal has been told about
|
|
12715
12951
|
|
|
12716
12952
|
def _read_log() -> str:
|
|
12717
12953
|
log_fh.flush()
|
|
@@ -12744,7 +12980,7 @@ def run_agent_chat(cfg):
|
|
|
12744
12980
|
if rc == 0 else f"FAILED (exit {rc})"
|
|
12745
12981
|
)
|
|
12746
12982
|
_emit({"type": "step", "text":
|
|
12747
|
-
f"Command {'finished' if rc == 0 else 'failed'} → {command
|
|
12983
|
+
f"Command {'finished' if rc == 0 else 'failed'} → {_command_label(command, 50)}"})
|
|
12748
12984
|
# SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
|
|
12749
12985
|
# was indistinguishable from a command that never ran — which is exactly
|
|
12750
12986
|
# what `cd X && Y` used to do — and the model read the silence as a
|
|
@@ -12818,6 +13054,8 @@ def run_agent_chat(cfg):
|
|
|
12818
13054
|
f"{t}s — killed. Output so far:\n{tail}")
|
|
12819
13055
|
_last_obs["text"] = msg
|
|
12820
13056
|
return msg
|
|
13057
|
+
log_fh.flush()
|
|
13058
|
+
_emit_command_progress(log_path, _out_seen)
|
|
12821
13059
|
_t.sleep(2)
|
|
12822
13060
|
except Exception as ex: # noqa: BLE001
|
|
12823
13061
|
msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
|
package/gu-repl.mjs
CHANGED
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
ragSegmentLabel,
|
|
64
64
|
searchSegmentLabel,
|
|
65
65
|
runningDetailRows,
|
|
66
|
+
runningCommandLabel,
|
|
67
|
+
runningCommandRows,
|
|
66
68
|
formatThinkTime,
|
|
67
69
|
plainAnswer,
|
|
68
70
|
summarizeError,
|
|
@@ -128,6 +130,7 @@ import {
|
|
|
128
130
|
} from "./terminal-layout.mjs";
|
|
129
131
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
130
132
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
133
|
+
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
131
134
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
132
135
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
133
136
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -891,6 +894,7 @@ const COMMANDS = [
|
|
|
891
894
|
{ name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
|
|
892
895
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
893
896
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
897
|
+
{ name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
|
|
894
898
|
{ name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
|
|
895
899
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
896
900
|
// for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
|
|
@@ -1462,6 +1466,11 @@ function applyClick({ row, col }) {
|
|
|
1462
1466
|
void runPricesUpdate();
|
|
1463
1467
|
return;
|
|
1464
1468
|
}
|
|
1469
|
+
if (logsRowTarget && row === updateRowAbs &&
|
|
1470
|
+
col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
|
|
1471
|
+
void runClearLogs();
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1465
1474
|
if (agentsRowTarget && row === updateRowAbs &&
|
|
1466
1475
|
col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
|
|
1467
1476
|
void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
|
|
@@ -2680,6 +2689,9 @@ function zoneNotice(width) {
|
|
|
2680
2689
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2681
2690
|
const agents = agentsIndicator(otherAgents);
|
|
2682
2691
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
2692
|
+
// LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
|
|
2693
|
+
const logs = clearLogsNoticeText(logsBytes);
|
|
2694
|
+
if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
|
|
2683
2695
|
return noticeRow(items, { width, gutter: GUTTER });
|
|
2684
2696
|
}
|
|
2685
2697
|
|
|
@@ -2760,6 +2772,7 @@ function frozenZone() {
|
|
|
2760
2772
|
updateRowTarget = notice?.spans.update ?? null;
|
|
2761
2773
|
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2762
2774
|
pricesRowTarget = notice?.spans.prices ?? null;
|
|
2775
|
+
logsRowTarget = notice?.spans.logs ?? null;
|
|
2763
2776
|
jumpSpan = notice?.spans.jump ?? null;
|
|
2764
2777
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
2765
2778
|
}
|
|
@@ -2829,7 +2842,7 @@ function paintBar() {
|
|
|
2829
2842
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2830
2843
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2831
2844
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2832
|
-
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
|
|
2845
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
2833
2846
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2834
2847
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2835
2848
|
ui(
|
|
@@ -2991,6 +3004,8 @@ let updateRowAbs = 0;
|
|
|
2991
3004
|
let agentsRowTarget = null;
|
|
2992
3005
|
// …and "new prices updated <date>" (#323).
|
|
2993
3006
|
let pricesRowTarget = null;
|
|
3007
|
+
// …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
|
|
3008
|
+
let logsRowTarget = null;
|
|
2994
3009
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
2995
3010
|
// bracketed-paste and mouse-reporting teardowns.
|
|
2996
3011
|
process.on("exit", () => {
|
|
@@ -7879,6 +7894,128 @@ async function runPricesUpdate() {
|
|
|
7879
7894
|
}
|
|
7880
7895
|
}
|
|
7881
7896
|
|
|
7897
|
+
// ---------------------------------------------------------------------------------------------
|
|
7898
|
+
// "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
|
|
7899
|
+
// and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
|
|
7900
|
+
// and beside them the two things that are actually big, the RAG indexes and the downloaded API
|
|
7901
|
+
// versions — shows that with a total, then asks about each. What each "clear" may and may not
|
|
7902
|
+
// touch is decided in clear-logs.mjs; this is the terminal half only.
|
|
7903
|
+
// ---------------------------------------------------------------------------------------------
|
|
7904
|
+
let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
|
|
7905
|
+
let clearLogsBusy = false;
|
|
7906
|
+
|
|
7907
|
+
async function refreshLogsSize() {
|
|
7908
|
+
try {
|
|
7909
|
+
const { surveyLogs } = await import("./clear-logs.mjs");
|
|
7910
|
+
const was = clearLogsNoticeText(logsBytes);
|
|
7911
|
+
logsBytes = (await surveyLogs()).bytes;
|
|
7912
|
+
if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
|
|
7913
|
+
} catch {
|
|
7914
|
+
/* a housekeeping notice is never worth an error */
|
|
7915
|
+
}
|
|
7916
|
+
}
|
|
7917
|
+
|
|
7918
|
+
async function runClearLogs() {
|
|
7919
|
+
// ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
|
|
7920
|
+
// (see showOtherAgents for what a second picker over the first one does).
|
|
7921
|
+
if (clearLogsBusy || listPickerActive || approvalActive || following) return;
|
|
7922
|
+
clearLogsBusy = true;
|
|
7923
|
+
try {
|
|
7924
|
+
const cl = await import("./clear-logs.mjs");
|
|
7925
|
+
const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
|
|
7926
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
7927
|
+
console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
|
|
7928
|
+
// Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
|
|
7929
|
+
const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
|
|
7930
|
+
.then((r) => r.json())
|
|
7931
|
+
.then((j) => String(j?.version ?? ""))
|
|
7932
|
+
.catch(() => "");
|
|
7933
|
+
const survey = await cl.surveyDisk({ runningVersion });
|
|
7934
|
+
console.log("");
|
|
7935
|
+
for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
|
|
7936
|
+
console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
|
|
7937
|
+
}
|
|
7938
|
+
const freed = [];
|
|
7939
|
+
// A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
|
|
7940
|
+
const question = (q) => {
|
|
7941
|
+
const hint = `(${PICK_HINT}):`;
|
|
7942
|
+
console.log("");
|
|
7943
|
+
if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
|
|
7944
|
+
else console.log(dim(` ${q}\n ${hint}`));
|
|
7945
|
+
};
|
|
7946
|
+
const cancelled = () => {
|
|
7947
|
+
console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
|
|
7948
|
+
paintBar();
|
|
7949
|
+
};
|
|
7950
|
+
|
|
7951
|
+
// 1. THE LOGS — what was asked for, so Yes is the first row.
|
|
7952
|
+
if (survey.logs.bytes > 0) {
|
|
7953
|
+
question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
|
|
7954
|
+
const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
|
|
7955
|
+
if (go < 0) return cancelled();
|
|
7956
|
+
if (go === 0) {
|
|
7957
|
+
const r = await cl.clearLogs(survey.logs);
|
|
7958
|
+
freed.push({ what: "logs", ...r });
|
|
7959
|
+
console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
7960
|
+
(r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
|
|
7961
|
+
} else {
|
|
7962
|
+
console.log(GUTTER + dim("logs kept"));
|
|
7963
|
+
}
|
|
7964
|
+
} else {
|
|
7965
|
+
console.log("\n" + GUTTER + dim("No logs to clear."));
|
|
7966
|
+
}
|
|
7967
|
+
|
|
7968
|
+
// 2. RAG — costs a re-index, so No is the first row.
|
|
7969
|
+
if (survey.rag.indexes.length) {
|
|
7970
|
+
const gone = survey.rag.indexes.filter((i) => i.missing);
|
|
7971
|
+
const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
|
|
7972
|
+
const choices = [{ id: "no", label: "No — keep them" }];
|
|
7973
|
+
if (gone.length && gone.length < survey.rag.indexes.length) {
|
|
7974
|
+
choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
|
|
7975
|
+
}
|
|
7976
|
+
choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
|
|
7977
|
+
question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
|
|
7978
|
+
const pick = await pickFromList(choices.map((c) => c.label), 0);
|
|
7979
|
+
if (pick < 0) return cancelled();
|
|
7980
|
+
if (choices[pick].id !== "no") {
|
|
7981
|
+
const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
|
|
7982
|
+
freed.push({ what: "rag", ...r });
|
|
7983
|
+
console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
7984
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
7985
|
+
} else {
|
|
7986
|
+
console.log(GUTTER + dim("RAG indexes kept"));
|
|
7987
|
+
}
|
|
7988
|
+
}
|
|
7989
|
+
|
|
7990
|
+
// 3. THE API — only ever the OLDER versions; the one in use is not on offer.
|
|
7991
|
+
if (survey.api.old.length) {
|
|
7992
|
+
const n = survey.api.old.length;
|
|
7993
|
+
question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
|
|
7994
|
+
const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
|
|
7995
|
+
dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
|
|
7996
|
+
if (go < 0) return cancelled();
|
|
7997
|
+
if (go === 1) {
|
|
7998
|
+
console.log(GUTTER + dim("Removing…"));
|
|
7999
|
+
const r = await cl.clearOldApiVersions(survey.api.old);
|
|
8000
|
+
freed.push({ what: "api", ...r });
|
|
8001
|
+
console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
|
|
8002
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
8003
|
+
} else {
|
|
8004
|
+
console.log(GUTTER + dim("older API versions kept"));
|
|
8005
|
+
}
|
|
8006
|
+
}
|
|
8007
|
+
|
|
8008
|
+
const total = freed.reduce((n, f) => n + f.freed, 0);
|
|
8009
|
+
console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
|
|
8010
|
+
} catch (e) {
|
|
8011
|
+
console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
|
|
8012
|
+
} finally {
|
|
8013
|
+
clearLogsBusy = false;
|
|
8014
|
+
await refreshLogsSize();
|
|
8015
|
+
if (barPinned()) paintBar();
|
|
8016
|
+
}
|
|
8017
|
+
}
|
|
8018
|
+
|
|
7882
8019
|
/**
|
|
7883
8020
|
* `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
|
|
7884
8021
|
*
|
|
@@ -9708,6 +9845,10 @@ const RAGSTATE_PREFIX = "__RAGSTATE__::";
|
|
|
9708
9845
|
// The tool ABOUT to run: "<label>\t<call>" (task #177). Swallowed from scrollback like
|
|
9709
9846
|
// "Running → …" — it belongs to the live block, and the finished bullet replaces it.
|
|
9710
9847
|
const RUNTOOL_PREFIX = "__RUNTOOL__::";
|
|
9848
|
+
// The command that is running, WHOLE (task #331): one line of JSON {cmd}, sent beside the
|
|
9849
|
+
// 70-character "Running → …" line — and how many lines it has printed so far. Both swallowed.
|
|
9850
|
+
const RUNCMD_PREFIX = "__RUNCMD__::";
|
|
9851
|
+
const RUNOUT_PREFIX = "__RUNOUT__::";
|
|
9711
9852
|
const MAXSTEP_PREFIX = "__MAXSTEP__::";
|
|
9712
9853
|
const TIMEOUT_PREFIX = "__TIMEOUT__::";
|
|
9713
9854
|
const COMPACT_PREFIX = "__COMPACT__::";
|
|
@@ -10842,6 +10983,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10842
10983
|
let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
|
|
10843
10984
|
let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
|
|
10844
10985
|
let runningCmd = null; // the command currently executing (from a "Running → …" event)
|
|
10986
|
+
// …the same command WHOLE, what it has printed so far, when it started, and whether the status
|
|
10987
|
+
// row is hiding some of it (i.e. a click has something to open) — task #331.
|
|
10988
|
+
let runningCmdFull = "";
|
|
10989
|
+
let runningCmdLines = 0;
|
|
10990
|
+
let runningCmdAt = 0;
|
|
10991
|
+
let runningCmdMore = false;
|
|
10845
10992
|
let runningTool = null; // { label, detail } for the tool executing right now (task #177)
|
|
10846
10993
|
// The model's CURRENT Thought (task #64): while it streams its reasoning inside the
|
|
10847
10994
|
// hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
|
|
@@ -11062,9 +11209,23 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11062
11209
|
// to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
|
|
11063
11210
|
// "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
|
|
11064
11211
|
// the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
|
|
11065
|
-
|
|
11212
|
+
// A RUNNING COMMAND is its own kind of line (#331): "$ <first line>… (17s · 5 lines)" on one
|
|
11213
|
+
// row, and a click opens the whole command underneath instead of a thought's remainder.
|
|
11214
|
+
const cmdView = runningCmd && !runningTool
|
|
11215
|
+
? runningCommandLabel(runningCmdFull || runningCmd, {
|
|
11216
|
+
room: max,
|
|
11217
|
+
secs: runningCmdAt ? Math.max(0, Math.round((now - runningCmdAt) / 1000)) : secs,
|
|
11218
|
+
lines: runningCmdLines,
|
|
11219
|
+
expanded: thoughtExpanded,
|
|
11220
|
+
})
|
|
11221
|
+
: null;
|
|
11222
|
+
runningCmdMore = Boolean(cmdView?.more);
|
|
11223
|
+
const clickable = cmdView ? cmdView.more : !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
|
|
11066
11224
|
let label, rest;
|
|
11067
|
-
if (
|
|
11225
|
+
if (cmdView) {
|
|
11226
|
+
label = cmdView.label;
|
|
11227
|
+
rest = "";
|
|
11228
|
+
} else if (clickable) {
|
|
11068
11229
|
// Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
|
|
11069
11230
|
// space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
|
|
11070
11231
|
// Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
|
|
@@ -11084,7 +11245,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11084
11245
|
}
|
|
11085
11246
|
// When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
|
|
11086
11247
|
// the playful-word line.
|
|
11087
|
-
const expLines =
|
|
11248
|
+
const expLines = cmdView
|
|
11249
|
+
? thoughtExpanded
|
|
11250
|
+
? runningCommandRows(runningCmdFull || runningCmd, {
|
|
11251
|
+
width: Math.max(20, process.stdout.columns || 80) - 1 - GUTTER.length,
|
|
11252
|
+
})
|
|
11253
|
+
: []
|
|
11254
|
+
: thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
|
|
11088
11255
|
// WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
|
|
11089
11256
|
// this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
|
|
11090
11257
|
// the #109/#138 failure mode.
|
|
@@ -11215,14 +11382,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11215
11382
|
if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
|
|
11216
11383
|
// Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
|
|
11217
11384
|
// so we don't flicker an empty expand.
|
|
11218
|
-
if (!thoughtExpanded && !(
|
|
11385
|
+
if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
|
|
11219
11386
|
toggleThought();
|
|
11220
11387
|
};
|
|
11221
11388
|
// Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
|
|
11222
11389
|
// toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
|
|
11223
11390
|
// empty expansion.
|
|
11224
11391
|
const toggleThought = () => {
|
|
11225
|
-
if (!thoughtExpanded && !(
|
|
11392
|
+
if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
|
|
11226
11393
|
thoughtExpanded = !thoughtExpanded;
|
|
11227
11394
|
clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
|
|
11228
11395
|
};
|
|
@@ -11258,6 +11425,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11258
11425
|
clearStatus();
|
|
11259
11426
|
thinking = false;
|
|
11260
11427
|
runningCmd = null;
|
|
11428
|
+
runningCmdFull = "";
|
|
11429
|
+
runningCmdLines = 0;
|
|
11430
|
+
runningCmdAt = 0;
|
|
11261
11431
|
runningTool = null;
|
|
11262
11432
|
// A concrete action landed → the Thought that led to it is spent; clear it so the
|
|
11263
11433
|
// next step's blinking line starts from "Thinking", not last step's stale clause.
|
|
@@ -11405,6 +11575,20 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11405
11575
|
lastContentAt = Date.now();
|
|
11406
11576
|
continue;
|
|
11407
11577
|
}
|
|
11578
|
+
if (line.startsWith(RUNCMD_PREFIX)) {
|
|
11579
|
+
try {
|
|
11580
|
+
runningCmdFull = String(JSON.parse(line.slice(RUNCMD_PREFIX.length))?.cmd ?? "");
|
|
11581
|
+
} catch {
|
|
11582
|
+
runningCmdFull = ""; // the short "Running → …" line still names it
|
|
11583
|
+
}
|
|
11584
|
+
lastContentAt = Date.now();
|
|
11585
|
+
continue;
|
|
11586
|
+
}
|
|
11587
|
+
if (line.startsWith(RUNOUT_PREFIX)) {
|
|
11588
|
+
runningCmdLines = Number(line.slice(RUNOUT_PREFIX.length).trim()) || 0;
|
|
11589
|
+
lastContentAt = Date.now();
|
|
11590
|
+
continue;
|
|
11591
|
+
}
|
|
11408
11592
|
if (line.startsWith(RUNTOOL_PREFIX)) {
|
|
11409
11593
|
const [label, ...rest] = line.slice(RUNTOOL_PREFIX.length).split("\t");
|
|
11410
11594
|
runningTool = { label: (label || "Working").trim(), detail: rest.join("\t").trim() };
|
|
@@ -11575,7 +11759,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11575
11759
|
// So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
|
|
11576
11760
|
if (!plainReplyFlow) {
|
|
11577
11761
|
const mRun = /^Running → (.+)$/.exec(line.trim());
|
|
11578
|
-
if (mRun) {
|
|
11762
|
+
if (mRun) {
|
|
11763
|
+
runningCmd = mRun[1];
|
|
11764
|
+
// a NEW command: its own clock and count, and it starts as one row (#331)
|
|
11765
|
+
runningCmdFull = "";
|
|
11766
|
+
runningCmdLines = 0;
|
|
11767
|
+
runningCmdAt = Date.now();
|
|
11768
|
+
thoughtExpanded = false;
|
|
11769
|
+
thinking = false;
|
|
11770
|
+
lastContentAt = Date.now();
|
|
11771
|
+
continue;
|
|
11772
|
+
}
|
|
11579
11773
|
}
|
|
11580
11774
|
// Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
|
|
11581
11775
|
// prints yet, so the thinking ticker should keep animating until completion.
|
|
@@ -12803,6 +12997,8 @@ async function main() {
|
|
|
12803
12997
|
// #323: gu's newest published price + cache tables — same cadence, same silent failure.
|
|
12804
12998
|
void refreshPriceUpdate();
|
|
12805
12999
|
setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
|
|
13000
|
+
void refreshLogsSize();
|
|
13001
|
+
setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
|
|
12806
13002
|
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
12807
13003
|
startupTokenRefresh = refreshCliToken();
|
|
12808
13004
|
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
@@ -13263,6 +13459,10 @@ async function main() {
|
|
|
13263
13459
|
await runPricesCommand(line);
|
|
13264
13460
|
continue;
|
|
13265
13461
|
}
|
|
13462
|
+
if (line === "/clear-logs") {
|
|
13463
|
+
await runClearLogs();
|
|
13464
|
+
continue;
|
|
13465
|
+
}
|
|
13266
13466
|
if (line === "/tokens") {
|
|
13267
13467
|
// Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
|
|
13268
13468
|
// this answers "and what displaced it", which is the part that tells you whether RAG is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.788",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"poll-errors.mjs",
|
|
78
78
|
"node-version.mjs",
|
|
79
79
|
"clear-state.mjs",
|
|
80
|
+
"clear-logs.mjs",
|
|
80
81
|
"platform-tools.mjs",
|
|
81
82
|
"job-pools.mjs",
|
|
82
83
|
"agent-stop.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2586,6 +2586,65 @@ export function runningDetailRows(detail, { width = 80, indent = 5, maxRows = 2
|
|
|
2586
2586
|
];
|
|
2587
2587
|
}
|
|
2588
2588
|
|
|
2589
|
+
/**
|
|
2590
|
+
* THE COMMAND THAT IS RUNNING, on the live status row (task #331): "$ find . -type f … (17s · 5 lines)".
|
|
2591
|
+
*
|
|
2592
|
+
* It used to read "Running <first 70 characters>… (17s)" with every newline squashed to a space,
|
|
2593
|
+
* so a heredoc was one run-on line and a long pipeline stopped mid-flag. Now: the shell's "$", the
|
|
2594
|
+
* command's FIRST LINE (a heredoc's first line is what names it), and at the right what the user
|
|
2595
|
+
* is waiting to learn — how long it has run and how much it has printed.
|
|
2596
|
+
*
|
|
2597
|
+
* ONE ROW, ALWAYS — the status block is redrawn in place and a taller block moves the input line.
|
|
2598
|
+
* The rest of the command is a click (or Ctrl+T) away: see runningCommandRows. → { label, more }
|
|
2599
|
+
* `more` = there is more of the command than this row shows, i.e. the click has something to open.
|
|
2600
|
+
*/
|
|
2601
|
+
export function runningCommandLabel(command, { room = 66, secs = 0, lines = 0, expanded = false } = {}) {
|
|
2602
|
+
const all = String(command ?? "").replace(/\r/g, "").split("\n").map((l) => l.replace(/\t/g, " ").replace(/\s+$/, "")).filter((l) => l.trim());
|
|
2603
|
+
const n = Math.max(0, Number(lines) || 0);
|
|
2604
|
+
const suffix = `(${Math.max(0, secs | 0)}s${n ? ` · ${n} line${n === 1 ? "" : "s"}` : ""})`;
|
|
2605
|
+
const multi = all.length > 1;
|
|
2606
|
+
// opened: the rows underneath ARE the command, so the row above them just says what they are
|
|
2607
|
+
if (expanded) return { label: `Running command ${suffix}`, more: true };
|
|
2608
|
+
const head = `$ ${(all[0] ?? "").trim()}`;
|
|
2609
|
+
const space = Math.max(8, (room | 0) - suffix.length - 2);
|
|
2610
|
+
const cut = head.length + (multi ? 2 : 0) > space;
|
|
2611
|
+
const shown = cut ? head.slice(0, Math.max(1, space - 1)).replace(/\s+$/, "") + "…" : head + (multi ? " …" : "");
|
|
2612
|
+
return { label: `${shown} ${suffix}`, more: cut || multi };
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/**
|
|
2616
|
+
* The whole command, opened under the status row: "└ $ …" with a hanging indent, its own line
|
|
2617
|
+
* breaks kept (a heredoc reads as the script it is), long lines wrapped at a space, and a cap —
|
|
2618
|
+
* past it the last row ends in "…". Rows NEVER exceed `width`: the block is redrawn by walking
|
|
2619
|
+
* physical rows, and a row that wraps on its own breaks every row under it (#109, #138).
|
|
2620
|
+
*/
|
|
2621
|
+
export function runningCommandRows(command, { width = 76, maxRows = 5 } = {}) {
|
|
2622
|
+
const lead = "└ $ ";
|
|
2623
|
+
const pad = " ".repeat(lead.length);
|
|
2624
|
+
const room = Math.max(12, (width | 0) - lead.length);
|
|
2625
|
+
const out = [];
|
|
2626
|
+
for (const raw of String(command ?? "").replace(/\r/g, "").split("\n")) {
|
|
2627
|
+
let rest = raw.replace(/\t/g, " ").replace(/\s+$/, "");
|
|
2628
|
+
if (!rest.trim()) continue;
|
|
2629
|
+
while (rest.length > room) {
|
|
2630
|
+
const sp = rest.lastIndexOf(" ", room);
|
|
2631
|
+
// …but never break inside the leading indentation, or a wrapped line loses its shape
|
|
2632
|
+
const cut = sp > room * 0.5 ? sp : room;
|
|
2633
|
+
out.push(rest.slice(0, cut));
|
|
2634
|
+
rest = rest.slice(cut).replace(/^ /, "");
|
|
2635
|
+
}
|
|
2636
|
+
out.push(rest);
|
|
2637
|
+
}
|
|
2638
|
+
if (!out.length) return [];
|
|
2639
|
+
const cap = Math.max(1, maxRows | 0);
|
|
2640
|
+
const rows = out.slice(0, cap);
|
|
2641
|
+
if (out.length > cap) {
|
|
2642
|
+
const last = rows[cap - 1];
|
|
2643
|
+
rows[cap - 1] = (last.length >= room ? last.slice(0, room - 1) : last) + "…";
|
|
2644
|
+
}
|
|
2645
|
+
return rows.map((r, i) => (i ? pad : lead) + r);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2589
2648
|
/**
|
|
2590
2649
|
* How long the turn took, for the line above the answer (task #178).
|
|
2591
2650
|
*
|