@tiens.nguyen/gu-cli 1.0.837 → 1.0.839

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 } = {}) {
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,10 @@ 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
+ ...(storeUrl
178
+ ? ["To let it use YOUR Chrome (your logins, a tab you share), add the gu", `extension: ${storeUrl}`]
179
+ : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
136
180
  here,
137
181
  ...twoCopies,
138
182
  ];
@@ -140,9 +184,10 @@ export function browserReport({ connected, version, installType, refused = [] },
140
184
 
141
185
  // /browser install — the extension, for the user's own Chrome. The store page when there is one;
142
186
  // 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."];
187
+ export function installSteps(folder, platform = process.platform, storeUrl = STORE_URL) {
188
+ if (storeUrl) {
189
+ return ["Add the gu extension from the Chrome Web Store — click \"Add to Chrome\":", ` ${storeUrl}`,
190
+ "Then type /browser — it should say connected."];
146
191
  }
147
192
  const pick = platform === "darwin"
148
193
  ? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
@@ -171,6 +216,23 @@ export function openExtensionsPage(platform = process.platform) {
171
216
  }
172
217
  }
173
218
 
219
+ /** Open the store listing (#385): Chrome on macOS when it is there, else the default browser. */
220
+ export function openStorePage(url = STORE_URL, platform = process.platform) {
221
+ if (!url || process.env.GU_NO_OPEN_APPS) return false;
222
+ try {
223
+ const [cmd, args] =
224
+ platform === "darwin"
225
+ ? ["open", existsSync("/Applications/Google Chrome.app") ? ["-a", "Google Chrome", url] : [url]]
226
+ : platform === "win32"
227
+ ? ["explorer.exe", [url]]
228
+ : ["xdg-open", [url]];
229
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
174
236
  // macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
175
237
  export function helpInstall(folder, platform = process.platform) {
176
238
  if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS) return false;
@@ -12,7 +12,7 @@
12
12
  // host.close(tabId)
13
13
  // host.runsJs — true only where running gu's own JavaScript text is allowed (gu's own window).
14
14
  // The Chrome Web Store extension leaves it unset: text sent at run time and executed is
15
- // "remote code", which a Manifest V3 store extension may not run (#377, GitHub #158).
15
+ // "remote code", which a Manifest V3 store extension may not run (#377, GitHub #164).
16
16
  // No chrome.* and no Node API in here: it runs in an extension service worker and in Node.
17
17
 
18
18
  const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
@@ -2226,6 +2226,41 @@ def _announces_next_step(stripped: str) -> bool:
2226
2226
  return bool(_NEXT_STEP_RE.search(last)) and not _HANDED_TO_USER_RE.search(last)
2227
2227
 
2228
2228
 
2229
+ # A SENTENCE OF INTENT WITH NO CODE (#379, GitHub #163), measured: after a failed `cd` the model
2230
+ # replied "The shell was already in `frontend`, so `cd frontend` failed. Run without the cd." and
2231
+ # then "I forgot the code block. Run the tests with the correct workdir." — no code either time,
2232
+ # two failed steps (~94k input tokens). Neither says "let me" / "I'll", so _announces_next_step
2233
+ # missed them. A SHORT reply whose last sentence tells ITSELF to act (an imperative, not addressed
2234
+ # to "you"), or that admits leaving the code out, is a plan. A short report of finished work
2235
+ # ("Done. Run `npm start` to see it.") is not.
2236
+ _SELF_ACTION_RE = re.compile(
2237
+ r"^(?:(?:now|so|then|next|ok(?:ay)?),?\s+)?(?:run|re-?run|retry|try|check|read|open|call|use|"
2238
+ r"fix|edit|update|test|execute|search|grep|list|look|install|build|verify|inspect|add|remove|"
2239
+ r"stage|commit|push|create|write|redo|repeat|drop|skip|pass|set)\b", re.I)
2240
+ _SELF_FIX_RE = re.compile(
2241
+ r"\bI\s+(?:forgot|missed|left\s+out|omitted|didn't\s+include|did\s+not\s+include)\b[^.]*\b(?:code|block)\b"
2242
+ r"|\bno\s+code\s+block\b", re.I)
2243
+ _FINISHED_RE = re.compile(
2244
+ r"\b(?:done|committed|pushed|fixed|created|deployed|merged|finished|completed|all set)\b|✅", re.I)
2245
+
2246
+
2247
+ def _short_self_directive(stripped: str) -> bool:
2248
+ """A short, code-less reply that ends by telling itself what to do, or admits it left the
2249
+ code out. PURE, so the measured replies can be replayed."""
2250
+ t = (stripped or "").strip()
2251
+ if not t or len(t) > 300 or "\n\n" in t:
2252
+ return False
2253
+ if _SELF_FIX_RE.search(t):
2254
+ return True
2255
+ sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+", t) if x.strip()]
2256
+ if not sentences or len(sentences) > 3:
2257
+ return False
2258
+ last = sentences[-1]
2259
+ if re.search(r"\byou(?:r)?\b", last, re.I) or _FINISHED_RE.search(t):
2260
+ return False
2261
+ return bool(_SELF_ACTION_RE.match(last))
2262
+
2263
+
2229
2264
  def _looks_like_scratchpad(stripped: str) -> bool:
2230
2265
  """True if `stripped` reads like unresolved internal deliberation rather than a
2231
2266
  finished, user-facing answer — see module comment above for the signals used."""
@@ -2233,6 +2268,8 @@ def _looks_like_scratchpad(stripped: str) -> bool:
2233
2268
  return True
2234
2269
  if _announces_next_step(stripped):
2235
2270
  return True
2271
+ if _short_self_directive(stripped):
2272
+ return True
2236
2273
  return len(_DELIBERATION_RE.findall(stripped)) >= 3
2237
2274
 
2238
2275
 
@@ -8734,6 +8771,25 @@ def _ws_delete_targets(argv):
8734
8771
  runner = runner[:-4]
8735
8772
  if runner in _WS_DELETE_RUNNERS:
8736
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]
8737
8793
  if runner == "find" and any(a in ("-delete",) for a in argv[1:]):
8738
8794
  # `find <paths…> <expression>`: the paths come before the first expression token.
8739
8795
  out = []
@@ -8768,7 +8824,8 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
8768
8824
  outside.append(t)
8769
8825
  if not outside:
8770
8826
  return None
8771
- 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])
8772
8829
  + (" …" if len(outside) > 3 else "") + ")")
