pi-background-run 0.4.0 → 0.5.0

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.
@@ -34,17 +34,19 @@ import { Type } from "typebox";
34
34
  import { Box, Text } from "@earendil-works/pi-tui";
35
35
  import { spawn } from "node:child_process";
36
36
  import {
37
- openSync,
37
+ appendFileSync,
38
38
  closeSync,
39
- readFileSync,
39
+ existsSync,
40
40
  mkdirSync,
41
+ openSync,
42
+ readFileSync,
41
43
  readdirSync,
42
44
  renameSync,
43
- unlinkSync,
44
45
  statSync,
46
+ unlinkSync,
45
47
  writeFileSync,
46
48
  } from "node:fs";
47
- import { join } from "node:path";
49
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
48
50
  import { homedir } from "node:os";
49
51
 
50
52
  // Exit marker appended to every log so the file is self-describing: the exit
@@ -54,6 +56,14 @@ const EXIT_MARKER = "__BGRUN_EXIT__=";
54
56
 
55
57
  const DEFAULT_CLEANUP_DAYS = 7;
56
58
  const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle
59
+ const GLOBAL_JOBS_DIR = join(homedir(), ".pi-bgrun", "jobs");
60
+
61
+ // Default regex for bggrep when the caller passes no pattern: common failure
62
+ // signatures across test runners and build tools. ONLY a convenience default —
63
+ // bggrep's contract is that the caller's own pattern always wins, because a
64
+ // generic default on arbitrary tools/languages misses more than it catches.
65
+ export const DEFAULT_GREP_PATTERN =
66
+ "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖";
57
67
 
58
68
  // ── Configuration ───────────────────────────────────────────────────────────
59
69
  //
@@ -66,6 +76,10 @@ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child h
66
76
 
