@tiens.nguyen/gu-cli 1.0.704 → 1.0.706
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-state.mjs +2 -0
- package/cli-token.mjs +285 -0
- package/gonext_agent_chat.py +51 -6
- package/gu-cli.mjs +44 -0
- package/gu-repl.mjs +145 -8
- package/package.json +3 -1
- package/verbose-lines.mjs +33 -0
package/clear-state.mjs
CHANGED
|
@@ -32,6 +32,8 @@ export const STATE_ITEMS = [
|
|
|
32
32
|
what: "Local-mode setup progress and the chosen mode", cost: "setup starts over" },
|
|
33
33
|
{ path: "instance", group: "identity",
|
|
34
34
|
what: "this machine's id", cost: "it gets a new one" },
|
|
35
|
+
{ path: "cli-token.json", group: "identity",
|
|
36
|
+
what: "the gu token (plan + computers)", cost: "a Client Expert machine needs it pasted again" },
|
|
35
37
|
{ path: "prefs.json", group: "identity", what: "CLI preferences", cost: "defaults return" },
|
|
36
38
|
{ path: "wizard-state.json", group: "identity",
|
|
37
39
|
what: "installer progress", cost: "the wizard starts over" },
|
package/cli-token.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CLI TOKEN, CLIENT SIDE (#232) — keep it fresh, and say one line when something needs attention.
|
|
3
|
+
*
|
|
4
|
+
* A SOFT LICENCE, by the owner's choice: nothing here ever blocks a session, refuses a command or
|
|
5
|
+
* delays a prompt. The worst it does is one line in the bottom bar. Someone who patches it out is
|
|
6
|
+
* acceptable.
|
|
7
|
+
*
|
|
8
|
+
* The token is signed by the hosted API (api/src/billing/cliToken.ts) and checked HERE, offline,
|
|
9
|
+
* with the public key only. The rules in verifyCliToken must match the API's; the test file checks
|
|
10
|
+
* a token the real API code signed.
|
|
11
|
+
*
|
|
12
|
+
* RENEWAL, agreed with the owner:
|
|
13
|
+
* · Client mode (hosted API): at start, then at most once a DAY — with the worker key when there is
|
|
14
|
+
* no token yet, with the token after that.
|
|
15
|
+
* · Client Expert (local-only): at most once a WEEK, and always against the HOSTED API. The local
|
|
16
|
+
* API cannot sign tokens or see payments, and a local-only machine should make the fewest
|
|
17
|
+
* outside calls that still work.
|
|
18
|
+
* · Either mode renews at start while a warning is showing, so someone who just paid is not told
|
|
19
|
+
* "plan ended" for a week.
|
|
20
|
+
* · Every failure is silent: the current token stays and nothing new is shown.
|
|
21
|
+
*
|
|
22
|
+
* THE KEY. EMBEDDED_PUBLIC_KEY is the public half of the production pair, generated ONCE on
|
|
23
|
+
* 2026-09-17 (api: npm run cli-token:keygen). The private half lives only in the API's api.env and the
|
|
24
|
+
* owner's password manager. Generating another pair makes every installed CLI reject every new token
|
|
25
|
+
* — see the note in api/scripts/cli-token-keygen.mts before ever doing it.
|
|
26
|
+
* With an empty key (GU_CLI_TOKEN_PUBLIC_KEY unset and none embedded) the feature shows nothing and
|
|
27
|
+
* sends nothing.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
32
|
+
import { hostname as osHostname } from "node:os";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
|
|
35
|
+
/** The public half of the API's CLI_TOKEN_PRIVATE_KEY, base64 SPKI DER (Ed25519). Generated 2026-09-17. */
|
|
36
|
+
export const EMBEDDED_PUBLIC_KEY = "MCowBQYDK2VwAyEA2xkKnY+xWGoS4Nvc7DEHCCurdQQYZGl/x7kPp+gDmvY=";
|
|
37
|
+
|
|
38
|
+
export const TOKEN_PREFIX = "gu1";
|
|
39
|
+
export const STALE_AFTER_DAYS = 30;
|
|
40
|
+
export const RENEW_EVERY_MS = { client: 86_400_000, expert: 7 * 86_400_000 };
|
|
41
|
+
/** Where the notices send people. Overridable for staging. */
|
|
42
|
+
export const DEFAULT_SITE = "https://gu.gomarsic.cc";
|
|
43
|
+
|
|
44
|
+
export function publicKey(env = process.env) {
|
|
45
|
+
return String(env.GU_CLI_TOKEN_PUBLIC_KEY ?? "").trim() || EMBEDDED_PUBLIC_KEY;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function siteUrl(env = process.env) {
|
|
49
|
+
return (String(env.GU_SITE_URL ?? "").trim() || DEFAULT_SITE).replace(/\/+$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isIso = (v) => typeof v === "string" && v.length > 0 && !Number.isNaN(Date.parse(v));
|
|
53
|
+
|
|
54
|
+
/** Same rules as api/src/billing/cliToken.ts verifyCliToken. Never throws. */
|
|
55
|
+
export function verifyCliToken(token, publicKeyB64) {
|
|
56
|
+
if (typeof token !== "string") return { ok: false, why: "malformed" };
|
|
57
|
+
const parts = token.trim().split(".");
|
|
58
|
+
if (parts.length !== 3 || !parts[1] || !parts[2]) return { ok: false, why: "malformed" };
|
|
59
|
+
if (parts[0] !== TOKEN_PREFIX) return { ok: false, why: "unknown-version" };
|
|
60
|
+
let key;
|
|
61
|
+
try {
|
|
62
|
+
key = createPublicKey({ key: Buffer.from(String(publicKeyB64), "base64"), format: "der", type: "spki" });
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, why: "bad-signature" };
|
|
65
|
+
}
|
|
66
|
+
let good = false;
|
|
67
|
+
try {
|
|
68
|
+
good = verify(null, Buffer.from(`${parts[0]}.${parts[1]}`, "utf8"), key, Buffer.from(parts[2], "base64url"));
|
|
69
|
+
} catch {
|
|
70
|
+
good = false;
|
|
71
|
+
}
|
|
72
|
+
if (!good) return { ok: false, why: "bad-signature" };
|
|
73
|
+
let p;
|
|
74
|
+
try {
|
|
75
|
+
p = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, why: "bad-payload" };
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
!p || typeof p !== "object" ||
|
|
81
|
+
p.v !== 1 ||
|
|
82
|
+
typeof p.sub !== "string" || !p.sub ||
|
|
83
|
+
(p.plan !== "pro" && p.plan !== "free") ||
|
|
84
|
+
!(p.paidUntil === null || isIso(p.paidUntil)) ||
|
|
85
|
+
!isIso(p.issuedAt) ||
|
|
86
|
+
(p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
|
|
87
|
+
!(p.device === null || (typeof p.device === "string" && /^[0-9a-f]{64}$/.test(p.device))) ||
|
|
88
|
+
!(p.activeDevices === null || (Number.isInteger(p.activeDevices) && p.activeDevices >= 0)) ||
|
|
89
|
+
!(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0)
|
|
90
|
+
) {
|
|
91
|
+
return { ok: false, why: "bad-payload" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, payload: p };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// THIS COMPUTER
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const run = (cmd, args) =>
|
|
101
|
+
new Promise((resolve) => {
|
|
102
|
+
execFile(cmd, args, { timeout: 3000, windowsHide: true }, (err, stdout) => resolve(err ? "" : String(stdout)));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The operating system's own id for this machine, or "".
|
|
107
|
+
*
|
|
108
|
+
* NOT ~/.gonext/instance: copying the folder to another computer carries that file along, and
|
|
109
|
+
* `gu clear` makes a new one, which would count one laptop several times. The OS id survives both.
|
|
110
|
+
*/
|
|
111
|
+
export async function osMachineId({ platform = process.platform, read = (p) => readFile(p, "utf8"), exec = run } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
if (platform === "darwin") {
|
|
114
|
+
const out = await exec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
115
|
+
return out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1]?.trim() ?? "";
|
|
116
|
+
}
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
const out = await exec("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
|
|
119
|
+
return out.match(/MachineGuid\s+REG_SZ\s+(\S+)/i)?.[1]?.trim() ?? "";
|
|
120
|
+
}
|
|
121
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
122
|
+
const id = (await read(p).catch(() => "")).trim();
|
|
123
|
+
if (id) return id;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
/* fall through to "" */
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
|
|
132
|
+
export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
|
|
133
|
+
const raw = machineId ?? (await osMachineId({ platform }));
|
|
134
|
+
const source = raw || (instanceId ? `instance:${instanceId}` : "");
|
|
135
|
+
if (!source) return null;
|
|
136
|
+
return {
|
|
137
|
+
id: createHash("sha256").update(`gu-device-v1:${source}`).digest("hex"),
|
|
138
|
+
hostName: String(hostName ?? "").slice(0, 64),
|
|
139
|
+
os: platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// STATE: ~/.gonext/cli-token.json
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export const STATE_FILE = "cli-token.json";
|
|
148
|
+
|
|
149
|
+
export async function readState(dir) {
|
|
150
|
+
try {
|
|
151
|
+
const s = JSON.parse(await readFile(join(dir, STATE_FILE), "utf8"));
|
|
152
|
+
return {
|
|
153
|
+
token: typeof s.token === "string" ? s.token : "",
|
|
154
|
+
lastAttemptAt: isIso(s.lastAttemptAt) ? s.lastAttemptAt : null,
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return { token: "", lastAttemptAt: null };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Atomic and owner-only: a token is not a password, but it is someone's, and renews for them. */
|
|
162
|
+
export async function writeState(dir, state) {
|
|
163
|
+
const file = join(dir, STATE_FILE);
|
|
164
|
+
await mkdir(dirname(file), { recursive: true });
|
|
165
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
166
|
+
await writeFile(tmp, JSON.stringify({ token: state.token ?? "", lastAttemptAt: state.lastAttemptAt ?? null }, null, 2) + "\n", {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
await rename(tmp, file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// WHEN TO RENEW, AND HOW
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** mode: "client" | "expert". warningShowing: a notice is up right now. */
|
|
178
|
+
export function shouldRenew({ state, mode, now = new Date(), warningShowing = false }) {
|
|
179
|
+
if (warningShowing) return true;
|
|
180
|
+
const last = Date.parse(state?.lastAttemptAt ?? "");
|
|
181
|
+
if (Number.isNaN(last)) return true;
|
|
182
|
+
return now.getTime() - last >= (RENEW_EVERY_MS[mode] ?? RENEW_EVERY_MS.client);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One renewal request. Returns { ok: true, token } or { ok: false, why } — never throws.
|
|
187
|
+
*
|
|
188
|
+
* With a token: POST /api/billing/cli-token/renew, bearer = token. Without one, in Client mode only:
|
|
189
|
+
* POST /api/billing/cli-token with the worker key. A Client Expert worker key belongs to the LOCAL
|
|
190
|
+
* API and means nothing to the hosted one, so it is never sent there.
|
|
191
|
+
*/
|
|
192
|
+
export async function renewToken({ mode, apiBase, hostedBase, token, workerKey, device, fetchImpl = fetch, timeoutMs = 8000 }) {
|
|
193
|
+
const base = String(mode === "expert" ? hostedBase : apiBase || hostedBase).replace(/\/+$/, "");
|
|
194
|
+
const body = JSON.stringify(device ? { device } : {});
|
|
195
|
+
let url;
|
|
196
|
+
const headers = { "content-type": "application/json" };
|
|
197
|
+
if (token) {
|
|
198
|
+
url = `${base}/api/billing/cli-token/renew`;
|
|
199
|
+
headers.authorization = `Bearer ${token}`;
|
|
200
|
+
} else if (mode === "client" && workerKey) {
|
|
201
|
+
url = `${base}/api/billing/cli-token`;
|
|
202
|
+
headers["x-worker-key"] = workerKey;
|
|
203
|
+
} else {
|
|
204
|
+
return { ok: false, why: "no-credential" };
|
|
205
|
+
}
|
|
206
|
+
if (!base) return { ok: false, why: "no-api" };
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(timeoutMs) });
|
|
209
|
+
if (!res.ok) return { ok: false, why: `http-${res.status}` };
|
|
210
|
+
const data = await res.json().catch(() => null);
|
|
211
|
+
return typeof data?.token === "string" ? { ok: true, token: data.token } : { ok: false, why: "bad-response" };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { ok: false, why: e?.name === "TimeoutError" ? "timeout" : "network" };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// WHAT THE BAR SAYS
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
222
|
+
/** "12 Oct 2026" — spelled out here rather than via toLocaleDateString, whose month names vary by ICU version. */
|
|
223
|
+
export function shortDate(iso) {
|
|
224
|
+
const d = new Date(iso);
|
|
225
|
+
return Number.isNaN(d.getTime()) ? "" : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The one line for the bottom bar, or null. Highest priority first:
|
|
230
|
+
*
|
|
231
|
+
* 1. the plan ended — money, and the fix is on the site
|
|
232
|
+
* 2. more computers than the plan covers
|
|
233
|
+
* 3. no usable token, or one over 30 days old — only AFTER a renewal was tried in Client mode,
|
|
234
|
+
* where one is normally fetched automatically; straight away in Client Expert, where nothing
|
|
235
|
+
* fetches one until a token is pasted
|
|
236
|
+
*
|
|
237
|
+
* Never the word "licence". Returns null when the feature is off (no public key).
|
|
238
|
+
*/
|
|
239
|
+
export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempted = false, site = DEFAULT_SITE }) {
|
|
240
|
+
if (!publicKeyB64) return null;
|
|
241
|
+
const host = site.replace(/^https?:\/\//, "");
|
|
242
|
+
// Shown without the scheme — shorter on a narrow bar, and still what a person types.
|
|
243
|
+
const pricingUrl = `${host}/pricing`;
|
|
244
|
+
const accountUrl = `${host}/account`;
|
|
245
|
+
const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
|
|
246
|
+
|
|
247
|
+
if (check.ok) {
|
|
248
|
+
const p = check.payload;
|
|
249
|
+
const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
|
|
250
|
+
if (ended) return { kind: "plan-ended", text: `Your gu Pro plan ended on ${shortDate(p.paidUntil)} — renew at ${pricingUrl}` };
|
|
251
|
+
if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
252
|
+
return {
|
|
253
|
+
kind: "over-limit",
|
|
254
|
+
text: `gu is active on ${p.activeDevices} computers — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const age = now.getTime() - Date.parse(p.issuedAt);
|
|
258
|
+
if (age <= STALE_AFTER_DAYS * 86_400_000) return null;
|
|
259
|
+
} else if (mode === "client" && !attempted) {
|
|
260
|
+
return null; // Client mode fetches one by itself; do not complain before it has tried.
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return mode === "expert"
|
|
264
|
+
? { kind: "stale", text: `Get a fresh gu token at ${accountUrl} to keep getting updates` }
|
|
265
|
+
: { kind: "stale", text: "Sign in to keep getting gu updates — run gu login" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** What `gu token` prints about a valid token — plain lines, no colour, for the command to style. */
|
|
269
|
+
export function tokenSummary(payload, now = new Date()) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
if (payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime()) {
|
|
272
|
+
lines.push(`Pro until ${shortDate(payload.paidUntil)}.`);
|
|
273
|
+
} else if (payload.paidUntil) {
|
|
274
|
+
lines.push(`Free — the Pro plan ended on ${shortDate(payload.paidUntil)}.`);
|
|
275
|
+
} else {
|
|
276
|
+
lines.push("Free.");
|
|
277
|
+
}
|
|
278
|
+
if (Number.isInteger(payload.activeDevices)) {
|
|
279
|
+
const n = payload.activeDevices;
|
|
280
|
+
lines.push(`Active on ${n} ${n === 1 ? "computer" : "computers"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
|
|
283
|
+
return lines;
|
|
284
|
+
}
|
|
285
|
+
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1329,6 +1329,11 @@ def _render_numbered_tool_list(tools) -> str:
|
|
|
1329
1329
|
return ("\n".join(lines) + "\n") if lines else ""
|
|
1330
1330
|
|
|
1331
1331
|
|
|
1332
|
+
# A step line only `gu --verbose` shows (#45): retry notices and context trimming are how the
|
|
1333
|
+
# agent keeps itself going, and the user can do nothing with them.
|
|
1334
|
+
_VERBOSE_PREFIX = "__VERBOSE__::"
|
|
1335
|
+
|
|
1336
|
+
|
|
1332
1337
|
def _summarise_step(step_log):
|
|
1333
1338
|
"""Return a short human-readable description of an agent step."""
|
|
1334
1339
|
tool_calls = getattr(step_log, "tool_calls", None) or []
|
|
@@ -1337,6 +1342,9 @@ def _summarise_step(step_log):
|
|
|
1337
1342
|
step_num = getattr(step_log, "step_number", None)
|
|
1338
1343
|
|
|
1339
1344
|
parts = []
|
|
1345
|
+
# A retry the agent recovers from by itself is machinery, not progress: the terminal shows
|
|
1346
|
+
# it only under `gu --verbose` (#45). The model still gets smolagents' exact error text.
|
|
1347
|
+
verbose_only = False
|
|
1340
1348
|
for tc in tool_calls:
|
|
1341
1349
|
name = getattr(tc, "name", "")
|
|
1342
1350
|
args = getattr(tc, "arguments", None)
|
|
@@ -1404,15 +1412,18 @@ def _summarise_step(step_log):
|
|
|
1404
1412
|
# step loop), so the model keeps the precise wording it needs to self-correct.
|
|
1405
1413
|
_msg = "the model wrote no code this step — asking again"
|
|
1406
1414
|
parts.append(f"→ {_msg}" if parts else _msg)
|
|
1415
|
+
verbose_only = True
|
|
1407
1416
|
elif "code parsing failed" in err.lower() or "syntaxerror" in err.lower():
|
|
1408
1417
|
# The sibling shape: there WAS a block, and its body would not compile.
|
|
1409
1418
|
_msg = "the model's code wouldn't run — asking again"
|
|
1410
1419
|
parts.append(f"→ {_msg}" if parts else _msg)
|
|
1420
|
+
verbose_only = True
|
|
1411
1421
|
else:
|
|
1412
1422
|
parts.append(f"→ Error: {_clip(err, 160)}")
|
|
1413
1423
|
|
|
1414
1424
|
# No numeric "Step N:" prefix — show only the semantic action.
|
|
1415
|
-
|
|
1425
|
+
text = " | ".join(parts) if parts else "thinking…"
|
|
1426
|
+
return f"{_VERBOSE_PREFIX}{text}" if verbose_only else text
|
|
1416
1427
|
|
|
1417
1428
|
|
|
1418
1429
|
# Keywords that strongly indicate the user wants to make an HTTP/network request,
|
|
@@ -6012,6 +6023,35 @@ def _shell_syntax_error(command, argv):
|
|
|
6012
6023
|
# commands are confirmed. False = confirm every command. Module-level default so the tool body
|
|
6013
6024
|
# reads a real boolean even on a path where run() never set it.
|
|
6014
6025
|
_accept_edits = True
|
|
6026
|
+
|
|
6027
|
+
|
|
6028
|
+
def _live_accept_edits(api_base, worker_key, job_id, current, _fetch=None):
|
|
6029
|
+
"""The accept-edits switch AS IT IS NOW (#47), not as it was when the turn was queued.
|
|
6030
|
+
|
|
6031
|
+
Shift+Tab during a turn posts the new value to the job; reading it here, right before a
|
|
6032
|
+
command is gated, is what lets it apply to the turn already running. Any failure keeps
|
|
6033
|
+
`current` — a network blip must not silently flip a safety setting in either direction.
|
|
6034
|
+
One GET per command: small next to running the command, and only when a terminal is there
|
|
6035
|
+
to ask (the caller checks)."""
|
|
6036
|
+
base = (api_base or "").rstrip("/")
|
|
6037
|
+
if not base or not worker_key or not job_id:
|
|
6038
|
+
return current
|
|
6039
|
+
try:
|
|
6040
|
+
if _fetch is None:
|
|
6041
|
+
import urllib.request as _u
|
|
6042
|
+
req = _u.Request(f"{base}/api/worker/jobs/{job_id}",
|
|
6043
|
+
headers={"X-Worker-Key": worker_key}, method="GET")
|
|
6044
|
+
with _u.urlopen(req, timeout=5, context=_ssl_context()) as resp:
|
|
6045
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
6046
|
+
else:
|
|
6047
|
+
data = _fetch(f"{base}/api/worker/jobs/{job_id}")
|
|
6048
|
+
except Exception as e: # noqa: BLE001
|
|
6049
|
+
_log(f"accept-edits read failed: {e} — keeping {'on' if current else 'off'}")
|
|
6050
|
+
return current
|
|
6051
|
+
value = data.get("acceptEdits") if isinstance(data, dict) else None
|
|
6052
|
+
return value if isinstance(value, bool) else current
|
|
6053
|
+
|
|
6054
|
+
|
|
6015
6055
|
_WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
|
|
6016
6056
|
_WS_RUN_DENYLIST = set() # extra user blocks from cfg
|
|
6017
6057
|
|
|
@@ -8689,7 +8729,7 @@ def run_agent_chat(cfg):
|
|
|
8689
8729
|
# once, and that answer is remembered. The line exists so the figure is not mistaken
|
|
8690
8730
|
# for something the endpoint reported — see the "measured vs remembered" note above.
|
|
8691
8731
|
_emit({"type": "step", "text": (
|
|
8692
|
-
f"context window {_ctx_window // 1000}k for {coding_model_id} (published default — "
|
|
8732
|
+
f"{_VERBOSE_PREFIX}context window {_ctx_window // 1000}k for {coding_model_id} (published default — "
|
|
8693
8733
|
f"this endpoint does not report one). Corrects itself only if it is too HIGH; "
|
|
8694
8734
|
f"/context-window to set it yourself")})
|
|
8695
8735
|
else:
|
|
@@ -8705,7 +8745,7 @@ def run_agent_chat(cfg):
|
|
|
8705
8745
|
# command immediately and cannot act on an env var without leaving, editing a file and
|
|
8706
8746
|
# restarting — which is how this stayed unset long enough to matter.
|
|
8707
8747
|
_emit({"type": "step", "text": (
|
|
8708
|
-
f"context window unknown — compacting at ~{_compact_at // 1000}k tokens; "
|
|
8748
|
+
f"{_VERBOSE_PREFIX}context window unknown — compacting at ~{_compact_at // 1000}k tokens; "
|
|
8709
8749
|
"run /context-window <tokens> to use more")})
|
|
8710
8750
|
|
|
8711
8751
|
# Task #90 Phase 3 — completion gate (code-enforced, complements the prompt). Fires at
|
|
@@ -8958,7 +8998,7 @@ def run_agent_chat(cfg):
|
|
|
8958
8998
|
if _cs and not _compact_state["told"]:
|
|
8959
8999
|
_compact_state["told"] = True
|
|
8960
9000
|
_emit({"type": "step", "text": (
|
|
8961
|
-
f"Context reached ~{_in_tok // 1000}k tokens — trimming older "
|
|
9001
|
+
f"{_VERBOSE_PREFIX}Context reached ~{_in_tok // 1000}k tokens — trimming older "
|
|
8962
9002
|
"steps to stay fast (files are re-readable)")})
|
|
8963
9003
|
if _cs:
|
|
8964
9004
|
_log(f"context compact: saved ~{_cs} chars "
|
|
@@ -9477,9 +9517,9 @@ def run_agent_chat(cfg):
|
|
|
9477
9517
|
# to say. On empty-content there is genuinely nothing to show — the model spent
|
|
9478
9518
|
# the turn in its reasoning channel and wrote nothing — so a short line stays.
|
|
9479
9519
|
_emit({"type": "step", "text": (
|
|
9480
|
-
"The model thought but didn't act — asking it again…"
|
|
9520
|
+
f"{_VERBOSE_PREFIX}The model thought but didn't act — asking it again…"
|
|
9481
9521
|
if why == "empty-content"
|
|
9482
|
-
else "Asking the model to take the action it described…")})
|
|
9522
|
+
else f"{_VERBOSE_PREFIX}Asking the model to take the action it described…")})
|
|
9483
9523
|
retry_kwargs = dict(completion_kwargs)
|
|
9484
9524
|
retry_kwargs["messages"] = list(completion_kwargs.get("messages") or []) + [
|
|
9485
9525
|
{"role": "user", "content": self._CODE_NOW_DIRECTIVE}
|
|
@@ -11590,6 +11630,11 @@ def run_agent_chat(cfg):
|
|
|
11590
11630
|
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
11591
11631
|
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
11592
11632
|
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
|
|
11633
|
+
# Shift+Tab may have switched it since the turn started (#47).
|
|
11634
|
+
global _accept_edits
|
|
11635
|
+
if _interactive_approval and _job_id:
|
|
11636
|
+
_accept_edits = _live_accept_edits(worker_api_base, worker_api_key,
|
|
11637
|
+
_job_id, _accept_edits)
|
|
11593
11638
|
# ACCEPT EDITS OFF ⇒ ASK BEFORE RUNNING ANYTHING (task #179). The risk gate is
|
|
11594
11639
|
# the FLOOR, not the whole policy: sudo and rm -rf ask either way, and turning
|
|
11595
11640
|
# the toggle off raises the bar to every command. Off is the cautious setting,
|
package/gu-cli.mjs
CHANGED
|
@@ -76,6 +76,8 @@ gu-cli
|
|
|
76
76
|
Usage:
|
|
77
77
|
gu-cli
|
|
78
78
|
gu-cli login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
|
|
79
|
+
gu-cli token [<token>] # show this computer's gu token, or save one pasted from
|
|
80
|
+
# the Account page (Client Expert machines)
|
|
79
81
|
gu-cli set <workerKey> [--api-base <url>]
|
|
80
82
|
gu-cli start | stop | status | restart # run the polling daemon in the background
|
|
81
83
|
gu-cli status # daemon + a table of the running model servers
|
|
@@ -181,6 +183,48 @@ if (args[0] === "set") {
|
|
|
181
183
|
await setConfig();
|
|
182
184
|
process.exit(0);
|
|
183
185
|
}
|
|
186
|
+
if (args[0] === "token") {
|
|
187
|
+
// #232. `gu token <token>` saves a token pasted from the web Account page — the only way a Client
|
|
188
|
+
// Expert machine, which has no worker key, gets its first one. `gu token` alone shows what the
|
|
189
|
+
// saved one says. Checked OFFLINE against the public key before saving, so a typo or a token from
|
|
190
|
+
// another server is refused here rather than turning into a warning later.
|
|
191
|
+
// NOT `gu tokens`: that is token USAGE, and stays exactly as it was.
|
|
192
|
+
const ct = await import("./cli-token.mjs");
|
|
193
|
+
const dir = join(homedir(), ".gonext");
|
|
194
|
+
const pk = ct.publicKey();
|
|
195
|
+
const pasted = String(args[1] ?? "").trim();
|
|
196
|
+
if (!pk) {
|
|
197
|
+
console.error("This version of gu cannot check tokens yet — nothing to do, gu works without one.");
|
|
198
|
+
process.exit(pasted ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
if (pasted) {
|
|
201
|
+
const check = ct.verifyCliToken(pasted, pk);
|
|
202
|
+
if (!check.ok) {
|
|
203
|
+
const why =
|
|
204
|
+
check.why === "bad-signature" ? "it was not issued by gu (or was copied incompletely)"
|
|
205
|
+
: check.why === "unknown-version" ? "it is from a newer gu — update this one first"
|
|
206
|
+
: "it is not a whole token — copy it again from the Account page";
|
|
207
|
+
console.error(`That token was not saved: ${why}.`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
// lastAttemptAt null: the next gu start renews at once, which records THIS computer.
|
|
211
|
+
await ct.writeState(dir, { token: pasted, lastAttemptAt: null });
|
|
212
|
+
console.log(accent("Saved.") + " It renews itself from now on.");
|
|
213
|
+
for (const l of ct.tokenSummary(check.payload)) console.log(" " + l);
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
const state = await ct.readState(dir);
|
|
217
|
+
const check = state.token ? ct.verifyCliToken(state.token, pk) : { ok: false, why: "missing" };
|
|
218
|
+
if (!check.ok) {
|
|
219
|
+
console.log(state.token ? "The saved token is not valid here." : "No gu token on this computer.");
|
|
220
|
+
console.log(` Get one at ${ct.siteUrl()}/account, then: gu token <paste>`);
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
for (const l of ct.tokenSummary(check.payload)) console.log(l);
|
|
224
|
+
if (state.lastAttemptAt) console.log(`Last renewal attempt ${ct.shortDate(state.lastAttemptAt)}.`);
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
if (args[0] === "login") {
|
|
185
229
|
// Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
|
|
186
230
|
// which API to pair against (defaults to the production API).
|
package/gu-repl.mjs
CHANGED
|
@@ -137,6 +137,7 @@ import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } fro
|
|
|
137
137
|
// plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
|
|
138
138
|
// sixteen-colour magenta from what this one actually claims to support.
|
|
139
139
|
import { accent } from "./accent.mjs";
|
|
140
|
+
import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
|
|
140
141
|
import {
|
|
141
142
|
createTranscript,
|
|
142
143
|
displayRows,
|
|
@@ -521,7 +522,7 @@ const argv = process.argv.slice(2);
|
|
|
521
522
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
522
523
|
const CLI_SUBCOMMANDS = [
|
|
523
524
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
524
|
-
"clear", "reset", "api", "token-usage", "tokens",
|
|
525
|
+
"clear", "reset", "api", "token-usage", "tokens", "token",
|
|
525
526
|
];
|
|
526
527
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
527
528
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -1847,6 +1848,69 @@ async function refreshAvailableUpdates() {
|
|
|
1847
1848
|
* output and corrupt a step's story. When that happens the notice waits: the main loop calls
|
|
1848
1849
|
* this again at the next prompt, and the bar has been showing the control the whole time.
|
|
1849
1850
|
*/
|
|
1851
|
+
/**
|
|
1852
|
+
* THE gu TOKEN (#232) — renewed quietly, and at most one line on the separator row.
|
|
1853
|
+
*
|
|
1854
|
+
* A SOFT LICENCE: nothing here blocks, prompts or delays. The schedule lives in cli-token.mjs
|
|
1855
|
+
* (Client: a day; Client Expert, local-only: a week — the owner's call) so this runs on a cheap
|
|
1856
|
+
* hourly tick that usually just re-reads a file and decides nothing is due.
|
|
1857
|
+
*
|
|
1858
|
+
* `startupRenewDone` is what stops "renew while a warning is up" from becoming "renew every hour
|
|
1859
|
+
* while a warning is up": an account over its computer limit stays over it, and would otherwise
|
|
1860
|
+
* call the hosted API all day. It gets ONE early renewal per gu start — enough for someone who just
|
|
1861
|
+
* paid to see the warning go.
|
|
1862
|
+
*/
|
|
1863
|
+
let tokenNotice = null;
|
|
1864
|
+
let tokenBusy = false;
|
|
1865
|
+
let startupRenewDone = false;
|
|
1866
|
+
async function refreshCliToken() {
|
|
1867
|
+
if (tokenBusy) return;
|
|
1868
|
+
tokenBusy = true;
|
|
1869
|
+
try {
|
|
1870
|
+
const ct = await import("./cli-token.mjs");
|
|
1871
|
+
const pk = ct.publicKey();
|
|
1872
|
+
if (!pk) {
|
|
1873
|
+
tokenNotice = null; // no key (none embedded, GU_CLI_TOKEN_PUBLIC_KEY empty): nothing to check against
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const dir = join(homedir(), ".gonext");
|
|
1877
|
+
const mode = isClientExpert() ? "expert" : "client";
|
|
1878
|
+
const site = ct.siteUrl();
|
|
1879
|
+
let state = await ct.readState(dir);
|
|
1880
|
+
const now = new Date();
|
|
1881
|
+
const noticeNow = () =>
|
|
1882
|
+
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
1883
|
+
tokenNotice = noticeNow();
|
|
1884
|
+
|
|
1885
|
+
const early = Boolean(tokenNotice) && !startupRenewDone;
|
|
1886
|
+
startupRenewDone = true;
|
|
1887
|
+
if (ct.shouldRenew({ state, mode, now, warningShowing: early })) {
|
|
1888
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
1889
|
+
const device = await ct.thisDevice({ instanceId: myInstanceId || "" }).catch(() => null);
|
|
1890
|
+
const r = await ct.renewToken({
|
|
1891
|
+
mode,
|
|
1892
|
+
apiBase,
|
|
1893
|
+
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
1894
|
+
token: state.token,
|
|
1895
|
+
workerKey,
|
|
1896
|
+
device,
|
|
1897
|
+
});
|
|
1898
|
+
// A reply is only kept if it checks out here — a proxy's HTML page or another server's token
|
|
1899
|
+
// must not replace a good one.
|
|
1900
|
+
const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
|
|
1901
|
+
state = { token: fresh, lastAttemptAt: now.toISOString() };
|
|
1902
|
+
await ct.writeState(dir, state).catch(() => {});
|
|
1903
|
+
tokenNotice = noticeNow();
|
|
1904
|
+
}
|
|
1905
|
+
} catch {
|
|
1906
|
+
/* never a reason to disturb a session */
|
|
1907
|
+
} finally {
|
|
1908
|
+
tokenBusy = false;
|
|
1909
|
+
if (barPinned()) paintBar();
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
const TOKEN_TICK_MS = 60 * 60 * 1000;
|
|
1913
|
+
|
|
1850
1914
|
let cliNoticeShown = false;
|
|
1851
1915
|
function announceCliUpdate() {
|
|
1852
1916
|
if (cliNoticeShown || !availableUpdates.cli) return;
|
|
@@ -2147,8 +2211,11 @@ function frozenZone() {
|
|
|
2147
2211
|
// separator exists to lift a CONTROL off the typed line, so with no control there is nothing
|
|
2148
2212
|
// to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
|
|
2149
2213
|
// cannot drift into disagreeing about whether a control is showing.
|
|
2214
|
+
// The token notice counts only when it FITS — the same test its write below uses. With
|
|
2215
|
+
// Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
|
|
2216
|
+
const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
|
|
2150
2217
|
const headControl =
|
|
2151
|
-
scrolledBack() || availableUpdates.any || Boolean(agentsIndicator(otherAgents));
|
|
2218
|
+
scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
|
|
2152
2219
|
const zone = inputZoneRows({
|
|
2153
2220
|
headControl,
|
|
2154
2221
|
width,
|
|
@@ -2212,6 +2279,14 @@ function frozenZone() {
|
|
|
2212
2279
|
rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
|
|
2213
2280
|
updateRowTarget = { from: up.from, to: up.to };
|
|
2214
2281
|
}
|
|
2282
|
+
} else if (tokenFits) {
|
|
2283
|
+
// The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
|
|
2284
|
+
// control, because an update is actionable with a click and this is a standing fact; ABOVE the
|
|
2285
|
+
// agents count, because it needs the user and that does not. Not clickable. Too narrow to say
|
|
2286
|
+
// it whole → the condition above lets the agents count (or nothing) have the row instead.
|
|
2287
|
+
const len = visibleLen(tokenNotice.text);
|
|
2288
|
+
const from = width - 1 - len;
|
|
2289
|
+
rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + yellow(tokenNotice.text), width - 1);
|
|
2215
2290
|
} else if (agentsIndicator(otherAgents)) {
|
|
2216
2291
|
// …and when there is no update to offer, the same row carries the agents count (task #195).
|
|
2217
2292
|
// THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
|
|
@@ -3113,6 +3188,34 @@ function eraseFooter() {
|
|
|
3113
3188
|
}
|
|
3114
3189
|
// Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
|
|
3115
3190
|
const redrawModeLine = () => drawFooter();
|
|
3191
|
+
// Shift+Tab (#47). At the prompt it sets the mode for the next message. During a turn it ALSO
|
|
3192
|
+
// tells the running agent, which re-reads it before each command — the flag the turn was queued
|
|
3193
|
+
// with is only its starting value. If the API cannot take it (an older one without the route),
|
|
3194
|
+
// say once that it applies from the next message, rather than let the bar claim something the
|
|
3195
|
+
// running agent never heard.
|
|
3196
|
+
let editModeLiveWarnedJob = null;
|
|
3197
|
+
function switchEditMode() {
|
|
3198
|
+
editMode = editMode === "accept" ? "manual" : "accept";
|
|
3199
|
+
redrawModeLine();
|
|
3200
|
+
const jobId = currentJobId;
|
|
3201
|
+
if (!jobId || !apiBase || !workerKey) return;
|
|
3202
|
+
fetch(`${apiBase}/api/worker/jobs/${encodeURIComponent(jobId)}/accept-edits`, {
|
|
3203
|
+
method: "POST",
|
|
3204
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
3205
|
+
body: JSON.stringify({ acceptEdits: editMode === "accept" }),
|
|
3206
|
+
signal: AbortSignal.timeout(10_000),
|
|
3207
|
+
})
|
|
3208
|
+
.then((r) => {
|
|
3209
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3210
|
+
})
|
|
3211
|
+
.catch(() => {
|
|
3212
|
+
if (editModeLiveWarnedJob === jobId || currentJobId !== jobId) return;
|
|
3213
|
+
editModeLiveWarnedJob = jobId;
|
|
3214
|
+
process.stdout.write(
|
|
3215
|
+
"\n" + GUTTER + dim("accept edits: the running agent did not get the change — it applies from your next message") + "\n"
|
|
3216
|
+
);
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3116
3219
|
|
|
3117
3220
|
// ---------- the gap between Enter and the agent (task #140) -----------------------------
|
|
3118
3221
|
// Enter tears the bar down at once (it must: the echoed newline would otherwise land on the
|
|
@@ -3470,6 +3573,10 @@ if (process.stdin.isTTY) {
|
|
|
3470
3573
|
// hover-click, so the fuller thought stays reachable with the mouse capture off (the
|
|
3471
3574
|
// capture is what would eat the scroll wheel; see hoverMouse).
|
|
3472
3575
|
if (key && key.ctrl && key.name === "t" && toggleActiveThought) toggleActiveThought();
|
|
3576
|
+
// Shift+Tab WORKS DURING A TURN (#47). This branch used to swallow every key but Ctrl+T,
|
|
3577
|
+
// so the switch did nothing while the agent was thinking — which is exactly when a user
|
|
3578
|
+
// watching it reach for commands wants to change it.
|
|
3579
|
+
if (key && key.name === "tab" && key.shift) switchEditMode();
|
|
3473
3580
|
rl.line = "";
|
|
3474
3581
|
rl.cursor = 0;
|
|
3475
3582
|
rl.historyIndex = -1;
|
|
@@ -3500,10 +3607,7 @@ if (process.stdin.isTTY) {
|
|
|
3500
3607
|
// Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
|
|
3501
3608
|
// slash overlay owns the screen so we don't fight its redraw.
|
|
3502
3609
|
if (key && key.name === "tab" && key.shift) {
|
|
3503
|
-
if (!slashOverlayActive)
|
|
3504
|
-
editMode = editMode === "accept" ? "manual" : "accept";
|
|
3505
|
-
redrawModeLine();
|
|
3506
|
-
}
|
|
3610
|
+
if (!slashOverlayActive) switchEditMode();
|
|
3507
3611
|
return;
|
|
3508
3612
|
}
|
|
3509
3613
|
// Enter is handled by rl.on("line") — don't redraw the hint on the way out.
|
|
@@ -8466,6 +8570,16 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8466
8570
|
// 400 chars — enough for a one-line label, not for the paragraphs we now print). Bounded so a
|
|
8467
8571
|
// runaway generation cannot grow without limit; the prose we keep is far shorter anyway.
|
|
8468
8572
|
let stepThought = "";
|
|
8573
|
+
// #45: the prose of a step whose output had NO code, held back until the next line says what
|
|
8574
|
+
// became of it. A no-code retry notice discards it (the model restates the plan on the retry);
|
|
8575
|
+
// anything else prints it. Always null under --verbose, which shows everything as it comes.
|
|
8576
|
+
let heldThought = null;
|
|
8577
|
+
const releaseHeldThought = () => {
|
|
8578
|
+
if (heldThought === null) return;
|
|
8579
|
+
const raw = heldThought;
|
|
8580
|
+
heldThought = null;
|
|
8581
|
+
if (printThoughtBlock(raw, onContent)) lastWasBlank = false;
|
|
8582
|
+
};
|
|
8469
8583
|
let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
|
|
8470
8584
|
let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
|
|
8471
8585
|
let lastDsrAt = 0; // throttle the cursor-position probe
|
|
@@ -8894,7 +9008,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8894
9008
|
carry += stripThinkTags(chunk);
|
|
8895
9009
|
let nl;
|
|
8896
9010
|
while ((nl = carry.indexOf("\n")) >= 0) {
|
|
8897
|
-
|
|
9011
|
+
let line = carry.slice(0, nl);
|
|
8898
9012
|
carry = carry.slice(nl + 1);
|
|
8899
9013
|
const alreadyFlushed = carryFlushedLen;
|
|
8900
9014
|
carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
|
|
@@ -8907,6 +9021,21 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8907
9021
|
almostDone = /almost/i.test(line);
|
|
8908
9022
|
continue;
|
|
8909
9023
|
}
|
|
9024
|
+
// #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
|
|
9025
|
+
// under --verbose only; otherwise swallowed, together with the no-code false start the
|
|
9026
|
+
// retry notice is about.
|
|
9027
|
+
{
|
|
9028
|
+
const v = splitVerboseLine(line);
|
|
9029
|
+
if (v.verboseOnly) {
|
|
9030
|
+
if (!REPL_VERBOSE) {
|
|
9031
|
+
if (isNoCodeRetry(v.text)) heldThought = null;
|
|
9032
|
+
lastContentAt = Date.now();
|
|
9033
|
+
continue;
|
|
9034
|
+
}
|
|
9035
|
+
line = v.text;
|
|
9036
|
+
}
|
|
9037
|
+
}
|
|
9038
|
+
if (heldThought !== null && line.trim() !== "") releaseHeldThought();
|
|
8910
9039
|
// Task #136: a RAG index file was written. Swallow the line — it belongs in the
|
|
8911
9040
|
// BOTTOM BAR, not in the action list — and remember the newest one. Kept out of the
|
|
8912
9041
|
// bullet stream deliberately: indexing writes a shard per batch, so printing them
|
|
@@ -8939,7 +9068,11 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8939
9068
|
// The step's model output is complete. Print its reasoning HERE, before the tools
|
|
8940
9069
|
// report what they did, so the transcript reads in the order it happened: the model
|
|
8941
9070
|
// says what it intends, then the actions it took appear under it.
|
|
8942
|
-
if (
|
|
9071
|
+
if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
|
|
9072
|
+
heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
|
|
9073
|
+
} else if (printThoughtBlock(stepThought, onContent)) {
|
|
9074
|
+
lastWasBlank = false;
|
|
9075
|
+
}
|
|
8943
9076
|
stepThought = "";
|
|
8944
9077
|
}
|
|
8945
9078
|
continue;
|
|
@@ -9328,6 +9461,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9328
9461
|
lastWasBlank = false;
|
|
9329
9462
|
}
|
|
9330
9463
|
}
|
|
9464
|
+
releaseHeldThought(); // #45: nothing followed it, so it was not a false start
|
|
9331
9465
|
clearStatus();
|
|
9332
9466
|
// Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
|
|
9333
9467
|
// total (all turns for this user + coding server) so the caller can print a
|
|
@@ -10060,6 +10194,9 @@ async function main() {
|
|
|
10060
10194
|
// reason gu refuses to exit.
|
|
10061
10195
|
void refreshAvailableUpdates();
|
|
10062
10196
|
setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
|
|
10197
|
+
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
10198
|
+
void refreshCliToken();
|
|
10199
|
+
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
10063
10200
|
// …and the other-agents count (task #195). Slower than the turn's own poll and skipped while
|
|
10064
10201
|
// a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
|
|
10065
10202
|
void refreshOtherAgents();
|
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.706",
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"terminal-copy.mjs",
|
|
52
52
|
"version-check.mjs",
|
|
53
53
|
"rag-selftest.mjs",
|
|
54
|
+
"cli-token.mjs",
|
|
54
55
|
"device-login.mjs",
|
|
55
56
|
"daemon-control.mjs",
|
|
56
57
|
"poll-errors.mjs",
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
"job-pools.mjs",
|
|
61
62
|
"rag-job-config.mjs",
|
|
62
63
|
"completed-flush.mjs",
|
|
64
|
+
"verbose-lines.mjs",
|
|
63
65
|
"agent-model-label.mjs",
|
|
64
66
|
"coding-choice-tags.mjs",
|
|
65
67
|
"coding-provider-setup.mjs",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step lines only `gu --verbose` shows (#45).
|
|
3
|
+
*
|
|
4
|
+
* The agent keeps itself going with retries ("the model wrote no code this step — asking
|
|
5
|
+
* again") and context bookkeeping ("context window 128k … published default", "Context reached
|
|
6
|
+
* ~40k tokens — trimming older steps"). The user can do nothing with either, so the agent tags
|
|
7
|
+
* them with this prefix and the terminal drops them unless it runs with --verbose.
|
|
8
|
+
*/
|
|
9
|
+
export const VERBOSE_PREFIX = "__VERBOSE__::";
|
|
10
|
+
|
|
11
|
+
/** Untag a line: `{ verboseOnly, text }`. Lines without the prefix come back unchanged. */
|
|
12
|
+
export function splitVerboseLine(line) {
|
|
13
|
+
const s = String(line ?? "");
|
|
14
|
+
return s.startsWith(VERBOSE_PREFIX)
|
|
15
|
+
? { verboseOnly: true, text: s.slice(VERBOSE_PREFIX.length) }
|
|
16
|
+
: { verboseOnly: false, text: s };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The notice that the model's LAST reply carried no code at all. That reply's prose is then a
|
|
21
|
+
* false start: the model is asked again and restates it, so printing both shows the same plan
|
|
22
|
+
* twice around a notice the user does not need.
|
|
23
|
+
*/
|
|
24
|
+
export function isNoCodeRetry(text) {
|
|
25
|
+
return /wrote no code this step|thought but didn't act|take the action it described/i.test(
|
|
26
|
+
String(text ?? "")
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Did a step's raw model output contain a code block? */
|
|
31
|
+
export function stepHasCode(raw) {
|
|
32
|
+
return /<code[\s>]/i.test(String(raw ?? ""));
|
|
33
|
+
}
|