@tiens.nguyen/gu-cli 1.0.705 → 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
+
@@ -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
@@ -522,7 +522,7 @@ const argv = process.argv.slice(2);
522
522
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
523
523
  const CLI_SUBCOMMANDS = [
524
524
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
525
- "clear", "reset", "api", "token-usage", "tokens",
525
+ "clear", "reset", "api", "token-usage", "tokens", "token",
526
526
  ];
527
527
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
528
528
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -1848,6 +1848,69 @@ async function refreshAvailableUpdates() {
1848
1848
  * output and corrupt a step's story. When that happens the notice waits: the main loop calls
1849
1849
  * this again at the next prompt, and the bar has been showing the control the whole time.
1850
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
+
1851
1914
  let cliNoticeShown = false;
1852
1915
  function announceCliUpdate() {
1853
1916
  if (cliNoticeShown || !availableUpdates.cli) return;
@@ -2148,8 +2211,11 @@ function frozenZone() {
2148
2211
  // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2149
2212
  // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
2150
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;
2151
2217
  const headControl =
2152
- scrolledBack() || availableUpdates.any || Boolean(agentsIndicator(otherAgents));
2218
+ scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
2153
2219
  const zone = inputZoneRows({
2154
2220
  headControl,
2155
2221
  width,
@@ -2213,6 +2279,14 @@ function frozenZone() {
2213
2279
  rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2214
2280
  updateRowTarget = { from: up.from, to: up.to };
2215
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);
2216
2290
  } else if (agentsIndicator(otherAgents)) {
2217
2291
  // …and when there is no update to offer, the same row carries the agents count (task #195).
2218
2292
  // THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
@@ -3114,6 +3188,34 @@ function eraseFooter() {
3114
3188
  }
3115
3189
  // Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
3116
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
+ }
3117
3219
 
3118
3220
  // ---------- the gap between Enter and the agent (task #140) -----------------------------
3119
3221
  // Enter tears the bar down at once (it must: the echoed newline would otherwise land on the
@@ -3471,6 +3573,10 @@ if (process.stdin.isTTY) {
3471
3573
  // hover-click, so the fuller thought stays reachable with the mouse capture off (the
3472
3574
  // capture is what would eat the scroll wheel; see hoverMouse).
3473
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();
3474
3580
  rl.line = "";
3475
3581
  rl.cursor = 0;
3476
3582
  rl.historyIndex = -1;
@@ -3501,10 +3607,7 @@ if (process.stdin.isTTY) {
3501
3607
  // Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
3502
3608
  // slash overlay owns the screen so we don't fight its redraw.
3503
3609
  if (key && key.name === "tab" && key.shift) {
3504
- if (!slashOverlayActive) {
3505
- editMode = editMode === "accept" ? "manual" : "accept";
3506
- redrawModeLine();
3507
- }
3610
+ if (!slashOverlayActive) switchEditMode();
3508
3611
  return;
3509
3612
  }
3510
3613
  // Enter is handled by rl.on("line") — don't redraw the hint on the way out.
@@ -10091,6 +10194,9 @@ async function main() {
10091
10194
  // reason gu refuses to exit.
10092
10195
  void refreshAvailableUpdates();
10093
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();
10094
10200
  // …and the other-agents count (task #195). Slower than the turn's own poll and skipped while
10095
10201
  // a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
10096
10202
  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.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",