67
77
  interface BgrunConfig {
68
78
  jobsDir: string;
79
+ // True when jobsDir came from a RELATIVE path resolved against the project
80
+ // root (project-local logs). Only then does bgrun auto-ignore the dir in
81
+ // .git/info/exclude — an absolute dir is the user's explicit choice.
82
+ jobsDirProjectLocal: boolean;
69
83
  // Adopt other sessions' running jobs (found in the shared jobs dir) into
70
84
  // this session's widget and job list. Default false — most sessions don't
71
85
  // want unrelated jobs from other projects cluttering the widget.
@@ -112,6 +126,111 @@ function readConfigFile(path: string): BgrunConfigFile {
112
126
  return {};
113
127
  }
114
128
 
129
+ // ── Project-local jobs dir ──────────────────────────────────────────────────
130
+ //
131
+ // A RELATIVE `jobsDir` (from any config layer, or PI_BGRUN_DIR) opts into
132
+ // project-local logs: it resolves against the session's project root, so logs
133
+ // land inside the workspace. That keeps them within the project sandbox —
134
+ // analysis tools confined to the project root (e.g. context-mode's
135
+ // ctx_execute_file/ctx_index) can then process whole logs without flooding
136
+ // context. Absolute paths behave exactly as in older versions
137
+ // (migration-safe), and with no recognizable project root a relative path
138
+ // falls back to the global dir instead of scattering logs across whatever
139
+ // directory pi happened to start in.
140
+
141
+ function isProjectRootLike(dir: string): boolean {
142
+ // Cheap heuristic: a directory holding .git or pi's config dir is a project.
143
+ return (
144
+ existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME))
145
+ );
146
+ }
147
+
148
+ export function resolveJobsDirPath(
149
+ raw: string | undefined,
150
+ ctx?: { cwd?: string },
151
+ ): { dir: string; projectLocal: boolean } {
152
+ if (!raw) return { dir: GLOBAL_JOBS_DIR, projectLocal: false };
153
+ if (isAbsolute(raw)) return { dir: raw, projectLocal: false };
154
+ const root = ctx?.cwd ?? process.cwd();
155
+ if (!root || !isProjectRootLike(root)) {
156
+ return { dir: GLOBAL_JOBS_DIR, projectLocal: false };
157
+ }
158
+ return { dir: join(root, raw), projectLocal: true };
159
+ }
160
+
161
+ // Auto-ignore a project-local jobs dir in git so logs never pollute
162
+ // `git status`: appends the dir pattern to the enclosing repo's
163
+ // .git/info/exclude (local-only — the tracked .gitignore is never touched).
164
+ // Memoized only on SUCCESS — a transient failure (unwritable exclude file,
165
+ // .git appearing later) is retried on the next bgrun. Every step is
166
+ // best-effort and must never fail a bgrun.
167
+ const gitExcludedDirs = new Set<string>();
168
+
169
+ // Returns true when the dir is settled (pattern written, already present, or
170
+ // legitimately nothing to do — no repo above, dir is the repo root itself).
171
+ // False only on failure, so the caller retries next time.
172
+ export function ensureGitExcluded(jobsDir: string): boolean {
173
+ if (gitExcludedDirs.has(jobsDir)) return true;
174
+ if (tryEnsureGitExcluded(jobsDir)) {
175
+ gitExcludedDirs.add(jobsDir);
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ function tryEnsureGitExcluded(jobsDir: string): boolean {
182
+ try {
183
+ // Walk up from jobsDir to the enclosing work tree.
184
+ let cur = jobsDir;
185
+ for (;;) {
186
+ const dot = join(cur, ".git");
187
+ if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir);
188
+ const parent = dirname(cur);
189
+ if (parent === cur) return true; // filesystem root — no repo above; nothing to do
190
+ cur = parent;
191
+ }
192
+ } catch {
193
+ // best-effort — ignore hygiene must never break job creation
194
+ return false;
195
+ }
196
+ }
197
+
198
+ function appendExcludePattern(
199
+ repoRoot: string,
200
+ dotGit: string,
201
+ jobsDir: string,
202
+ ): boolean {
203
+ if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do
204
+ // `.git` is a directory in a normal checkout, or a file pointing at the
205
+ // real git dir in linked worktrees (git worktree add) and submodules.
206
+ let gitDir = dotGit;
207
+ if (statSync(dotGit).isFile()) {
208
+ const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
209
+ if (!m) return false; // unparseable .git file — retry later
210
+ gitDir = m[1].trim();
211
+ }
212
+ const rel = relative(repoRoot, jobsDir);
213
+ // Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but
214
+ // a future caller or symlinked path could break that — ../-prefixed
215
+ // patterns are silently useless in gitignore semantics, so skip them.
216
+ if (rel.startsWith("..") || isAbsolute(rel)) return true;
217
+ const pattern = rel.split(sep).join("/") + "/";
218
+ const excludePath = join(gitDir, "info", "exclude");
219
+ let existing = "";
220
+ try {
221
+ existing = readFileSync(excludePath, "utf8");
222
+ } catch {
223
+ // no exclude file yet — we'll create it
224
+ }
225
+ if (existing.split("\n").some((l) => l.trim() === pattern)) return true;
226
+ mkdirSync(join(gitDir, "info"), { recursive: true });
227
+ appendFileSync(
228
+ excludePath,
229
+ `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`,
230
+ );
231
+ return true;
232
+ }
233
+
115
234
  // Resolved per call (cheap: at most two small file reads) so env/config
116
235
  // changes are picked up without module reloads — and tests can isolate.
117
236
  function resolveConfig(ctx?: {
@@ -154,11 +273,11 @@ function resolveConfig(ctx?: {
154
273
  : undefined;
155
274
  const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
156
275
  const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
276
+ const { dir: jobsDir, projectLocal: jobsDirProjectLocal } =
277
+ resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx);
157
278
  return {
158
- jobsDir:
159
- process.env.PI_BGRUN_DIR ||
160
- dirFile ||
161
- join(homedir(), ".pi-bgrun", "jobs"),
279
+ jobsDir,
280
+ jobsDirProjectLocal,
162
281
  adoptForeignJobs:
163
282
  parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
164
283
  showCompletedJobs:
@@ -711,7 +830,7 @@ export default function (pi: ExtensionAPI) {
711
830
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
712
831
  "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.",
713
832
  "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
714
- "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use ctx_execute_file on the log path only when the condensed tail is insufficient.",
833
+ "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.",
715
834
  ],
716
835
  parameters: Type.Object({
717
836
  command: Type.String({
@@ -733,7 +852,11 @@ export default function (pi: ExtensionAPI) {
733
852
  }
734
853
  const name = sanitizeName(rawName);
735
854
 
736
- const jobsDir = resolveConfig(ctx).jobsDir;
855
+ const cfg = resolveConfig(ctx);
856
+ // Project-local logs are auto-ignored in .git/info/exclude (best-effort)
857
+ // so they never pollute `git status`. Absolute dirs are left untouched.
858
+ if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir);
859
+ const jobsDir = cfg.jobsDir;
737
860
  mkdirSync(jobsDir, { recursive: true });
738
861
 
739
862
  const slug = makeSlug(name ?? command);
@@ -948,7 +1071,22 @@ export default function (pi: ExtensionAPI) {
948
1071
  return { text: out.join("\n"), truncated: notes };
949
1072
  }
950
1073
 
951
- // ── bgtail: read last N lines of a job's log, condensed for context ────────
1074
+ // ── bgtail: read the newest lines of a job's log, condensed for context ────
1075
+ //
1076
+ // Delta tailing: each read bookmarks the total raw line count at read time
1077
+ // (the high-water mark of what the caller has had the opportunity to see).
1078
+ // The FIRST read for a job returns the full last-N tail; repeat reads return
1079
+ // only lines appended since, so polling a running job never re-pays context
1080
+ // for lines already seen. Deliberately-skipped prefix lines are never
1081
+ // replayed as "new". raw: true keeps the verbatim last-N window (no delta
1082
+ // header) but still advances the bookmark. A shrunken log (rotated/replaced)
1083
+ // resets to a full tail. Bookmarks are in-memory only — a session restart
1084
+ // starts fresh with a full tail.
1085
+
1086
+ const tailBookmarks = new Map<
1087
+ string,
1088
+ { lines: number; bytes: number; first: string }
1089
+ >();
952
1090
 
953
1091
  // Shared by the bgtail tool (agent-facing) and the /bgtail slash command
954
1092
  // (human-facing).
@@ -960,25 +1098,101 @@ export default function (pi: ExtensionAPI) {
960
1098
  details: Record<string, unknown>;
961
1099
  isError?: boolean;
962
1100
  }> {
963
- const { id, lines = 40, raw = false } = params;
1101
+ const { id, lines: linesParam = 40, raw = false } = params;
1102
+ // Clamp defensively — direct callers (e.g. the slash command) bypass the
1103
+ // tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log).
1104
+ const lines = Math.max(1, Math.floor(linesParam));
964
1105
  if (!id) throw new Error("bgtail: id is required");
965
- const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
1106
+ // Prefer this session's record: its logPath stays correct even if the
1107
+ // config (and thus the resolved jobs dir) changes mid-session — e.g. a
1108
+ // user switching to project-local logs right after upgrading.
1109
+ const logPath =
1110
+ jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
966
1111
  try {
967
1112
  const content = readFileSync(logPath, "utf8");
968
- const all = content
969
- .split("\n")
1113
+ // Content lines only: the exit marker and blanks are filtered BEFORE the
1114
+ // window is sliced, so "last N lines" means the last N content lines
1115
+ // (matching pre-delta behavior) and bookmarks count content lines.
1116
+ // /\r?\n/ keeps CRLF logs from leaving a stray \r on every line.
1117
+ const rawLines = content
1118
+ .split(/\r?\n/)
970
1119
  .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
971
- const tail = all.slice(-lines);
972
- const { text, truncated } = condenseLogLines(tail, { raw });
1120
+ const total = rawLines.length;
1121
+ const first = rawLines[0]?.slice(0, 200) ?? "";
1122
+ const prev = tailBookmarks.get(id);
1123
+ // Append-only logs never mutate earlier lines, so a changed first
1124
+ // content line means the log was replaced or rotated — reset to a full
1125
+ // tail. Catches same-size replacements the shrink checks cannot see.
1126
+ // (A previously-empty log growing content is growth, not replacement.)
1127
+ const replaced =
1128
+ prev !== undefined && prev.lines > 0 && prev.first !== first;
1129
+ const shrank =
1130
+ prev !== undefined &&
1131
+ (prev.lines > total || prev.bytes > content.length);
1132
+ let window: string[];
1133
+ let header: string | undefined;
1134
+ let newLines: number | undefined;
1135
+ if (raw || prev === undefined || shrank || replaced) {
1136
+ // Full tail: first read, raw mode, or a shrunken/replaced log (reset).
1137
+ window = rawLines.slice(-lines);
1138
+ if (!raw && (shrank || replaced)) {
1139
+ header = shrank
1140
+ ? "log shrank since last read — showing full tail"
1141
+ : "log was replaced since last read — showing full tail";
1142
+ }
1143
+ } else {
1144
+ const fresh = rawLines.slice(prev.lines);
1145
+ newLines = fresh.length;
1146
+ if (fresh.length === 0) {
1147
+ tailBookmarks.set(id, {
1148
+ lines: total,
1149
+ bytes: content.length,
1150
+ first,
1151
+ });
1152
+ return {
1153
+ content: [
1154
+ {
1155
+ type: "text",
1156
+ text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})`,
1157
+ },
1158
+ ],
1159
+ details: {
1160
+ id,
1161
+ linesShown: 0,
1162
+ logPath,
1163
+ notFound: false,
1164
+ condensed: true,
1165
+ newLines: 0,
1166
+ totalLines: total,
1167
+ },
1168
+ };
1169
+ }
1170
+ window = fresh.length > lines ? fresh.slice(-lines) : fresh;
1171
+ header =
1172
+ `+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` +
1173
+ `log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`;
1174
+ }
1175
+ tailBookmarks.set(id, {
1176
+ lines: total,
1177
+ bytes: content.length,
1178
+ first,
1179
+ });
1180
+ const shown = window;
1181
+ const { text, truncated } = condenseLogLines(shown, { raw });
1182
+ // Delta reads early-return above, so an empty window here can only be
1183
+ // a first read of an empty log (full-tail path).
1184
+ const body = shown.length === 0 ? "(empty log)" : text;
973
1185
  const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
1186
+ const head = header ? `${header}\n` : "";
974
1187
  return {
975
- content: [{ type: "text", text: text + notes || "(empty log)" }],
1188
+ content: [{ type: "text", text: head + body + notes }],
976
1189
  details: {
977
1190
  id,
978
- linesShown: tail.length,
1191
+ linesShown: shown.length,
979
1192
  logPath,
980
1193
  notFound: false,
981
1194
  condensed: !raw,
1195
+ ...(newLines === undefined ? {} : { newLines, totalLines: total }),
982
1196
  ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
983
1197
  },
984
1198
  };
@@ -997,14 +1211,17 @@ export default function (pi: ExtensionAPI) {
997
1211
  name: "bgtail",
998
1212
  label: "Tail Background Log",
999
1213
  description:
1000
- "Print the last N lines of a background job's log (default 40), condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. Pass raw: true for unprocessed output; use ctx_execute_file on the log path for whole-log failure analysis.",
1214
+ "Read the newest lines of a background job's log, condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. The first read returns the last N lines (default 40); REPEAT reads return only lines appended since your last read (delta tailing) — polling a running job never re-pays for the same lines. raw: true returns the unprocessed last-N window. A shrunken or replaced log resets to a full tail. For pattern search use bggrep; for whole-log analysis, ctx_execute_file on the log path.",
1001
1215
  promptSnippet: "Read the last N lines of a bgrun job's log",
1002
1216
  parameters: Type.Object({
1003
1217
  id: Type.String({
1004
1218
  description: "Job id (from bgrun's 'started: <id>' response)",
1005
1219
  }),
1006
1220
  lines: Type.Optional(
1007
- Type.Number({ description: "Number of lines to show (default 40)" }),
1221
+ Type.Number({
1222
+ description: "Number of lines to show (default 40)",
1223
+ minimum: 1,
1224
+ }),
1008
1225
  ),
1009
1226
  raw: Type.Optional(
1010
1227
  Type.Boolean({
@@ -1018,6 +1235,152 @@ export default function (pi: ExtensionAPI) {
1018
1235
  },
1019
1236
  });
1020
1237
 
1238
+ // ── bggrep: pattern search over a job's log, capped for context ───────────
1239
+ //
1240
+ // The sandboxed whole-log path (ctx_execute_file) is confined to the
1241
+ // project root, which a global jobs dir sits outside of — bggrep runs
1242
+ // inside the extension with native fs access, so it works on any
1243
+ // configured jobs dir. Matches are line-numbered (grep -n style),
1244
+ // optionally with context lines, capped at MAX_GREP_MATCHES, and run
1245
+ // through the same condenser as bgtail so a search can never flood context.
1246
+
1247
+ const MAX_GREP_MATCHES = 50;
1248
+
1249
+ async function bggrepCore(
1250
+ params: { id: string; pattern?: string; context?: number },
1251
+ ctx?: ExtensionContext,
1252
+ ): Promise<{
1253
+ content: { type: "text"; text: string }[];
1254
+ details: Record<string, unknown>;
1255
+ isError?: boolean;
1256
+ }> {
1257
+ const { id, pattern, context: contextParam = 0 } = params;
1258
+ // Clamp defensively — negative context would exclude the match lines
1259
+ // themselves from the context windows (lo > hi no-ops the inner loop).
1260
+ const context = Math.max(0, Math.floor(contextParam));
1261
+ if (!id) throw new Error("bggrep: id is required");
1262
+ // Record-first, same as bgtail — correct across config changes.
1263
+ const logPath =
1264
+ jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
1265
+ const source = pattern ?? DEFAULT_GREP_PATTERN;
1266
+ let re: RegExp;
1267
+ try {
1268
+ re = new RegExp(source);
1269
+ } catch (err) {
1270
+ throw new Error(
1271
+ `bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`,
1272
+ );
1273
+ }
1274
+ let rawLines: string[];
1275
+ try {
1276
+ const content = readFileSync(logPath, "utf8");
1277
+ // /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns
1278
+ // and leak into output); blank lines are KEPT so L<n> numbers match the
1279
+ // file. A trailing empty split element is dropped; "" yields zero lines.
1280
+ const split = content === "" ? [] : content.split(/\r?\n/);
1281
+ if (split.length > 0 && split[split.length - 1] === "") split.pop();
1282
+ rawLines = split.filter((l) => !l.startsWith(EXIT_MARKER));
1283
+ } catch {
1284
+ return {
1285
+ content: [
1286
+ { type: "text", text: `No log found for job ${id} at ${logPath}` },
1287
+ ],
1288
+ details: { id, matches: 0, logPath, notFound: true },
1289
+ isError: true,
1290
+ };
1291
+ }
1292
+ const matchIdx: number[] = [];
1293
+ for (let i = 0; i < rawLines.length; i++) {
1294
+ if (re.test(rawLines[i])) matchIdx.push(i);
1295
+ }
1296
+ const header =
1297
+ `${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` +
1298
+ `in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`;
1299
+ if (matchIdx.length === 0) {
1300
+ return {
1301
+ content: [{ type: "text", text: `${header} — none` }],
1302
+ details: {
1303
+ id,
1304
+ matches: 0,
1305
+ linesSearched: rawLines.length,
1306
+ logPath,
1307
+ notFound: false,
1308
+ },
1309
+ };
1310
+ }
1311
+ const capped = matchIdx.length > MAX_GREP_MATCHES;
1312
+ const shownIdx = capped ? matchIdx.slice(0, MAX_GREP_MATCHES) : matchIdx;
1313
+ // Context windows, merged where they overlap or touch (grep -C style).
1314
+ const include = new Set<number>();
1315
+ for (const i of shownIdx) {
1316
+ const lo = Math.max(0, i - context);
1317
+ const hi = Math.min(rawLines.length - 1, i + context);
1318
+ for (let j = lo; j <= hi; j++) include.add(j);
1319
+ }
1320
+ const sorted = [...include].sort((a, b) => a - b);
1321
+ const out: string[] = [];
1322
+ let prev = -2;
1323
+ for (const i of sorted) {
1324
+ if (prev >= 0 && i > prev + 1) {
1325
+ const gap = i - prev - 1;
1326
+ out.push(`…[${gap} line${gap === 1 ? "" : "s"} skipped]…`);
1327
+ }
1328
+ out.push(`L${i + 1}: ${rawLines[i]}`);
1329
+ prev = i;
1330
+ }
1331
+ const { text, truncated } = condenseLogLines(out);
1332
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
1333
+ const capNote = capped
1334
+ ? ` — showing first ${MAX_GREP_MATCHES}; ${matchIdx.length - MAX_GREP_MATCHES} more not shown`
1335
+ : "";
1336
+ return {
1337
+ content: [{ type: "text", text: `${header}${capNote}\n${text}${notes}` }],
1338
+ details: {
1339
+ id,
1340
+ matches: matchIdx.length,
1341
+ linesSearched: rawLines.length,
1342
+ logPath,
1343
+ notFound: false,
1344
+ pattern: source,
1345
+ capped,
1346
+ },
1347
+ };
1348
+ }
1349
+
1350
+ pi.registerTool({
1351
+ name: "bggrep",
1352
+ label: "Grep Background Log",
1353
+ description:
1354
+ "Search a background job's log with a regex; returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Runs inside the extension, so it works on any jobs dir — including global logs that project-sandboxed tools (ctx_execute_file) cannot reach. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).",
1355
+ promptSnippet: "Search a bgrun job's log for a pattern",
1356
+ promptGuidelines: [
1357
+ "Never search a bgrun log with the bash tool — uncapped output can flood context, and it needs manual log-path reconstruction and regex shell-quoting; bggrep is bounded by design.",
1358
+ "Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.",
1359
+ "Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.",
1360
+ ],
1361
+ parameters: Type.Object({
1362
+ id: Type.String({
1363
+ description: "Job id (from bgrun's 'started: <id>' response)",
1364
+ }),
1365
+ pattern: Type.Optional(
1366
+ Type.String({
1367
+ description:
1368
+ "Regex to search for. Default: generic failure signatures — override when you know the format.",
1369
+ }),
1370
+ ),
1371
+ context: Type.Optional(
1372
+ Type.Number({
1373
+ description:
1374
+ "Context lines around each match (default 0, grep -C style)",
1375
+ minimum: 0,
1376
+ }),
1377
+ ),
1378
+ }),
1379
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1380
+ return bggrepCore(params, ctx);
1381
+ },
1382
+ });
1383
+
1021
1384
  // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1022
1385
 
1023
1386
  // Shared by the bgstatus tool (agent-facing) and the /bgstatus slash command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background-run",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@ no polling.
30
30
  |---|---|
31
31
  | Start | `bgrun(command: "make test-short", name: "unit-tests")` → `started: <job-id>` (name is an optional short label; use it so jobs are recognizable in `bgstatus`, the status widget, and wake messages) |
32
32
  | Status | `bgstatus(<job-id>)` for one job, or `bgstatus()` for this session's running jobs — finished jobs are hidden by default; pass `includeDone: true` to list them |
33
- | Tail | `bgtail(<job-id>, 40)` |
33
+ | Tail | `bgtail(<job-id>, 40)` — first read: last-40 tail; later reads: only lines appended since (delta tailing) |
34
+ | Grep | `bggrep(<job-id>, "pattern", context?)` — line-numbered matches, capped and condensed; default pattern = generic failure signatures (override when you know the format) |
34
35
  | Clean | `bgclean()` for this session's old logs; `bgclean all` to sweep every session's (default 7-day retention) |
35
36
 
36
37
  ## Workflow
@@ -51,10 +52,11 @@ no polling.
51
52
 
52
53
  ### Reading results without flooding context
53
54
 
54
- - **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker.
55
+ - **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. The first read returns the last-40 tail; repeat reads return only lines appended since your last read (delta tailing) — polling a running job is nearly free.
56
+ - **Failure extraction:** `bggrep(<job-id>, "pattern")` — line-numbered matches with optional context lines, capped and condensed. Works on global jobs dirs that `ctx_execute_file` cannot reach (it runs inside the extension). Pass your own pattern whenever you know the tool's output format; the default only catches common failure signatures.
55
57
  - **Whole-log failure analysis:** `ctx_execute_file` on the log path:
56
58
 
57
- ```
59
+ ```javascript
58
60
  ctx_execute_file(
59
61
  path: "~/.pi-bgrun/jobs/<JOB>.log",
60
62
  language: "javascript",
@@ -67,8 +69,22 @@ no polling.
67
69
 
68
70
  A 10 000-line `make test` log collapses to a ~30-line summary in context.
69
71
 
70
- **Never `cat`, `Read`, or `bash cat` a full bgrun log.** Always `bgtail` or
71
- `ctx_execute_file`.
72
+ **Why `bggrep` instead of `bash grep` on the log?**
73
+
74
+ - `bash grep` output is uncapped — a retry-storm log can dump thousands of
75
+ matching lines (megabytes) straight into context, and staying safe depends
76
+ on remembering `| head` on every single call. `bggrep` is bounded by design
77
+ (~50 matches, ~2KB/line, ~8KB).
78
+ - It takes the job id — no log-path reconstruction, no shell-quoting of the
79
+ regex — and works on any jobs dir, including global logs that
80
+ project-sandboxed `ctx_execute_file` cannot reach.
81
+ - Output is self-describing: match count, line numbers, `…[N skipped]…` gap
82
+ markers, `— none` for no-match.
83
+
84
+ Plain `grep` via bash is fine only for a one-off search you know is tiny.
85
+
86
+ **Never `cat`, `Read`, `bash cat`, or `bash grep` a full bgrun log.** Always
87
+ `bgtail`, `bggrep`, or `ctx_execute_file`.
72
88
 
73
89
  ## After a pi restart or session switch
74
90
 
@@ -85,7 +101,10 @@ no polling.
85
101
 
86
102
  - Call the tools; never hand-roll `nohup … &` inline.
87
103
  - One job = one id. Multiple concurrent jobs are fine — each has its own log.
88
- - Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`).
104
+ - Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`). A **relative**
105
+ `jobsDir` in the project config (e.g. `.pi-bgrun/jobs`) puts logs inside the
106
+ project — auto-ignored via `.git/info/exclude` — which keeps them reachable
107
+ for project-sandboxed analysis tools like `ctx_execute_file`.
89
108
  - Cleanup: `bgclean` removes only THIS session's old logs; `bgclean all`
90
109
  sweeps every session's. Auto-sweeps at session start/shutdown are
91
110
  session-scoped plus a global orphan pass (default on — removes finished