@tiens.nguyen/gu-cli 1.0.705 → 1.0.707

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,341 @@
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
+ // Missing means OFF — tokens issued before the switch existed (#238) are in CLIs right now.
91
+ !(p.enforce === undefined || typeof p.enforce === "boolean")
92
+ ) {
93
+ return { ok: false, why: "bad-payload" };
94
+ }
95
+ return { ok: true, payload: { ...p, enforce: p.enforce === true } };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // THIS COMPUTER
100
+ // ---------------------------------------------------------------------------
101
+
102
+ const run = (cmd, args) =>
103
+ new Promise((resolve) => {
104
+ execFile(cmd, args, { timeout: 3000, windowsHide: true }, (err, stdout) => resolve(err ? "" : String(stdout)));
105
+ });
106
+
107
+ /**
108
+ * The operating system's own id for this machine, or "".
109
+ *
110
+ * NOT ~/.gonext/instance: copying the folder to another computer carries that file along, and
111
+ * `gu clear` makes a new one, which would count one laptop several times. The OS id survives both.
112
+ */
113
+ export async function osMachineId({ platform = process.platform, read = (p) => readFile(p, "utf8"), exec = run } = {}) {
114
+ try {
115
+ if (platform === "darwin") {
116
+ const out = await exec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
117
+ return out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1]?.trim() ?? "";
118
+ }
119
+ if (platform === "win32") {
120
+ const out = await exec("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
121
+ return out.match(/MachineGuid\s+REG_SZ\s+(\S+)/i)?.[1]?.trim() ?? "";
122
+ }
123
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
124
+ const id = (await read(p).catch(() => "")).trim();
125
+ if (id) return id;
126
+ }
127
+ } catch {
128
+ /* fall through to "" */
129
+ }
130
+ return "";
131
+ }
132
+
133
+ /** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
134
+ export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
135
+ const raw = machineId ?? (await osMachineId({ platform }));
136
+ const source = raw || (instanceId ? `instance:${instanceId}` : "");
137
+ if (!source) return null;
138
+ return {
139
+ id: createHash("sha256").update(`gu-device-v1:${source}`).digest("hex"),
140
+ hostName: String(hostName ?? "").slice(0, 64),
141
+ os: platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other",
142
+ };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // STATE: ~/.gonext/cli-token.json
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export const STATE_FILE = "cli-token.json";
150
+
151
+ export async function readState(dir) {
152
+ try {
153
+ const s = JSON.parse(await readFile(join(dir, STATE_FILE), "utf8"));
154
+ return {
155
+ token: typeof s.token === "string" ? s.token : "",
156
+ lastAttemptAt: isIso(s.lastAttemptAt) ? s.lastAttemptAt : null,
157
+ };
158
+ } catch {
159
+ return { token: "", lastAttemptAt: null };
160
+ }
161
+ }
162
+
163
+ /** Atomic and owner-only: a token is not a password, but it is someone's, and renews for them. */
164
+ export async function writeState(dir, state) {
165
+ const file = join(dir, STATE_FILE);
166
+ await mkdir(dirname(file), { recursive: true });
167
+ const tmp = `${file}.${process.pid}.tmp`;
168
+ await writeFile(tmp, JSON.stringify({ token: state.token ?? "", lastAttemptAt: state.lastAttemptAt ?? null }, null, 2) + "\n", {
169
+ encoding: "utf8",
170
+ mode: 0o600,
171
+ });
172
+ await rename(tmp, file);
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // WHEN TO RENEW, AND HOW
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** mode: "client" | "expert". warningShowing: a notice is up right now. */
180
+ export function shouldRenew({ state, mode, now = new Date(), warningShowing = false }) {
181
+ if (warningShowing) return true;
182
+ const last = Date.parse(state?.lastAttemptAt ?? "");
183
+ if (Number.isNaN(last)) return true;
184
+ return now.getTime() - last >= (RENEW_EVERY_MS[mode] ?? RENEW_EVERY_MS.client);
185
+ }
186
+
187
+ /**
188
+ * One renewal request. Returns { ok: true, token } or { ok: false, why } — never throws.
189
+ *
190
+ * With a token: POST /api/billing/cli-token/renew, bearer = token. Without one, in Client mode only:
191
+ * POST /api/billing/cli-token with the worker key. A Client Expert worker key belongs to the LOCAL
192
+ * API and means nothing to the hosted one, so it is never sent there.
193
+ */
194
+ export async function renewToken({ mode, apiBase, hostedBase, token, workerKey, device, fetchImpl = fetch, timeoutMs = 8000 }) {
195
+ const base = String(mode === "expert" ? hostedBase : apiBase || hostedBase).replace(/\/+$/, "");
196
+ const body = JSON.stringify(device ? { device } : {});
197
+ let url;
198
+ const headers = { "content-type": "application/json" };
199
+ if (token) {
200
+ url = `${base}/api/billing/cli-token/renew`;
201
+ headers.authorization = `Bearer ${token}`;
202
+ } else if (mode === "client" && workerKey) {
203
+ url = `${base}/api/billing/cli-token`;
204
+ headers["x-worker-key"] = workerKey;
205
+ } else {
206
+ return { ok: false, why: "no-credential" };
207
+ }
208
+ if (!base) return { ok: false, why: "no-api" };
209
+ try {
210
+ const res = await fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(timeoutMs) });
211
+ if (!res.ok) return { ok: false, why: `http-${res.status}` };
212
+ const data = await res.json().catch(() => null);
213
+ return typeof data?.token === "string" ? { ok: true, token: data.token } : { ok: false, why: "bad-response" };
214
+ } catch (e) {
215
+ return { ok: false, why: e?.name === "TimeoutError" ? "timeout" : "network" };
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // WHAT THE BAR SAYS
221
+ // ---------------------------------------------------------------------------
222
+
223
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
224
+ /** "12 Oct 2026" — spelled out here rather than via toLocaleDateString, whose month names vary by ICU version. */
225
+ export function shortDate(iso) {
226
+ const d = new Date(iso);
227
+ return Number.isNaN(d.getTime()) ? "" : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
228
+ }
229
+
230
+ /**
231
+ * The one line for the bottom bar, or null. Highest priority first:
232
+ *
233
+ * 1. the plan ended — money, and the fix is on the site
234
+ * 2. more computers than the plan covers
235
+ * 3. no usable token, or one over 30 days old — only AFTER a renewal was tried in Client mode,
236
+ * where one is normally fetched automatically; straight away in Client Expert, where nothing
237
+ * fetches one until a token is pasted
238
+ *
239
+ * Never the word "licence". Returns null when the feature is off (no public key).
240
+ */
241
+ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempted = false, site = DEFAULT_SITE }) {
242
+ if (!publicKeyB64) return null;
243
+ const host = site.replace(/^https?:\/\//, "");
244
+ // Shown without the scheme — shorter on a narrow bar, and still what a person types.
245
+ const pricingUrl = `${host}/pricing`;
246
+ const accountUrl = `${host}/account`;
247
+ const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
248
+
249
+ if (check.ok) {
250
+ const p = check.payload;
251
+ const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
252
+ if (ended) return { kind: "plan-ended", text: `Your gu Pro plan ended on ${shortDate(p.paidUntil)} — renew at ${pricingUrl}` };
253
+ if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
254
+ return {
255
+ kind: "over-limit",
256
+ text: `gu is active on ${p.activeDevices} devices — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
257
+ };
258
+ }
259
+ const age = now.getTime() - Date.parse(p.issuedAt);
260
+ if (age <= STALE_AFTER_DAYS * 86_400_000) return null;
261
+ } else if (mode === "client" && !attempted) {
262
+ return null; // Client mode fetches one by itself; do not complain before it has tried.
263
+ }
264
+
265
+ return mode === "expert"
266
+ ? { kind: "stale", text: `Get a fresh gu token at ${accountUrl} to keep getting updates` }
267
+ : { kind: "stale", text: "Sign in to keep getting gu updates — run gu login" };
268
+ }
269
+
270
+ /** What `gu token` prints about a valid token — plain lines, no colour, for the command to style. */
271
+ export function tokenSummary(payload, now = new Date()) {
272
+ const lines = [];
273
+ if (payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime()) {
274
+ lines.push(`Pro until ${shortDate(payload.paidUntil)}.`);
275
+ } else if (payload.paidUntil) {
276
+ lines.push(`Free — the Pro plan ended on ${shortDate(payload.paidUntil)}.`);
277
+ } else {
278
+ lines.push("Free.");
279
+ }
280
+ if (Number.isInteger(payload.activeDevices)) {
281
+ const n = payload.activeDevices;
282
+ lines.push(`Active on ${n} ${n === 1 ? "device" : "devices"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
283
+ }
284
+ lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
285
+ return lines;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // ENFORCEMENT (#238) — decided ONCE, when gu starts
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * May this session send turns?
294
+ *
295
+ * The switch lives on the server and rides in the token (payload.enforce). This is read ONCE at
296
+ * startup, by the owner's requirement — a session that began is never interrupted by a later renewal,
297
+ * and nothing asks the network again per turn.
298
+ *
299
+ * WITH THE SWITCH OFF (the default and what ships today) the answer is always yes: the licence stays
300
+ * soft — barNotice() says its one line and nothing is blocked.
301
+ *
302
+ * WITH IT ON, a session may send turns when the token is valid, the plan is Pro, its paid time has not
303
+ * run out, and the account is within its computer limit. Otherwise gu says why and refuses to SEND
304
+ * TURNS — `gu login`, `gu token`, /model and every other command keep working, because the person has
305
+ * to be able to fix it from where they are.
306
+ */
307
+ export function enforcementDecision({ token, publicKeyB64, now = new Date(), site = DEFAULT_SITE }) {
308
+ if (!publicKeyB64) return { enforced: false, allowed: true, why: "" };
309
+ const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
310
+ // An unreadable or missing token cannot say whether enforcement is on, and this must not be a way
311
+ // to switch it off by deleting a file — but it is also not a reason to block someone whose server
312
+ // has enforcement OFF. Unknown means soft, exactly like a server that cannot be reached.
313
+ if (!check.ok) return { enforced: false, allowed: true, why: "" };
314
+ const p = check.payload;
315
+ if (!p.enforce) return { enforced: false, allowed: true, why: "" };
316
+
317
+ const host = site.replace(/^https?:\/\//, "");
318
+ const pricingUrl = `${host}/pricing`;
319
+ const accountUrl = `${host}/account`;
320
+ const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
321
+ if (p.plan !== "pro" || ended) {
322
+ return {
323
+ enforced: true,
324
+ allowed: false,
325
+ why: ended
326
+ ? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
327
+ : `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
328
+ };
329
+ }
330
+ if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
331
+ return {
332
+ enforced: true,
333
+ allowed: false,
334
+ why:
335
+ `This account is active on ${p.activeDevices} devices and Pro covers ${p.deviceLimit}. ` +
336
+ `Sign out of one, or remove it at ${accountUrl}.`,
337
+ };
338
+ }
339
+ return { enforced: true, allowed: true, why: "" };
340
+ }
341
+
@@ -0,0 +1,82 @@
1
+ /**
2
+ * WHEN THE CREDENTIALS ON THIS MACHINE NO LONGER WORK (task #240).
3
+ *
4
+ * THE DEAD END THIS EXISTS TO CLOSE, reproduced in a clean Ubuntu container: a machine whose
5
+ * ~/.gonext/worker.env holds a key the API rejects (rotated, revoked, pasted from another
6
+ * account, or written by the wizard for a different server) could not reach the first-run flow
7
+ * at all. `gu` printed one red line and exited — every time, forever. The questions a first run
8
+ * asks were unreachable, and the only route back was `gu clear`, which is a RESET: it deletes
9
+ * conversation history, registered workspaces, deploy targets, the agent's edit backups and the
10
+ * RAG indexes. Reported as "first run question doesn't appear … I then needed gu clear then gu
11
+ * again", and that is exactly what the user had been driven to.
12
+ *
13
+ * A REJECTED KEY IS THE SAME SITUATION AS NO KEY. Both mean this machine is not connected to an
14
+ * account, and the answer to both is the sign-in that the first run already offers. So gu offers
15
+ * it here too, in place, keeping everything else on the machine.
16
+ *
17
+ * WHY A MODULE AND NOT AN `if` IN THE REPL: so the rule can be called by a test. The conduct
18
+ * being pinned is "which situations offer a sign-in", and open-coded it could only be guarded by
19
+ * a regex over gu-repl.mjs — which pins the WORDING and lets the conduct change underneath.
20
+ *
21
+ * THE CODE, NOT THE WORDING, decides. payloadError() stamps `code` on the error the API call
22
+ * throws; nothing downstream matches on the message text, which is free to be reworded.
23
+ */
24
+
25
+ /** The error an agent-payload call failed with, carrying a code the recovery can switch on. */
26
+ export function payloadError(status, data) {
27
+ const message = (data && typeof data === "object" && data.error) || "";
28
+ if (status === 404) {
29
+ const e = new Error(
30
+ "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
31
+ );
32
+ e.code = "API_TOO_OLD";
33
+ return e;
34
+ }
35
+ // 401/403: the key is present and WRONG. We never re-validate a stored key proactively, so
36
+ // this is where a rotated or revoked one surfaces — the first thing gu does on startup.
37
+ if (status === 401 || status === 403) {
38
+ const e = new Error(
39
+ "your worker key was rejected (it may have been rotated or revoked). " +
40
+ "Run `gu login` to sign in again, or update it with `gu set <workerKey> --api-base <url>`."
41
+ );
42
+ e.code = "KEY_REJECTED";
43
+ return e;
44
+ }
45
+ const e = new Error(message || `agent-payload failed (HTTP ${status})`);
46
+ e.code = "API_ERROR";
47
+ return e;
48
+ }
49
+
50
+ /**
51
+ * What should happen when the startup probe fails?
52
+ *
53
+ * "offer-signin" — ask, here and now, and carry on with the session when it works.
54
+ * "report" — print what is wrong and stop. The caller's existing message.
55
+ *
56
+ * ONLY a rejected key, and only with somebody there to answer. A network failure is not a
57
+ * credential problem (the key may be perfect and the wifi off), and a pipe or CI job has nobody
58
+ * at the keyboard — offering there would hang a script on a prompt no one can see, which is the
59
+ * failure mode that made `gu` unusable in the deploy app's stages.
60
+ *
61
+ * NOT ON A CLIENT EXPERT MACHINE. Its API is local and issues its own key; a hosted device-login
62
+ * there points the terminal away from the very stack it was set up to use. The same reasoning
63
+ * that made first-run setup skip the sign-in after Client Expert applies to the recovery.
64
+ */
65
+ export function recoveryPlan({ code, isTty, clientExpert } = {}) {
66
+ if (code !== "KEY_REJECTED") return { action: "report" };
67
+ if (!isTty) return { action: "report" };
68
+ if (clientExpert) return { action: "report" };
69
+ return {
70
+ action: "offer-signin",
71
+ // What it says matters as much as that it asks: someone who has just been told their key is
72
+ // bad needs to know that answering will not cost them their work.
73
+ lines: [
74
+ "gu: this machine's worker key was rejected — it may have been rotated or revoked.",
75
+ " Sign in again to reconnect this machine to an account.",
76
+ " Nothing else is touched: your folders, chat history and RAG indexes stay as they are.",
77
+ ],
78
+ // UN-INDENTED, like every other ask() caller: ask() insets by the gutter itself, and a
79
+ // prompt carrying its own spaces comes out sitting two columns right of every other question.
80
+ prompt: "worker key (or Enter to sign in): ",
81
+ };
82
+ }
@@ -6023,6 +6023,35 @@ def _shell_syntax_error(command, argv):
6023
6023
  # commands are confirmed. False = confirm every command. Module-level default so the tool body
6024
6024
  # reads a real boolean even on a path where run() never set it.
6025
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
+
6026
6055
  _WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
6027
6056
  _WS_RUN_DENYLIST = set() # extra user blocks from cfg
6028
6057
 
@@ -11601,6 +11630,11 @@ def run_agent_chat(cfg):
11601
11630
  # client can approve interactively (terminal), PAUSE and ask the user;
11602
11631
  # otherwise (web) keep hard-blocking the privilege/denylist cases.
11603
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)
11604
11638
  # ACCEPT EDITS OFF ⇒ ASK BEFORE RUNNING ANYTHING (task #179). The risk gate is
11605
11639
  # the FLOOR, not the whole policy: sudo and rm -rf ask either way, and turning
11606
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,9 @@ 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
+ // Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
141
+ // from in place instead of ending the session.
142
+ import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
140
143
  import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
141
144
  import {
142
145
  createTranscript,
@@ -522,7 +525,7 @@ const argv = process.argv.slice(2);
522
525
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
523
526
  const CLI_SUBCOMMANDS = [
524
527
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
525
- "clear", "reset", "api", "token-usage", "tokens",
528
+ "clear", "reset", "api", "token-usage", "tokens", "token",
526
529
  ];
527
530
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
528
531
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -1848,6 +1851,87 @@ async function refreshAvailableUpdates() {
1848
1851
  * output and corrupt a step's story. When that happens the notice waits: the main loop calls
1849
1852
  * this again at the next prompt, and the bar has been showing the control the whole time.
1850
1853
  */
1854
+ /**
1855
+ * THE gu TOKEN (#232) — renewed quietly, and at most one line on the separator row.
1856
+ *
1857
+ * A SOFT LICENCE: nothing here blocks, prompts or delays. The schedule lives in cli-token.mjs
1858
+ * (Client: a day; Client Expert, local-only: a week — the owner's call) so this runs on a cheap
1859
+ * hourly tick that usually just re-reads a file and decides nothing is due.
1860
+ *
1861
+ * `startupRenewDone` is what stops "renew while a warning is up" from becoming "renew every hour
1862
+ * while a warning is up": an account over its computer limit stays over it, and would otherwise
1863
+ * call the hosted API all day. It gets ONE early renewal per gu start — enough for someone who just
1864
+ * paid to see the warning go.
1865
+ */
1866
+ let tokenNotice = null;
1867
+ let tokenBusy = false;
1868
+ let startupRenewDone = false;
1869
+ /**
1870
+ * THE ENFORCEMENT DECISION (#238) — taken ONCE, on the first refresh after gu starts, and never
1871
+ * revisited. The owner's requirement: check when gu starts, not repeatedly. A session already running
1872
+ * is never cut off by a switch flipped elsewhere, and a later renewal only updates the bar's line.
1873
+ */
1874
+ let licenceBlock = null;
1875
+ let licenceDecided = false;
1876
+ async function refreshCliToken() {
1877
+ if (tokenBusy) return;
1878
+ tokenBusy = true;
1879
+ try {
1880
+ const ct = await import("./cli-token.mjs");
1881
+ const pk = ct.publicKey();
1882
+ if (!pk) {
1883
+ tokenNotice = null; // no key (none embedded, GU_CLI_TOKEN_PUBLIC_KEY empty): nothing to check against
1884
+ return;
1885
+ }
1886
+ const dir = join(homedir(), ".gonext");
1887
+ const mode = isClientExpert() ? "expert" : "client";
1888
+ const site = ct.siteUrl();
1889
+ let state = await ct.readState(dir);
1890
+ const now = new Date();
1891
+ const noticeNow = () =>
1892
+ ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
1893
+ tokenNotice = noticeNow();
1894
+
1895
+ const early = Boolean(tokenNotice) && !startupRenewDone;
1896
+ startupRenewDone = true;
1897
+ if (ct.shouldRenew({ state, mode, now, warningShowing: early })) {
1898
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
1899
+ const device = await ct.thisDevice({ instanceId: myInstanceId || "" }).catch(() => null);
1900
+ const r = await ct.renewToken({
1901
+ mode,
1902
+ apiBase,
1903
+ hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
1904
+ token: state.token,
1905
+ workerKey,
1906
+ device,
1907
+ });
1908
+ // A reply is only kept if it checks out here — a proxy's HTML page or another server's token
1909
+ // must not replace a good one.
1910
+ const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
1911
+ state = { token: fresh, lastAttemptAt: now.toISOString() };
1912
+ await ct.writeState(dir, state).catch(() => {});
1913
+ tokenNotice = noticeNow();
1914
+ }
1915
+ if (!licenceDecided) {
1916
+ licenceDecided = true;
1917
+ const d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site });
1918
+ licenceBlock = d.enforced && !d.allowed ? d.why : null;
1919
+ if (licenceBlock) {
1920
+ console.log("");
1921
+ console.log(GUTTER + orange("gu cannot run turns on this account.") );
1922
+ console.log(GUTTER + dim(" " + licenceBlock));
1923
+ console.log(GUTTER + dim(" Everything else still works: /model, gu token, gu login.") + "\n");
1924
+ }
1925
+ }
1926
+ } catch {
1927
+ /* never a reason to disturb a session */
1928
+ } finally {
1929
+ tokenBusy = false;
1930
+ if (barPinned()) paintBar();
1931
+ }
1932
+ }
1933
+ const TOKEN_TICK_MS = 60 * 60 * 1000;
1934
+
1851
1935
  let cliNoticeShown = false;
1852
1936
  function announceCliUpdate() {
1853
1937
  if (cliNoticeShown || !availableUpdates.cli) return;
@@ -2148,8 +2232,11 @@ function frozenZone() {
2148
2232
  // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2149
2233
  // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
2150
2234
  // cannot drift into disagreeing about whether a control is showing.
2235
+ // The token notice counts only when it FITS — the same test its write below uses. With
2236
+ // Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
2237
+ const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
2151
2238
  const headControl =
2152
- scrolledBack() || availableUpdates.any || Boolean(agentsIndicator(otherAgents));
2239
+ scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
2153
2240
  const zone = inputZoneRows({
2154
2241
  headControl,
2155
2242
  width,
@@ -2213,6 +2300,14 @@ function frozenZone() {
2213
2300
  rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2214
2301
  updateRowTarget = { from: up.from, to: up.to };
2215
2302
  }
2303
+ } else if (tokenFits) {
2304
+ // The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
2305
+ // control, because an update is actionable with a click and this is a standing fact; ABOVE the
2306
+ // agents count, because it needs the user and that does not. Not clickable. Too narrow to say
2307
+ // it whole → the condition above lets the agents count (or nothing) have the row instead.
2308
+ const len = visibleLen(tokenNotice.text);
2309
+ const from = width - 1 - len;
2310
+ rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + yellow(tokenNotice.text), width - 1);
2216
2311
  } else if (agentsIndicator(otherAgents)) {
2217
2312
  // …and when there is no update to offer, the same row carries the agents count (task #195).
2218
2313
  // THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
@@ -3114,6 +3209,34 @@ function eraseFooter() {
3114
3209
  }
3115
3210
  // Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
3116
3211
  const redrawModeLine = () => drawFooter();
3212
+ // Shift+Tab (#47). At the prompt it sets the mode for the next message. During a turn it ALSO
3213
+ // tells the running agent, which re-reads it before each command — the flag the turn was queued
3214
+ // with is only its starting value. If the API cannot take it (an older one without the route),
3215
+ // say once that it applies from the next message, rather than let the bar claim something the
3216
+ // running agent never heard.
3217
+ let editModeLiveWarnedJob = null;
3218
+ function switchEditMode() {
3219
+ editMode = editMode === "accept" ? "manual" : "accept";
3220
+ redrawModeLine();
3221
+ const jobId = currentJobId;
3222
+ if (!jobId || !apiBase || !workerKey) return;
3223
+ fetch(`${apiBase}/api/worker/jobs/${encodeURIComponent(jobId)}/accept-edits`, {
3224
+ method: "POST",
3225
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
3226
+ body: JSON.stringify({ acceptEdits: editMode === "accept" }),
3227
+ signal: AbortSignal.timeout(10_000),
3228
+ })
3229
+ .then((r) => {
3230
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
3231
+ })
3232
+ .catch(() => {
3233
+ if (editModeLiveWarnedJob === jobId || currentJobId !== jobId) return;
3234
+ editModeLiveWarnedJob = jobId;
3235
+ process.stdout.write(
3236
+ "\n" + GUTTER + dim("accept edits: the running agent did not get the change — it applies from your next message") + "\n"
3237
+ );
3238
+ });
3239
+ }
3117
3240
 
3118
3241
  // ---------- the gap between Enter and the agent (task #140) -----------------------------
3119
3242
  // Enter tears the bar down at once (it must: the echoed newline would otherwise land on the
@@ -3471,6 +3594,10 @@ if (process.stdin.isTTY) {
3471
3594
  // hover-click, so the fuller thought stays reachable with the mouse capture off (the
3472
3595
  // capture is what would eat the scroll wheel; see hoverMouse).
3473
3596
  if (key && key.ctrl && key.name === "t" && toggleActiveThought) toggleActiveThought();
3597
+ // Shift+Tab WORKS DURING A TURN (#47). This branch used to swallow every key but Ctrl+T,
3598
+ // so the switch did nothing while the agent was thinking — which is exactly when a user
3599
+ // watching it reach for commands wants to change it.
3600
+ if (key && key.name === "tab" && key.shift) switchEditMode();
3474
3601
  rl.line = "";
3475
3602
  rl.cursor = 0;
3476
3603
  rl.historyIndex = -1;
@@ -3501,10 +3628,7 @@ if (process.stdin.isTTY) {
3501
3628
  // Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
3502
3629
  // slash overlay owns the screen so we don't fight its redraw.
3503
3630
  if (key && key.name === "tab" && key.shift) {
3504
- if (!slashOverlayActive) {
3505
- editMode = editMode === "accept" ? "manual" : "accept";
3506
- redrawModeLine();
3507
- }
3631
+ if (!slashOverlayActive) switchEditMode();
3508
3632
  return;
3509
3633
  }
3510
3634
  // Enter is handled by rl.on("line") — don't redraw the hint on the way out.
@@ -7521,27 +7645,65 @@ async function fetchAgentPayload(messages, codingModelOverride = "") {
7521
7645
  data = {};
7522
7646
  }
7523
7647
  if (!res.ok) {
7524
- if (res.status === 404) {
7525
- throw new Error(
7526
- "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
7527
- );
7528
- }
7529
7648
  // Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
7530
- // kept the stale one) shows up as 401/403. Point the user at re-login rather than a
7531
- // generic error — we don't proactively re-validate the key, so the first rejection is
7532
- // where this surfaces.
7533
- if (res.status === 401 || res.status === 403) {
7534
- throw new Error(
7535
- "your worker key was rejected (it may have been rotated or revoked). " +
7536
- "Run `gu-cli login` to sign in again, or update it with " +
7537
- "`gu-cli set <workerKey> --api-base <url>`."
7538
- );
7539
- }
7540
- throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
7649
+ // kept the stale one) shows up as 401/403. We don't proactively re-validate the key, so
7650
+ // the first rejection is where this surfaces.
7651
+ //
7652
+ // THE MESSAGES AND THE CODES NOW LIVE IN credential-recovery.mjs (task #240), because the
7653
+ // startup probe does more than print them: a rejected key there OFFERS THE SIGN-IN rather
7654
+ // than exiting, and that decision must switch on a code, not on a regex over wording.
7655
+ throw payloadError(res.status, data);
7541
7656
  }
7542
7657
  return data;
7543
7658
  }
7544
7659
 
7660
+ /**
7661
+ * The STARTUP probe, with one way back from a rejected key (task #240).
7662
+ *
7663
+ * Reproduced in a clean Ubuntu container: with a key the API rejects, `gu` printed one red line
7664
+ * and exited — every time. The first-run questions were unreachable from that state, so the only
7665
+ * route back was `gu clear`, which also deletes chat history, workspaces, edit backups and RAG
7666
+ * indexes. A rejected key means this machine is not connected to an account, which is the same
7667
+ * situation a first run is for; so the sign-in is offered here, keeping everything else.
7668
+ *
7669
+ * EXACTLY ONE OFFER. The retry is outside the recovery, so a key that is rejected AGAIN (the
7670
+ * account has no worker, the API is confused) falls through to the normal error and exit instead
7671
+ * of asking in a loop.
7672
+ *
7673
+ * Only the startup probe recovers. The other fetchAgentPayload callers run mid-session, where a
7674
+ * sign-in prompt would interrupt work in progress; they keep their existing error handling.
7675
+ */
7676
+ async function probeWithRecovery() {
7677
+ const ping = [{ role: "user", content: "ping" }];
7678
+ try {
7679
+ return await fetchAgentPayload(ping);
7680
+ } catch (err) {
7681
+ const plan = recoveryPlan({
7682
+ code: err?.code,
7683
+ isTty: IS_TTY,
7684
+ clientExpert: isClientExpert(),
7685
+ });
7686
+ if (plan.action !== "offer-signin") throw err;
7687
+ console.log("");
7688
+ console.log(red(plan.lines[0]));
7689
+ for (const line of plan.lines.slice(1)) console.log(dim(line));
7690
+ console.log("");
7691
+ const { deviceLogin, saveWorkerKey, DEFAULT_API_BASE } = await import("./device-login.mjs");
7692
+ const typed = (await ask(plan.prompt)).trim();
7693
+ const r = typed
7694
+ ? await saveWorkerKey({
7695
+ apiBase: apiBase || DEFAULT_API_BASE,
7696
+ workerKey: typed,
7697
+ log: (s) => console.log(s),
7698
+ })
7699
+ : await deviceLogin({ apiBase: apiBase || DEFAULT_API_BASE, log: (s) => console.log(s) });
7700
+ // worker.env is written by those two; this process is still holding the rejected values.
7701
+ workerKey = r.workerKey;
7702
+ apiBase = r.apiBase;
7703
+ return await fetchAgentPayload(ping);
7704
+ }
7705
+ }
7706
+
7545
7707
  // ---------- run one agent turn via the JOB QUEUE (the daemon executes it) ----------
7546
7708
  // The question is enqueued exactly like a web question, so the RUNNING
7547
7709
  // gu-cli daemon claims + executes it — its terminal shows the full
@@ -8254,6 +8416,12 @@ const wrapThought = (s, width, maxLines) => {
8254
8416
  * own conversation.
8255
8417
  */
8256
8418
  async function runAgentTurn(history, { attachJobId = "" } = {}) {
8419
+ // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
8420
+ // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
8421
+ if (licenceBlock) {
8422
+ console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
8423
+ return;
8424
+ }
8257
8425
  const attached = Boolean(attachJobId);
8258
8426
  inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
8259
8427
  // Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
@@ -9680,7 +9848,7 @@ async function main() {
9680
9848
  }
9681
9849
  // Show which models will answer, straight from user settings (also validates auth).
9682
9850
  try {
9683
- const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
9851
+ const probe = await probeWithRecovery();
9684
9852
  const p = probe?.payload ?? {};
9685
9853
  // Remember the default coder id for the token footer's global-total label, and the
9686
9854
  // default backend's coding URL so the GLOBAL output-token total is keyed to it (task
@@ -10075,6 +10243,12 @@ async function main() {
10075
10243
  dim("\n If that fails, check MongoDB is running and see ~/.gonext/local-api.log\n")
10076
10244
  );
10077
10245
  }
10246
+ } else if (err?.code === "KEY_REJECTED") {
10247
+ // WE REACHED THE API PERFECTLY WELL — it told us the key is no good (task #240). Wrapping
10248
+ // that in "couldn't reach the API" sends the reader off debugging DNS, a proxy and their
10249
+ // wifi for a problem the server already named. Seen in the container: a second bad key
10250
+ // pasted at the recovery prompt produced exactly that sentence.
10251
+ console.error(red("gu: " + err.message));
10078
10252
  } else {
10079
10253
  // The SAME bare "fetch failed" the Client Expert branch above was written to fix — that
10080
10254
  // one names the host and offers a next step, this one printed node's four words and
@@ -10091,6 +10265,9 @@ async function main() {
10091
10265
  // reason gu refuses to exit.
10092
10266
  void refreshAvailableUpdates();
10093
10267
  setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
10268
+ // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
10269
+ void refreshCliToken();
10270
+ setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
10094
10271
  // …and the other-agents count (task #195). Slower than the turn's own poll and skipped while
10095
10272
  // a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
10096
10273
  void refreshOtherAgents();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.705",
3
+ "version": "1.0.707",
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,8 @@
51
51
  "terminal-copy.mjs",
52
52
  "version-check.mjs",
53
53
  "rag-selftest.mjs",
54
+ "cli-token.mjs",
55
+ "credential-recovery.mjs",
54
56
  "device-login.mjs",
55
57
  "daemon-control.mjs",
56
58
  "poll-errors.mjs",