@tiens.nguyen/gu-cli 1.0.834 → 1.0.836
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 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +424 -59
- package/gu-cli.mjs +45 -15
- package/gu-repl.mjs +185 -26
- package/gu_browser.py +92 -20
- package/gu_powershell.py +675 -0
- package/install-path.mjs +19 -2
- package/package.json +5 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +95 -26
- package/terminal-layout.mjs +1 -0
- package/win-path.mjs +185 -0
package/gu-cli.mjs
CHANGED
|
@@ -139,7 +139,7 @@ Usage:
|
|
|
139
139
|
gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
|
|
140
140
|
gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
|
|
141
141
|
gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
|
|
142
|
-
gu-cli fix-path
|
|
142
|
+
gu-cli fix-path [--check] # put npm's global folder on PATH (Windows: check, then ask)
|
|
143
143
|
# (what the install does, if npm skipped it)
|
|
144
144
|
gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
|
|
145
145
|
# (it is Get-Unique and shadows this CLI)
|
|
@@ -854,8 +854,9 @@ const currentApiVersion = async () => {
|
|
|
854
854
|
}
|
|
855
855
|
// RESTART LAST, and only after the new version can actually run — currentVersion() now
|
|
856
856
|
// refuses a deps-less directory, so a failed install above leaves the old one serving.
|
|
857
|
-
|
|
858
|
-
|
|
857
|
+
// Only while the pid on record is still the local API (#373): a stale file's id may be another program's.
|
|
858
|
+
const { pid } = await ce.apiPidToStop();
|
|
859
|
+
if (pid) {
|
|
859
860
|
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
|
860
861
|
// WAIT FOR THE PORT, not just for the signal. SIGTERM returns immediately and the old
|
|
861
862
|
// server keeps the socket for a moment; starting the new one into that gap fails as
|
|
@@ -883,9 +884,9 @@ const currentApiVersion = async () => {
|
|
|
883
884
|
}
|
|
884
885
|
|
|
885
886
|
if (sub === "stop") {
|
|
886
|
-
const pid =
|
|
887
|
-
if (!
|
|
888
|
-
console.log(
|
|
887
|
+
const { pid, why } = await ce.apiPidToStop();
|
|
888
|
+
if (!pid) {
|
|
889
|
+
console.log(`\n Nothing stopped: ${why}.\n`);
|
|
889
890
|
process.exit(0);
|
|
890
891
|
}
|
|
891
892
|
try {
|
|
@@ -946,9 +947,9 @@ const currentApiVersion = async () => {
|
|
|
946
947
|
* ANSWERING before starting the new one.
|
|
947
948
|
*/
|
|
948
949
|
if (sub === "restart") {
|
|
949
|
-
const pid =
|
|
950
|
+
const { pid } = await ce.apiPidToStop();
|
|
950
951
|
const before = await ce.probeLocalApi();
|
|
951
|
-
if (
|
|
952
|
+
if (pid) {
|
|
952
953
|
try {
|
|
953
954
|
process.kill(pid, "SIGTERM");
|
|
954
955
|
console.log(`\n Stopping the local API (pid ${pid})…`);
|
|
@@ -964,7 +965,7 @@ const currentApiVersion = async () => {
|
|
|
964
965
|
// Answering, but we have no pid to signal — started by hand, or the file was lost. Say so
|
|
965
966
|
// rather than starting a SECOND one on the same port and reporting success.
|
|
966
967
|
console.log(
|
|
967
|
-
`\n The API is answering on ${ce.LOCAL_API_BASE} but
|
|
968
|
+
`\n The API is answering on ${ce.LOCAL_API_BASE} but no pid on record is still it,` +
|
|
968
969
|
"\n so it cannot be stopped from here. Stop it yourself, then: gu-cli api start\n"
|
|
969
970
|
);
|
|
970
971
|
process.exit(1);
|
|
@@ -1067,8 +1068,39 @@ if (args[0] === "token-usage" || args[0] === "tokens") {
|
|
|
1067
1068
|
// prefix worked out from where this file is installed, so there is one implementation.
|
|
1068
1069
|
if (args[0] === "fix-path") {
|
|
1069
1070
|
if (process.platform === "win32") {
|
|
1070
|
-
|
|
1071
|
-
|
|
1071
|
+
// #375 (GitHub #156): Windows keeps PATH in the registry — check the SAVED one (what a new
|
|
1072
|
+
// terminal reads), say what to do the way the installer does, and add it only on a yes.
|
|
1073
|
+
const wp = await import("./win-path.mjs");
|
|
1074
|
+
const { fileURLToPath } = await import("node:url");
|
|
1075
|
+
const { dirname, join } = await import("node:path");
|
|
1076
|
+
const { readFileSync } = await import("node:fs");
|
|
1077
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1078
|
+
const dir = wp.windowsPrefixOf(here) || String(process.env.npm_config_prefix || "");
|
|
1079
|
+
if (!dir) {
|
|
1080
|
+
console.log(` gu is not installed globally with npm (${here}) — there is no PATH to fix.`);
|
|
1081
|
+
console.log(" Install it with: irm https://gu.getmarsic.cc/install.ps1 | iex");
|
|
1082
|
+
process.exit(0);
|
|
1083
|
+
}
|
|
1084
|
+
let version = "?";
|
|
1085
|
+
try { version = JSON.parse(readFileSync(join(here, "package.json"), "utf8")).version; } catch { /* shown as ? */ }
|
|
1086
|
+
const saved = wp.readSavedPaths();
|
|
1087
|
+
const status = wp.pathStatus({ dir, sessionPath: process.env.Path || process.env.PATH || "", ...saved });
|
|
1088
|
+
const psHost = Boolean(process.env.PSModulePath) && !process.env.PROMPT; // cmd.exe sets PROMPT
|
|
1089
|
+
let summary = wp.installSummary({ version, location: wp.guLocation(dir), dir, status, powershell: psHost });
|
|
1090
|
+
if (!status.saved && !args.includes("--check") && process.stdin.isTTY) {
|
|
1091
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1092
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1093
|
+
const answer = (await rl.question(` Add ${dir} to your user PATH now? [Y/n] `)).trim().toLowerCase();
|
|
1094
|
+
rl.close();
|
|
1095
|
+
if (answer === "" || answer === "y" || answer === "yes") {
|
|
1096
|
+
const r = wp.addToUserPath(dir);
|
|
1097
|
+
if (r.ok) {
|
|
1098
|
+
console.log(" Added. Open a new terminal and gu is there.");
|
|
1099
|
+
summary = wp.installSummary({ version, location: wp.guLocation(dir), dir, status: { saved: true, session: status.session }, powershell: psHost });
|
|
1100
|
+
} else console.log(` Could not change PATH: ${r.error || "PowerShell refused"}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
console.log(wp.formatSummary(summary, { color: Boolean(process.stdout.isTTY) }).replace(/^/gm, " "));
|
|
1072
1104
|
process.exit(0);
|
|
1073
1105
|
}
|
|
1074
1106
|
const { fixPathPrefix } = await import("./install-path.mjs");
|
|
@@ -1292,10 +1324,8 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1292
1324
|
// nothing on the machine any longer knows about.
|
|
1293
1325
|
if (groups.includes("identity")) {
|
|
1294
1326
|
const ce = await import("./client-expert-api.mjs");
|
|
1295
|
-
const pid =
|
|
1296
|
-
|
|
1297
|
-
);
|
|
1298
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
1327
|
+
const { pid } = await ce.apiPidToStop();
|
|
1328
|
+
if (pid) {
|
|
1299
1329
|
try {
|
|
1300
1330
|
process.kill(pid, "SIGTERM");
|
|
1301
1331
|
console.log(`stopped the local API (pid ${pid})`);
|
package/gu-repl.mjs
CHANGED
|
@@ -139,8 +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 { browserReport, bridgeStatus, extensionDir, helpInstall, installExtensionFiles } from "./browser-command.mjs";
|
|
143
|
-
import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
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, 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";
|
|
146
146
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -954,7 +954,7 @@ const COMMANDS = [
|
|
|
954
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" },
|
|
955
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" },
|
|
956
956
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
957
|
-
{ name: "/browser", desc: "the gu Chrome extension the agent uses to open and click web pages (UI tests) — is it connected, and how to install it" },
|
|
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" },
|
|
958
958
|
{ name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
|
|
959
959
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
960
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" },
|
|
@@ -9565,21 +9565,87 @@ function printShellDetails(rec) {
|
|
|
9565
9565
|
*/
|
|
9566
9566
|
// /browser (#363): the extension's connection, else how to install it.
|
|
9567
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() {
|
|
9568
9579
|
let folder = extensionDir();
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
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
|
+
}
|
|
9573
9586
|
}
|
|
9574
|
-
|
|
9575
|
-
const lines = browserReport(st, folder);
|
|
9587
|
+
await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
|
|
9576
9588
|
console.log("");
|
|
9577
|
-
for (const
|
|
9578
|
-
if (
|
|
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.)"));
|
|
9579
9591
|
console.log("");
|
|
9580
9592
|
paintBar();
|
|
9581
9593
|
}
|
|
9582
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
|
+
|
|
9583
9649
|
async function showShells() {
|
|
9584
9650
|
if (listPickerActive || approvalActive) return;
|
|
9585
9651
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
@@ -9602,20 +9668,49 @@ async function showShells() {
|
|
|
9602
9668
|
}
|
|
9603
9669
|
}
|
|
9604
9670
|
const rec = entries[pick].rec;
|
|
9605
|
-
for
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
9671
|
+
// During a turn a picker would fight the turn for the keyboard: the details are printed instead.
|
|
9672
|
+
if (following) {
|
|
9673
|
+
printShellDetails(rec);
|
|
9674
|
+
refreshShells();
|
|
9675
|
+
paintBar();
|
|
9676
|
+
return;
|
|
9677
|
+
}
|
|
9678
|
+
// AT THE BOTTOM, ABOVE THE BAR, LIVE; Esc closes (#376, GitHub #157) — Claude Code's Shell
|
|
9679
|
+
// details. Nothing goes into the scrollback: the panel and its choices are one block that the
|
|
9680
|
+
// picker wipes when it closes. Its height is fixed when it opens (what the screen allows, at most
|
|
9681
|
+
// the details plus 15 output lines) and it redraws once a second, so a build's runtime and
|
|
9682
|
+
// newest output move while you watch.
|
|
9683
|
+
shellsViewed.add(String(rec.id));
|
|
9684
|
+
const panel = (rowsWanted) => {
|
|
9685
|
+
const st = shellState(rec);
|
|
9686
|
+
let output = logTail(rec.log, { lines: 40 });
|
|
9687
|
+
let outputFrom = "";
|
|
9688
|
+
if (!output.length) {
|
|
9689
|
+
const lf = commandLogFile(rec.command, rec.workdir);
|
|
9690
|
+
if (lf) {
|
|
9691
|
+
output = logTail(lf, { lines: 40 });
|
|
9692
|
+
outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
|
|
9693
|
+
}
|
|
9694
|
+
}
|
|
9695
|
+
return shellPanelRows({
|
|
9696
|
+
rec, st, output, outputFrom, width: Math.max(20, process.stdout.columns || 80) - 1, gutter: GUTTER, rows: rowsWanted,
|
|
9697
|
+
footer: "Esc to close",
|
|
9698
|
+
style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
|
|
9699
|
+
});
|
|
9700
|
+
};
|
|
9701
|
+
const running = shellState(rec).state === "running";
|
|
9702
|
+
const actions = running
|
|
9703
|
+
? [{ id: "kill", label: "Stop it", note: "kills the command and everything it started" }, { id: "close", label: "Close" }]
|
|
9704
|
+
: [{ id: "close", label: "Close" }];
|
|
9705
|
+
const headerRows = Math.max(8, Math.min(24, viewportHeight() - 3 - actions.length));
|
|
9706
|
+
const act = await pickFromList(
|
|
9707
|
+
actions.map((a) => a.label + (a.note ? dim(` · ${a.note}`) : "")),
|
|
9708
|
+
actions.length - 1, // Close: Enter on its own never stops a build
|
|
9709
|
+
{ header: panel, headerRows, refreshMs: 1000 },
|
|
9710
|
+
);
|
|
9711
|
+
if (act >= 0 && actions[act].id === "kill") {
|
|
9712
|
+
await killShell(rec);
|
|
9713
|
+
console.log(GUTTER + accent(`✓ stopped shell ${rec.id}`) + dim(" — the agent sees it as killed next turn.\n"));
|
|
9619
9714
|
}
|
|
9620
9715
|
refreshShells();
|
|
9621
9716
|
paintBar();
|
|
@@ -10540,6 +10635,9 @@ const S3SETUP_PREFIX = "__S3SETUP__::";
|
|
|
10540
10635
|
// "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
|
|
10541
10636
|
// call, so it counts from the next call of this same turn, and for every turn after.
|
|
10542
10637
|
const MCPTOOL_PREFIX = "__MCPTOOL__::";
|
|
10638
|
+
// The agent's first use of the browser in a turn (#150): "<folder>\t<what it is about to do>".
|
|
10639
|
+
// "Always" puts the folder on ~/.gonext/browser-allow.json, which the agent reads before asking.
|
|
10640
|
+
const BROWSER_PREFIX = "__BROWSER__::";
|
|
10543
10641
|
async function approvalPrompt(command) {
|
|
10544
10642
|
// Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
|
|
10545
10643
|
// redrawing it (the tick early-returns while a picker is active) — otherwise the block
|
|
@@ -10597,6 +10695,25 @@ async function approvalPrompt(command) {
|
|
|
10597
10695
|
);
|
|
10598
10696
|
return { allow: idx === 0, dontAsk: false };
|
|
10599
10697
|
}
|
|
10698
|
+
if (command.startsWith(BROWSER_PREFIX)) {
|
|
10699
|
+
const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
|
|
10700
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
10701
|
+
process.stdout.write(
|
|
10702
|
+
"\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = No)") + "\n" +
|
|
10703
|
+
dim(` It wants to: ${what || "open a page"}`) + "\n" +
|
|
10704
|
+
dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
|
|
10705
|
+
);
|
|
10706
|
+
const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 2 });
|
|
10707
|
+
if (idx === 1) {
|
|
10708
|
+
try {
|
|
10709
|
+
allowIn(folder || resolve(process.cwd()), true);
|
|
10710
|
+
process.stdout.write(dim(" (won't ask again here — /browser forget undoes it)\n"));
|
|
10711
|
+
} catch (e) {
|
|
10712
|
+
process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
|
|
10713
|
+
}
|
|
10714
|
+
}
|
|
10715
|
+
return { allow: idx === 0 || idx === 1, dontAsk: false };
|
|
10716
|
+
}
|
|
10600
10717
|
if (command.startsWith(MCPTOOL_PREFIX)) {
|
|
10601
10718
|
const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
|
|
10602
10719
|
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
@@ -10707,6 +10824,12 @@ let listDeleteRequested = false;
|
|
|
10707
10824
|
// /use-presets' { o: "open" } (#312, #322). Null unless a caller asks, like ctrl+d above.
|
|
10708
10825
|
let listKeys = null;
|
|
10709
10826
|
let listKeyAction = "";
|
|
10827
|
+
// A PANEL ABOVE THE CHOICES (#376): `listHeader(rows)` returns exactly `listHeaderRows` rows, drawn
|
|
10828
|
+
// at the top of the same in-place block — Shell details sits at the bottom of the screen with its
|
|
10829
|
+
// actions under it, and goes with them (Esc) without leaving a line in the scrollback. A fixed
|
|
10830
|
+
// height, so the once-a-second redraw walks up the same rows it drew.
|
|
10831
|
+
let listHeader = null;
|
|
10832
|
+
let listHeaderRows = 0;
|
|
10710
10833
|
/** #321: the screen under a picker when it opened (content rows, pad, region height), and its tallest draw. */
|
|
10711
10834
|
let listOpenedOver = null;
|
|
10712
10835
|
let listMaxRows = 0;
|
|
@@ -10719,7 +10842,7 @@ function drawList(redraw) {
|
|
|
10719
10842
|
// provider's /v1/models was taller than the terminal: the screen scrolled, the highlighted row
|
|
10720
10843
|
// went off the top, and the ESC[nA walk below then counted rows that had moved — "cannot scroll
|
|
10721
10844
|
// up or down, stuck here". Two rows of margin keep the block clear of the line above it.
|
|
10722
|
-
const maxBlock = Math.max(5, viewportHeight() - 2) - (listNote ? 1 : 0);
|
|
10845
|
+
const maxBlock = Math.max(5, viewportHeight() - 2) - (listNote ? 1 : 0) - (listHeader ? listHeaderRows : 0);
|
|
10723
10846
|
const win = listWindow(listItems.length, listSel, listTop, maxBlock);
|
|
10724
10847
|
listTop = win.top;
|
|
10725
10848
|
const row = (i) => {
|
|
@@ -10733,6 +10856,11 @@ function drawList(redraw) {
|
|
|
10733
10856
|
};
|
|
10734
10857
|
const marker = (text) => clipVisible(GUTTER + dim(text), width - 1);
|
|
10735
10858
|
const rows = [];
|
|
10859
|
+
if (listHeader) {
|
|
10860
|
+
let head = [];
|
|
10861
|
+
try { head = listHeader(listHeaderRows) || []; } catch { /* a panel that fails to draw is blank */ }
|
|
10862
|
+
for (let i = 0; i < listHeaderRows; i++) rows.push(clipVisible(head[i] ?? "", width - 1));
|
|
10863
|
+
}
|
|
10736
10864
|
// Drawn even when empty, so the block keeps a CONSTANT height: every redraw walks up exactly as
|
|
10737
10865
|
// many rows as it drew, and a block that changed height at the ends would leave that walk out
|
|
10738
10866
|
// by a row — the bug physicalRows() exists to prevent.
|
|
@@ -10775,6 +10903,8 @@ function finishList(index) {
|
|
|
10775
10903
|
listMaxRows = 0;
|
|
10776
10904
|
listRowsDrawn = [];
|
|
10777
10905
|
listTop = 0;
|
|
10906
|
+
listHeader = null;
|
|
10907
|
+
listHeaderRows = 0;
|
|
10778
10908
|
const r = listResolve;
|
|
10779
10909
|
const multi = listMulti;
|
|
10780
10910
|
const checked = [...listChecked].sort((a, b) => a - b);
|
|
@@ -10919,6 +11049,19 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
10919
11049
|
listPickerActive = true;
|
|
10920
11050
|
listDeadline = 0;
|
|
10921
11051
|
listTimeoutIndex = -1;
|
|
11052
|
+
listHeader = typeof opts.header === "function" ? opts.header : null;
|
|
11053
|
+
listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
|
|
11054
|
+
if (listHeader && opts.refreshMs > 0) {
|
|
11055
|
+
// A live panel (#376): redrawn in place until the picker closes.
|
|
11056
|
+
const live = setInterval(() => {
|
|
11057
|
+
if (listPickerActive && listHeader) drawList(true);
|
|
11058
|
+
}, opts.refreshMs);
|
|
11059
|
+
const done = listResolve;
|
|
11060
|
+
listResolve = (v) => {
|
|
11061
|
+
clearInterval(live);
|
|
11062
|
+
done(v);
|
|
11063
|
+
};
|
|
11064
|
+
}
|
|
10922
11065
|
if (opts && opts.timeoutMs > 0 && (opts.timeoutIndex ?? -1) >= 0) {
|
|
10923
11066
|
listDeadline = Date.now() + opts.timeoutMs;
|
|
10924
11067
|
listTimeoutIndex = opts.timeoutIndex;
|
|
@@ -14211,6 +14354,22 @@ async function main() {
|
|
|
14211
14354
|
await showBrowser();
|
|
14212
14355
|
continue;
|
|
14213
14356
|
}
|
|
14357
|
+
if (line === "/browser cleanup") {
|
|
14358
|
+
await browserCleanup();
|
|
14359
|
+
continue;
|
|
14360
|
+
}
|
|
14361
|
+
if (line === "/browser install") {
|
|
14362
|
+
await browserInstall();
|
|
14363
|
+
continue;
|
|
14364
|
+
}
|
|
14365
|
+
if (line === "/browser allow" || line === "/browser forget") {
|
|
14366
|
+
allowIn(cwd, line === "/browser allow");
|
|
14367
|
+
console.log("\n" + GUTTER + (line === "/browser allow"
|
|
14368
|
+
? accent("✓ The agent may use the browser in this folder without asking.") + dim(" (/browser forget undoes it)")
|
|
14369
|
+
: accent("✓ The agent asks again before it uses the browser in this folder.")) + "\n");
|
|
14370
|
+
paintBar();
|
|
14371
|
+
continue;
|
|
14372
|
+
}
|
|
14214
14373
|
if (line === "/model") {
|
|
14215
14374
|
await chooseModel();
|
|
14216
14375
|
continue;
|
package/gu_browser.py
CHANGED
|
@@ -18,7 +18,7 @@ import urllib.request
|
|
|
18
18
|
|
|
19
19
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
20
20
|
EXT_SRC = os.path.join(HERE, "browser-extension")
|
|
21
|
-
EXT_FILES = ("manifest.json", "background.js", "popup.html", "popup.js",
|
|
21
|
+
EXT_FILES = ("manifest.json", "background.js", "actions.js", "popup.html", "popup.js",
|
|
22
22
|
"icons/icon16.png", "icons/icon48.png", "icons/icon128.png")
|
|
23
23
|
BRIDGE = os.path.join(HERE, "browser-bridge.mjs")
|
|
24
24
|
|
|
@@ -69,18 +69,75 @@ def install_extension_files():
|
|
|
69
69
|
return dst
|
|
70
70
|
|
|
71
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
|
+
|
|
72
125
|
def install_text(reason="not connected"):
|
|
73
126
|
"""What the model is told when the extension is not there: stop and ask the user."""
|
|
74
|
-
|
|
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.
|
|
75
135
|
return (
|
|
76
|
-
f"BROWSER NOT AVAILABLE ({reason})
|
|
77
|
-
"not
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
f" 3. Click \"Load unpacked\" and choose the folder {folder}\n"
|
|
82
|
-
" 4. (Or type /browser in the gu terminal for these steps and the connection status.)\n"
|
|
83
|
-
"gu only uses its own tabs (a tab group named \"gu\"); the user's other tabs are never touched.")
|
|
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 ""))
|
|
84
141
|
|
|
85
142
|
|
|
86
143
|
def _node():
|
|
@@ -135,8 +192,10 @@ def _start_bridge(log=None):
|
|
|
135
192
|
return None
|
|
136
193
|
|
|
137
194
|
|
|
138
|
-
def connect(wait=True, log=None, on_wait=None):
|
|
139
|
-
"""→ (state, None) when the extension is connected, else (None, reason).
|
|
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."""
|
|
140
199
|
st = _read_state()
|
|
141
200
|
started = False
|
|
142
201
|
try:
|
|
@@ -151,7 +210,10 @@ def connect(wait=True, log=None, on_wait=None):
|
|
|
151
210
|
# A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
|
|
152
211
|
# long only when an extension has connected before — otherwise it is most likely not installed.
|
|
153
212
|
seen = os.path.exists(seen_path())
|
|
154
|
-
|
|
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
|
|
155
217
|
if wait_ms >= 8000 and on_wait:
|
|
156
218
|
on_wait()
|
|
157
219
|
try:
|
|
@@ -159,21 +221,26 @@ def connect(wait=True, log=None, on_wait=None):
|
|
|
159
221
|
except OSError as e:
|
|
160
222
|
return None, f"the gu browser bridge is not answering ({e})"
|
|
161
223
|
if not s.get("connected"):
|
|
162
|
-
return None, "extension not connected"
|
|
163
|
-
|
|
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:
|
|
164
227
|
try:
|
|
165
228
|
with open(seen_path(), "w") as f:
|
|
166
|
-
|
|
229
|
+
json.dump(mark, f)
|
|
167
230
|
except OSError:
|
|
168
231
|
pass
|
|
169
232
|
return st, None
|
|
170
233
|
|
|
171
234
|
|
|
172
|
-
def command(cmd, log=None, on_wait=None):
|
|
173
|
-
"""Send one command. → (ok, result_or_error, not_connected).
|
|
174
|
-
|
|
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)
|
|
175
240
|
if not st:
|
|
176
241
|
return False, why, True
|
|
242
|
+
if own:
|
|
243
|
+
cmd = dict(cmd, backend="auto")
|
|
177
244
|
timeout_s = 75 if cmd.get("action") in ("open", "wait", "js") else 40
|
|
178
245
|
try:
|
|
179
246
|
r = _http(st, "POST", "/cmd", body=dict(cmd, timeout_ms=(timeout_s - 5) * 1000), timeout=timeout_s)
|
|
@@ -181,9 +248,14 @@ def command(cmd, log=None, on_wait=None):
|
|
|
181
248
|
return False, f"the browser bridge did not answer ({e})", False
|
|
182
249
|
if r.get("_status") == 503:
|
|
183
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
|
|
184
253
|
if not r.get("ok"):
|
|
185
254
|
return False, str(r.get("error") or "failed"), False
|
|
186
|
-
|
|
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
|
|
187
259
|
|
|
188
260
|
|
|
189
261
|
# ── what the model reads ────────────────────────────────────────────────────────────────────
|