@tiens.nguyen/gu-cli 1.0.833 → 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/shell-details.mjs CHANGED
@@ -11,10 +11,13 @@
11
11
  // read from the same two facts the agent uses: is the process group alive, and has the shell
12
12
  // written its exit file.
13
13
 
14
- import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
14
+ import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { isAbsolute, join, normalize } from "node:path";
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;
@@ -63,7 +95,7 @@ export function shellState(rec, { alive, exit, now = Date.now() / 1000 } = {}) {
63
95
  if (live && !ex && !rec?.killed) return { state: "running", code: null, runtime: Math.max(0, now - started), ended: now };
64
96
  const ended = Number(rec?.ended) || ex?.at || now;
65
97
  const state = rec?.killed ? "killed" : !ex ? "ended" : ex.code === 0 ? "exited" : "failed";
66
- return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended };
98
+ return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended, by: String(rec?.killed_by ?? "") };
67
99
  }
68
100
 
69
101
  /** 7m 2s — Claude Code's spelling. */
@@ -79,7 +111,10 @@ export function fmtRuntime(seconds) {
79
111
  export function statusText(st) {
80
112
  if (st.state === "running") return "running";
81
113
  if (st.state === "exited" || st.state === "failed") return `exited ${st.code}`;
82
- return st.state === "killed" ? "killed" : "ended (exit code unknown)";
114
+ // Who stopped it (#362/#143): a bare "killed" read as a crash when the agent had stopped its own
115
+ // search on purpose.
116
+ if (st.state === "killed") return st.by ? `killed — stopped by ${st.by}` : "killed";
117
+ return "ended (exit code unknown)";
83
118
  }
84
119
 
85
120
  /**
@@ -234,15 +269,17 @@ export function shellPickerLabel(rec, st, width = 80) {
234
269
  * Stop a shell's whole group and record that it was killed — the record is what the agent reads
235
270
  * next turn, so "killed" must not later read as "ended (exit code unknown)".
236
271
  */
237
- export async function killShell(rec, { path = shellsPath(), platform = process.platform } = {}) {
272
+ export async function killShell(rec, { path = shellsPath(), platform = process.platform, startOf = procStart } = {}) {
238
273
  const pid = Number(rec?.pgid || rec?.pid || 0);
239
- 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 })) {
240
277
  if (platform === "win32") {
241
278
  spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
242
279
  } else {
243
280
  try { process.kill(-pid, "SIGTERM"); } catch { /* gone already */ }
244
- for (let i = 0; i < 10 && isAlive(rec, platform); i++) await new Promise((r) => setTimeout(r, 100));
245
- 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 */ }
246
283
  }
247
284
  }
248
285
  if (!existsSync(path)) return;
@@ -250,8 +287,59 @@ export async function killShell(rec, { path = shellsPath(), platform = process.p
250
287
  const mine = shells.find((s) => String(s.id) === String(rec.id));
251
288
  if (!mine || readExit(mine)) return; // it finished on its own first: keep the real exit code
252
289
  mine.killed = true;
290
+ mine.killed_by = "you (Stop it in Shell details)";
291
+ mine.announced = true; // you stopped it — no recap turn for it
253
292
  mine.ended = Date.now() / 1000;
254
293
  const tmp = `${path}.${process.pid}.tmp`;
255
294
  writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
256
295
  renameSync(tmp, path);
257
296
  }
297
+
298
+ /**
299
+ * Shells whose ending the agent has not heard about (#362) — Claude Code tells its model when a
300
+ * background command completes; gu's terminal starts a short recap turn for these. Only this
301
+ * folder's shells (shells.json is shared by every project), only ones that ended while this
302
+ * terminal was open, never one the agent already read (`seen`) or was told about (`announced`),
303
+ * and never one you stopped yourself.
304
+ */
305
+ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {}) {
306
+ // The nearest folder that still exists, resolved (macOS: /var → /private/var), plus the rest —
307
+ // a build may have deleted the folder it ran in.
308
+ const real = (p) => {
309
+ const raw = String(p ?? "");
310
+ if (!raw) return raw;
311
+ try { return realpathSync(raw); } catch { /* gone */ }
312
+ const up = dirname(raw);
313
+ return up === raw ? raw : join(real(up), basename(raw));
314
+ };
315
+ const base = real(cwd).replace(/\/+$/, "");
316
+ const under = (wd) => {
317
+ const w = real(wd);
318
+ return w === base || w.startsWith(base + "/");
319
+ };
320
+ return entries
321
+ .filter(({ rec, st }) => st.state !== "running" && st.state !== "killed" && !rec.seen && !rec.announced
322
+ && st.ended >= since && under(rec.workdir))
323
+ .map((e) => e.rec);
324
+ }
325
+
326
+ /** Mark these shells announced in shells.json, so neither the agent nor another terminal repeats it. */
327
+ export function markAnnounced(ids, { path = shellsPath() } = {}) {
328
+ const shells = readShells(path);
329
+ let n = 0;
330
+ for (const s of shells) if (ids.includes(String(s.id)) && !s.announced) { s.announced = true; n++; }
331
+ if (!n) return 0;
332
+ const tmp = `${path}.${process.pid}.tmp`;
333
+ writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
334
+ renameSync(tmp, path);
335
+ return n;
336
+ }
337
+
338
+ /** The recap question the terminal asks for finished shells. */
339
+ export function recapQuestion(entries) {
340
+ const parts = entries.map(({ rec, st }) =>
341
+ `shell ${rec.id} ${statusText(st)} after ${fmtRuntime(st.runtime)} (${String(rec.command ?? "").replace(/\s+/g, " ").trim().slice(0, 120)})`);
342
+ const ids = entries.map(({ rec }) => `shell_output('${rec.id}')`).join(", ");
343
+ return `[gu: background ${entries.length === 1 ? "shell" : "shells"} finished — ${parts.join("; ")}] `
344
+ + `Read the result with ${ids}, tell me briefly what happened, and continue with the next step if the task needs one.`;
345
+ }
@@ -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) {