8773
8830
 
8774
8831
 
@@ -13812,11 +13869,12 @@ def run_agent_chat(cfg):
13812
13869
 
13813
13870
  def _sanitize_parse_errors(self, msgs):
13814
13871
  """Swap smolagents' code-parsing error text for a delimiter-free directive
13815
- (see _PARSE_ERROR_REPLACEMENT). No-op unless the backend is kind=openai, and
13872
+ (see _PARSE_ERROR_REPLACEMENT), on every backend; a
13816
13873
  no-op on every other kind of message — genuine SyntaxError feedback from a
13817
13874
  block that DID run is left intact, since that is real information."""
13818
- if not self._openai_backend:
13819
- return
13875
+ # Every backend now (#379, GitHub #163): ollama.com is an "ollama" backend, kept the
13876
+ # stock text, and its model answered it twice more without code. gu runs strong
13877
+ # models only (2026-09-25), the reason the CODE-NOW retry went ungated in #139.
13820
13878
  n = 0
13821
13879
  for m in msgs or []:
13822
13880
  if not isinstance(m, dict):
@@ -13833,8 +13891,7 @@ def run_agent_chat(cfg):
13833
13891
  part["text"] = self._PARSE_ERROR_REPLACEMENT
13834
13892
  n += 1
13835
13893
  if n:
13836
- _log(f"#113 replaced {n} code-parsing error message(s) with a "
13837
- "delimiter-free directive (openai backend)")
13894
+ _log(f"#113 replaced {n} code-parsing error message(s) with a direct directive")
13838
13895
 
13839
13896
  def _prepare_completion_kwargs(self, *args, **kwargs):
13840
13897
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
@@ -16064,7 +16121,9 @@ def run_agent_chat(cfg):
16064
16121
  _emit({"type": "step", "text": "Using gu's own Chrome window" if where == "chrome"
16065
16122
  else "Using your Chrome (gu extension)"})
16066
16123
  if not_connected:
16067
- _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")})
16068
16127
  msg = _gb.install_text(str(res))
16069
16128
  elif not ok:
16070
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, 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);
@@ -9587,7 +9641,8 @@ async function browserInstall() {
9587
9641
  await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
9588
9642
  console.log("");
9589
9643
  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.)"));
9644
+ // #385: the store link opens the STORE PAGE (it used to open chrome://extensions and say "Opened Chrome").
9645
+ if (STORE_URL ? openStorePage() : helpInstall(folder)) console.log(GUTTER + dim(STORE_URL ? "(Opened the Chrome Web Store page.)" : "(Opened chrome://extensions and copied the folder to your clipboard.)"));
9591
9646
  console.log("");
9592
9647
  paintBar();
9593
9648
  }
@@ -9668,52 +9723,57 @@ async function showShells() {
9668
9723
  }
9669
9724
  }
9670
9725
  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();
