@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 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
+
@@ -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
- return (" | ".join(parts) if parts else "thinking…")
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
- const line = carry.slice(0, nl);
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 (printThoughtBlock(stepThought, onContent)) lastWasBlank = false;
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.704",
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
+ }