@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/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 # macOS/Linux: put npm's global bin folder on 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
- const pid = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
858
- if (Number.isFinite(pid) && pid > 0) {
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
887
- if (!Number.isFinite(pid) || pid <= 0) {
888
- console.log("\n No local API pid on record.\n");
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
950
+ const { pid } = await ce.apiPidToStop();
950
951
  const before = await ce.probeLocalApi();
951
- if (Number.isFinite(pid) && pid > 0) {
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 there is no pid on record,` +
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
- console.log(" Nothing to fix here — on Windows npm's folder is on PATH through the registry.");
1071
- console.log(" If `gu` is not found in PowerShell: gu-cli fix-powershell");
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 = Number(
1296
- (await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim()
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
- try {
9570
- folder = installExtensionFiles();
9571
- } catch (e) {
9572
- console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
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
- const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
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 [i, l] of lines.entries()) console.log(GUTTER + (i === 0 ? (st.connected ? accent(l) : l) : dim(l)));
9578
- if (!st.connected && st.running && helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
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
- const st = printShellDetails(rec);
9607
- if (following) break;
9608
- const actions = [{ id: "refresh", label: "Refresh" }];
9609
- if (st.state === "running") actions.push({ id: "kill", label: "Stop it", note: "kills the command and everything it started" });
9610
- actions.push({ id: "close", label: "Close" });
9611
- const act = await pickFromList(actions.map((a) => a.label + (a.note ? dim(` · ${a.note}`) : "")), 0);
9612
- const id = act < 0 ? "close" : actions[act].id;
9613
- if (id === "refresh") continue;
9614
- if (id === "kill") {
9615
- await killShell(rec);
9616
- console.log(GUTTER + accent("✓ stopped") + dim(" — the agent sees it as killed next turn.\n"));
9617
- }
9618
- break;
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
- folder = install_extension_files()
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}): the gu Chrome extension is not installed, or Chrome is "
77
- "not running. Do NOT try to work around it with curl or a headless script for a UI check — "
78
- "STOP and ask the user to install it, then continue when they say it is done. Tell them:\n"
79
- " 1. Open Chrome and go to chrome://extensions\n"
80
- " 2. Turn on Developer mode (top right)\n"
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
- wait_ms = (35000 if (seen and started) else 8000 if seen else 4000) if wait else 0
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
- if not seen:
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
- f.write(str(s.get("version") or ""))
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
- st, why = connect(log=log, on_wait=on_wait)
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
- return True, r.get("result") or {}, 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
187
259
 
188
260
 
189
261
  # ── what the model reads ────────────────────────────────────────────────────────────────────