@tiens.nguyen/gu-cli 1.0.838 → 1.0.840

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.
@@ -0,0 +1,244 @@
1
+ // gu's OWN Chrome window (task #369, GitHub #150) — the browser tool with nothing to install.
2
+ //
3
+ // Chrome lets no program turn on Developer mode or load an unpacked extension, so without the
4
+ // gu extension the bridge starts a Chrome of its own: a normal, visible window on a separate
5
+ // profile (~/.gonext/browser-profile), started with its DevTools port open, and drives it over the
6
+ // DevTools protocol — the same protocol the extension uses through chrome.debugger, through the
7
+ // same actions.js. Nothing in the user's own Chrome is touched; the agent asks the user before it
8
+ // is first used (gu_browser.py).
9
+ //
10
+ // The profile is gu's alone: the user's logins are not in it (sign in there once if a test needs
11
+ // it — it is kept). Chrome 136+ refuses a DevTools port on the default profile anyway.
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { WebSocket } from "undici";
17
+ import { enableDomains, forgetTab, onCdpEvent, runAction } from "./browser-extension/actions.js";
18
+
19
+ export const profileDir = () => process.env.GU_BROWSER_PROFILE || join(homedir(), ".gonext", "browser-profile");
20
+
21
+ /** The Chrome to start: GU_BROWSER_CHROME, else Chrome / Chromium / Edge / Brave where they install. */
22
+ export function findChrome(platform = process.platform, env = process.env, exists = existsSync) {
23
+ if (env.GU_BROWSER_CHROME) return exists(env.GU_BROWSER_CHROME) ? env.GU_BROWSER_CHROME : "";
24
+ let list = [];
25
+ if (platform === "darwin") {
26
+ list = ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
27
+ } else if (platform === "win32") {
28
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
29
+ list = roots.flatMap((r) => [join(r, "Google", "Chrome", "Application", "chrome.exe"), join(r, "Microsoft", "Edge", "Application", "msedge.exe"), join(r, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")]);
30
+ } else {
31
+ list = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"]
32
+ .flatMap((n) => (env.PATH || "/usr/bin:/usr/local/bin").split(":").map((d) => join(d, n)));
33
+ }
34
+ return list.find((p) => exists(p)) || "";
35
+ }
36
+
37
+ /** DevToolsActivePort: line 1 the port, line 2 the browser's WebSocket path. */
38
+ export function readActivePort(dir) {
39
+ try {
40
+ const [port, path] = readFileSync(join(dir, "DevToolsActivePort"), "utf8").trim().split("\n");
41
+ return Number(port) > 0 && path ? { port: Number(port), path: path.trim() } : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ export class OwnChrome {
50
+ constructor({ dir = profileDir(), chrome = findChrome(), headless = process.env.GU_BROWSER_HEADLESS === "1", log = () => {} } = {}) {
51
+ Object.assign(this, { dir, chrome, headless, log });
52
+ this.ws = null;
53
+ this.nextId = 1;
54
+ this.pending = new Map();
55
+ this.sessions = new Map(); // targetId → sessionId
56
+ this.targetOf = new Map(); // sessionId → targetId
57
+ this.current = null;
58
+ this.launched = false;
59
+ // Main frames between "started loading" and "stopped loading". During a navigation the OLD
60
+ // document still says readyState "complete", so that alone cannot tell a page has moved on.
61
+ this.loading = new Set();
62
+ }
63
+
64
+ get running() {
65
+ return Boolean(this.ws && this.ws.readyState === WebSocket.OPEN);
66
+ }
67
+
68
+ // ── the DevTools connection ──
69
+ send(method, params = {}, sessionId) {
70
+ return new Promise((resolve, reject) => {
71
+ if (!this.running) return reject(new Error("gu's Chrome window is not running"));
72
+ const id = this.nextId++;
73
+ const timer = setTimeout(() => {
74
+ this.pending.delete(id);
75
+ reject(new Error(`${method} timed out`));
76
+ }, 30000);
77
+ this.pending.set(id, { resolve, reject, timer });
78
+ this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
79
+ });
80
+ }
81
+
82
+ async connect(ap) {
83
+ const ws = new WebSocket(`ws://127.0.0.1:${ap.port}${ap.path}`);
84
+ await new Promise((resolve, reject) => {
85
+ ws.onopen = resolve;
86
+ ws.onerror = () => reject(new Error("cannot reach Chrome's DevTools port"));
87
+ });
88
+ this.ws = ws;
89
+ ws.onmessage = (ev) => this.onMessage(JSON.parse(String(ev.data)));
90
+ ws.onclose = () => {
91
+ if (this.ws !== ws) return;
92
+ this.ws = null; // the user closed the window: the next command starts it again
93
+ for (const p of this.pending.values()) {
94
+ clearTimeout(p.timer);
95
+ p.reject(new Error("gu's Chrome window was closed"));
96
+ }
97
+ this.pending.clear();
98
+ this.sessions.clear();
99
+ this.targetOf.clear();
100
+ this.current = null;
101
+ };
102
+ await this.send("Target.setDiscoverTargets", { discover: true });
103
+ }
104
+
105
+ onMessage(m) {
106
+ if (m.id !== undefined) {
107
+ const p = this.pending.get(m.id);
108
+ if (!p) return;
109
+ this.pending.delete(m.id);
110
+ clearTimeout(p.timer);
111
+ if (m.error) p.reject(new Error(m.error.message || "DevTools error"));
112
+ else p.resolve(m.result || {});
113
+ return;
114
+ }
115
+ if (m.method === "Target.targetCreated") {
116
+ // A page gu's tab opened (target=_blank, window.open) becomes the current one.
117
+ const t = m.params.targetInfo;
118
+ if (t.type === "page" && t.openerId && t.openerId === this.current) this.current = t.targetId;
119
+ } else if (m.method === "Target.targetDestroyed" || m.method === "Target.detachedFromTarget") {
120
+ const id = m.params.targetId || this.targetOf.get(m.params.sessionId);
121
+ if (id) {
122
+ const s = this.sessions.get(id);
123
+ this.sessions.delete(id);
124
+ if (s) this.targetOf.delete(s);
125
+ if (m.method === "Target.targetDestroyed") forgetTab(id);
126
+ if (this.current === id && m.method === "Target.targetDestroyed") this.current = null;
127
+ }
128
+ } else if (m.sessionId && this.targetOf.has(m.sessionId)) {
129
+ const id = this.targetOf.get(m.sessionId);
130
+ // A tab's main frame has the tab's own id.
131
+ if (m.method === "Page.frameStartedLoading" && m.params.frameId === id) this.loading.add(id);
132
+ else if (m.method === "Page.frameStoppedLoading" && m.params.frameId === id) this.loading.delete(id);
133
+ onCdpEvent(id, m.method, m.params || {}, this.cdp);
134
+ }
135
+ }
136
+
137
+ /** Connect to gu's Chrome, starting it if it is not running. */
138
+ async ensure() {
139
+ if (this.running) return;
140
+ let ap = readActivePort(this.dir);
141
+ if (ap) {
142
+ try {
143
+ await this.connect(ap); // still running from an earlier bridge
144
+ return;
145
+ } catch {
146
+ /* stale file */
147
+ }
148
+ }
149
+ if (!this.chrome) throw new Error("no Chrome, Chromium, Edge or Brave found on this computer (set GU_BROWSER_CHROME to one)");
150
+ mkdirSync(this.dir, { recursive: true });
151
+ rmSync(join(this.dir, "DevToolsActivePort"), { force: true });
152
+ const args = [`--user-data-dir=${this.dir}`, "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", "--window-size=1280,900", ...(this.headless ? ["--headless=new"] : []), "about:blank"];
153
+ spawn(this.chrome, args, { detached: true, stdio: "ignore" }).unref();
154
+ this.log(`started gu's Chrome window (${this.chrome})`);
155
+ this.launched = true;
156
+ const end = Date.now() + 20000;
157
+ while (Date.now() < end) {
158
+ await sleep(150);
159
+ ap = readActivePort(this.dir);
160
+ if (!ap) continue;
161
+ try {
162
+ await this.connect(ap);
163
+ return;
164
+ } catch {
165
+ /* not listening yet */
166
+ }
167
+ }
168
+ throw new Error("gu's Chrome window did not start");
169
+ }
170
+
171
+ // ── the host actions.js runs on ──
172
+ cdp = (targetId, method, params = {}) => this.send(method, params, this.sessions.get(targetId));
173
+
174
+ async pages() {
175
+ const { targetInfos } = await this.send("Target.getTargets");
176
+ return targetInfos.filter((t) => t.type === "page" && !/^(devtools|chrome-extension):/.test(t.url));
177
+ }
178
+
179
+ host() {
180
+ return {
181
+ // gu's own window is not an extension: no store rules, so the agent's js action runs here.
182
+ runsJs: true,
183
+ cdp: this.cdp,
184
+ attach: async (id) => {
185
+ if (this.sessions.has(id)) return;
186
+ const { sessionId } = await this.send("Target.attachToTarget", { targetId: id, flatten: true });
187
+ this.sessions.set(id, sessionId);
188
+ this.targetOf.set(sessionId, id);
189
+ await enableDomains(this.cdp, id);
190
+ },
191
+ open: async (url, newTab) => {
192
+ const pages = await this.pages();
193
+ const reuse = newTab ? null : pages.find((t) => t.targetId === this.current) || pages[0];
194
+ // A new tab starts blank and is navigated once attached, so its load events are seen.
195
+ const id = reuse ? reuse.targetId : (await this.send("Target.createTarget", { url: "about:blank" })).targetId;
196
+ await this.host().attach(id);
197
+ this.loading.add(id);
198
+ await this.cdp(id, "Page.navigate", { url });
199
+ this.current = id;
200
+ await this.send("Target.activateTarget", { targetId: id }).catch(() => {});
201
+ return id;
202
+ },
203
+ tabs: async () => (await this.pages()).map((t) => ({ id: t.targetId, title: t.title, url: t.url, current: t.targetId === this.current })),
204
+ switchTo: async (id) => {
205
+ if (!(await this.pages()).some((t) => t.targetId === id)) return false;
206
+ this.current = id;
207
+ await this.send("Target.activateTarget", { targetId: id }).catch(() => {});
208
+ return true;
209
+ },
210
+ current: async () => {
211
+ const pages = await this.pages();
212
+ const t = pages.find((p) => p.targetId === this.current) || pages[pages.length - 1];
213
+ if (!t) throw new Error('gu has no tab open yet — use action "open" with a url first');
214
+ this.current = t.targetId;
215
+ return t.targetId;
216
+ },
217
+ info: async (id) => {
218
+ const t = (await this.pages()).find((p) => p.targetId === id);
219
+ if (!t) return null;
220
+ let status = "loading";
221
+ if (this.sessions.has(id) && !this.loading.has(id)) {
222
+ const r = await Promise.race([this.cdp(id, "Runtime.evaluate", { expression: "document.readyState", returnByValue: true }).catch(() => null), sleep(2000)]);
223
+ if (r?.result?.value === "complete") status = "complete";
224
+ }
225
+ return { url: t.url, title: t.title, status };
226
+ },
227
+ activate: (id) => this.send("Target.activateTarget", { targetId: id }).catch(() => {}),
228
+ close: (id) => this.send("Target.closeTarget", { targetId: id }),
229
+ };
230
+ }
231
+
232
+ async run(cmd) {
233
+ await this.ensure();
234
+ return runAction(cmd, this.host());
235
+ }
236
+
237
+ /** Close gu's window (tests, and /browser cleanup). */
238
+ async quit() {
239
+ if (!this.running) return;
240
+ await this.send("Browser.close").catch(() => {});
241
+ this.ws?.close();
242
+ this.ws = null;
243
+ }
244
+ }
@@ -47,6 +47,34 @@ async function status(st, waitMs = 0) {
47
47
 
48
48
  // → { running, connected, version }. Starts the bridge when it is not running, so an extension
49
49
  // installed a moment from now has something to connect to.
50
+ /**
51
+ * The error a bridge that died on start left in its log, in one line — or "" (#381, GitHub #170).
52
+ * Same reading as gu_browser.bridge_error: gu 1.0.838 shipped without browser-chrome.mjs, and a
53
+ * bridge that cannot start was shown as "nothing to install" / "no Chrome".
54
+ */
55
+ export function bridgeError(logText) {
56
+ let lines = String(logText || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
57
+ for (let i = lines.length - 1; i >= 0; i--) {
58
+ if (lines[i].includes("listening on 127.0.0.1")) {
59
+ lines = lines.slice(i + 1);
60
+ break;
61
+ }
62
+ }
63
+ const hits = lines.filter((l) => /Cannot find (?:module|package)|ERR_[A-Z_]+|\b\w*Error\b:|cannot listen|EADDRINUSE|EACCES/.test(l));
64
+ if (!hits.length) return "";
65
+ const line = hits.find((l) => l.includes("Cannot find")) ?? hits[hits.length - 1];
66
+ return line.replace(/\s+/g, " ").slice(0, 240);
67
+ }
68
+
69
+ function bridgeLogTail(maxBytes = 16000) {
70
+ try {
71
+ const text = readFileSync(join(dataDir(), "browser-bridge.log"), "utf8");
72
+ return text.slice(-maxBytes);
73
+ } catch {
74
+ return "";
75
+ }
76
+ }
77
+
50
78
  export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
51
79
  let st = readState();
52
80
  try {
@@ -57,10 +85,13 @@ export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
57
85
  if (!start) return { running: false, connected: false };
58
86
  mkdirSync(dataDir(), { recursive: true });
59
87
  const log = openSync(join(dataDir(), "browser-bridge.log"), "a");
60
- spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] }).unref();
88
+ const child = spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] });
89
+ let died = false;
90
+ child.once("exit", () => { died = true; });
91
+ child.unref();
61
92
  closeSync(log);
62
93
  const end = Date.now() + 5000;
63
- while (Date.now() < end) {
94
+ while (Date.now() < end && !died) {
64
95
  await new Promise((r) => setTimeout(r, 150));
65
96
  st = readState();
66
97
  if (!st) continue;
@@ -70,11 +101,14 @@ export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
70
101
  /* not listening yet */
71
102
  }
72
103
  }
73
- return { running: false, connected: false };
104
+ return { running: false, connected: false, error: bridgeError(bridgeLogTail()) || "it did not start" };
74
105
  }
75
106
 
76
107
  // The Chrome Web Store page of the gu extension, once published (#149/#150). Empty until then.
77
- export const STORE_URL = process.env.GU_BROWSER_STORE_URL || "";
108
+ // The published listing (#385, GitHub #175) — live since 2026-10-16, gu browser 1.0.4. The env var
109
+ // still overrides it (a staging listing, or "" to fall back to the developer steps).
110
+ export const PUBLISHED_STORE_URL = "https://chromewebstore.google.com/detail/gu-browser/ldfcjapmdbgdkdhlbdkbkiankneabagj";
111
+ export const STORE_URL = process.env.GU_BROWSER_STORE_URL ?? PUBLISHED_STORE_URL;
78
112
 
79
113
  // "Always allow the browser in this folder" (#150) — the agent reads the same file
80
114
  // (gu_browser.allowed_in) before it asks.
@@ -106,7 +140,16 @@ export function allowIn(folder, allow = true) {
106
140
  writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
107
141
  }
108
142
 
109
- export function browserReport({ connected, version, installType, refused = [] }, { allowed = false } = {}) {
143
+ export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
144
+ // The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
145
+ if (!running && error) {
146
+ return [
147
+ `⚠ gu's browser bridge did not start: ${error}`,
148
+ "⚠ This is a problem in gu, not in Chrome — no browser needs installing.",
149
+ "Update gu: npm install -g @tiens.nguyen/gu-cli@latest — then restart gu.",
150
+ "The bridge's log: ~/.gonext/browser-bridge.log",
151
+ ];
152
+ }
110
153
  // Two copies installed (the store one next to a developer one): the bridge kept one (#148).
111
154
  const twoCopies = refused.length
112
155
  ? [`⚠ A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
@@ -130,9 +173,15 @@ export function browserReport({ connected, version, installType, refused = [] },
130
173
  "Nothing to install. When the agent needs a browser it asks you first, then",
131
174
  "opens gu's own Chrome window (a separate profile — not signed in to your",
132
175
  "accounts) and works in it with a real mouse and keyboard.",
133
- STORE_URL
134
- ? `To let it use YOUR Chrome (your logins, a tab you share): ${STORE_URL}`
135
- : "To let it use YOUR Chrome (your logins, a tab you share): /browser install",
176
+ // The link on a row of its own (#385): the published one is longer than a row beside the text.
177
+ // #386: which step is missing — added but not connected, or not added at all.
178
+ ...(storeUrl && storeInstalled
179
+ ? ["The gu extension is added to Chrome but not connected: keep Chrome open (it",
180
+ "connects within 30 s) — /browser install waits for it."]
181
+ : storeUrl
182
+ ? ["To let it use YOUR Chrome (your logins, a tab you share), add the gu",
183
+ "extension — /browser install guides you through it:", ` ${storeUrl}`]
184
+ : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
136
185
  here,
137
186
  ...twoCopies,
138
187
  ];
@@ -140,9 +189,10 @@ export function browserReport({ connected, version, installType, refused = [] },
140
189
 
141
190
  // /browser install — the extension, for the user's own Chrome. The store page when there is one;
142
191
  // until then the developer way (Developer mode → Load unpacked).
143
- export function installSteps(folder, platform = process.platform) {
144
- if (STORE_URL) {
145
- return [`Open ${STORE_URL} in Chrome and click "Add to Chrome".`, "Then type /browser — it should say connected."];
192
+ export function installSteps(folder, platform = process.platform, storeUrl = STORE_URL) {
193
+ if (storeUrl) {
194
+ return ["Add the gu extension from the Chrome Web Store — click \"Add to Chrome\":", ` ${storeUrl}`,
195
+ "Then type /browser — it should say connected."];
146
196
  }
147
197
  const pick = platform === "darwin"
148
198
  ? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
@@ -171,6 +221,23 @@ export function openExtensionsPage(platform = process.platform) {
171
221
  }
172
222
  }
173
223
 
224
+ /** Open the store listing (#385): Chrome on macOS when it is there, else the default browser. */
225
+ export function openStorePage(url = STORE_URL, platform = process.platform) {
226
+ if (!url || process.env.GU_NO_OPEN_APPS) return false;
227
+ try {
228
+ const [cmd, args] =
229
+ platform === "darwin"
230
+ ? ["open", existsSync("/Applications/Google Chrome.app") ? ["-a", "Google Chrome", url] : [url]]
231
+ : platform === "win32"
232
+ ? ["explorer.exe", [url]]
233
+ : ["xdg-open", [url]];
234
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
174
241
  // macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
175
242
  export function helpInstall(folder, platform = process.platform) {
176
243
  if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS) return false;
@@ -257,6 +324,86 @@ export function findDevInstalls({ roots = browserRoots(), extDir = extensionDir(
257
324
  return found;
258
325
  }
259
326
 
327
+ // THE PUBLISHED EXTENSION, FOUND IN CHROME'S OWN RECORDS (#386, GitHub #176). Its ID is fixed by the
328
+ // store (the last part of the store URL), so a profile has it when its preferences list that ID
329
+ // from anywhere but an unpacked folder. Disabled is told apart: it is installed but cannot connect.
330
+ export const STORE_EXTENSION_ID = "ldfcjapmdbgdkdhlbdkbkiankneabagj";
331
+
332
+ export function findStoreInstalls({ roots = browserRoots(), id = STORE_EXTENSION_ID } = {}) {
333
+ const found = [];
334
+ for (const { browser, dir } of roots) {
335
+ let profiles = [];
336
+ try {
337
+ profiles = readdirSync(dir).filter((p) => p === "Default" || /^Profile \d+$/.test(p));
338
+ } catch {
339
+ continue;
340
+ }
341
+ for (const profile of profiles) {
342
+ for (const file of ["Secure Preferences", "Preferences"]) {
343
+ let e;
344
+ try {
345
+ e = JSON.parse(readFileSync(join(dir, profile, file), "utf8"))?.extensions?.settings?.[id];
346
+ } catch {
347
+ continue;
348
+ }
349
+ if (!e || UNPACKED.has(e.location)) continue;
350
+ const reasons = Array.isArray(e.disable_reasons) ? e.disable_reasons.length : Number(e.disable_reasons) || 0;
351
+ found.push({ browser, profile, id, disabled: e.state === 0 || reasons > 0 });
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ return found;
357
+ }
358
+
359
+ /**
360
+ * Where the install stands, and what to tell the user — PURE, so every stage can be tested.
361
+ * → { stage, lines }: "remove-dev" (a developer copy would block the store one, #148), "add" (not
362
+ * in Chrome yet), "enable" (added but switched off), "connect" (added, not connected yet), "done".
363
+ */
364
+ export function installStage({ dev = [], store = [], status = {}, storeUrl = STORE_URL } = {}) {
365
+ if (status.connected && status.installType === "normal") {
366
+ return { stage: "done", lines: [`✓ gu browser is connected (Chrome Web Store, v${status.version ?? "?"}).`] };
367
+ }
368
+ if (dev.length) {
369
+ return {
370
+ stage: "remove-dev",
371
+ lines: [
372
+ "1. First remove the developer copy of gu browser — only one can connect.",
373
+ " In chrome://extensions (gu opened it), click Remove on this one:",
374
+ ...dev.map((d) => ` ${d.browser} · ${d.profile} · ID ${d.id}`),
375
+ " Waiting for it to be removed…",
376
+ ],
377
+ };
378
+ }
379
+ if (!store.length) {
380
+ return {
381
+ stage: "add",
382
+ lines: [
383
+ "1. Add gu browser to Chrome: on its store page, click \"Add to Chrome\".",
384
+ ` ${storeUrl}`,
385
+ " (gu opened the page for you.) Waiting for it to be added…",
386
+ ],
387
+ };
388
+ }
389
+ if (store.every((s) => s.disabled)) {
390
+ return {
391
+ stage: "enable",
392
+ lines: [
393
+ "2. gu browser is added but switched off. In chrome://extensions, turn it on.",
394
+ " Waiting for it to be turned on…",
395
+ ],
396
+ };
397
+ }
398
+ return {
399
+ stage: "connect",
400
+ lines: [
401
+ "2. gu browser is added. Waiting for it to connect to gu…",
402
+ " Keep Chrome open — it connects within 30 s, or click its icon → Connect now.",
403
+ ],
404
+ };
405
+ }
406
+
260
407
  // gu's own leftovers of the developer install, in ~/.gonext.
261
408
  export function localLeftovers() {
262
409
  return [extensionDir(), statePath(), seenPath(), logPath()].filter((p) => existsSync(p));
@@ -8771,6 +8771,25 @@ def _ws_delete_targets(argv):
8771
8771
  runner = runner[:-4]
8772
8772
  if runner in _WS_DELETE_RUNNERS:
8773
8773
  return [a for a in argv[1:] if a and not a.startswith("-") and a != "--"]
8774
+ if runner in ("mv", "move"):
8775
+ # A MOVE REMOVES ITS SOURCE (#381, GitHub #171), measured: `mv '/Applications/Google
8776
+ # Chrome.app' /tmp/Google-Chrome-old.app` ran without a question while the same with rm
8777
+ # would have asked. The sources are every operand but the destination — or, with
8778
+ # -t DIR / --target-directory=DIR, every operand.
8779
+ ops, target_given, skip = [], False, False
8780
+ for a in argv[1:]:
8781
+ if skip:
8782
+ skip = False
8783
+ continue
8784
+ if a in ("-t", "--target-directory"):
8785
+ target_given, skip = True, True
8786
+ continue
8787
+ if a.startswith("--target-directory="):
8788
+ target_given = True
8789
+ continue
8790
+ if a and not a.startswith("-") and a != "--":
8791
+ ops.append(a)
8792
+ return ops if target_given else ops[:-1]
8774
8793
  if runner == "find" and any(a in ("-delete",) for a in argv[1:]):
8775
8794
  # `find <paths…> <expression>`: the paths come before the first expression token.
8776
8795
  out = []
@@ -8805,7 +8824,8 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
8805
8824
  outside.append(t)
8806
8825
  if not outside:
8807
8826
  return None
8808
- return ("it deletes files OUTSIDE the project (" + ", ".join(outside[:3])
8827
+ verb = "moves" if os.path.basename(argv[0]).lower() in ("mv", "move") else "deletes"
8828
+ return (f"it {verb} files OUTSIDE the project (" + ", ".join(outside[:3])
8809
8829
  + (" …" if len(outside) > 3 else "") + ")")
8810
8830
 
8811
8831
 
@@ -16101,7 +16121,9 @@ def run_agent_chat(cfg):
16101
16121
  _emit({"type": "step", "text": "Using gu's own Chrome window" if where == "chrome"
16102
16122
  else "Using your Chrome (gu extension)"})
16103
16123
  if not_connected:
16104
- _emit({"type": "step", "text": "⚠ No browser gu can use — /browser shows why"})
16124
+ _emit({"type": "step", "text": ("⚠ gu's browser bridge failed to start — /browser shows why"
16125
+ if str(res).startswith(_gb.BRIDGE_FAILED)
16126
+ else "⚠ No browser gu can use — /browser shows why")})
16105
16127
  msg = _gb.install_text(str(res))
16106
16128
  elif not ok:
16107
16129
  msg = f"Error: browser {a} failed: {res}"
package/gu-repl.mjs CHANGED
@@ -137,9 +137,9 @@ import {
137
137
  trimWords,
138
138
  } from "./terminal-layout.mjs";
139
139
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
140
- import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
140
+ import { isScaffoldStep, isGroupableStep, stepGroupMark, stepGroupRows, relativizePaths } from "./step-group.mjs";
141
141
  import { clearLogsNoticeText } from "./clear-logs.mjs";
142
- import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
142
+ import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, findStoreInstalls, installStage, openExtensionsPage, openStorePage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
143
143
  import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPanelRows, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
144
144
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
145
145
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
@@ -420,6 +420,8 @@ const bold = paint(1);
420
420
  // toward white (no bold/underline/anchor) so it subtly signals "hovered" without looking like
421
421
  // a link. 97 = bright white.
422
422
  const hoverThought = (s) => `\x1b[97m${s}\x1b[0m`;
423
+ // A thought at rest: the terminal's own text colour (#383) — no longer dim.
424
+ const thoughtText = (s) => s;
423
425
 
424
426
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
425
427
  // SWALLOW them from the terminal (the local ticker below drives progress instead), and
@@ -563,7 +565,10 @@ const BULLET_STYLE = {
563
565
  // A look-up (grep/pgrep/rg…) that found nothing: not an error — a dim grey ✗, dim text.
564
566
  soft: { mark: CROSS, markColor: dim, textColor: dim },
565
567
  ok: { mark: CHECK, markColor: accent, textColor: dim },
566
- info: { mark: BULLET, markColor: dim, textColor: dim },
568
+ // #383 (GitHub #173): a "●" row that stands alone is the agent saying something — a warning, a
569
+ // wait for approval — so its text is white. Look-ups and finished commands are activity and go
570
+ // into the dim counted group instead (step-group.mjs); they never reach this style.
571
+ info: { mark: BULLET, markColor: dim, textColor: brightText },
567
572
  };
568
573
  const classifyBullet = (text) => BULLET_STYLE[bulletKind(text)];
569
574
 
@@ -1985,9 +1990,9 @@ rl.on("line", (l) => {
1985
1990
  // Enter ends any drag: if a release was lost (focus left the window mid-drag) the highlight
1986
1991
  // would otherwise stay painted over text nobody is selecting — see endDrag.
1987
1992
  endDrag();
1988
- // A list picker owns the keyboard (its Enter resolves via the keypress handler) — never
1989
- // let that Enter submit a stray empty line into the command loop.
1990
- if (listPickerActive) {
1993
+ // A list picker — or a panel in the input box's place (#384) — owns the keyboard (its Enter
1994
+ // resolves via the keypress handler): never let that Enter submit a stray empty line.
1995
+ if (listPickerActive || zonePanel) {
1991
1996
  rl.line = "";
1992
1997
  rl.cursor = 0;
1993
1998
  return;
@@ -2985,8 +2990,42 @@ function zoneNotice(width) {
2985
2990
  * carries — off the line being typed (INPUT_HEAD_ROWS). All of it lives below the scrolling region, so all of it stays put — including
2986
2991
  * what the user is typing, which is why gu renders that line itself (see inputZoneRows).
2987
2992
  */
2993
+ // A PANEL IN PLACE OF THE INPUT BOX (#384, GitHub #174) — Claude Code's Shell details. While one
2994
+ // is open it owns the bottom zone from the separator down: the input box, the notice row and the
2995
+ // bar give way to it, a turn's live status (if any) stays above it, and Esc gives the zone back.
2996
+ // `rows(width, maxRows)` → the panel's rows (it may use fewer); `onKey(key)` → true when handled.
2997
+ let zonePanel = null;
2998
+
2999
+ function openZonePanel(panel) {
3000
+ closeZonePanel();
3001
+ zonePanel = { ...panel, timer: panel.refreshMs ? setInterval(() => paintBar(), panel.refreshMs) : null };
3002
+ paintBar();
3003
+ }
3004
+
3005
+ function closeZonePanel() {
3006
+ if (!zonePanel) return;
3007
+ if (zonePanel.timer) clearInterval(zonePanel.timer);
3008
+ const done = zonePanel.onClose;
3009
+ zonePanel = null;
3010
+ paintBar();
3011
+ if (done) done();
3012
+ }
3013
+
2988
3014
  function frozenZone() {
2989
3015
  const width = Math.max(20, process.stdout.columns || 80);
3016
+ if (zonePanel) {
3017
+ const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
3018
+ // At least four rows of conversation stay visible, as with a tall input (maxInputRows).
3019
+ const room = Math.max(3, viewportRows() - status.length - 2 - 4);
3020
+ let body = [];
3021
+ try { body = zonePanel.rows(width, room) || []; } catch { body = []; }
3022
+ const rule = dim("─".repeat(width));
3023
+ const rows = status.concat([rule], body.slice(0, room).map((r) => clipVisible(r, width - 1)), [rule]);
3024
+ return {
3025
+ rows, statusRows: status.length, noticeRow: false, headRows: 1, rowCount: 1,
3026
+ caretRow: 0, caretCol: 1, offsetAt: () => 0,
3027
+ };
3028
+ }
2990
3029
  // The status rows sit at the TOP of the zone — directly under the transcript, which is
2991
3030
  // visually where the block already appeared, so nothing jumps. Everything below is unchanged.
2992
3031
  //
@@ -3596,12 +3635,18 @@ const hoverCtl = createHoverController({
3596
3635
  * that is the one place they are told apart.
3597
3636
  */
3598
3637
  const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3599
- const paint = (r) => (hovered ? hoverThought(r) : dim(r));
3600
3638
  if (block.kind === "steps") {
3639
+ // Activity (look-ups, finished commands): dim, like a ✓ row; bright while hovered.
3640
+ const paint = (r) => (hovered ? hoverThought(r) : dim(r));
3641
+ const ok = stepGroupMark(block.items) === "ok";
3601
3642
  return stepGroupRows(block.items, { width: thoughtWidth(), collapsed }).map(
3602
- (r) => (r.head ? markLine(dim(BULLET)) : GUTTER) + paint(r.text)
3643
+ (r) => (r.head ? markLine(ok ? accent(CHECK) : dim(BULLET)) : GUTTER) + paint(r.text)
3603
3644
  );
3604
3645
  }
3646
+ // THE AGENT'S OWN WORDS ARE WHITE (#383, GitHub #173) — what it found, what it will do next.
3647
+ // They were grey scaffolding (#148), as dim as the commands, and the user missed them; now
3648
+ // only activity recedes. Hover still lifts the block to bright white, the cue it can open.
3649
+ const paint = (r) => (hovered ? hoverThought(r) : thoughtText(r));
3605
3650
  const rows = thoughtBlockRows(block.prose, {
3606
3651
  width: thoughtWidth(),
3607
3652
  collapsed,
@@ -3615,7 +3660,7 @@ const thoughtWidth = () =>
3615
3660
  Math.max(24, (process.stdout.columns || 80) - GUTTER.length - 1);
3616
3661
 
3617
3662
  /**
3618
- * Print one step's reasoning into the transcript, collapsed, in grey (task #148).
3663
+ * Print one step's reasoning into the transcript, collapsed — in the normal text colour since #383 (grey before, task #148).
3619
3664
  *
3620
3665
  * Grey and collapsed for the same reason: this is ON by default, so it has to stay scaffolding.
3621
3666
  * Progress recedes and the answer is the only full-bright thing on screen — a page of bright
@@ -3656,7 +3701,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3656
3701
  lines: THOUGHT_COLLAPSED_LINES,
3657
3702
  });
3658
3703
  const rowsBeforeMerge = screenRows().length;
3659
- const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
3704
+ const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + thoughtText(r)));
3660
3705
  if (newEnd !== null) {
3661
3706
  prev.prose = merged;
3662
3707
  prev.to = newEnd;
@@ -3675,7 +3720,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3675
3720
  // as its error text. A thought is printed when its step's reasoning ends, BEFORE that step's
3676
3721
  // tools report: it introduces what comes next, it does not belong to the row above. Only for a
3677
3722
  // NEW block — a continuation is merged into the previous one above and never gets here.
3678
- process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + dim(r)), { gap }));
3723
+ process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + thoughtText(r)), { gap }));
3679
3724
  // Record where it landed. The blank is above `from`, so the block is still exactly its rows. Measured AFTER the write so a trim during it cannot skew the
3680
3725
  // range: the last completed line is length-2 (the final entry is the still-open line).
3681
3726
  const endAbs = transcript.dropped() + transcript.length() - 2;
@@ -4474,6 +4519,15 @@ if (process.stdin.isTTY) {
4474
4519
  // #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
4475
4520
  // is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
4476
4521
  if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
4522
+ // A panel holds the input box's place (#384): its keys, and nothing reaches the line.
4523
+ if (zonePanel) {
4524
+ if (key && (key.name === "escape" || (key.ctrl && key.name === "c"))) closeZonePanel();
4525
+ else if (zonePanel.onKey) zonePanel.onKey(key, _ch);
4526
+ rl.line = "";
4527
+ rl.cursor = 0;
4528
+ rl.historyIndex = -1;
4529
+ return;
4530
+ }
4477
4531
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
4478
4532
  if (listPickerActive) {
4479
4533
  onListKey(key);
@@ -9566,30 +9620,100 @@ function printShellDetails(rec) {
9566
9620
  // /browser (#363): the extension's connection, else how to install it.
9567
9621
  async function showBrowser() {
9568
9622
  const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9569
- const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
9623
+ const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())), storeInstalled: findStoreInstalls().length > 0 });
9570
9624
  console.log("");
9571
9625
  for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
9572
9626
  console.log("");
9573
9627
  paintBar();
9574
9628
  }
9575
9629
 
9576
- // /browser install (#150): the extension, for the agent to use YOUR Chrome. Optional — gu's own
9577
- // window needs nothing. The store page once published; until then the developer way.
9630
+ // /browser install (#150; guided since #386, GitHub #176): the published extension, for the agent to
9631
+ // use YOUR Chrome — the way Claude Code sets up its Chrome extension. gu walks it through to the end:
9632
+ // a developer copy is removed first (it would block the store one, #148), then the store page is
9633
+ // opened, and the panel in place of the input box (#384) shows each stage live until the store copy
9634
+ // is CONNECTED — or Esc stops waiting. gu's own window still needs none of this.
9578
9635
  async function browserInstall() {
9579
- let folder = extensionDir();
9636
+ if (listPickerActive || approvalActive) return;
9637
+ const done = (line) => {
9638
+ console.log("\n" + GUTTER + line + "\n");
9639
+ paintBar();
9640
+ };
9580
9641
  if (!STORE_URL) {
9642
+ // No published listing configured (GU_BROWSER_STORE_URL=""): the developer way, as before.
9643
+ let folder = extensionDir();
9581
9644
  try {
9582
9645
  folder = installExtensionFiles();
9583
9646
  } catch (e) {
9584
9647
  console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
9585
9648
  }
9649
+ await bridgeStatus().catch(() => null);
9650
+ console.log("");
9651
+ for (const l of installSteps(folder, process.platform, "")) console.log(GUTTER + dim(l));
9652
+ if (helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
9653
+ console.log("");
9654
+ paintBar();
9655
+ return;
9586
9656
  }
9587
- await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
9588
- console.log("");
9589
- for (const l of installSteps(folder)) console.log(GUTTER + dim(l));
9590
- if (STORE_URL ? openExtensionsPage() : helpInstall(folder)) console.log(GUTTER + dim(STORE_URL ? "(Opened Chrome.)" : "(Opened chrome://extensions and copied the folder to your clipboard.)"));
9591
- console.log("");
9592
- paintBar();
9657
+ let status = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9658
+ const opened = new Set();
9659
+ const state = () => {
9660
+ const st = installStage({ dev: findDevInstalls(), store: findStoreInstalls(), status });
9661
+ // Open the page each stage needs, once.
9662
+ if (st.stage === "remove-dev" && !opened.has("ext")) { opened.add("ext"); openExtensionsPage(); }
9663
+ if (st.stage === "add" && !opened.has("store")) { opened.add("store"); openStorePage(); }
9664
+ if (st.stage === "enable" && !opened.has("ext2")) { opened.add("ext2"); openExtensionsPage(); }
9665
+ return st;
9666
+ };
9667
+ let st = state();
9668
+ if (st.stage === "done") return done(accent(st.lines[0]));
9669
+ if (!barPinned()) {
9670
+ // No zone to wait in: say what to do once.
9671
+ console.log("");
9672
+ for (const l of st.lines) console.log(GUTTER + dim(l));
9673
+ console.log(GUTTER + dim("Then type /browser — it should say connected.") + "\n");
9674
+ paintBar();
9675
+ return;
9676
+ }
9677
+ let polling = false;
9678
+ const poll = setInterval(async () => {
9679
+ if (polling) return;
9680
+ polling = true;
9681
+ try {
9682
+ status = await bridgeStatus({ waitMs: 0 }).catch(() => status);
9683
+ st = state();
9684
+ if (st.stage === "done") {
9685
+ clearInterval(poll);
9686
+ closeZonePanel();
9687
+ done(accent(st.lines[0]) + dim(" The agent can use your Chrome now."));
9688
+ }
9689
+ } finally {
9690
+ polling = false;
9691
+ }
9692
+ }, 2000);
9693
+ openZonePanel({
9694
+ refreshMs: 1000,
9695
+ rows: (width) => {
9696
+ // A row the panel would clip is WRAPPED instead: the store link is 84 characters, and a link
9697
+ // cut short at 80 columns cannot be copied.
9698
+ const room = Math.max(20, width - 1 - GUTTER.length);
9699
+ const wrap = (l) => {
9700
+ const out = [];
9701
+ for (let rest = l; rest.length; rest = rest.slice(room)) out.push(rest.slice(0, room));
9702
+ return out.length ? out : [""];
9703
+ };
9704
+ return [
9705
+ GUTTER + bold(accent("Install gu browser")) + dim(" — so the agent can use your own Chrome"),
9706
+ "",
9707
+ ...st.lines.flatMap((l) => wrap(/https?:\/\//.test(l) ? l.trim() : l).map((r) => GUTTER + (/^\d\./.test(l) ? r : dim(r)))),
9708
+ "",
9709
+ GUTTER + dim("Esc to stop waiting · /browser install picks up where you left off"),
9710
+ ];
9711
+ },
9712
+ onClose: () => {
9713
+ clearInterval(poll);
9714
+ if (st.stage !== "done") done(dim("(stopped waiting — /browser install picks up where you left off)"));
9715
+ },
9716
+ });
9593
9717
  }
9594
9718
 
9595
9719
  // /browser cleanup (#145, #147): remove the developer (unpacked) install so the Chrome Web Store
@@ -9668,52 +9792,57 @@ async function showShells() {
9668
9792
  }
9669
9793
  }
9670
9794
  const rec = entries[pick].rec;
9671
- // During a turn a picker would fight the turn for the keyboard: the details are printed instead.
9672
- if (following) {
9673
- printShellDetails(rec);
9674
- refreshShells();
9675
- paintBar();
9795
+ // IN PLACE OF THE INPUT BOX (#384, GitHub #174), the way Claude Code shows it: from the top of
9796
+ // the bottom zone, live, until Esc — which gives the input box and the "N shell(s)" link back.
9797
+ // Nothing goes into the scrollback, and it works during a turn too: it holds two keys (Esc, s)
9798
+ // and swallows the rest, as a picker does. The shell keeps running when it closes.
9799
+ shellsViewed.add(String(rec.id));
9800
+ if (!barPinned()) {
9801
+ printShellDetails(rec); // no zone to draw in (a short window, piped output): print, as before
9676
9802
  return;
9677
9803
  }
9678
- // AT THE BOTTOM, ABOVE THE BAR, LIVE; Esc closes (#376, GitHub #157) — Claude Code's Shell
9679
- // details. Nothing goes into the scrollback: the panel and its choices are one block that the
9680
- // picker wipes when it closes. Its height is fixed when it opens (what the screen allows, at most
9681
- // the details plus 15 output lines) and it redraws once a second, so a build's runtime and
9682
- // newest output move while you watch.
9683
- shellsViewed.add(String(rec.id));
9684
- const panel = (rowsWanted) => {
9804
+ let note = "";
9805
+ const rowsFor = (width, room) => {
9685
9806
  const st = shellState(rec);
9686
- let output = logTail(rec.log, { lines: 40 });
9807
+ let output = logTail(rec.log, { lines: 60 });
9687
9808
  let outputFrom = "";
9688
9809
  if (!output.length) {
9689
9810
  const lf = commandLogFile(rec.command, rec.workdir);
9690
9811
  if (lf) {
9691
- output = logTail(lf, { lines: 40 });
9812
+ output = logTail(lf, { lines: 60 });
9692
9813
  outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
9693
9814
  }
9694
9815
  }
9816
+ const footer = note || (st.state === "running" ? "Esc to close · s to stop it" : "Esc to close");
9817
+ // As tall as it needs — its own detail rows (a long command wraps to 4), up to 15 output
9818
+ // lines and the footer — never taller than the room.
9819
+ const headRows = renderShellDetails({ rec, st, output: [], outputFrom, width: width - 1, gutter: GUTTER }).length - 1;
9820
+ const want = Math.min(room, headRows + Math.max(1, Math.min(15, output.length)) + 1);
9695
9821
  return shellPanelRows({
9696
- rec, st, output, outputFrom, width: Math.max(20, process.stdout.columns || 80) - 1, gutter: GUTTER, rows: rowsWanted,
9697
- footer: "Esc to close",
9822
+ rec, st, output, outputFrom, width: width - 1, gutter: GUTTER, rows: want, footer,
9698
9823
  style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
9699
9824
  });
9700
9825
  };
9701
- const running = shellState(rec).state === "running";
9702
- const actions = running
9703
- ? [{ id: "kill", label: "Stop it", note: "kills the command and everything it started" }, { id: "close", label: "Close" }]
9704
- : [{ id: "close", label: "Close" }];
9705
- const headerRows = Math.max(8, Math.min(24, viewportHeight() - 3 - actions.length));
9706
- const act = await pickFromList(
9707
- actions.map((a) => a.label + (a.note ? dim(` · ${a.note}`) : "")),
9708
- actions.length - 1, // Close: Enter on its own never stops a build
9709
- { header: panel, headerRows, refreshMs: 1000 },
9710
- );
9711
- if (act >= 0 && actions[act].id === "kill") {
9712
- await killShell(rec);
9713
- console.log(GUTTER + accent(`✓ stopped shell ${rec.id}`) + dim(" — the agent sees it as killed next turn.\n"));
9714
- }
9715
- refreshShells();
9716
- paintBar();
9826
+ openZonePanel({
9827
+ rows: rowsFor,
9828
+ refreshMs: 1000,
9829
+ onKey: (key) => {
9830
+ if (key?.name === "s" && !key.ctrl && !key.meta && shellState(rec).state === "running") {
9831
+ note = "stopping…";
9832
+ paintBar();
9833
+ killShell(rec).then(() => {
9834
+ note = "";
9835
+ console.log(GUTTER + accent(`✓ stopped shell ${rec.id}`) + dim(" — the agent sees it as killed next turn."));
9836
+ refreshShells();
9837
+ paintBar();
9838
+ });
9839
+ }
9840
+ },
9841
+ onClose: () => {
9842
+ refreshShells();
9843
+ paintBar();
9844
+ },
9845
+ });
9717
9846
  }
9718
9847
  async function refreshOtherAgents() {
9719
9848
  if (!barPinned() || following) return;
@@ -10699,11 +10828,18 @@ async function approvalPrompt(command) {
10699
10828
  const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
10700
10829
  if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
10701
10830
  process.stdout.write(
10702
- "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = No)") + "\n" +
10831
+ "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = Yes, this time)") + "\n" +
10703
10832
  dim(` It wants to: ${what || "open a page"}`) + "\n" +
10704
- dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
10833
+ dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n" +
10834
+ // #386: the way to "your Chrome", said where the question is asked.
10835
+ (STORE_URL && !findStoreInstalls().length
10836
+ ? dim(" To use your own Chrome (your logins): /browser install adds the gu extension.") + "\n"
10837
+ : "")
10705
10838
  );
10706
- const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 2 });
10839
+ // "Yes, this time" is THE default (#380, GitHub #167): highlighted, so Enter allows it once, and
10840
+ // also what an unanswered question becomes — the countdown used to sit on "No", which read as
10841
+ // the default. Remembering the folder is never automatic; it takes an explicit choice.
10842
+ const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 0 });
10707
10843
  if (idx === 1) {
10708
10844
  try {
10709
10845
  allowIn(folder || resolve(process.cwd()), true);
@@ -12391,17 +12527,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12391
12527
  /** Is the open group still followed by nothing but thought blocks? */
12392
12528
  const stepGroupStillOpen = () => {
12393
12529
  if (!stepGroup || !barPinned() || scrolledBack()) return false;
12394
- let end = stepGroup.to;
12395
- const at = thoughtBlocks.indexOf(stepGroup);
12396
- if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
12397
- for (let i = at + 1; i < thoughtBlocks.length; i++) {
12398
- // Directly under, or under the ONE blank a thought now opens with (#344) — the look-up
12399
- // group must stay open across it, or every thought would start a new counted row.
12400
- const step = thoughtBlocks[i].from - end;
12401
- if (step !== 1 && step !== 2) return false;
12402
- end = thoughtBlocks[i].to;
12403
- }
12404
- return end === transcriptTail();
12530
+ if (thoughtBlocks.indexOf(stepGroup) < 0) return false; // dropped from the registry (it is capped)
12531
+ // ONLY WHILE IT IS THE LAST THING ON SCREEN (#383, GitHub #173). It used to stay open across
12532
+ // the agent's notes, so a look-up made AFTER a note was counted in the row ABOVE it. With
12533
+ // finished commands joining groups and notes now white — the part the user reads — that put
12534
+ // activity above the note that came before it ("Chrome opened but the log still shows…" under
12535
+ // commands it had not seen yet). A note closes the group; the next activity starts a new one
12536
+ // below it, so the history reads in order: activity, note, activity, note.
12537
+ return stepGroup.to === transcriptTail();
12405
12538
  };
12406
12539
  const addLookupStep = (text) => {
12407
12540
  if (stepGroupStillOpen()) {
package/gu_browser.py CHANGED
@@ -8,6 +8,7 @@ model reads. No model or smolagents import here — it is tested on its own.
8
8
 
9
9
  import base64
10
10
  import json
11
+ import re
11
12
  import os
12
13
  import shutil
13
14
  import subprocess
@@ -71,7 +72,9 @@ def install_extension_files():
71
72
 
72
73
  # The Chrome Web Store page of the gu extension, once it is published (#149/#150). Until then the
73
74
  # extension is the developer path only — and nobody needs it: gu's own Chrome window works without.
74
- STORE_URL = os.environ.get("GU_BROWSER_STORE_URL", "")
75
+ # The published Chrome Web Store listing (#385, GitHub #175); the env var still overrides it.
76
+ PUBLISHED_STORE_URL = "https://chromewebstore.google.com/detail/gu-browser/ldfcjapmdbgdkdhlbdkbkiankneabagj"
77
+ STORE_URL = os.environ.get("GU_BROWSER_STORE_URL", PUBLISHED_STORE_URL)
75
78
 
76
79
 
77
80
  def allow_path():
@@ -122,8 +125,45 @@ def last_seen():
122
125
  return {}
123
126
 
124
127
 
128
+ BRIDGE_FAILED = "the gu browser bridge did not start"
129
+
130
+
131
+ def bridge_error(log_text):
132
+ """The error a bridge that died on start left in its log — the last one, in one line — or "".
133
+ gu 1.0.838 shipped without browser-chrome.mjs: the bridge died with ERR_MODULE_NOT_FOUND, and
134
+ the agent was told "no Chrome" and reinstalled a Chrome that was fine (#381, GitHub #170)."""
135
+ lines = [l.strip() for l in (log_text or "").splitlines() if l.strip()]
136
+ # Only what came after the last successful start counts.
137
+ for i in range(len(lines) - 1, -1, -1):
138
+ if "listening on 127.0.0.1" in lines[i]:
139
+ lines = lines[i + 1:]
140
+ break
141
+ pat = re.compile(r"Cannot find (?:module|package)|ERR_[A-Z_]+|\b\w*Error\b:|cannot listen|EADDRINUSE|EACCES")
142
+ hits = [l for l in lines if pat.search(l)]
143
+ if not hits:
144
+ return ""
145
+ line = hits[-1] if "Cannot find" not in " ".join(hits) else next(l for l in hits if "Cannot find" in l)
146
+ return re.sub(r"\s+", " ", line)[:240]
147
+
148
+
149
+ def bridge_log_tail(max_bytes=16000):
150
+ try:
151
+ with open(os.path.join(data_dir(), "browser-bridge.log"), "rb") as f:
152
+ f.seek(0, 2)
153
+ f.seek(max(0, f.tell() - max_bytes))
154
+ return f.read().decode("utf-8", "replace")
155
+ except OSError:
156
+ return ""
157
+
158
+
125
159
  def install_text(reason="not connected"):
126
160
  """What the model is told when the extension is not there: stop and ask the user."""
161
+ if str(reason).startswith(BRIDGE_FAILED):
162
+ # A fault in gu itself — never "install Chrome" (#170): the agent took that literally.
163
+ return (f"BROWSER NOT AVAILABLE — {reason}. This is a problem in gu's own browser bridge, NOT a "
164
+ "missing or broken Chrome: do NOT install, reinstall, move or delete any browser. STOP "
165
+ "and tell the user, with that error: update gu (npm install -g @tiens.nguyen/gu-cli@latest), "
166
+ "restart it, and ask again; the bridge's log is ~/.gonext/browser-bridge.log.")
127
167
  if last_seen().get("installType") == "normal":
128
168
  # The Chrome Web Store copy is installed: never point at the developer folder again (#145).
129
169
  return (f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension (Chrome Web Store) is "
@@ -174,13 +214,15 @@ def _start_bridge(log=None):
174
214
  logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
175
215
  kw = {"start_new_session": True} if os.name != "nt" else {
176
216
  "creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
177
- subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
178
- env=dict(os.environ), **kw)
217
+ proc = subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
218
+ env=dict(os.environ), **kw)
179
219
  logf.close()
180
220
  if log:
181
221
  log("browser: started the bridge")
182
222
  end = time.time() + 6
183
223
  while time.time() < end:
224
+ if proc.poll() is not None: # it died: no point waiting the full 6 s (#170)
225
+ return None
184
226
  st = _read_state()
185
227
  if st:
186
228
  try:
@@ -206,7 +248,8 @@ def connect(wait=True, log=None, on_wait=None, own=False):
206
248
  st = _start_bridge(log)
207
249
  started = True
208
250
  if not st:
209
- return None, "the gu browser bridge did not start (is node installed?)"
251
+ err = bridge_error(bridge_log_tail())
252
+ return None, f"{BRIDGE_FAILED}: {err}" if err else f"{BRIDGE_FAILED} (is node installed?)"
210
253
  # A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
211
254
  # long only when an extension has connected before — otherwise it is most likely not installed.
212
255
  seen = os.path.exists(seen_path())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.838",
3
+ "version": "1.0.840",
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",
@@ -129,6 +129,7 @@
129
129
  "win-path.mjs",
130
130
  "browser-command.mjs",
131
131
  "browser-bridge.mjs",
132
+ "browser-chrome.mjs",
132
133
  "browser-extension/manifest.json",
133
134
  "browser-extension/background.js",
134
135
  "browser-extension/actions.js",
package/step-group.mjs CHANGED
@@ -42,11 +42,24 @@ export function stepLabel(text) {
42
42
  return /^[A-Za-z][A-Za-z0-9 _.\-/]*$/.test(label) ? label : "";
43
43
  }
44
44
 
45
- /** Does this step join a group? `kind` is bulletKind()'s: only neutral ("info") steps do. */
45
+ // A COMMAND THAT FINISHED joins the group too (#383, GitHub #173): a turn that checks eight tools
46
+ // printed eight "✓ Command finished → …" rows in a column. They are activity, like the look-ups —
47
+ // one counted row, a click from the commands. A FAILED command never joins: it is the row to see.
48
+ const COMMAND_DONE = /^Command finished → /;
49
+ export const isCommandStep = (text) => COMMAND_DONE.test(String(text ?? "").trim());
50
+
51
+ /** Does this step join a group? `kind` is bulletKind()'s: neutral look-ups, and finished commands. */
46
52
  export function isGroupableStep(text, kind) {
53
+ if (kind === "ok") return isCommandStep(text);
47
54
  return kind === "info" && !isScaffoldStep(text) && stepLabel(text) !== "";
48
55
  }
49
56
 
57
+ /** The group's marker: ✓ when every step in it is a finished command, else the neutral ●. */
58
+ export function stepGroupMark(items) {
59
+ const list = items ?? [];
60
+ return list.length && list.every(isCommandStep) ? "ok" : "info";
61
+ }
62
+
50
63
  // "Searching code" reads as work in progress; a finished group is said in the past. Only the first
51
64
  // word changes, and only when it is one of these — anything else is shown as the agent wrote it.
52
65
  const PAST = {
@@ -68,7 +81,9 @@ export function stepGroupSummary(items) {
68
81
  const label = stepLabel(it) || String(it ?? "").trim();
69
82
  counts.set(label, (counts.get(label) ?? 0) + 1);
70
83
  }
71
- return [...counts].map(([label, n]) => pastLabel(label) + (n > 1 ? ` ${n}×` : "")).join(" · ");
84
+ return [...counts]
85
+ .map(([label, n]) => (label === "Command finished" ? `ran ${n} command${n > 1 ? "s" : ""}` : pastLabel(label) + (n > 1 ? ` ${n}×` : "")))
86
+ .join(" · ");
72
87
  }
73
88
 
74
89
  function wrapWords(text, width) {