@tiens.nguyen/gu-cli 1.0.833 → 1.0.835
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/README.md +1 -0
- package/browser-bridge.mjs +335 -0
- package/browser-command.mjs +310 -0
- package/browser-extension/README.md +151 -0
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +261 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +37 -0
- package/browser-extension/popup.html +26 -0
- package/browser-extension/popup.js +45 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +325 -19
- package/gu-cli.mjs +14 -12
- package/gu-repl.mjs +170 -2
- package/gu_browser.py +335 -0
- package/package.json +11 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +98 -10
- package/terminal-layout.mjs +1 -0
package/gu-repl.mjs
CHANGED
|
@@ -139,7 +139,8 @@ import {
|
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
140
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
-
import {
|
|
142
|
+
import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
|
|
143
|
+
import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
143
144
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
144
145
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
145
146
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -953,6 +954,7 @@ const COMMANDS = [
|
|
|
953
954
|
{ name: "/open-presets", usage: "/open-presets [name]", desc: "your whole setup now, keys included, read-only in your editor — to copy to another computer; with a name, edit that preset" },
|
|
954
955
|
{ name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
|
|
955
956
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
957
|
+
{ name: "/browser", usage: "/browser [allow|forget|install|cleanup]", desc: "the gu Chrome extension the agent uses to open and click web pages (UI tests) — is it connected, and how to install it" },
|
|
956
958
|
{ name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
|
|
957
959
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
958
960
|
{ name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
|
|
@@ -9503,6 +9505,33 @@ function refreshShells() {
|
|
|
9503
9505
|
shellEntries = readShells().map((rec) => ({ rec, st: shellState(rec) }));
|
|
9504
9506
|
const after = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE })?.text ?? "";
|
|
9505
9507
|
if (after !== before) paintBar();
|
|
9508
|
+
wakeForShells();
|
|
9509
|
+
}
|
|
9510
|
+
|
|
9511
|
+
/**
|
|
9512
|
+
* WHEN A BACKGROUND SHELL FINISHES, THE AGENT HEARS ABOUT IT (#362) — Claude Code tells its model
|
|
9513
|
+
* "background command completed" and it recaps or carries on. A shell that ends DURING a turn is
|
|
9514
|
+
* told on the next step (the agent's step_callback); one that ends after the turn starts a short
|
|
9515
|
+
* recap turn here — only at the empty main prompt (never into a question you are answering or a
|
|
9516
|
+
* line you are typing), only for this folder's shells, and never for one you stopped yourself.
|
|
9517
|
+
* GU_SHELL_RECAP=0 turns it off.
|
|
9518
|
+
*/
|
|
9519
|
+
let mainPromptWaiting = false;
|
|
9520
|
+
const SHELL_DONE_PREFIX = "\u0000shell-done:";
|
|
9521
|
+
function wakeForShells() {
|
|
9522
|
+
if (process.env.GU_SHELL_RECAP === "0") return;
|
|
9523
|
+
if (!mainPromptWaiting || following || listPickerActive || approvalActive || (rl?.line ?? "") !== "") return;
|
|
9524
|
+
const due = shellsToRecap(shellEntries, { cwd: process.cwd(), since: SHELLS_SINCE });
|
|
9525
|
+
if (!due.length) return;
|
|
9526
|
+
const ids = due.map((r) => String(r.id));
|
|
9527
|
+
try {
|
|
9528
|
+
if (!markAnnounced(ids)) return; // another terminal got there first
|
|
9529
|
+
} catch {
|
|
9530
|
+
return;
|
|
9531
|
+
}
|
|
9532
|
+
for (const id of ids) shellsViewed.add(id);
|
|
9533
|
+
mainPromptWaiting = false;
|
|
9534
|
+
submitSyntheticLine(SHELL_DONE_PREFIX + ids.join(","));
|
|
9506
9535
|
}
|
|
9507
9536
|
|
|
9508
9537
|
/** The Shell details block for one shell, printed into the transcript. */
|
|
@@ -9534,6 +9563,89 @@ function printShellDetails(rec) {
|
|
|
9534
9563
|
* details, then refresh / stop it / close. During a turn it only prints the details — a picker
|
|
9535
9564
|
* would fight the turn for the keyboard, as showOtherAgents explains.
|
|
9536
9565
|
*/
|
|
9566
|
+
// /browser (#363): the extension's connection, else how to install it.
|
|
9567
|
+
async function showBrowser() {
|
|
9568
|
+
const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
|
|
9569
|
+
const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
|
|
9570
|
+
console.log("");
|
|
9571
|
+
for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
|
|
9572
|
+
console.log("");
|
|
9573
|
+
paintBar();
|
|
9574
|
+
}
|
|
9575
|
+
|
|
9576
|
+
// /browser install (#150): the extension, for the agent to use YOUR Chrome. Optional — gu's own
|
|
9577
|
+
// window needs nothing. The store page once published; until then the developer way.
|
|
9578
|
+
async function browserInstall() {
|
|
9579
|
+
let folder = extensionDir();
|
|
9580
|
+
if (!STORE_URL) {
|
|
9581
|
+
try {
|
|
9582
|
+
folder = installExtensionFiles();
|
|
9583
|
+
} catch (e) {
|
|
9584
|
+
console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
|
|
9585
|
+
}
|
|
9586
|
+
}
|
|
9587
|
+
await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
|
|
9588
|
+
console.log("");
|
|
9589
|
+
for (const l of installSteps(folder)) console.log(GUTTER + dim(l));
|
|
9590
|
+
if (STORE_URL ? openExtensionsPage() : helpInstall(folder)) console.log(GUTTER + dim(STORE_URL ? "(Opened Chrome.)" : "(Opened chrome://extensions and copied the folder to your clipboard.)"));
|
|
9591
|
+
console.log("");
|
|
9592
|
+
paintBar();
|
|
9593
|
+
}
|
|
9594
|
+
|
|
9595
|
+
// /browser cleanup (#145, #147): remove the developer (unpacked) install so the Chrome Web Store
|
|
9596
|
+
// one can take its place. Chrome lets no other program uninstall an extension, so: the connected
|
|
9597
|
+
// developer copy removes itself (chrome.management.uninstallSelf), other profiles get a Remove
|
|
9598
|
+
// click from you, and then gu's own files go.
|
|
9599
|
+
async function browserCleanup() {
|
|
9600
|
+
if (listPickerActive || approvalActive) return;
|
|
9601
|
+
const out = (l = "") => console.log(l ? GUTTER + l : "");
|
|
9602
|
+
const st = await bridgeStatus({ start: false, waitMs: 0 }).catch(() => ({ connected: false }));
|
|
9603
|
+
const devConnected = st.connected && st.installType === "development";
|
|
9604
|
+
const installs = findDevInstalls();
|
|
9605
|
+
const files = localLeftovers();
|
|
9606
|
+
out();
|
|
9607
|
+
if (!installs.length && !devConnected && !files.length) {
|
|
9608
|
+
out(accent("✓ Nothing of the gu extension's developer install is left."));
|
|
9609
|
+
out();
|
|
9610
|
+
paintBar();
|
|
9611
|
+
return;
|
|
9612
|
+
}
|
|
9613
|
+
out("The gu extension's developer install — what this removes:");
|
|
9614
|
+
for (const f of installs) out(dim(` • ${f.browser} — ${f.profile}: gu browser, unpacked (${f.id})`));
|
|
9615
|
+
if (devConnected && !installs.some((f) => f.id === st.id)) out(dim(` • the connected developer copy (${st.id})`));
|
|
9616
|
+
for (const f of files) out(dim(` • ${f.replace(homedir(), "~")}`));
|
|
9617
|
+
if (st.connected && st.installType === "normal") out(dim(" (The Chrome Web Store copy is connected — it stays.)"));
|
|
9618
|
+
out();
|
|
9619
|
+
const go = await pickFromList(["Clean up", "Cancel"], 0);
|
|
9620
|
+
if (go !== 0) {
|
|
9621
|
+
out(dim("(cancelled)"));
|
|
9622
|
+
out();
|
|
9623
|
+
paintBar();
|
|
9624
|
+
return;
|
|
9625
|
+
}
|
|
9626
|
+
if (devConnected) {
|
|
9627
|
+
const r = await bridgeCommand({ action: "uninstall_self" });
|
|
9628
|
+
out(r.ok ? accent(`✓ Removed from Chrome: the connected developer copy (${st.id}).`) : yellow(`could not remove the connected copy: ${r.error}`));
|
|
9629
|
+
await new Promise((res) => setTimeout(res, 1500)); // Chrome writes its profile a moment later
|
|
9630
|
+
}
|
|
9631
|
+
const left = findDevInstalls().filter((f) => !(devConnected && f.id === st.id));
|
|
9632
|
+
if (left.length) {
|
|
9633
|
+
out("Chrome does not let gu remove an extension in another profile. In each of these,");
|
|
9634
|
+
out("open chrome://extensions, find \"gu browser\" and click Remove:");
|
|
9635
|
+
for (const f of left) out(dim(` • ${f.browser} — ${f.profile}`));
|
|
9636
|
+
if (openExtensionsPage()) out(dim("(Opened chrome://extensions.)"));
|
|
9637
|
+
await pickFromList(["Done — I removed it", "Skip — leave it in that browser"], 0);
|
|
9638
|
+
}
|
|
9639
|
+
stopBridge();
|
|
9640
|
+
await new Promise((res) => setTimeout(res, 300));
|
|
9641
|
+
const gone = removeLocalFiles();
|
|
9642
|
+
if (gone.length) out(accent(`✓ Deleted ${gone.length} of gu's file(s) in ~/.gonext.`));
|
|
9643
|
+
const still = [...findDevInstalls().filter((f) => !(devConnected && f.id === st.id)).map((f) => `${f.browser} — ${f.profile}`), ...localLeftovers()];
|
|
9644
|
+
out(still.length ? yellow(`Still there: ${still.join(", ")}`) : accent("✓ The developer install is gone. Install the Chrome Web Store copy, then /browser."));
|
|
9645
|
+
out();
|
|
9646
|
+
paintBar();
|
|
9647
|
+
}
|
|
9648
|
+
|
|
9537
9649
|
async function showShells() {
|
|
9538
9650
|
if (listPickerActive || approvalActive) return;
|
|
9539
9651
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
@@ -10494,6 +10606,9 @@ const S3SETUP_PREFIX = "__S3SETUP__::";
|
|
|
10494
10606
|
// "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
|
|
10495
10607
|
// call, so it counts from the next call of this same turn, and for every turn after.
|
|
10496
10608
|
const MCPTOOL_PREFIX = "__MCPTOOL__::";
|
|
10609
|
+
// The agent's first use of the browser in a turn (#150): "<folder>\t<what it is about to do>".
|
|
10610
|
+
// "Always" puts the folder on ~/.gonext/browser-allow.json, which the agent reads before asking.
|
|
10611
|
+
const BROWSER_PREFIX = "__BROWSER__::";
|
|
10497
10612
|
async function approvalPrompt(command) {
|
|
10498
10613
|
// Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
|
|
10499
10614
|
// redrawing it (the tick early-returns while a picker is active) — otherwise the block
|
|
@@ -10551,6 +10666,25 @@ async function approvalPrompt(command) {
|
|
|
10551
10666
|
);
|
|
10552
10667
|
return { allow: idx === 0, dontAsk: false };
|
|
10553
10668
|
}
|
|
10669
|
+
if (command.startsWith(BROWSER_PREFIX)) {
|
|
10670
|
+
const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
|
|
10671
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
10672
|
+
process.stdout.write(
|
|
10673
|
+
"\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = No)") + "\n" +
|
|
10674
|
+
dim(` It wants to: ${what || "open a page"}`) + "\n" +
|
|
10675
|
+
dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
|
|
10676
|
+
);
|
|
10677
|
+
const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 2 });
|
|
10678
|
+
if (idx === 1) {
|
|
10679
|
+
try {
|
|
10680
|
+
allowIn(folder || resolve(process.cwd()), true);
|
|
10681
|
+
process.stdout.write(dim(" (won't ask again here — /browser forget undoes it)\n"));
|
|
10682
|
+
} catch (e) {
|
|
10683
|
+
process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
|
|
10684
|
+
}
|
|
10685
|
+
}
|
|
10686
|
+
return { allow: idx === 0 || idx === 1, dontAsk: false };
|
|
10687
|
+
}
|
|
10554
10688
|
if (command.startsWith(MCPTOOL_PREFIX)) {
|
|
10555
10689
|
const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
|
|
10556
10690
|
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
@@ -14007,6 +14141,7 @@ async function main() {
|
|
|
14007
14141
|
// the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
|
|
14008
14142
|
// in place (\r + refresh) rather than stacking ">> >> >> " on one row.
|
|
14009
14143
|
let lineRaw;
|
|
14144
|
+
mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
|
|
14010
14145
|
for (;;) {
|
|
14011
14146
|
lineRaw = await nextLine();
|
|
14012
14147
|
const echoed = enterEchoed;
|
|
@@ -14035,8 +14170,21 @@ async function main() {
|
|
|
14035
14170
|
}
|
|
14036
14171
|
}
|
|
14037
14172
|
enterEchoed = false;
|
|
14173
|
+
mainPromptWaiting = false;
|
|
14038
14174
|
footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
|
|
14039
14175
|
if (lineRaw === null) break; // stdin closed
|
|
14176
|
+
// A RECAP TURN FOR A FINISHED BACKGROUND SHELL (#362). Nobody typed it, so it is not echoed as
|
|
14177
|
+
// a question under ">>" — one dim line says what started it, then the turn runs as usual.
|
|
14178
|
+
let shellRecap = false;
|
|
14179
|
+
if (lineRaw.startsWith(SHELL_DONE_PREFIX)) {
|
|
14180
|
+
const ids = lineRaw.slice(SHELL_DONE_PREFIX.length).split(",");
|
|
14181
|
+
const entries = readShells().map((rec) => ({ rec, st: shellState(rec) })).filter((e) => ids.includes(String(e.rec.id)));
|
|
14182
|
+
if (!entries.length) continue;
|
|
14183
|
+
shellRecap = true;
|
|
14184
|
+
alignOutputToLineStart();
|
|
14185
|
+
console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
|
|
14186
|
+
lineRaw = recapQuestion(entries);
|
|
14187
|
+
}
|
|
14040
14188
|
const line = lineRaw.trim();
|
|
14041
14189
|
// #302: what was typed for THIS line (before expansion), and whether it was sent from a
|
|
14042
14190
|
// stopped turn's restored question.
|
|
@@ -14051,7 +14199,7 @@ async function main() {
|
|
|
14051
14199
|
// carries a NUL and a raw jobId, and echoing it as a question would put both in the
|
|
14052
14200
|
// scrollback under a ">>" that claims the user wrote them. Its branch prints its own
|
|
14053
14201
|
// "⇄ following: …" line, which is the readable version of the same event.
|
|
14054
|
-
if (barPinned() && line && !line.startsWith(ATTACH_PREFIX)) {
|
|
14202
|
+
if (barPinned() && line && !line.startsWith(ATTACH_PREFIX) && !shellRecap) {
|
|
14055
14203
|
inputVisible = false; // the zone goes back to an empty prompt for the turn
|
|
14056
14204
|
// START THIS LINE AT COLUMN 0, don't assume it is already there.
|
|
14057
14205
|
//
|
|
@@ -14147,6 +14295,26 @@ async function main() {
|
|
|
14147
14295
|
await showShells();
|
|
14148
14296
|
continue;
|
|
14149
14297
|
}
|
|
14298
|
+
if (line === "/browser") {
|
|
14299
|
+
await showBrowser();
|
|
14300
|
+
continue;
|
|
14301
|
+
}
|
|
14302
|
+
if (line === "/browser cleanup") {
|
|
14303
|
+
await browserCleanup();
|
|
14304
|
+
continue;
|
|
14305
|
+
}
|
|
14306
|
+
if (line === "/browser install") {
|
|
14307
|
+
await browserInstall();
|
|
14308
|
+
continue;
|
|
14309
|
+
}
|
|
14310
|
+
if (line === "/browser allow" || line === "/browser forget") {
|
|
14311
|
+
allowIn(cwd, line === "/browser allow");
|
|
14312
|
+
console.log("\n" + GUTTER + (line === "/browser allow"
|
|
14313
|
+
? accent("✓ The agent may use the browser in this folder without asking.") + dim(" (/browser forget undoes it)")
|
|
14314
|
+
: accent("✓ The agent asks again before it uses the browser in this folder.")) + "\n");
|
|
14315
|
+
paintBar();
|
|
14316
|
+
continue;
|
|
14317
|
+
}
|
|
14150
14318
|
if (line === "/model") {
|
|
14151
14319
|
await chooseModel();
|
|
14152
14320
|
continue;
|
package/gu_browser.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The agent's browser tool, Python side (task #363, GitHub #144).
|
|
2
|
+
|
|
3
|
+
The agent runs in a new process every turn, so it never holds the Chrome extension itself: it talks
|
|
4
|
+
to browser-bridge.mjs (a small local Node process that does) over HTTP, starting it when needed.
|
|
5
|
+
This module finds or starts the bridge, sends one command, and turns the answer into the text the
|
|
6
|
+
model reads. No model or smolagents import here — it is tested on its own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
EXT_SRC = os.path.join(HERE, "browser-extension")
|
|
21
|
+
EXT_FILES = ("manifest.json", "background.js", "actions.js", "popup.html", "popup.js",
|
|
22
|
+
"icons/icon16.png", "icons/icon48.png", "icons/icon128.png")
|
|
23
|
+
BRIDGE = os.path.join(HERE, "browser-bridge.mjs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def data_dir():
|
|
27
|
+
return os.path.join(os.path.expanduser("~"), ".gonext")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def state_path():
|
|
31
|
+
return os.environ.get("GU_BROWSER_STATE") or os.path.join(data_dir(), "browser-bridge.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def seen_path():
|
|
35
|
+
# Written the first time an extension answers: from then on a missing connection means "Chrome
|
|
36
|
+
# is asleep or closed", worth a longer wait, not "never installed".
|
|
37
|
+
return os.path.join(os.path.dirname(state_path()), "browser-extension-seen")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extension_dir():
|
|
41
|
+
"""The folder the user loads into Chrome. A fixed place under ~/.gonext, not the npm package:
|
|
42
|
+
an update moves the package, and Chrome would lose an unpacked extension loaded from there."""
|
|
43
|
+
return os.environ.get("GU_BROWSER_EXT_DIR") or os.path.join(data_dir(), "browser-extension")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install_extension_files():
|
|
47
|
+
"""Copy the extension to extension_dir() when it is missing or older. Returns the folder."""
|
|
48
|
+
dst = extension_dir()
|
|
49
|
+
try:
|
|
50
|
+
with open(os.path.join(EXT_SRC, "manifest.json"), encoding="utf-8") as f:
|
|
51
|
+
want = f.read()
|
|
52
|
+
try:
|
|
53
|
+
with open(os.path.join(dst, "manifest.json"), encoding="utf-8") as f:
|
|
54
|
+
have = f.read()
|
|
55
|
+
except OSError:
|
|
56
|
+
have = ""
|
|
57
|
+
def same(n):
|
|
58
|
+
try:
|
|
59
|
+
with open(os.path.join(dst, n), "rb") as a, open(os.path.join(EXT_SRC, n), "rb") as b:
|
|
60
|
+
return a.read() == b.read()
|
|
61
|
+
except OSError:
|
|
62
|
+
return False
|
|
63
|
+
if have != want or not all(same(n) for n in EXT_FILES):
|
|
64
|
+
os.makedirs(os.path.join(dst, "icons"), exist_ok=True)
|
|
65
|
+
for n in EXT_FILES:
|
|
66
|
+
shutil.copyfile(os.path.join(EXT_SRC, n), os.path.join(dst, n))
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
return dst
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# The Chrome Web Store page of the gu extension, once it is published (#149/#150). Until then the
|
|
73
|
+
# 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
|
+
|
|
76
|
+
|
|
77
|
+
def allow_path():
|
|
78
|
+
return os.path.join(os.path.dirname(state_path()), "browser-allow.json")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _real(folder):
|
|
82
|
+
try:
|
|
83
|
+
return os.path.realpath(folder)
|
|
84
|
+
except OSError:
|
|
85
|
+
return folder
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def allowed_in(folder):
|
|
89
|
+
"""Has the user said "always allow the browser" for this folder (#150)?"""
|
|
90
|
+
try:
|
|
91
|
+
with open(allow_path(), encoding="utf-8") as f:
|
|
92
|
+
return _real(folder) in (json.load(f).get("folders") or [])
|
|
93
|
+
except (OSError, ValueError):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def allow_in(folder, allow=True):
|
|
98
|
+
"""Add (or remove) a folder on the "always allow" list. The terminal writes it on "Always"."""
|
|
99
|
+
try:
|
|
100
|
+
with open(allow_path(), encoding="utf-8") as f:
|
|
101
|
+
folders = json.load(f).get("folders") or []
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
folders = []
|
|
104
|
+
f_ = _real(folder)
|
|
105
|
+
folders = [x for x in folders if x != f_] + ([f_] if allow else [])
|
|
106
|
+
os.makedirs(os.path.dirname(allow_path()), exist_ok=True)
|
|
107
|
+
with open(allow_path(), "w", encoding="utf-8") as f:
|
|
108
|
+
json.dump({"folders": folders}, f, indent=2)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def last_seen():
|
|
112
|
+
"""{version, installType} of the extension that last connected, or {} (#146). An older marker
|
|
113
|
+
holds only the version."""
|
|
114
|
+
try:
|
|
115
|
+
with open(seen_path(), encoding="utf-8") as f:
|
|
116
|
+
raw = f.read().strip()
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(raw) if raw.startswith("{") else {"version": raw}
|
|
119
|
+
except ValueError:
|
|
120
|
+
return {"version": raw}
|
|
121
|
+
except OSError:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def install_text(reason="not connected"):
|
|
126
|
+
"""What the model is told when the extension is not there: stop and ask the user."""
|
|
127
|
+
if last_seen().get("installType") == "normal":
|
|
128
|
+
# The Chrome Web Store copy is installed: never point at the developer folder again (#145).
|
|
129
|
+
return (f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension (Chrome Web Store) is "
|
|
130
|
+
"installed but not connected — Chrome is probably closed. STOP and ask the user to "
|
|
131
|
+
"open Chrome (the extension reconnects within 30 seconds, or its icon → Connect now), "
|
|
132
|
+
"then continue. /browser in the gu terminal shows the status.")
|
|
133
|
+
# gu's own Chrome window needs no extension (#150), so this is reached only when there is no
|
|
134
|
+
# Chrome to start at all.
|
|
135
|
+
return (
|
|
136
|
+
f"BROWSER NOT AVAILABLE ({reason}). gu opens its own Chrome window when the gu extension is "
|
|
137
|
+
"not there, and it found no Chrome, Chromium, Edge or Brave on this computer. Do NOT work "
|
|
138
|
+
"around it with curl or a headless script for a UI check — STOP and tell the user: install "
|
|
139
|
+
"Google Chrome (or set GU_BROWSER_CHROME to a Chromium-based browser), then ask again."
|
|
140
|
+
+ (f" To use their own Chrome instead, they can add the gu extension: {STORE_URL}" if STORE_URL else ""))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _node():
|
|
144
|
+
return os.environ.get("GU_NODE") or shutil.which("node") or "node"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_state():
|
|
148
|
+
try:
|
|
149
|
+
with open(state_path(), encoding="utf-8") as f:
|
|
150
|
+
st = json.load(f)
|
|
151
|
+
return st if st.get("port") and st.get("token") else None
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _http(st, method, path, body=None, timeout=10):
|
|
157
|
+
req = urllib.request.Request(
|
|
158
|
+
f"http://127.0.0.1:{st['port']}{path}", method=method,
|
|
159
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
160
|
+
headers={"authorization": f"Bearer {st['token']}", "content-type": "application/json"})
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
163
|
+
return json.loads(r.read() or b"{}")
|
|
164
|
+
except urllib.error.HTTPError as e:
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(e.read() or b"{}") | {"_status": e.code}
|
|
167
|
+
except ValueError:
|
|
168
|
+
return {"_status": e.code, "error": str(e)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _start_bridge(log=None):
|
|
172
|
+
"""Start browser-bridge.mjs detached (it outlives this turn) and wait for its state file."""
|
|
173
|
+
os.makedirs(data_dir(), exist_ok=True)
|
|
174
|
+
logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
|
|
175
|
+
kw = {"start_new_session": True} if os.name != "nt" else {
|
|
176
|
+
"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)
|
|
179
|
+
logf.close()
|
|
180
|
+
if log:
|
|
181
|
+
log("browser: started the bridge")
|
|
182
|
+
end = time.time() + 6
|
|
183
|
+
while time.time() < end:
|
|
184
|
+
st = _read_state()
|
|
185
|
+
if st:
|
|
186
|
+
try:
|
|
187
|
+
_http(st, "GET", "/status", timeout=2)
|
|
188
|
+
return st
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
time.sleep(0.15)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def connect(wait=True, log=None, on_wait=None, own=False):
|
|
196
|
+
"""→ (state, None) when the extension is connected, else (None, reason).
|
|
197
|
+
own=True (#150): gu's own Chrome window may stand in, so a missing extension is no failure —
|
|
198
|
+
→ (state, None) either way, and the wait for a sleeping extension is shorter."""
|
|
199
|
+
st = _read_state()
|
|
200
|
+
started = False
|
|
201
|
+
try:
|
|
202
|
+
if not st:
|
|
203
|
+
raise OSError("no bridge")
|
|
204
|
+
_http(st, "GET", "/status", timeout=3)
|
|
205
|
+
except OSError:
|
|
206
|
+
st = _start_bridge(log)
|
|
207
|
+
started = True
|
|
208
|
+
if not st:
|
|
209
|
+
return None, "the gu browser bridge did not start (is node installed?)"
|
|
210
|
+
# A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
|
|
211
|
+
# long only when an extension has connected before — otherwise it is most likely not installed.
|
|
212
|
+
seen = os.path.exists(seen_path())
|
|
213
|
+
if own:
|
|
214
|
+
wait_ms = (8000 if seen else 0) if wait else 0 # then gu's own window
|
|
215
|
+
else:
|
|
216
|
+
wait_ms = (35000 if (seen and started) else 8000 if seen else 4000) if wait else 0
|
|
217
|
+
if wait_ms >= 8000 and on_wait:
|
|
218
|
+
on_wait()
|
|
219
|
+
try:
|
|
220
|
+
s = _http(st, "GET", f"/status?wait_ms={wait_ms}", timeout=wait_ms / 1000 + 5)
|
|
221
|
+
except OSError as e:
|
|
222
|
+
return None, f"the gu browser bridge is not answering ({e})"
|
|
223
|
+
if not s.get("connected"):
|
|
224
|
+
return (st, None) if own else (None, "extension not connected")
|
|
225
|
+
mark = {"version": s.get("version"), "installType": s.get("installType")}
|
|
226
|
+
if last_seen() != mark:
|
|
227
|
+
try:
|
|
228
|
+
with open(seen_path(), "w") as f:
|
|
229
|
+
json.dump(mark, f)
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
return st, None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def command(cmd, log=None, on_wait=None, own=False):
|
|
236
|
+
"""Send one command. → (ok, result_or_error, not_connected).
|
|
237
|
+
own=True: without the extension, the bridge uses gu's own Chrome window (#150). The result
|
|
238
|
+
then carries "_backend": "chrome"."""
|
|
239
|
+
st, why = connect(log=log, on_wait=on_wait, own=own)
|
|
240
|
+
if not st:
|
|
241
|
+
return False, why, True
|
|
242
|
+
if own:
|
|
243
|
+
cmd = dict(cmd, backend="auto")
|
|
244
|
+
timeout_s = 75 if cmd.get("action") in ("open", "wait", "js") else 40
|
|
245
|
+
try:
|
|
246
|
+
r = _http(st, "POST", "/cmd", body=dict(cmd, timeout_ms=(timeout_s - 5) * 1000), timeout=timeout_s)
|
|
247
|
+
except OSError as e:
|
|
248
|
+
return False, f"the browser bridge did not answer ({e})", False
|
|
249
|
+
if r.get("_status") == 503:
|
|
250
|
+
return False, "extension not connected", True
|
|
251
|
+
if r.get("noChrome"):
|
|
252
|
+
return False, str(r.get("error")), True # nothing to drive: the install answer applies
|
|
253
|
+
if not r.get("ok"):
|
|
254
|
+
return False, str(r.get("error") or "failed"), False
|
|
255
|
+
res = r.get("result") or {}
|
|
256
|
+
if isinstance(res, dict) and r.get("backend"):
|
|
257
|
+
res["_backend"] = r["backend"]
|
|
258
|
+
return True, res, False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── what the model reads ────────────────────────────────────────────────────────────────────
|
|
262
|
+
def _head(res):
|
|
263
|
+
out = f"Page: {res.get('title') or '(no title)'} — {res.get('url') or ''}"
|
|
264
|
+
for d in res.get("dialogs") or []:
|
|
265
|
+
out += f"\nA dialog opened and gu accepted it: {d}"
|
|
266
|
+
errs = res.get("errors") or []
|
|
267
|
+
if errs:
|
|
268
|
+
out += "\nNEW console errors:\n" + "\n".join(" " + e for e in errs)
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_read(res):
|
|
273
|
+
els = res.get("elements") or []
|
|
274
|
+
lines = [_head(res), f"Viewport {res.get('viewport')}, scrolled {res.get('scroll')} px."]
|
|
275
|
+
if els:
|
|
276
|
+
lines.append("Interactive elements in view — click/type by [number] (good until the next read):")
|
|
277
|
+
lines += [" " + e for e in els]
|
|
278
|
+
else:
|
|
279
|
+
lines.append("No interactive elements in view.")
|
|
280
|
+
if res.get("offscreen"):
|
|
281
|
+
lines.append(f"(+{res['offscreen']} more off screen — scroll to reach them)")
|
|
282
|
+
text = (res.get("text") or "").strip()
|
|
283
|
+
lines.append("Page text:\n" + (text or "(empty)"))
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def format_result(action, res):
|
|
288
|
+
a = action
|
|
289
|
+
if a == "tabs":
|
|
290
|
+
tabs = res.get("tabs") or []
|
|
291
|
+
if not tabs:
|
|
292
|
+
return "gu has no tab open."
|
|
293
|
+
return "gu's tabs:\n" + "\n".join(
|
|
294
|
+
f" {t['id']}{' (current)' if t.get('current') else ''}: {t.get('title') or ''} — {t.get('url')}" for t in tabs)
|
|
295
|
+
if a == "read":
|
|
296
|
+
return format_read(res)
|
|
297
|
+
if a == "close":
|
|
298
|
+
return f"Closed tab {res.get('closed')}."
|
|
299
|
+
out = _head(res)
|
|
300
|
+
if a in ("click", "double_click", "right_click", "hover"):
|
|
301
|
+
out = f"{a.replace('_', ' ').capitalize()} at {res.get('at')} on {res.get('hit')}.\n" + out
|
|
302
|
+
if a == "scroll":
|
|
303
|
+
out += f"\nScrolled to {res.get('scroll')} px."
|
|
304
|
+
if a == "js":
|
|
305
|
+
out += f"\nResult: {res.get('value')}"
|
|
306
|
+
if a == "console":
|
|
307
|
+
msgs = res.get("console") or []
|
|
308
|
+
out += ("\nConsole since the last read:\n" + "\n".join(f" [{m.get('level')}] {m.get('text')}" for m in msgs)
|
|
309
|
+
if msgs else "\nConsole: nothing new.")
|
|
310
|
+
if a in ("open", "click", "double_click", "type", "key", "back", "reload"):
|
|
311
|
+
out += "\n(read or screenshot to see the page now)"
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def decode_screenshot(res, max_edge=1568):
|
|
316
|
+
"""The screenshot as a PIL image at the page's CSS size, so its pixels ARE click coordinates.
|
|
317
|
+
→ (image, note). Chrome captures in device pixels (2× on a Retina screen)."""
|
|
318
|
+
from io import BytesIO
|
|
319
|
+
from PIL import Image
|
|
320
|
+
im = Image.open(BytesIO(base64.b64decode(res.get("image") or ""))).convert("RGB")
|
|
321
|
+
w, h = int(res.get("width") or im.width), int(res.get("height") or im.height)
|
|
322
|
+
if (w, h) != im.size and w > 0 and h > 0:
|
|
323
|
+
im = im.resize((w, h), Image.LANCZOS)
|
|
324
|
+
note = f"Screenshot {im.width}×{im.height} — its pixels are the page's x,y for click."
|
|
325
|
+
if max(im.size) > max_edge:
|
|
326
|
+
k = max_edge / max(im.size)
|
|
327
|
+
im = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))), Image.LANCZOS)
|
|
328
|
+
note = (f"Screenshot shrunk to {im.width}×{im.height}; the page is {w}×{h} — multiply a point "
|
|
329
|
+
f"in it by {1 / k:.3f} for click x,y.")
|
|
330
|
+
return im, note
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__": # tiny manual check: python3 gu_browser.py open https://example.com
|
|
334
|
+
ok, res, _ = command({"action": sys.argv[1], "url": sys.argv[2] if len(sys.argv) > 2 else ""})
|
|
335
|
+
print(format_result(sys.argv[1], res) if ok else res)
|
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.835",
|
|
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",
|
|
@@ -125,6 +125,15 @@
|
|
|
125
125
|
"skills.mjs",
|
|
126
126
|
"skill-source.mjs",
|
|
127
127
|
"shell-details.mjs",
|
|
128
|
+
"proc-identity.mjs",
|
|
129
|
+
"browser-command.mjs",
|
|
130
|
+
"browser-bridge.mjs",
|
|
131
|
+
"browser-extension/manifest.json",
|
|
132
|
+
"browser-extension/background.js",
|
|
133
|
+
"browser-extension/actions.js",
|
|
134
|
+
"browser-extension/popup.html",
|
|
135
|
+
"browser-extension/popup.js",
|
|
136
|
+
"browser-extension/icons/",
|
|
128
137
|
"step-group.mjs",
|
|
129
138
|
"mcp-config.mjs",
|
|
130
139
|
"mcp-command.mjs",
|
|
@@ -136,6 +145,7 @@
|
|
|
136
145
|
"gu_mcp.py",
|
|
137
146
|
"gu_skills.py",
|
|
138
147
|
"gu_test_digest.py",
|
|
148
|
+
"gu_browser.py",
|
|
139
149
|
"gu_hooks.py",
|
|
140
150
|
"prompt_scaffold_check.py",
|
|
141
151
|
"gonext_transcribe.py",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// IS THIS PID STILL THE PROCESS WE MEANT? (task #373, GitHub #154)
|
|
2
|
+
//
|
|
3
|
+
// gu keeps process ids in files — shells.json, the local API's pid file, the browser bridge's state —
|
|
4
|
+
// and those files outlive their processes. After a reboot or a crash the id is free, and the OS
|
|
5
|
+
// hands it to the next program (VS Code, Chrome); a SIGTERM to the saved id then lands on that.
|
|
6
|
+
// A process's START TIME is what tells two holders of one id apart, so it is what gu records and
|
|
7
|
+
// checks before it signals anything. POSIX only: `ps -o lstart=` reads the same on macOS and procps.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec";
|
|
12
|
+
|
|
13
|
+
/** When `pid` started (epoch s); null = no such process; NaN = cannot tell (no ps, Windows). */
|
|
14
|
+
export function procStart(pid, { platform = process.platform } = {}) {
|
|
15
|
+
if (platform === "win32" || !(Number(pid) > 0)) return NaN;
|
|
16
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
|
19
|
+
timeout: 5000,
|
|
20
|
+
});
|
|
21
|
+
if (r.error) return NaN;
|
|
22
|
+
const raw = String(r.stdout || "").trim().replace(/\s+/g, " ");
|
|
23
|
+
if (!raw) return null;
|
|
24
|
+
// "Wed Oct 14 23:26:17 2026", local time.
|
|
25
|
+
const m = raw.match(/^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/);
|
|
26
|
+
const mon = m ? MONTHS.indexOf(m[1]) / 3 : -1;
|
|
27
|
+
if (!m || mon < 0 || !Number.isInteger(mon)) return NaN;
|
|
28
|
+
return new Date(Number(m[6]), mon, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime() / 1000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The command line of `pid` ("" when there is none, or no ps). */
|
|
32
|
+
export function procCommand(pid, { platform = process.platform } = {}) {
|
|
33
|
+
if (platform === "win32" || !(Number(pid) > 0)) return "";
|
|
34
|
+
const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
|
|
35
|
+
return r.error ? "" : String(r.stdout || "").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Is `pid` the process that was running at `startedAt` (epoch s, as recorded)?
|
|
40
|
+
* "same" — it started then (within `before` s before and `after` s after the record);
|
|
41
|
+
* "gone" — no process has that id;
|
|
42
|
+
* "other" — that id belongs to a process started at another time: never signal it;
|
|
43
|
+
* "unknown" — nothing to compare (Windows, no ps, nothing recorded).
|
|
44
|
+
* `before` is the room for a record written a while after its process started (a server that
|
|
45
|
+
* records itself once it listens); `after` is ps's whole-second rounding.
|
|
46
|
+
*/
|
|
47
|
+
export function sameProcess(pid, startedAt, { before = 5, after = 5, startOf = procStart } = {}) {
|
|
48
|
+
const t = startOf(pid);
|
|
49
|
+
if (t === null) return "gone";
|
|
50
|
+
const rec = Number(startedAt);
|
|
51
|
+
if (!Number.isFinite(t) || startedAt == null || !Number.isFinite(rec)) return "unknown";
|
|
52
|
+
return t >= rec - before && t <= rec + after ? "same" : "other";
|
|
53
|
+
}
|