9726
+ // IN PLACE OF THE INPUT BOX (#384, GitHub #174), the way Claude Code shows it: from the top of
9727
+ // the bottom zone, live, until Esc — which gives the input box and the "N shell(s)" link back.
9728
+ // Nothing goes into the scrollback, and it works during a turn too: it holds two keys (Esc, s)
9729
+ // and swallows the rest, as a picker does. The shell keeps running when it closes.
9730
+ shellsViewed.add(String(rec.id));
9731
+ if (!barPinned()) {
9732
+ printShellDetails(rec); // no zone to draw in (a short window, piped output): print, as before
9676
9733
  return;
9677
9734
  }
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) => {
9735
+ let note = "";
9736
+ const rowsFor = (width, room) => {
9685
9737
  const st = shellState(rec);
9686
- let output = logTail(rec.log, { lines: 40 });
9738
+ let output = logTail(rec.log, { lines: 60 });
9687
9739
  let outputFrom = "";
9688
9740
  if (!output.length) {
9689
9741
  const lf = commandLogFile(rec.command, rec.workdir);
9690
9742
  if (lf) {
9691
- output = logTail(lf, { lines: 40 });
9743
+ output = logTail(lf, { lines: 60 });
9692
9744
  outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
9693
9745
  }
9694
9746
  }
9747
+ const footer = note || (st.state === "running" ? "Esc to close · s to stop it" : "Esc to close");
9748
+ // As tall as it needs — its own detail rows (a long command wraps to 4), up to 15 output
9749
+ // lines and the footer — never taller than the room.
9750
+ const headRows = renderShellDetails({ rec, st, output: [], outputFrom, width: width - 1, gutter: GUTTER }).length - 1;
9751
+ const want = Math.min(room, headRows + Math.max(1, Math.min(15, output.length)) + 1);
9695
9752
  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",
9753
+ rec, st, output, outputFrom, width: width - 1, gutter: GUTTER, rows: want, footer,
9698
9754
  style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
9699
9755
  });
9700
9756
  };
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();
9757
+ openZonePanel({
9758
+ rows: rowsFor,
9759
+ refreshMs: 1000,
9760
+ onKey: (key) => {
9761
+ if (key?.name === "s" && !key.ctrl && !key.meta && shellState(rec).state === "running") {
9762
+ note = "stopping…";
9763
+ paintBar();
9764
+ killShell(rec).then(() => {
9765
+ note = "";
9766
+ console.log(GUTTER + accent(`✓ stopped shell ${rec.id}`) + dim(" — the agent sees it as killed next turn."));
9767
+ refreshShells();
9768
+ paintBar();
9769
+ });
9770
+ }
9771
+ },
9772
+ onClose: () => {
9773
+ refreshShells();
9774
+ paintBar();
9775
+ },
9776
+ });
9717
9777
  }
9718
9778
  async function refreshOtherAgents() {
9719
9779
  if (!barPinned() || following) return;
@@ -10699,11 +10759,14 @@ async function approvalPrompt(command) {
10699
10759
  const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
10700
10760
  if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
10701
10761
  process.stdout.write(
10702
- "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = No)") + "\n" +
10762
+ "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = Yes, this time)") + "\n" +
10703
10763
  dim(` It wants to: ${what || "open a page"}`) + "\n" +
10704
10764
  dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
10705
10765
  );
10706
- const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 2 });
10766
+ // "Yes, this time" is THE default (#380, GitHub #167): highlighted, so Enter allows it once, and
10767
+ // also what an unanswered question becomes — the countdown used to sit on "No", which read as
10768
+ // the default. Remembering the folder is never automatic; it takes an explicit choice.
10769
+ const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 0 });
10707
10770
  if (idx === 1) {
10708
10771
  try {
10709
10772
  allowIn(folder || resolve(process.cwd()), true);
@@ -12391,17 +12454,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12391
12454
  /** Is the open group still followed by nothing but thought blocks? */
12392
12455
  const stepGroupStillOpen = () => {
12393
12456
  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();
12457
+ if (thoughtBlocks.indexOf(stepGroup) < 0) return false; // dropped from the registry (it is capped)
12458
+ // ONLY WHILE IT IS THE LAST THING ON SCREEN (#383, GitHub #173). It used to stay open across
12459
+ // the agent's notes, so a look-up made AFTER a note was counted in the row ABOVE it. With
12460
+ // finished commands joining groups and notes now white — the part the user reads — that put
12461
+ // activity above the note that came before it ("Chrome opened but the log still shows…" under
12462
+ // commands it had not seen yet). A note closes the group; the next activity starts a new one
12463
+ // below it, so the history reads in order: activity, note, activity, note.
12464
+ return stepGroup.to === transcriptTail();
12405
12465
  };
12406
12466
  const addLookupStep = (text) => {
12407
12467
  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.837",
3
+ "version": "1.0.839",
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) {