@stablekernel/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.
- package/README.md +53 -7
- package/extension/index.test.ts +799 -2
- package/extension/index.ts +384 -21
- package/package.json +1 -1
- package/skill/run-bg/SKILL.md +25 -6
package/extension/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
+
appendFileSync,
|
|
38
38
|
closeSync,
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
|
972
|
-
const
|
|
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:
|
|
1188
|
+
content: [{ type: "text", text: head + body + notes }],
|
|
976
1189
|
details: {
|
|
977
1190
|
id,
|
|
978
|
-
linesShown:
|
|
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
|
-
"
|
|
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({
|
|
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": "@stablekernel/pi-background-run",
|
|
3
|
-
"version": "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",
|
package/skill/run-bg/SKILL.md
CHANGED
|
@@ -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
|
-
**
|
|
71
|
-
|
|
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
|