@tiens.nguyen/gu-cli 1.0.839 → 1.0.841

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.
@@ -140,7 +140,7 @@ export function allowIn(folder, allow = true) {
140
140
  writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
141
141
  }
142
142
 
143
- export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL } = {}) {
143
+ export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
144
144
  // The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
145
145
  if (!running && error) {
146
146
  return [
@@ -174,9 +174,14 @@ export function browserReport({ connected, version, installType, refused = [], r
174
174
  "opens gu's own Chrome window (a separate profile — not signed in to your",
175
175
  "accounts) and works in it with a real mouse and keyboard.",
176
176
  // The link on a row of its own (#385): the published one is longer than a row beside the text.
177
- ...(storeUrl
178
- ? ["To let it use YOUR Chrome (your logins, a tab you share), add the gu", `extension: ${storeUrl}`]
179
- : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
177
+ // #386: which step is missing — added but not connected, or not added at all.
178
+ ...(storeUrl && storeInstalled
179
+ ? ["The gu extension is added to Chrome but not connected: keep Chrome open (it",
180
+ "connects within 30 s) — /browser install waits for it."]
181
+ : storeUrl
182
+ ? ["To let it use YOUR Chrome (your logins, a tab you share), add the gu",
183
+ "extension — /browser install guides you through it:", ` ${storeUrl}`]
184
+ : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
180
185
  here,
181
186
  ...twoCopies,
182
187
  ];
@@ -319,6 +324,86 @@ export function findDevInstalls({ roots = browserRoots(), extDir = extensionDir(
319
324
  return found;
320
325
  }
321
326
 
327
+ // THE PUBLISHED EXTENSION, FOUND IN CHROME'S OWN RECORDS (#386, GitHub #176). Its ID is fixed by the
328
+ // store (the last part of the store URL), so a profile has it when its preferences list that ID
329
+ // from anywhere but an unpacked folder. Disabled is told apart: it is installed but cannot connect.
330
+ export const STORE_EXTENSION_ID = "ldfcjapmdbgdkdhlbdkbkiankneabagj";
331
+
332
+ export function findStoreInstalls({ roots = browserRoots(), id = STORE_EXTENSION_ID } = {}) {
333
+ const found = [];
334
+ for (const { browser, dir } of roots) {
335
+ let profiles = [];
336
+ try {
337
+ profiles = readdirSync(dir).filter((p) => p === "Default" || /^Profile \d+$/.test(p));
338
+ } catch {
339
+ continue;
340
+ }
341
+ for (const profile of profiles) {
342
+ for (const file of ["Secure Preferences", "Preferences"]) {
343
+ let e;
344
+ try {
345
+ e = JSON.parse(readFileSync(join(dir, profile, file), "utf8"))?.extensions?.settings?.[id];
346
+ } catch {
347
+ continue;
348
+ }
349
+ if (!e || UNPACKED.has(e.location)) continue;
350
+ const reasons = Array.isArray(e.disable_reasons) ? e.disable_reasons.length : Number(e.disable_reasons) || 0;
351
+ found.push({ browser, profile, id, disabled: e.state === 0 || reasons > 0 });
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ return found;
357
+ }
358
+
359
+ /**
360
+ * Where the install stands, and what to tell the user — PURE, so every stage can be tested.
361
+ * → { stage, lines }: "remove-dev" (a developer copy would block the store one, #148), "add" (not
362
+ * in Chrome yet), "enable" (added but switched off), "connect" (added, not connected yet), "done".
363
+ */
364
+ export function installStage({ dev = [], store = [], status = {}, storeUrl = STORE_URL } = {}) {
365
+ if (status.connected && status.installType === "normal") {
366
+ return { stage: "done", lines: [`✓ gu browser is connected (Chrome Web Store, v${status.version ?? "?"}).`] };
367
+ }
368
+ if (dev.length) {
369
+ return {
370
+ stage: "remove-dev",
371
+ lines: [
372
+ "1. First remove the developer copy of gu browser — only one can connect.",
373
+ " In chrome://extensions (gu opened it), click Remove on this one:",
374
+ ...dev.map((d) => ` ${d.browser} · ${d.profile} · ID ${d.id}`),
375
+ " Waiting for it to be removed…",
376
+ ],
377
+ };
378
+ }
379
+ if (!store.length) {
380
+ return {
381
+ stage: "add",
382
+ lines: [
383
+ "1. Add gu browser to Chrome: on its store page, click \"Add to Chrome\".",
384
+ ` ${storeUrl}`,
385
+ " (gu opened the page for you.) Waiting for it to be added…",
386
+ ],
387
+ };
388
+ }
389
+ if (store.every((s) => s.disabled)) {
390
+ return {
391
+ stage: "enable",
392
+ lines: [
393
+ "2. gu browser is added but switched off. In chrome://extensions, turn it on.",
394
+ " Waiting for it to be turned on…",
395
+ ],
396
+ };
397
+ }
398
+ return {
399
+ stage: "connect",
400
+ lines: [
401
+ "2. gu browser is added. Waiting for it to connect to gu…",
402
+ " Keep Chrome open — it connects within 30 s, or click its icon → Connect now.",
403
+ ],
404
+ };
405
+ }
406
+
322
407
  // gu's own leftovers of the developer install, in ~/.gonext.
323
408
  export function localLeftovers() {
324
409
  return [extensionDir(), statePath(), seenPath(), logPath()].filter((p) => existsSync(p));
@@ -9511,6 +9511,52 @@ def _shell_exit_code(s: dict):
9511
9511
  return (None, None)
9512
9512
 
9513
9513
 
9514
+ def _shells_add_new(rec: dict) -> None:
9515
+ """Append one record to shells.json under an exclusive lock, RE-READING the file inside the
9516
+ lock so two overlapping turns do not overwrite each other's record (#387). _to_background used
9517
+ to _shells_load() OUTSIDE any lock then _shells_save() the grown list — a concurrent reader's
9518
+ own save in between silently dropped this record, and the repl's "N shells running (click)"
9519
+ link disappeared for a live background task."""
9520
+ import fcntl # macOS/Linux posix lock; the workspace here is the user's Mac
9521
+ import json as _json
9522
+ import os
9523
+ import time as _t
9524
+ path = _shells_path()
9525
+ lock_fd = os.open(path + ".lock", os.O_RDWR | os.O_CREAT, 0o644)
9526
+ try:
9527
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
9528
+ # Re-read INSIDE the lock (the dict envelope {"shells": [...]} matches _shells_save).
9529
+ try:
9530
+ with open(path, encoding="utf-8") as fh:
9531
+ shells = _json.load(fh).get("shells", [])
9532
+ except Exception: # noqa: BLE001
9533
+ shells = []
9534
+ shells = [s for s in shells if isinstance(s, dict) and s.get("pid")]
9535
+ shells.append(rec)
9536
+ # Same prune policy as _shells_save so the file stays bounded.
9537
+ now = _t.time()
9538
+ kept = []
9539
+ for s in shells:
9540
+ st = _shell_state(s, now=now)
9541
+ if st["state"] != "running" and now - st["ended"] > _SHELLS_KEEP_DONE_S:
9542
+ continue
9543
+ kept.append(s)
9544
+ kept = kept[-_SHELLS_KEEP:]
9545
+ tmp = f"{path}.{os.getpid()}.tmp"
9546
+ with open(tmp, "w", encoding="utf-8") as fh:
9547
+ _json.dump({"shells": kept}, fh, indent=2)
9548
+ fh.flush()
9549
+ os.fsync(fh.fileno())
9550
+ os.replace(tmp, path) # the terminal polls this file — never let it read half of it
9551
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
9552
+ finally:
9553
+ try:
9554
+ os.close(lock_fd)
9555
+ except OSError:
9556
+ pass
9557
+ _log(f"shells.json +1 → pid {rec.get('pid')} registered (#387)")
9558
+
9559
+
9514
9560
  def _shell_state(s: dict, alive=None, now=None) -> dict:
9515
9561
  """{state, code, runtime, ended} for a shell record. state: running | exited (0) | failed |
9516
9562
  killed | ended (gone, exit code unknown). `alive` is injectable so tests need no process."""
@@ -15575,14 +15621,12 @@ def run_agent_chat(cfg):
15575
15621
 
15576
15622
  def _to_background(why: str) -> str:
15577
15623
  """Leave the command running as a background shell; say so to the model."""
15578
- shells = _shells_load()
15579
- shells.append({
15624
+ _shells_add_new({
15580
15625
  "id": str(proc.pid), "pid": proc.pid, "pgid": proc.pid,
15581
15626
  "command": command, "workdir": wd, "log": log_path,
15582
15627
  "exit_file": exit_path if _use_shell else "",
15583
15628
  "started": start, "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
15584
15629
  })
15585
- _shells_save(shells)
15586
15630
  _emit({"type": "step", "text":
15587
15631
  f"Running in background → {_trim_words(command)} (shell {proc.pid})"})
15588
15632
  tail = _ws_distill_output(_read_log())
@@ -15673,6 +15717,15 @@ def run_agent_chat(cfg):
15673
15717
  # waiting for it like any command; it hands off at the deadline if needed.
15674
15718
  if ports and all(_ws_is_ephemeral_port(p_) for p_ in ports):
15675
15719
  ports = []
15720
+ if ports and background:
15721
+ # AN EXPLICIT background=True STILL LANDS IN shells.json (#387, GitHub
15722
+ # #177). A shell that opens a port in its first 3s used to be filed ONLY
15723
+ # as a server here — so the repl bar read shells.json, found nothing
15724
+ # running, and showed no "N shells running (click)" link for a build the
15725
+ # model had just told the user was in the background.
15726
+ msg = _to_background("started (also listening)")
15727
+ _last_obs["text"] = msg
15728
+ return msg
15676
15729
  if ports:
15677
15730
  servers = _ws_load_servers()
15678
15731
  servers.append({
@@ -15697,6 +15750,14 @@ def run_agent_chat(cfg):
15697
15750
  # no port, so it would otherwise hit the timeout kill below and its
15698
15751
  # window would be destroyed). Returning fast here also dodges the
15699
15752
  # smolagents 60s executor cap that was killing the tool call.
15753
+ if background:
15754
+ # The window check above would classify it as an app and skip
15755
+ # shells.json entirely — the same missing-link bug as the port
15756
+ # branch (#387, GitHub #177). background=True asked for a shell,
15757
+ # so a shell it is.
15758
+ msg = _to_background("started (window open)")
15759
+ _last_obs["text"] = msg
15760
+ return msg
15700
15761
  if _ws_gui_pids(proc.pid):
15701
15762
  servers = _ws_load_servers()
15702
15763
  servers.append({
package/gu-repl.mjs CHANGED
@@ -139,7 +139,7 @@ import {
139
139
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
140
140
  import { isScaffoldStep, isGroupableStep, stepGroupMark, stepGroupRows, relativizePaths } from "./step-group.mjs";
141
141
  import { clearLogsNoticeText } from "./clear-logs.mjs";
142
- import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, openStorePage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
142
+ import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, findStoreInstalls, installStage, openExtensionsPage, openStorePage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
143
143
  import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPanelRows, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
144
144
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
145
145
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
@@ -9620,31 +9620,100 @@ function printShellDetails(rec) {
9620
9620
  // /browser (#363): the extension's connection, else how to install it.
9621
9621
  async function showBrowser() {
9622
9622
  const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9623
- const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
9623
+ const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())), storeInstalled: findStoreInstalls().length > 0 });
9624
9624
  console.log("");
9625
9625
  for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
9626
9626
  console.log("");
9627
9627
  paintBar();
9628
9628
  }
9629
9629
 
9630
- // /browser install (#150): the extension, for the agent to use YOUR Chrome. Optional — gu's own
9631
- // window needs nothing. The store page once published; until then the developer way.
9630
+ // /browser install (#150; guided since #386, GitHub #176): the published extension, for the agent to
9631
+ // use YOUR Chrome — the way Claude Code sets up its Chrome extension. gu walks it through to the end:
9632
+ // a developer copy is removed first (it would block the store one, #148), then the store page is
9633
+ // opened, and the panel in place of the input box (#384) shows each stage live until the store copy
9634
+ // is CONNECTED — or Esc stops waiting. gu's own window still needs none of this.
9632
9635
  async function browserInstall() {
9633
- let folder = extensionDir();
9636
+ if (listPickerActive || approvalActive) return;
9637
+ const done = (line) => {
9638
+ console.log("\n" + GUTTER + line + "\n");
9639
+ paintBar();
9640
+ };
9634
9641
  if (!STORE_URL) {
9642
+ // No published listing configured (GU_BROWSER_STORE_URL=""): the developer way, as before.
9643
+ let folder = extensionDir();
9635
9644
  try {
9636
9645
  folder = installExtensionFiles();
9637
9646
  } catch (e) {
9638
9647
  console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
9639
9648
  }
9649
+ await bridgeStatus().catch(() => null);
9650
+ console.log("");
9651
+ for (const l of installSteps(folder, process.platform, "")) console.log(GUTTER + dim(l));
9652
+ if (helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
9653
+ console.log("");
9654
+ paintBar();
9655
+ return;
9640
9656
  }
9641
- await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
9642
- console.log("");
9643
- for (const l of installSteps(folder)) console.log(GUTTER + dim(l));
9644
- // #385: the store link opens the STORE PAGE (it used to open chrome://extensions and say "Opened Chrome").
9645
- if (STORE_URL ? openStorePage() : helpInstall(folder)) console.log(GUTTER + dim(STORE_URL ? "(Opened the Chrome Web Store page.)" : "(Opened chrome://extensions and copied the folder to your clipboard.)"));
9646
- console.log("");
9647
- paintBar();
9657
+ let status = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9658
+ const opened = new Set();
9659
+ const state = () => {
9660
+ const st = installStage({ dev: findDevInstalls(), store: findStoreInstalls(), status });
9661
+ // Open the page each stage needs, once.
9662
+ if (st.stage === "remove-dev" && !opened.has("ext")) { opened.add("ext"); openExtensionsPage(); }
9663
+ if (st.stage === "add" && !opened.has("store")) { opened.add("store"); openStorePage(); }
9664
+ if (st.stage === "enable" && !opened.has("ext2")) { opened.add("ext2"); openExtensionsPage(); }
9665
+ return st;
9666
+ };
9667
+ let st = state();
9668
+ if (st.stage === "done") return done(accent(st.lines[0]));
9669
+ if (!barPinned()) {
9670
+ // No zone to wait in: say what to do once.
9671
+ console.log("");
9672
+ for (const l of st.lines) console.log(GUTTER + dim(l));
9673
+ console.log(GUTTER + dim("Then type /browser — it should say connected.") + "\n");
9674
+ paintBar();
9675
+ return;
9676
+ }
9677
+ let polling = false;
9678
+ const poll = setInterval(async () => {
9679
+ if (polling) return;
9680
+ polling = true;
9681
+ try {
9682
+ status = await bridgeStatus({ waitMs: 0 }).catch(() => status);
9683
+ st = state();
9684
+ if (st.stage === "done") {
9685
+ clearInterval(poll);
9686
+ closeZonePanel();
9687
+ done(accent(st.lines[0]) + dim(" The agent can use your Chrome now."));
9688
+ }
9689
+ } finally {
9690
+ polling = false;
9691
+ }
9692
+ }, 2000);
9693
+ openZonePanel({
9694
+ refreshMs: 1000,
9695
+ rows: (width) => {
9696
+ // A row the panel would clip is WRAPPED instead: the store link is 84 characters, and a link
9697
+ // cut short at 80 columns cannot be copied.
9698
+ const room = Math.max(20, width - 1 - GUTTER.length);
9699
+ const wrap = (l) => {
9700
+ const out = [];
9701
+ for (let rest = l; rest.length; rest = rest.slice(room)) out.push(rest.slice(0, room));
9702
+ return out.length ? out : [""];
9703
+ };
9704
+ return [
9705
+ GUTTER + bold(accent("Install gu browser")) + dim(" — so the agent can use your own Chrome"),
9706
+ "",
9707
+ ...st.lines.flatMap((l) => wrap(/https?:\/\//.test(l) ? l.trim() : l).map((r) => GUTTER + (/^\d\./.test(l) ? r : dim(r)))),
9708
+ "",
9709
+ GUTTER + dim("Esc to stop waiting · /browser install picks up where you left off"),
9710
+ ];
9711
+ },
9712
+ onClose: () => {
9713
+ clearInterval(poll);
9714
+ if (st.stage !== "done") done(dim("(stopped waiting — /browser install picks up where you left off)"));
9715
+ },
9716
+ });
9648
9717
  }
9649
9718
 
9650
9719
  // /browser cleanup (#145, #147): remove the developer (unpacked) install so the Chrome Web Store
@@ -10761,7 +10830,11 @@ async function approvalPrompt(command) {
10761
10830
  process.stdout.write(
10762
10831
  "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = Yes, this time)") + "\n" +
10763
10832
  dim(` It wants to: ${what || "open a page"}`) + "\n" +
10764
- dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
10833
+ dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n" +
10834
+ // #386: the way to "your Chrome", said where the question is asked.
10835
+ (STORE_URL && !findStoreInstalls().length
10836
+ ? dim(" To use your own Chrome (your logins): /browser install adds the gu extension.") + "\n"
10837
+ : "")
10765
10838
  );
10766
10839
  // "Yes, this time" is THE default (#380, GitHub #167): highlighted, so Enter allows it once, and
10767
10840
  // also what an unanswered question becomes — the countdown used to sit on "No", which read as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.839",
3
+ "version": "1.0.841",
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",