@tiens.nguyen/gu-cli 1.0.832 → 1.0.834

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.
@@ -0,0 +1,352 @@
1
+ """Test-result digest for run_command (task #362, GitHub #142) — PURE.
2
+
3
+ THE MODEL READ TEST OUTPUT THROUGH `| tail` AND `| grep`. Measured 2026-09-23 → 10-14 in local
4
+ Mongo: 84 of 397 turns ran tests, 32 of them spent 4+ steps doing it, and test output was piped
5
+ into tail/head/grep to find the result — the pipe that hid `No module named pytest` and let a
6
+ commit go in untested (#140 review). The answer is not a smaller window onto the output; it is
7
+ the result first: the counts, then each failing test with its message and file:line, computed
8
+ from the WHOLE output (the observation itself is capped, a digest never is).
9
+
10
+ One parser per runner, each recognising its own summary line, so a line that runs two runners
11
+ (`python3 -m unittest … && node --test`, this repo's npm test) gets both. Nothing recognised →
12
+ "" and the output is shown exactly as before. Names are the ones the runner prints, which are the
13
+ ones its run-one-test form takes (unittest's dotted id, pytest's node id, go's TestName…).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+
20
+ MAX_FAILURES = 10 # per runner; the rest are counted
21
+ MAX_MESSAGE = 220 # chars of one failure's message
22
+
23
+
24
+ def _clip(text: str, n: int = MAX_MESSAGE) -> str:
25
+ # Line breaks become one space; runs of spaces INSIDE a line stay — 'GU AGENT' vs 'GU AGENT'
26
+ # is the whole failure, and collapsing it printed two identical strings.
27
+ text = re.sub(r"[ \t]*\n[ \t]*", " ", (text or "").strip())
28
+ return text if len(text) <= n else text[: n - 1] + "…"
29
+
30
+
31
+ def _rel(path: str, cwd: str) -> str:
32
+ path = (path or "").strip().strip("'\"")
33
+ if path.startswith("file://"):
34
+ path = path[7:]
35
+ if cwd:
36
+ for base in {cwd, os.path.realpath(cwd)}:
37
+ if base and path.startswith(base.rstrip("/") + "/"):
38
+ return path[len(base.rstrip("/")) + 1:]
39
+ return path
40
+
41
+
42
+ def _fail(name, message="", where="") -> dict:
43
+ return {"name": _clip(name, 160), "message": _clip(message), "where": where}
44
+
45
+
46
+ # ---- node --test, spec reporter (the default) ---------------------------------------------------
47
+ _NODE_SPEC_SUM = re.compile(r"^ℹ (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
48
+
49
+
50
+ def _node_spec(out: str, cwd: str):
51
+ counts = {}
52
+ for k, v in _NODE_SPEC_SUM.findall(out):
53
+ counts[k] = counts.get(k, 0) + int(v)
54
+ if "tests" not in counts:
55
+ return None
56
+ fails = []
57
+ block = out.split("✖ failing tests:", 1)
58
+ if len(block) == 2:
59
+ # "test at FILE:L:C" / "✖ name (ms)" / indented message lines, until the next "test at".
60
+ for m in re.finditer(r"^test at (\S+?):(\d+):\d+\n✖ (.+?) \([\d.]+m?s\)[^\n]*\n((?:[ \t].*\n?|\n)*)",
61
+ block[1], re.M):
62
+ body = [ln.strip() for ln in m.group(4).splitlines()]
63
+ msg = []
64
+ for ln in body:
65
+ if ln.startswith("at ") or ln.startswith("{") or ln.startswith("generatedMessage"):
66
+ break
67
+ if ln and ln not in ("+ actual - expected", "^"):
68
+ msg.append(ln)
69
+ text = " ".join(msg[:5])
70
+ if text.strip("'\"") == "test failed":
71
+ # A FILE that failed to load: the runner only says 'test failed'; the cause
72
+ # (ERR_MODULE_NOT_FOUND, a SyntaxError) was printed above the summary.
73
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
74
+ text = _rel_in(err.group(1), cwd) if err else text
75
+ fails.append(_fail(m.group(3), text, f"{_rel(m.group(1), cwd)}:{m.group(2)}"))
76
+ else:
77
+ # A file that crashed before any test ran: "✖ tests/c.test.mjs (78ms)", the error above it.
78
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
79
+ for m in re.finditer(r"^✖ (.+?) \([\d.]+m?s\)", out, re.M):
80
+ fails.append(_fail(m.group(1), _rel_in(err.group(1), cwd) if err else ""))
81
+ return {"runner": "node --test", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
82
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
83
+ "failures": fails}
84
+
85
+
86
+ def _rel_in(text: str, cwd: str) -> str:
87
+ """Absolute paths under cwd inside a message, shortened."""
88
+ if not cwd:
89
+ return text
90
+ for base in {cwd.rstrip("/"), os.path.realpath(cwd).rstrip("/")}:
91
+ text = text.replace("file://" + base + "/", "").replace(base + "/", "")
92
+ return text
93
+
94
+
95
+ # ---- node --test / anything TAP ------------------------------------------------------------------
96
+ _TAP_SUM = re.compile(r"^# (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
97
+
98
+
99
+ def _tap(out: str, cwd: str):
100
+ counts = {}
101
+ for k, v in _TAP_SUM.findall(out):
102
+ counts[k] = counts.get(k, 0) + int(v)
103
+ if "tests" not in counts or "TAP version" not in out and "# Subtest" not in out:
104
+ return None
105
+ fails = []
106
+ for m in re.finditer(r"^[ \t]*not ok \d+ - (.+?)(?: # .*)?\n((?:[ \t]{2,}.*\n?|[ \t]*\n)*)", out, re.M):
107
+ yaml = m.group(2)
108
+ loc = re.search(r"location: '([^']+?):(\d+):\d+'", yaml)
109
+ err = re.search(r"error: \|-?\n((?:[ \t]{4,}.*\n?|[ \t]*\n)+)", yaml) or re.search(r"error: '([^']*)'", yaml)
110
+ msg = ""
111
+ if err:
112
+ msg = " ".join(ln.strip() for ln in err.group(1).splitlines()
113
+ if ln.strip() and ln.strip() not in ("+ actual - expected", "^"))
114
+ fails.append(_fail(m.group(1), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
115
+ return {"runner": "node --test (TAP)", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
116
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
117
+ "failures": fails}
118
+
119
+
120
+ # ---- python unittest ------------------------------------------------------------------------------
121
+ def _unittest(out: str, cwd: str):
122
+ ran = re.findall(r"^Ran (\d+) tests? in [\d.]+s\s*$", out, re.M)
123
+ if not ran:
124
+ return None
125
+ total = sum(int(x) for x in ran)
126
+ failures = errors = skipped = 0
127
+ for line in re.findall(r"^(?:OK|FAILED)(?: \((.*)\))?\s*$", out, re.M):
128
+ for k, v in re.findall(r"(\w[\w ]*?)=(\d+)", line or ""):
129
+ k = k.strip()
130
+ if k == "failures":
131
+ failures += int(v)
132
+ elif k == "errors":
133
+ errors += int(v)
134
+ elif k == "skipped":
135
+ skipped += int(v)
136
+ fails = []
137
+ for m in re.finditer(r"^(FAIL|ERROR): (\S+) \((\S+?)\)[^\n]*\n-{20,}\n(.*?)(?=^={20,}|^-{20,}\nRan |\Z)",
138
+ out, re.M | re.S):
139
+ kind, short, dotted, body = m.groups()
140
+ lines = [ln for ln in body.rstrip().splitlines() if ln.strip()]
141
+ msg = lines[-1].strip() if lines else kind
142
+ locs = [(f, ln) for f, ln in re.findall(r'File "([^"]+)", line (\d+)', body)
143
+ if "/lib/python" not in f and "unittest/" not in f]
144
+ where = f"{_rel(locs[-1][0], cwd)}:{locs[-1][1]}" if locs else ""
145
+ fails.append(_fail(dotted if "." in dotted else short, msg, where))
146
+ return {"runner": "unittest", "passed": max(0, total - failures - errors - skipped), "failed": failures,
147
+ "skipped": skipped, "errors": errors, "failures": fails}
148
+
149
+
150
+ # ---- pytest ---------------------------------------------------------------------------------------
151
+ _PYTEST_SUM = re.compile(r"^=+ ((?:\d+ \w+(?:, )?)+) in [\d.]+s(?: \([^)]*\))? =+\s*$", re.M)
152
+
153
+
154
+ def _pytest(out: str, cwd: str):
155
+ sums = _PYTEST_SUM.findall(out)
156
+ if not sums:
157
+ return None
158
+ c = {}
159
+ for k_v in ", ".join(sums).split(", "):
160
+ m = re.match(r"(\d+) (\w+)", k_v.strip())
161
+ if m:
162
+ c[m.group(2)] = c.get(m.group(2), 0) + int(m.group(1))
163
+ fails = []
164
+ for m in re.finditer(r"^(FAILED|ERROR) (\S+?)(?: - (.*))?$", out, re.M):
165
+ nodeid = m.group(2)
166
+ loc = re.search(rf"^({re.escape(nodeid.split('::')[0])}):(\d+): ", out, re.M)
167
+ fails.append(_fail(nodeid, m.group(3) or m.group(1), f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
168
+ return {"runner": "pytest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
169
+ "skipped": c.get("skipped", 0) + c.get("xfailed", 0), "errors": c.get("error", 0) + c.get("errors", 0),
170
+ "failures": fails}
171
+
172
+
173
+ # ---- jest / vitest --------------------------------------------------------------------------------
174
+ def _jest(out: str, cwd: str):
175
+ m = re.search(r"^Tests:\s+(.*\d+ total)\s*$", out, re.M)
176
+ if not m:
177
+ return None
178
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
179
+ fails = []
180
+ for f in re.finditer(r"^\s*● (.+?)\n\n((?:.*\n){1,6})", out, re.M):
181
+ if f.group(1).startswith("Console"):
182
+ continue
183
+ msg = next((ln.strip() for ln in f.group(2).splitlines() if ln.strip()), "")
184
+ loc = re.search(r"\(([^()\s]+?):(\d+):\d+\)", f.group(2)) or re.search(r"at .*?([\w./-]+\.\w+):(\d+):\d+", out)
185
+ fails.append(_fail(f.group(1).replace(" › ", " > "), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
186
+ return {"runner": "jest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
187
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
188
+
189
+
190
+ def _vitest(out: str, cwd: str):
191
+ m = re.search(r"^\s*Tests\s+(.*?)\s*\((\d+)\)\s*$", out, re.M)
192
+ if not m or "|" not in m.group(1) and not re.search(r"\d+ (passed|failed)", m.group(1)):
193
+ return None
194
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
195
+ fails = []
196
+ for f in re.finditer(r"^[ \t]*FAIL[ \t]+(\S+) > (.+?)\n((?:.*\n){0,4})", out, re.M):
197
+ msg = next((ln.strip() for ln in f.group(3).splitlines() if re.search(r"Error|expected", ln)), "")
198
+ loc = re.search(r"❯ (\S+?):(\d+):\d+", f.group(3))
199
+ fails.append(_fail(f"{f.group(1)} > {f.group(2).strip()}", msg,
200
+ f"{loc.group(1)}:{loc.group(2)}" if loc else f.group(1)))
201
+ if not fails: # no "Failed Tests" section (a reporter without it): the × lines
202
+ for f in re.finditer(r"^[ \t]*× (.+?)(?:[ \t]+\d+ms)?\n[ \t]*→ (.*)$", out, re.M):
203
+ fails.append(_fail(f.group(1), f.group(2)))
204
+ return {"runner": "vitest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
205
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
206
+
207
+
208
+ # ---- go test ---------------------------------------------------------------------------------------
209
+ def _gotest(out: str, cwd: str):
210
+ pkgs = re.findall(r"^(ok|FAIL|---? FAIL)\s+(\S+)\s+(?:[\d.]+s|\(cached\)|\[)", out, re.M)
211
+ fails_raw = list(re.finditer(r"^[ \t]*--- FAIL: (\S+) \([\d.]+s\)", out, re.M))
212
+ if not pkgs and not fails_raw:
213
+ return None
214
+ passed = len(re.findall(r"^\s*--- PASS: ", out, re.M))
215
+ skipped = len(re.findall(r"^\s*--- SKIP: ", out, re.M))
216
+ fails = []
217
+ for f in fails_raw:
218
+ # Since Go 1.14 a test's t.Error lines are printed between its "=== RUN" and "--- FAIL".
219
+ run = re.search(rf"^=== RUN\s+{re.escape(f.group(1))}\n((?:[ \t]{{4,}}.*\n)*)", out, re.M)
220
+ first = next((ln.strip() for ln in (run.group(1) if run else "").splitlines() if ln.strip()), "")
221
+ loc = re.match(r"([\w./-]+\.go):(\d+): (.*)", first)
222
+ fails.append(_fail(f.group(1), loc.group(3) if loc else first, f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
223
+ ok_pkgs = sum(1 for k, _ in pkgs if k == "ok")
224
+ bad_pkgs = sum(1 for k, _ in pkgs if k == "FAIL")
225
+ return {"runner": "go test", "passed": passed, "failed": len(fails_raw) or bad_pkgs, "skipped": skipped,
226
+ "errors": 0, "failures": fails,
227
+ "note": f"{ok_pkgs} package(s) ok, {bad_pkgs} FAIL" if pkgs else ""}
228
+
229
+
230
+ # ---- cargo test ------------------------------------------------------------------------------------
231
+ def _cargo(out: str, cwd: str):
232
+ res = re.findall(r"^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", out, re.M)
233
+ if not res:
234
+ return None
235
+ p = sum(int(a) for a, _, _ in res)
236
+ f = sum(int(b) for _, b, _ in res)
237
+ s = sum(int(c) for _, _, c in res)
238
+ fails = []
239
+ for m in re.finditer(r"^---- (\S+) stdout ----\n((?:.*\n){1,4})", out, re.M):
240
+ body = m.group(2)
241
+ loc = re.search(r"panicked at ([^\s:]+):(\d+):\d+", body)
242
+ msg = [ln.strip() for ln in body.splitlines() if ln.strip() and "panicked at" not in ln]
243
+ fails.append(_fail(m.group(1), msg[0] if msg else "panicked", f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
244
+ return {"runner": "cargo test", "passed": p, "failed": f, "skipped": s, "errors": 0, "failures": fails}
245
+
246
+
247
+ # ---- tsc / eslint (checks, not tests — same shape: count, then the first problems) ----------------
248
+ def _tsc(out: str, cwd: str):
249
+ errs = re.findall(r"^(.+?)\((\d+),\d+\): error (TS\d+): (.+)$", out, re.M)
250
+ if not errs:
251
+ return None
252
+ fails = [_fail(code, msg, f"{_rel(f, cwd)}:{ln}") for f, ln, code, msg in errs]
253
+ return {"runner": "tsc", "check": True, "errors": len(errs),
254
+ "files": len({e[0] for e in errs}), "failures": fails}
255
+
256
+
257
+ def _eslint(out: str, cwd: str):
258
+ m = re.search(r"^✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)", out, re.M)
259
+ if not m:
260
+ return None
261
+ fails, cur = [], ""
262
+ for ln in out.splitlines():
263
+ if ln and not ln.startswith(" ") and not ln.startswith("✖"):
264
+ cur = _rel(ln.strip(), cwd)
265
+ continue
266
+ e = re.match(r"^\s+(\d+):\d+\s+error\s+(.+?)\s{2,}(\S+)\s*$", ln)
267
+ if e and cur:
268
+ fails.append(_fail(e.group(3), e.group(2), f"{cur}:{e.group(1)}"))
269
+ return {"runner": "eslint", "check": True, "errors": int(m.group(2)), "warnings": int(m.group(3)),
270
+ "failures": fails}
271
+
272
+
273
+ _PARSERS = (_unittest, _pytest, _node_spec, _tap, _jest, _vitest, _gotest, _cargo, _tsc, _eslint)
274
+
275
+
276
+ def parse(output: str, cwd: str = "") -> list[dict]:
277
+ """Every runner whose summary appears in `output`, in that order. Never raises."""
278
+ found = []
279
+ for p in _PARSERS:
280
+ try:
281
+ r = p(output or "", cwd)
282
+ except Exception: # noqa: BLE001 — a digest must never break a command result
283
+ r = None
284
+ if r:
285
+ found.append(r)
286
+ return found
287
+
288
+
289
+ def _counts(r: dict) -> str:
290
+ if r.get("check"):
291
+ bits = [f"{r['errors']} error{'s' if r['errors'] != 1 else ''}"]
292
+ if r.get("warnings"):
293
+ bits.append(f"{r['warnings']} warning{'s' if r['warnings'] != 1 else ''}")
294
+ return " · ".join(bits)
295
+ bits = [f"{r['passed']} passed"]
296
+ if r["failed"]:
297
+ bits.append(f"{r['failed']} failed")
298
+ if r.get("errors"):
299
+ bits.append(f"{r['errors']} error{'s' if r['errors'] != 1 else ''}")
300
+ if r.get("skipped"):
301
+ bits.append(f"{r['skipped']} skipped")
302
+ if r.get("note"):
303
+ bits.append(r["note"])
304
+ return " · ".join(bits)
305
+
306
+
307
+ def digest(output: str, cwd: str = "") -> str:
308
+ """The block run_command puts ABOVE the output: counts per runner, then each failure as
309
+ `✗ name — message (file:line)`. "" when no runner was recognised."""
310
+ runs = parse(output, cwd)
311
+ if not runs:
312
+ return ""
313
+ bad = [r for r in runs if r.get("failed") or r.get("errors") or r.get("failures")]
314
+ if not bad:
315
+ return "TEST SUMMARY: " + " · ".join(f"{r['runner']} {_counts(r)}" for r in runs) + " — all passed."
316
+ lines = ["TEST SUMMARY (from the whole output — read this before the log below):"]
317
+ for r in runs:
318
+ lines.append(f" {r['runner']}: {_counts(r)}")
319
+ for f in r["failures"][:MAX_FAILURES]:
320
+ tail = f" ({f['where']})" if f["where"] else ""
321
+ lines.append(f" ✗ {f['name']}" + (f" — {f['message']}" if f["message"] else "") + tail)
322
+ if len(r["failures"]) > MAX_FAILURES:
323
+ lines.append(f" … and {len(r['failures']) - MAX_FAILURES} more")
324
+ return "\n".join(lines)
325
+
326
+
327
+ # ---- running ONE test ------------------------------------------------------------------------------
328
+ # Per runner, the form that runs a single test — what the model needs after the first full run, so
329
+ # a fix is checked in seconds and the suite runs once at the end. Keyed on what the project's own
330
+ # test command contains.
331
+ ONE_TEST = (
332
+ (r"\bnode --test\b", "node --test --test-name-pattern='<name>' <file>"),
333
+ (r"\bunittest\b", "python3 -m unittest <module>.<Class>.<test_name>"),
334
+ (r"\bpytest\b", "python3 -m pytest '<file>::<test>' -x"),
335
+ (r"\bvitest\b", "npx vitest run <file> -t '<name>'"),
336
+ (r"\bjest\b", "npx jest <file> -t '<name>'"),
337
+ (r"\bgo test\b", "go test -run '^<TestName>$' ./<pkg>"),
338
+ (r"\bcargo test\b", "cargo test <name>"),
339
+ (r"\bmvn\b.*\btest\b", "mvn test -Dtest=<Class>#<method>"),
340
+ (r"\bgradlew?\b.*\btest\b", "./gradlew test --tests '<Class.method>'"),
341
+ (r"\brspec\b", "bundle exec rspec <file>:<line>"),
342
+ )
343
+
344
+
345
+ def one_test_forms(commands) -> list[str]:
346
+ """Distinct run-one-test forms for the runners named in these test commands."""
347
+ seen = []
348
+ for cmd in commands or []:
349
+ for pat, form in ONE_TEST:
350
+ if re.search(pat, cmd or "") and form not in seen:
351
+ seen.append(form)
352
+ return seen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.832",
3
+ "version": "1.0.834",
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,6 +125,13 @@
125
125
  "skills.mjs",
126
126
  "skill-source.mjs",
127
127
  "shell-details.mjs",
128
+ "browser-command.mjs",
129
+ "browser-bridge.mjs",
130
+ "browser-extension/manifest.json",
131
+ "browser-extension/background.js",
132
+ "browser-extension/popup.html",
133
+ "browser-extension/popup.js",
134
+ "browser-extension/icons/",
128
135
  "step-group.mjs",
129
136
  "mcp-config.mjs",
130
137
  "mcp-command.mjs",
@@ -135,6 +142,8 @@
135
142
  "gonext_agent_chat.py",
136
143
  "gu_mcp.py",
137
144
  "gu_skills.py",
145
+ "gu_test_digest.py",
146
+ "gu_browser.py",
138
147
  "gu_hooks.py",
139
148
  "prompt_scaffold_check.py",
140
149
  "gonext_transcribe.py",
package/shell-details.mjs CHANGED
@@ -11,9 +11,9 @@
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
18
 
19
19
  export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
@@ -63,7 +63,7 @@ export function shellState(rec, { alive, exit, now = Date.now() / 1000 } = {}) {
63
63
  if (live && !ex && !rec?.killed) return { state: "running", code: null, runtime: Math.max(0, now - started), ended: now };
64
64
  const ended = Number(rec?.ended) || ex?.at || now;
65
65
  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 };
66
+ return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended, by: String(rec?.killed_by ?? "") };
67
67
  }
68
68
 
69
69
  /** 7m 2s — Claude Code's spelling. */
@@ -79,7 +79,10 @@ export function fmtRuntime(seconds) {
79
79
  export function statusText(st) {
80
80
  if (st.state === "running") return "running";
81
81
  if (st.state === "exited" || st.state === "failed") return `exited ${st.code}`;
82
- return st.state === "killed" ? "killed" : "ended (exit code unknown)";
82
+ // Who stopped it (#362/#143): a bare "killed" read as a crash when the agent had stopped its own
83
+ // search on purpose.
84
+ if (st.state === "killed") return st.by ? `killed — stopped by ${st.by}` : "killed";
85
+ return "ended (exit code unknown)";
83
86
  }
84
87
 
85
88
  /**
@@ -250,8 +253,59 @@ export async function killShell(rec, { path = shellsPath(), platform = process.p
250
253
  const mine = shells.find((s) => String(s.id) === String(rec.id));
251
254
  if (!mine || readExit(mine)) return; // it finished on its own first: keep the real exit code
252
255
  mine.killed = true;
256
+ mine.killed_by = "you (Stop it in Shell details)";
257
+ mine.announced = true; // you stopped it — no recap turn for it
253
258
  mine.ended = Date.now() / 1000;
254
259
  const tmp = `${path}.${process.pid}.tmp`;
255
260
  writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
256
261
  renameSync(tmp, path);
257
262
  }
263
+
264
+ /**
265
+ * Shells whose ending the agent has not heard about (#362) — Claude Code tells its model when a
266
+ * background command completes; gu's terminal starts a short recap turn for these. Only this
267
+ * folder's shells (shells.json is shared by every project), only ones that ended while this
268
+ * terminal was open, never one the agent already read (`seen`) or was told about (`announced`),
269
+ * and never one you stopped yourself.
270
+ */
271
+ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {}) {
272
+ // The nearest folder that still exists, resolved (macOS: /var → /private/var), plus the rest —
273
+ // a build may have deleted the folder it ran in.
274
+ const real = (p) => {
275
+ const raw = String(p ?? "");
276
+ if (!raw) return raw;
277
+ try { return realpathSync(raw); } catch { /* gone */ }
278
+ const up = dirname(raw);
279
+ return up === raw ? raw : join(real(up), basename(raw));
280
+ };
281
+ const base = real(cwd).replace(/\/+$/, "");
282
+ const under = (wd) => {
283
+ const w = real(wd);
284
+ return w === base || w.startsWith(base + "/");
285
+ };
286
+ return entries
287
+ .filter(({ rec, st }) => st.state !== "running" && st.state !== "killed" && !rec.seen && !rec.announced
288
+ && st.ended >= since && under(rec.workdir))
289
+ .map((e) => e.rec);
290
+ }
291
+
292
+ /** Mark these shells announced in shells.json, so neither the agent nor another terminal repeats it. */
293
+ export function markAnnounced(ids, { path = shellsPath() } = {}) {
294
+ const shells = readShells(path);
295
+ let n = 0;
296
+ for (const s of shells) if (ids.includes(String(s.id)) && !s.announced) { s.announced = true; n++; }
297
+ if (!n) return 0;
298
+ const tmp = `${path}.${process.pid}.tmp`;
299
+ writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
300
+ renameSync(tmp, path);
301
+ return n;
302
+ }
303
+
304
+ /** The recap question the terminal asks for finished shells. */
305
+ export function recapQuestion(entries) {
306
+ const parts = entries.map(({ rec, st }) =>
307
+ `shell ${rec.id} ${statusText(st)} after ${fmtRuntime(st.runtime)} (${String(rec.command ?? "").replace(/\s+/g, " ").trim().slice(0, 120)})`);
308
+ const ids = entries.map(({ rec }) => `shell_output('${rec.id}')`).join(", ");
309
+ return `[gu: background ${entries.length === 1 ? "shell" : "shells"} finished — ${parts.join("; ")}] `
310
+ + `Read the result with ${ids}, tell me briefly what happened, and continue with the next step if the task needs one.`;
311
+ }