@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.
- package/browser-chrome.mjs +244 -0
- package/browser-command.mjs +73 -11
- package/browser-extension/actions.js +1 -1
- package/gonext_agent_chat.py +66 -7
- package/gu-repl.mjs +117 -57
- package/gu_browser.py +47 -4
- package/package.json +2 -1
- package/step-group.mjs +17 -2
|
@@ -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
|
+
}
|
package/browser-command.mjs
CHANGED
|
@@ -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] })
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
145
|
-
return [
|
|
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 #
|
|
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
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
13819
|
-
|
|
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": "⚠
|
|
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
|
-
|
|
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
|
|
1989
|
-
// let that Enter submit a stray empty line
|
|
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
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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
|
-
//
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
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
|
-
|
|
9679
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9704
|
-
:
|
|
9705
|
-
|
|
9706
|
-
|
|
9707
|
-
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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]
|
|
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) {
|