@tiens.nguyen/gu-cli 1.0.834 → 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/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 ────────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.834",
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,10 +125,12 @@
125
125
  "skills.mjs",
126
126
  "skill-source.mjs",
127
127
  "shell-details.mjs",
128
+ "proc-identity.mjs",
128
129
  "browser-command.mjs",
129
130
  "browser-bridge.mjs",
130
131
  "browser-extension/manifest.json",
131
132
  "browser-extension/background.js",
133
+ "browser-extension/actions.js",
132
134
  "browser-extension/popup.html",
133
135
  "browser-extension/popup.js",
134
136
  "browser-extension/icons/",
@@ -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
+ }
package/shell-details.mjs CHANGED
@@ -15,6 +15,9 @@ import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, rea
15
15
  import { homedir } from "node:os";
16
16
  import { basename, dirname, isAbsolute, join, normalize } from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
+ import { procStart } from "./proc-identity.mjs";
19
+
20
+ export { procStart };
18
21
 
19
22
  export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
20
23
 
@@ -40,10 +43,39 @@ export function readExit(rec) {
40
43
  }
41
44
  }
42
45
 
43
- /** Is the shell's process group (POSIX) or process (Windows) still there? */
44
- export function isAlive(rec, platform = process.platform) {
46
+ // A SAVED PGID IS ONLY OURS WHILE ITS LEADER IS THE PROCESS WE STARTED (#373, GitHub #154) — the
47
+ // same rule as the agent's _ws_group_owner. shells.json outlives its commands; after a reboot or a
48
+ // crash the id goes to the next program (VS Code is its own group leader), and "a group with that
49
+ // id exists" read as running and offered "Stop it" on someone else's app. The record's start time
50
+ // is the proof: the process whose pid == the pgid must have started then (± START_SLACK_S). With
51
+ // no such process, members left in the group are still ours — an id in use is never reused.
52
+ export const START_SLACK_S = 5;
53
+
54
+ /** The record's start (epoch s): `started`, else the older "YYYY-MM-DD HH:MM:SS" `started_at`. */
55
+ function recordStarted(rec) {
56
+ const n = Number(rec?.started);
57
+ if (rec?.started != null && Number.isFinite(n)) return n;
58
+ const m = String(rec?.started_at ?? "").match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
59
+ return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() / 1000 : null;
60
+ }
61
+
62
+ /** "ours" | "leader-gone" | "other" (the id now belongs to another program) | "unknown". */
63
+ export function groupOwner(rec, { platform = process.platform, startOf = procStart } = {}) {
64
+ if (platform === "win32") return "ours";
65
+ const pgid = Number(rec?.pgid || rec?.pid || 0);
66
+ if (!pgid) return "unknown";
67
+ const leader = startOf(pgid);
68
+ if (leader === null) return "leader-gone";
69
+ const started = recordStarted(rec);
70
+ if (!Number.isFinite(leader) || started == null) return "unknown";
71
+ return Math.abs(leader - started) <= START_SLACK_S ? "ours" : "other";
72
+ }
73
+
74
+ /** Is the shell's process group (POSIX) or process (Windows) still there — and still ours? */
75
+ export function isAlive(rec, platform = process.platform, { startOf = procStart } = {}) {
45
76
  const pid = Number(rec?.pgid || rec?.pid || 0);
46
77
  if (!pid) return false;
78
+ if (groupOwner(rec, { platform, startOf }) === "other") return false;
47
79
  try {
48
80
  process.kill(platform === "win32" ? pid : -pid, 0);
49
81
  return true;
@@ -237,15 +269,17 @@ export function shellPickerLabel(rec, st, width = 80) {
237
269
  * Stop a shell's whole group and record that it was killed — the record is what the agent reads
238
270
  * next turn, so "killed" must not later read as "ended (exit code unknown)".
239
271
  */
240
- export async function killShell(rec, { path = shellsPath(), platform = process.platform } = {}) {
272
+ export async function killShell(rec, { path = shellsPath(), platform = process.platform, startOf = procStart } = {}) {
241
273
  const pid = Number(rec?.pgid || rec?.pid || 0);
242
- if (pid && isAlive(rec, platform)) {
274
+ // Only a group that is provably the one gu started is signalled — never "unknown", never "other".
275
+ const ours = ["ours", "leader-gone"].includes(groupOwner(rec, { platform, startOf }));
276
+ if (pid && ours && isAlive(rec, platform, { startOf })) {
243
277
  if (platform === "win32") {
244
278
  spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
245
279
  } else {
246
280
  try { process.kill(-pid, "SIGTERM"); } catch { /* gone already */ }
247
- for (let i = 0; i < 10 && isAlive(rec, platform); i++) await new Promise((r) => setTimeout(r, 100));
248
- if (isAlive(rec, platform)) try { process.kill(-pid, "SIGKILL"); } catch { /* gone */ }
281
+ for (let i = 0; i < 10 && isAlive(rec, platform, { startOf }); i++) await new Promise((r) => setTimeout(r, 100));
282
+ if (isAlive(rec, platform, { startOf })) try { process.kill(-pid, "SIGKILL"); } catch { /* gone */ }
249
283
  }
250
284
  }
251
285
  if (!existsSync(path)) return;
@@ -1148,6 +1148,7 @@ export function trimWords(text, limit) {
1148
1148
  // grey ✗ with dim text. Judged by the LAST segment of a chain/pipeline (its exit status is
1149
1149
  // the one reported): `ps aux | grep foo` and `cd src && rg Foo` are both look-ups.
1150
1150
  const SEARCH_CMDS = new Set([
1151
+ // platform-ok: names to recognise in a command the agent ran — nothing here runs them.
1151
1152
  "grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "pgrep", "which", "whereis", "lsof",
1152
1153
  ]);
1153
1154
  export function isSoftFailCommand(cmd) {