@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/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 +147 -24
- package/gu-cli.mjs +11 -12
- package/gu-repl.mjs +114 -10
- package/gu_browser.py +92 -20
- package/package.json +3 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +40 -6
- package/terminal-layout.mjs +1 -0
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 ────────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.835",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -125,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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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;
|
package/terminal-layout.mjs
CHANGED
|
@@ -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) {
|