@stablekernel/pi-background-run 0.3.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.
@@ -19,10 +19,12 @@ import {
19
19
  readFileSync,
20
20
  writeFileSync,
21
21
  existsSync,
22
+ mkdirSync,
23
+ appendFileSync,
22
24
  readdirSync,
23
25
  } from "node:fs";
24
26
  import { join } from "node:path";
25
- import { tmpdir } from "node:os";
27
+ import { homedir, tmpdir } from "node:os";
26
28
  import { pathToFileURL } from "node:url";
27
29
 
28
30
  interface CapturedWake {
@@ -30,11 +32,21 @@ interface CapturedWake {
30
32
  options?: Record<string, unknown>;
31
33
  }
32
34
 
33
- function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
35
+ function makeFakePi(
36
+ opts: {
37
+ idle?: boolean;
38
+ priorEntries?: any[];
39
+ ctxFields?: Record<string, unknown>;
40
+ } = {},
41
+ ): {
34
42
  pi: any;
35
43
  wakes: CapturedWake[];
36
44
  entries: any[];
37
45
  tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
46
+ commands: Map<
47
+ string,
48
+ { description?: string; handler: (...args: any[]) => Promise<void> }
49
+ >;
38
50
  ctx: any;
39
51
  handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
40
52
  fireSessionStart: () => Promise<void>;
@@ -45,6 +57,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
45
57
  string,
46
58
  { execute: (...args: any[]) => Promise<any> }
47
59
  >();
60
+ const commands = new Map<
61
+ string,
62
+ { description?: string; handler: (...args: any[]) => Promise<void> }
63
+ >();
48
64
  const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
49
65
  const idle = opts.idle ?? true;
50
66
  const ctx = {
@@ -52,6 +68,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
52
68
  hasUI: false,
53
69
  ui: { notify() {}, setWidget() {}, setStatus() {} },
54
70
  sessionManager: { getEntries: () => entries },
71
+ ...(opts.ctxFields as Record<string, unknown> | undefined),
55
72
  };
56
73
  const pi = {
57
74
  sendUserMessage(text: string, options?: Record<string, unknown>) {
@@ -64,6 +81,9 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
64
81
  registerTool(def: any) {
65
82
  tools.set(def.name, def);
66
83
  },
84
+ registerCommand(name: string, def: any) {
85
+ commands.set(name, def);
86
+ },
67
87
  on(event: string, handler: (...args: any[]) => Promise<any>) {
68
88
  const list = handlers.get(event) ?? [];
69
89
  list.push(handler);
@@ -75,7 +95,16 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
75
95
  await h({ reason: "startup" }, ctx);
76
96
  }
77
97
  };
78
- return { pi, wakes, entries, tools, ctx, handlers, fireSessionStart };
98
+ return {
99
+ pi,
100
+ wakes,
101
+ entries,
102
+ tools,
103
+ commands,
104
+ ctx,
105
+ handlers,
106
+ fireSessionStart,
107
+ };
79
108
  }
80
109
 
81
110
  async function loadExtension(
@@ -268,11 +297,19 @@ test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lin
268
297
  const text = tail.content[0].text as string;
269
298
  assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
270
299
  assert.ok(text.includes("OK green"), "text after stripping survives");
271
- assert.match(text, /wait \[x5\]/, "5 identical lines collapsed to one with count");
300
+ assert.match(
301
+ text,
302
+ /wait {2}\[x5\]/,
303
+ "5 identical lines collapsed to one with count",
304
+ );
272
305
  assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
273
306
  assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
274
307
  assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
275
- assert.match(text, /1 repeated-line run collapsed/, "notes mention run collapse");
308
+ assert.match(
309
+ text,
310
+ /1 repeated-line run collapsed/,
311
+ "notes mention run collapse",
312
+ );
276
313
  assert.ok((tail.details as any).condensed === true);
277
314
  } finally {
278
315
  delete process.env.PI_BGRUN_DIR;
@@ -309,7 +346,10 @@ test("bgtail: raw=true skips condensing", async () => {
309
346
  );
310
347
  const text = tail.content[0].text as string;
311
348
  assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
312
- assert.ok(text.includes("wait\nwait\nwait"), "raw keeps repeated lines uncollapsed");
349
+ assert.ok(
350
+ text.includes("wait\nwait\nwait"),
351
+ "raw keeps repeated lines uncollapsed",
352
+ );
313
353
  assert.ok((tail.details as any).condensed === false);
314
354
  } finally {
315
355
  delete process.env.PI_BGRUN_DIR;
@@ -667,7 +707,7 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
667
707
  );
668
708
  const text = res.content[0].text as string;
669
709
  // No 'name:' line in the response.
670
- assert.ok(!/^ name:/m.test(text), "no name line when name omitted");
710
+ assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
671
711
  const id = (text.match(/^started: ([^\n]+)/) || [])[1];
672
712
  assert.ok(
673
713
  id.startsWith("echo-unnamed-job-"),
@@ -702,7 +742,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
702
742
  ctx,
703
743
  );
704
744
  assert.ok(
705
- !/^ name:/m.test(res1.content[0].text as string),
745
+ !/^ {2}name:/m.test(res1.content[0].text as string),
706
746
  "blank name ignored",
707
747
  );
708
748
 
@@ -716,7 +756,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
716
756
  ctx,
717
757
  );
718
758
  const text2 = res2.content[0].text as string;
719
- const nameLine = (text2.match(/^ name: (.+)$/m) || [])[1];
759
+ const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
720
760
  assert.equal(nameLine.length, 80, "name truncated to 80 chars");
721
761
 
722
762
  await waitForWakes(wakes, 2);
@@ -1068,14 +1108,15 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
1068
1108
  }
1069
1109
  });
1070
1110
 
1071
- test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
1111
+ test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
1072
1112
  // Regression: exit marker must win over pid liveness. Old code checked
1073
1113
  // pid first, so any log whose id-pid happened to be a live process (e.g.
1074
1114
  // pi's own pid from the old id bug, or pid reuse) was kept forever.
1075
1115
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1076
1116
  process.env.PI_BGRUN_DIR = dir;
1077
1117
  try {
1078
- // Old finished log whose id-pid is THIS process (alive!) — must still be removed.
1118
+ // Old finished foreign log whose id-pid is THIS process (alive!) — must
1119
+ // still be removed by an explicit global sweep.
1079
1120
  const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
1080
1121
  writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
1081
1122
  const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
@@ -1086,14 +1127,31 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
1086
1127
  await loadExtension(pi);
1087
1128
  const bgclean = tools.get("bgclean")!;
1088
1129
 
1130
+ // Default scope: this session only — the foreign log is untouched.
1131
+ const scoped = await bgclean.execute(
1132
+ "call-stale-scoped",
1133
+ { days: 7 },
1134
+ undefined,
1135
+ undefined,
1136
+ ctx,
1137
+ );
1138
+ assert.match(scoped.content[0].text as string, /removed 0/);
1139
+ assert.ok(
1140
+ existsSync(oldPath),
1141
+ "foreign log untouched by session-scoped bgclean",
1142
+ );
1143
+
1089
1144
  const result = await bgclean.execute(
1090
1145
  "call-stale",
1091
- { days: 7 },
1146
+ { days: 7, all: true },
1092
1147
  undefined,
1093
1148
  undefined,
1094
1149
  ctx,
1095
1150
  );
1096
- assert.match(result.content[0].text as string, /removed 1/);
1151
+ assert.match(
1152
+ result.content[0].text as string,
1153
+ /removed 1 job log\(s\) \(all sessions\)/,
1154
+ );
1097
1155
  assert.ok(
1098
1156
  !existsSync(oldPath),
1099
1157
  "finished job's log removed despite live id-pid",
@@ -1134,10 +1192,267 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
1134
1192
  }
1135
1193
  });
1136
1194
 
1137
- test("auto-clean: throttled via .last-clean marker; manual bgclean always runs", async () => {
1195
+ test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
1196
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1197
+ process.env.PI_BGRUN_DIR = dir;
1198
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1199
+ try {
1200
+ // A job from 5 days ago whose transcript entry never got a done entry
1201
+ // (pi wasn't running when it exited), whose log is long gone and whose
1202
+ // pid is definitely dead.
1203
+ const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
1204
+ const logPath = join(dir, `${zombieId}.log`); // never created
1205
+ const priorEntries = [
1206
+ {
1207
+ type: "custom",
1208
+ customType: "bgrun-job",
1209
+ data: {
1210
+ id: zombieId,
1211
+ pid: 99999999,
1212
+ cmd: "cd /old/project && make test",
1213
+ name: undefined,
1214
+ started: Date.now() - 5 * 24 * 60 * 60 * 1000,
1215
+ logPath,
1216
+ state: "running",
1217
+ },
1218
+ },
1219
+ ];
1220
+ const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
1221
+ priorEntries,
1222
+ });
1223
+ ctx.hasUI = true;
1224
+ const widgetCalls: (string[] | undefined)[] = [];
1225
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1226
+ widgetCalls.push(lines);
1227
+
1228
+ await loadExtension(pi);
1229
+ await fireSessionStart();
1230
+
1231
+ // Revalidation runs before the widget ever renders — the zombie is
1232
+ // cleared immediately instead of showing as "running" forever.
1233
+ assert.ok(
1234
+ !widgetCalls.some((l) => Array.isArray(l)),
1235
+ "reconstructed zombie never shown in the widget",
1236
+ );
1237
+
1238
+ // A done entry is appended so future resumes reconstruct it as done.
1239
+ const doneEntry = entries.find(
1240
+ (e) =>
1241
+ e.customType === "bgrun-job" &&
1242
+ e.data?.id === zombieId &&
1243
+ e.data?.state === "done",
1244
+ );
1245
+ assert.ok(doneEntry, "done entry appended for the recovered job");
1246
+
1247
+ // Single-id lookup reports done, not running.
1248
+ const bgstatus = tools.get("bgstatus")!;
1249
+ const res = await bgstatus.execute(
1250
+ "call-z1",
1251
+ { id: zombieId },
1252
+ undefined,
1253
+ undefined,
1254
+ ctx,
1255
+ );
1256
+ assert.match(res.content[0].text as string, /: done/);
1257
+ } finally {
1258
+ delete process.env.PI_BGRUN_DIR;
1259
+ rmSync(dir, { recursive: true, force: true });
1260
+ }
1261
+ });
1262
+
1263
+ test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
1264
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1265
+ process.env.PI_BGRUN_DIR = dir;
1266
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1267
+ try {
1268
+ // Jobs killed by a signal persist state:"done" with exitCode: undefined —
1269
+ // reconstruction must honor the state field, not just the exit code.
1270
+ const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
1271
+ const logPath = join(dir, `${killedId}.log`);
1272
+ writeFileSync(logPath, "partial output\n"); // no marker — killed before it
1273
+ const priorEntries = [
1274
+ {
1275
+ type: "custom",
1276
+ customType: "bgrun-job",
1277
+ data: {
1278
+ id: killedId,
1279
+ pid: process.pid, // alive — liveness alone must not resurrect it as running
1280
+ cmd: "npm run watch",
1281
+ name: "nightly-watch",
1282
+ started: Date.now() - 60_000,
1283
+ logPath,
1284
+ state: "done",
1285
+ exitCode: undefined,
1286
+ exitedAt: Date.now() - 30_000,
1287
+ },
1288
+ },
1289
+ ];
1290
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1291
+ ctx.hasUI = true;
1292
+ const widgetCalls: (string[] | undefined)[] = [];
1293
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1294
+ widgetCalls.push(lines);
1295
+
1296
+ await loadExtension(pi);
1297
+ await fireSessionStart();
1298
+
1299
+ assert.ok(
1300
+ !widgetCalls.some((l) => Array.isArray(l)),
1301
+ "signal-killed job with a done entry is not resurrected as running",
1302
+ );
1303
+ const bgstatus = tools.get("bgstatus")!;
1304
+ const res = await bgstatus.execute(
1305
+ "call-z2",
1306
+ { id: killedId },
1307
+ undefined,
1308
+ undefined,
1309
+ ctx,
1310
+ );
1311
+ assert.match(res.content[0].text as string, /: done/);
1312
+ } finally {
1313
+ delete process.env.PI_BGRUN_DIR;
1314
+ rmSync(dir, { recursive: true, force: true });
1315
+ }
1316
+ });
1317
+
1318
+ test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
1319
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1320
+ process.env.PI_BGRUN_DIR = dir;
1321
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1322
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1323
+ try {
1324
+ const fs = await import("node:fs");
1325
+ const backdate = (path: string) => {
1326
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1327
+ fs.utimesSync(path, oldTime, oldTime);
1328
+ };
1329
+
1330
+ // This session's old done job (from the transcript) with a backdated log.
1331
+ const mineId = `my-old-job-${Date.now()}-99999999`;
1332
+ const myLog = join(dir, `${mineId}.log`);
1333
+ fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1334
+ backdate(myLog);
1335
+
1336
+ // A foreign session's week-old FINISHED log — an orphan; swept by default.
1337
+ const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1338
+ fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1339
+ backdate(orphanLog);
1340
+
1341
+ // A foreign session's RECENT finished log — within retention, kept.
1342
+ const recentForeignLog = join(
1343
+ dir,
1344
+ `foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
1345
+ );
1346
+ fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
1347
+
1348
+ // A foreign session's week-old RUNNING log (no marker, live pid) — running
1349
+ // jobs are pid-protected even when old.
1350
+ const runningForeignLog = join(
1351
+ dir,
1352
+ `foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
1353
+ );
1354
+ fs.writeFileSync(runningForeignLog, "still going\n");
1355
+ backdate(runningForeignLog);
1356
+
1357
+ const priorEntries = [
1358
+ {
1359
+ type: "custom",
1360
+ customType: "bgrun-job",
1361
+ data: {
1362
+ id: mineId,
1363
+ pid: 99999999,
1364
+ cmd: "echo mine",
1365
+ name: undefined,
1366
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1367
+ logPath: myLog,
1368
+ state: "done",
1369
+ exitCode: 0,
1370
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1371
+ },
1372
+ },
1373
+ ];
1374
+ const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1375
+ await loadExtension(pi);
1376
+ await fireSessionStart();
1377
+
1378
+ assert.ok(!fs.existsSync(myLog), "this session's old log swept");
1379
+ assert.ok(
1380
+ !fs.existsSync(orphanLog),
1381
+ "week-old finished foreign orphan swept by default",
1382
+ );
1383
+ assert.ok(
1384
+ fs.existsSync(recentForeignLog),
1385
+ "recent foreign log kept (within retention)",
1386
+ );
1387
+ assert.ok(
1388
+ fs.existsSync(runningForeignLog),
1389
+ "old but RUNNING foreign log kept (pid-protected)",
1390
+ );
1391
+ } finally {
1392
+ delete process.env.PI_BGRUN_DIR;
1393
+ rmSync(dir, { recursive: true, force: true });
1394
+ }
1395
+ });
1396
+
1397
+ test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
1398
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1399
+ process.env.PI_BGRUN_DIR = dir;
1400
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1401
+ process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1402
+ try {
1403
+ const fs = await import("node:fs");
1404
+ const backdate = (path: string) => {
1405
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1406
+ fs.utimesSync(path, oldTime, oldTime);
1407
+ };
1408
+
1409
+ const mineId = `my-old-job-${Date.now()}-99999999`;
1410
+ const myLog = join(dir, `${mineId}.log`);
1411
+ fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1412
+ backdate(myLog);
1413
+
1414
+ const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1415
+ fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1416
+ backdate(orphanLog);
1417
+
1418
+ const priorEntries = [
1419
+ {
1420
+ type: "custom",
1421
+ customType: "bgrun-job",
1422
+ data: {
1423
+ id: mineId,
1424
+ pid: 99999999,
1425
+ cmd: "echo mine",
1426
+ name: undefined,
1427
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1428
+ logPath: myLog,
1429
+ state: "done",
1430
+ exitCode: 0,
1431
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1432
+ },
1433
+ },
1434
+ ];
1435
+ const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1436
+ await loadExtension(pi);
1437
+ await fireSessionStart();
1438
+
1439
+ assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
1440
+ assert.ok(
1441
+ fs.existsSync(orphanLog),
1442
+ "foreign orphan untouched when globalAutoClean is off",
1443
+ );
1444
+ } finally {
1445
+ delete process.env.PI_BGRUN_DIR;
1446
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1447
+ rmSync(dir, { recursive: true, force: true });
1448
+ }
1449
+ });
1450
+
1451
+ test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
1138
1452
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1139
1453
  process.env.PI_BGRUN_DIR = dir;
1140
1454
  delete process.env.PI_BGRUN_FOREIGN_JOBS;
1455
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
1141
1456
  try {
1142
1457
  const fs = await import("node:fs");
1143
1458
  const backdate = (path: string) => {
@@ -1145,92 +1460,150 @@ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs",
1145
1460
  fs.utimesSync(path, oldTime, oldTime);
1146
1461
  };
1147
1462
 
1148
- // Old log A + first session_start (no marker yet) → sweep runs, A removed.
1463
+ // Old foreign log A + first session_start (no marker yet) → global sweep
1464
+ // runs, A removed.
1149
1465
  const logA = join(dir, "old-a-1000000000-99999.log");
1150
1466
  fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
1151
1467
  backdate(logA);
1152
1468
  {
1153
- const { pi, ctx, fireSessionStart } = makeFakePi();
1469
+ const { pi, fireSessionStart } = makeFakePi();
1154
1470
  await loadExtension(pi);
1155
1471
  await fireSessionStart();
1156
1472
  }
1157
- assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
1473
+ assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
1158
1474
  assert.ok(
1159
1475
  fs.existsSync(join(dir, ".last-clean")),
1160
1476
  "throttle marker written",
1161
1477
  );
1162
1478
 
1163
- // Old log B + second session_start while marker is fresh → throttled, B kept.
1479
+ // Old foreign log B + second session_start while marker is fresh →
1480
+ // throttled, B kept.
1164
1481
  const logB = join(dir, "old-b-1000000000-99998.log");
1165
1482
  fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
1166
1483
  backdate(logB);
1167
1484
  {
1168
- const { pi, ctx, fireSessionStart } = makeFakePi();
1485
+ const { pi, fireSessionStart } = makeFakePi();
1169
1486
  await loadExtension(pi);
1170
1487
  await fireSessionStart();
1171
1488
  }
1172
- assert.ok(fs.existsSync(logB), "second sweep throttled — old log B kept");
1489
+ assert.ok(
1490
+ fs.existsSync(logB),
1491
+ "second global sweep throttled — old log B kept",
1492
+ );
1173
1493
 
1174
- // Manual bgclean ignores the throttle and removes B.
1494
+ // Manual `bgclean all` ignores the throttle and removes B.
1175
1495
  const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
1176
1496
  await loadExtension(pi3);
1177
1497
  const bgclean = tools3.get("bgclean")!;
1178
1498
  const result = await bgclean.execute(
1179
1499
  "call-t1",
1180
- {},
1500
+ { all: true },
1181
1501
  undefined,
1182
1502
  undefined,
1183
1503
  ctx3,
1184
1504
  );
1185
- assert.match(result.content[0].text as string, /removed 1/);
1186
- assert.ok(!fs.existsSync(logB), "manual bgclean removed log B");
1505
+ assert.match(
1506
+ result.content[0].text as string,
1507
+ /removed 1 job log\(s\) \(all sessions\)/,
1508
+ );
1509
+ assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
1187
1510
  } finally {
1188
1511
  delete process.env.PI_BGRUN_DIR;
1512
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1189
1513
  rmSync(dir, { recursive: true, force: true });
1190
1514
  }
1191
1515
  });
1192
1516
 
1193
- test("bgclean: removes old logs, keeps recent ones", async () => {
1517
+ test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
1194
1518
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1195
1519
  process.env.PI_BGRUN_DIR = dir;
1520
+ // Isolate bgclean's scoping from the global orphan auto-sweep (default on)
1521
+ // so the foreign log survives session_start for bgclean to (not) act on.
1522
+ process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1196
1523
  try {
1197
- const { pi, tools, ctx } = makeFakePi();
1524
+ const fs = await import("node:fs");
1525
+ const backdate = (path: string) => {
1526
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1527
+ fs.utimesSync(path, oldTime, oldTime);
1528
+ };
1529
+
1530
+ const mkEntry = (
1531
+ id: string,
1532
+ logPath: string,
1533
+ extra: Record<string, unknown> = {},
1534
+ ) => ({
1535
+ type: "custom",
1536
+ customType: "bgrun-job",
1537
+ data: {
1538
+ id,
1539
+ pid: 99999999,
1540
+ cmd: `echo ${id}`,
1541
+ name: undefined,
1542
+ started: Date.now() - 60_000,
1543
+ logPath,
1544
+ state: "done",
1545
+ exitCode: 0,
1546
+ exitedAt: Date.now() - 30_000,
1547
+ ...extra,
1548
+ },
1549
+ });
1550
+
1551
+ // This session's recent done job (fresh log — kept).
1552
+ const recentId = `recent-job-${Date.now()}-99999998`;
1553
+ const recentLog = join(dir, `${recentId}.log`);
1554
+ fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
1555
+
1556
+ // This session's old done job (backdated log — removed by default scope).
1557
+ const oldId = `old-session-job-${Date.now()}-99999997`;
1558
+ const oldLog = join(dir, `${oldId}.log`);
1559
+ fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
1560
+ backdate(oldLog);
1561
+
1562
+ // A foreign session's old log — untouched by default, removed with all.
1563
+ const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
1564
+ fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
1565
+ backdate(foreignLog);
1566
+
1567
+ const priorEntries = [
1568
+ mkEntry(recentId, recentLog),
1569
+ mkEntry(oldId, oldLog, {
1570
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1571
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1572
+ }),
1573
+ ];
1574
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1198
1575
  await loadExtension(pi);
1199
- const bgrun = tools.get("bgrun")!;
1576
+ await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
1577
+
1200
1578
  const bgclean = tools.get("bgclean")!;
1201
1579
 
1202
- // Run a real job (recent log — should be kept).
1203
- await bgrun.execute(
1204
- "call-c1",
1205
- { command: "echo recent" },
1580
+ // Default: this session only.
1581
+ const scoped = await bgclean.execute(
1582
+ "call-c2",
1583
+ { days: 7 },
1206
1584
  undefined,
1207
1585
  undefined,
1208
1586
  ctx,
1209
1587
  );
1210
- await new Promise((r) => setTimeout(r, 200)); // let it finish
1211
-
1212
- // Write an old log file (backdated mtime).
1213
- const oldPath = join(dir, "old-job-1000000000-99999.log");
1214
- const fs = await import("node:fs");
1215
- fs.writeFileSync(oldPath, "old output\n__BGRUN_EXIT__=0\n");
1216
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
1217
- fs.utimesSync(oldPath, oldTime, oldTime);
1588
+ assert.match(scoped.content[0].text as string, /\(this session\)/);
1589
+ assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
1590
+ assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
1591
+ assert.ok(
1592
+ fs.existsSync(foreignLog),
1593
+ "foreign log untouched by session-scoped bgclean",
1594
+ );
1218
1595
 
1219
- const result = await bgclean.execute(
1220
- "call-c2",
1221
- { days: 7 },
1596
+ // all: true sweeps the shared dir.
1597
+ const global = await bgclean.execute(
1598
+ "call-c3",
1599
+ { days: 7, all: true },
1222
1600
  undefined,
1223
1601
  undefined,
1224
1602
  ctx,
1225
1603
  );
1226
- const text = result.content[0].text as string;
1227
- assert.match(text, /removed 1/);
1228
- assert.ok(!fs.existsSync(oldPath), "old log removed");
1229
- // The recent log should still exist.
1230
- const remaining = fs
1231
- .readdirSync(dir)
1232
- .filter((f: string) => f.endsWith(".log"));
1233
- assert.equal(remaining.length, 1, "recent log kept");
1604
+ assert.match(global.content[0].text as string, /\(all sessions\)/);
1605
+ assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
1606
+ assert.ok(fs.existsSync(recentLog), "recent log still kept");
1234
1607
  } finally {
1235
1608
  delete process.env.PI_BGRUN_DIR;
1236
1609
  rmSync(dir, { recursive: true, force: true });
@@ -1292,6 +1665,903 @@ test("bgclean: does not remove a running job's log", async () => {
1292
1665
  } catch {
1293
1666
  // already gone — fine
1294
1667
  }
1668
+ } finally {
1669
+ delete process.env.PI_BGRUN_DIR;
1670
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1671
+ rmSync(dir, { recursive: true, force: true });
1672
+ }
1673
+ });
1674
+
1675
+ test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
1676
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1677
+ process.env.PI_BGRUN_DIR = dir;
1678
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1679
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1680
+ try {
1681
+ const { pi, wakes, tools, commands, ctx } = makeFakePi();
1682
+ ctx.hasUI = true;
1683
+ const notes: { text: string; kind: string }[] = [];
1684
+ ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
1685
+
1686
+ await loadExtension(pi);
1687
+
1688
+ // All three human-facing commands are registered (/bgrun is agent-only).
1689
+ assert.ok(commands.has("bgstatus"), "/bgstatus registered");
1690
+ assert.ok(commands.has("bgtail"), "/bgtail registered");
1691
+ assert.ok(commands.has("bgclean"), "/bgclean registered");
1692
+ assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
1693
+
1694
+ // Run a real job to completion so there's something to inspect.
1695
+ const bgrun = tools.get("bgrun")!;
1696
+ const res = await bgrun.execute(
1697
+ "call-cmd1",
1698
+ { command: "echo cmd-mirror", name: "mirror-job" },
1699
+ undefined,
1700
+ undefined,
1701
+ ctx,
1702
+ );
1703
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
1704
+ await waitForWakes(wakes, 1);
1705
+
1706
+ // /bgstatus <id> → single-job status via notify.
1707
+ await commands.get("bgstatus")!.handler(id, ctx);
1708
+ assert.ok(
1709
+ notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
1710
+ "/bgstatus <id> notifies job status",
1711
+ );
1712
+
1713
+ // /bgstatus done → listing includes the finished job.
1714
+ await commands.get("bgstatus")!.handler("done", ctx);
1715
+ assert.ok(
1716
+ notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
1717
+ "/bgstatus done lists finished jobs",
1718
+ );
1719
+
1720
+ // /bgtail <id> <lines> → condensed tail via notify.
1721
+ await commands.get("bgtail")!.handler(`${id} 5`, ctx);
1722
+ assert.ok(
1723
+ notes.some((n) => n.text.includes("cmd-mirror")),
1724
+ "/bgtail notifies the log tail",
1725
+ );
1726
+
1727
+ // /bgtail with no args → usage error.
1728
+ await commands.get("bgtail")!.handler("", ctx);
1729
+ assert.ok(
1730
+ notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
1731
+ "/bgtail without id shows usage",
1732
+ );
1733
+
1734
+ // /bgclean (no args) → session-scoped summary via notify.
1735
+ await commands.get("bgclean")!.handler("", ctx);
1736
+ assert.ok(
1737
+ notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
1738
+ "/bgclean notifies the session-scoped summary",
1739
+ );
1740
+
1741
+ // /bgclean 7 all → global scope.
1742
+ await commands.get("bgclean")!.handler("7 all", ctx);
1743
+ assert.ok(
1744
+ notes.some((n) => /\(all sessions\)/.test(n.text)),
1745
+ "/bgclean all notifies the global summary",
1746
+ );
1747
+ } finally {
1748
+ delete process.env.PI_BGRUN_DIR;
1749
+ rmSync(dir, { recursive: true, force: true });
1750
+ }
1751
+ });
1752
+
1753
+ test("formatSince: same-day shows time only; older days include the date", async () => {
1754
+ const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
1755
+ const mod: any = await import(url);
1756
+ assert.equal(typeof mod.formatSince, "function");
1757
+
1758
+ const now = new Date("2026-09-09T10:00:00").getTime();
1759
+ const sameDay = new Date("2026-09-09T06:30:12").getTime();
1760
+ const prevDay = new Date("2026-09-04T15:05:40").getTime();
1761
+ const prevMonth = new Date("2026-08-12T23:59:59").getTime();
1762
+ const prevYear = new Date("2025-12-30T08:00:00").getTime();
1763
+
1764
+ // Same calendar day → time only (unchanged display).
1765
+ assert.equal(mod.formatSince(sameDay, now), "06:30:12");
1766
+
1767
+ // Different day, same year → date + time.
1768
+ const prevDayStr = mod.formatSince(prevDay, now);
1769
+ assert.match(prevDayStr, /Sep 4/);
1770
+ assert.match(prevDayStr, /15:05:40/);
1771
+
1772
+ const prevMonthStr = mod.formatSince(prevMonth, now);
1773
+ assert.match(prevMonthStr, /Aug 12/);
1774
+ assert.match(prevMonthStr, /23:59:59/);
1775
+
1776
+ // Different year → date includes the year.
1777
+ const prevYearStr = mod.formatSince(prevYear, now);
1778
+ assert.match(prevYearStr, /2025/);
1779
+ assert.match(prevYearStr, /Dec 30/);
1780
+ assert.match(prevYearStr, /08:00:00/);
1781
+ });
1782
+
1783
+ // ── Project-local jobs dir ──────────────────────────────────────────────────
1784
+
1785
+ test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => {
1786
+ const mod = await import(
1787
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1788
+ );
1789
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1790
+ const scratch = mkdtempSync(join(tmpdir(), "pi-bgrun-scratch-"));
1791
+ try {
1792
+ mkdirSync(join(proj, ".git"), { recursive: true });
1793
+
1794
+ // absolute → used as-is, never flagged project-local (older configs keep
1795
+ // working unchanged — the migration guarantee)
1796
+ const absPath = join(proj, "abs-jobs");
1797
+ const abs = mod.resolveJobsDirPath(absPath, { cwd: proj });
1798
+ assert.equal(abs.dir, absPath);
1799
+ assert.equal(abs.projectLocal, false);
1800
+
1801
+ // relative + project root → resolved against the root, flagged project-local
1802
+ const rel = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: proj });
1803
+ assert.equal(rel.dir, join(proj, ".pi-bgrun", "jobs"));
1804
+ assert.equal(rel.projectLocal, true);
1805
+
1806
+ // unset → global default
1807
+ const none = mod.resolveJobsDirPath(undefined, { cwd: proj });
1808
+ assert.equal(none.dir, join(homedir(), ".pi-bgrun", "jobs"));
1809
+ assert.equal(none.projectLocal, false);
1810
+
1811
+ // relative + cwd that is not a project → global fallback, never cwd-relative
1812
+ const fb = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: scratch });
1813
+ assert.equal(fb.dir, join(homedir(), ".pi-bgrun", "jobs"));
1814
+ assert.equal(fb.projectLocal, false);
1815
+ } finally {
1816
+ rmSync(proj, { recursive: true, force: true });
1817
+ rmSync(scratch, { recursive: true, force: true });
1818
+ }
1819
+ });
1820
+
1821
+ test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => {
1822
+ const mod = await import(
1823
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1824
+ );
1825
+ const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1826
+ try {
1827
+ mkdirSync(join(repo, ".git", "info"), { recursive: true });
1828
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1829
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1830
+ // a second, different jobs dir under the same repo adds its own pattern
1831
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "other"));
1832
+ const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1833
+ assert.match(exclude, /# pi-bgrun job logs/);
1834
+ assert.equal(
1835
+ exclude.split("\n").filter((l) => l.trim() === ".pi-bgrun/jobs/").length,
1836
+ 1,
1837
+ "pattern appears exactly once",
1838
+ );
1839
+ assert.ok(exclude.split("\n").includes(".pi-bgrun/other/"));
1840
+ } finally {
1841
+ rmSync(repo, { recursive: true, force: true });
1842
+ }
1843
+ });
1844
+
1845
+ test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => {
1846
+ const mod = await import(
1847
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1848
+ );
1849
+ const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1850
+ const gd = mkdtempSync(join(tmpdir(), "pi-bgrun-gitdir-"));
1851
+ try {
1852
+ writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1853
+ mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs"));
1854
+ const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1855
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1856
+ // nothing was created inside the worktree's own .git (it's a file)
1857
+ assert.ok(!existsSync(join(wt, ".git", "info")));
1858
+ } finally {
1859
+ rmSync(wt, { recursive: true, force: true });
1860
+ rmSync(gd, { recursive: true, force: true });
1861
+ }
1862
+ });
1863
+
1864
+ test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => {
1865
+ const mod = await import(
1866
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1867
+ );
1868
+ const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1869
+ const gd = join(tmpdir(), "pi-bgrun git dir with spaces");
1870
+ mkdirSync(gd, { recursive: true });
1871
+ try {
1872
+ writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1873
+ assert.equal(mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), true);
1874
+ const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1875
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1876
+ } finally {
1877
+ rmSync(wt, { recursive: true, force: true });
1878
+ rmSync(gd, { recursive: true, force: true });
1879
+ }
1880
+ });
1881
+
1882
+ test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => {
1883
+ const mod = await import(
1884
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1885
+ );
1886
+ const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1887
+ try {
1888
+ mkdirSync(join(repo, ".git", "info"), { recursive: true });
1889
+ // Block the exclude path with a directory → the append fails (EISDIR)
1890
+ mkdirSync(join(repo, ".git", "info", "exclude"));
1891
+ const jobsDir = join(repo, ".pi-bgrun", "jobs");
1892
+ assert.equal(mod.ensureGitExcluded(jobsDir), false);
1893
+
1894
+ // Unblock: the next call must retry (failure was not memoized) and succeed
1895
+ rmSync(join(repo, ".git", "info", "exclude"), { recursive: true });
1896
+ assert.equal(mod.ensureGitExcluded(jobsDir), true);
1897
+ const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1898
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1899
+ } finally {
1900
+ rmSync(repo, { recursive: true, force: true });
1901
+ }
1902
+ });
1903
+
1904
+ test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => {
1905
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1906
+ delete process.env.PI_BGRUN_DIR;
1907
+ try {
1908
+ mkdirSync(join(proj, ".git"), { recursive: true });
1909
+ mkdirSync(join(proj, ".pi"), { recursive: true });
1910
+ writeFileSync(
1911
+ join(proj, ".pi", "pi-bgrun.json"),
1912
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1913
+ );
1914
+ const { pi, wakes, tools, ctx } = makeFakePi({
1915
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
1916
+ });
1917
+ await loadExtension(pi);
1918
+ const bgrun = tools.get("bgrun")!;
1919
+
1920
+ const res = await bgrun.execute(
1921
+ "call-1",
1922
+ { command: "echo project-local" },
1923
+ undefined,
1924
+ undefined,
1925
+ ctx,
1926
+ );
1927
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1928
+ [])[1];
1929
+ assert.ok(id, "got a job id");
1930
+
1931
+ await waitForWakes(wakes, 1);
1932
+
1933
+ const logPath = join(proj, ".pi-bgrun", "jobs", `${id}.log`);
1934
+ assert.ok(existsSync(logPath), "log written inside the project");
1935
+ assert.match(readFileSync(logPath, "utf8"), /project-local/);
1936
+
1937
+ const exclude = join(proj, ".git", "info", "exclude");
1938
+ assert.ok(existsSync(exclude), "exclude file created");
1939
+ assert.match(readFileSync(exclude, "utf8"), /^\.pi-bgrun\/jobs\/$/m);
1940
+ } finally {
1941
+ delete process.env.PI_BGRUN_DIR;
1942
+ rmSync(proj, { recursive: true, force: true });
1943
+ }
1944
+ });
1945
+
1946
+ test("bgtail: prefers the session record's logPath when the jobsDir config changes", async () => {
1947
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1948
+ delete process.env.PI_BGRUN_DIR;
1949
+ try {
1950
+ mkdirSync(join(proj, ".git"), { recursive: true });
1951
+ mkdirSync(join(proj, ".pi"), { recursive: true });
1952
+ writeFileSync(
1953
+ join(proj, ".pi", "pi-bgrun.json"),
1954
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1955
+ );
1956
+ const { pi, wakes, tools, ctx } = makeFakePi({
1957
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
1958
+ });
1959
+ await loadExtension(pi);
1960
+ const bgrun = tools.get("bgrun")!;
1961
+ const bgtail = tools.get("bgtail")!;
1962
+
1963
+ const res = await bgrun.execute(
1964
+ "call-1",
1965
+ { command: "echo migrated-log" },
1966
+ undefined,
1967
+ undefined,
1968
+ ctx,
1969
+ );
1970
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1971
+ [])[1];
1972
+ assert.ok(id, "got a job id");
1973
+ await waitForWakes(wakes, 1);
1974
+
1975
+ // A ctx with no project config/trust now resolves the jobs dir to the
1976
+ // GLOBAL default — only the session record's logPath can still find the
1977
+ // log (the mid-upgrade config-change scenario).
1978
+ const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
1979
+ const tail = await bgtail.execute(
1980
+ "call-2",
1981
+ { id, lines: 10 },
1982
+ undefined,
1983
+ undefined,
1984
+ plainCtx,
1985
+ );
1986
+ assert.equal(tail.details.notFound, false);
1987
+ assert.match(tail.content[0].text as string, /migrated-log/);
1988
+ } finally {
1989
+ delete process.env.PI_BGRUN_DIR;
1990
+ rmSync(proj, { recursive: true, force: true });
1991
+ }
1992
+ });
1993
+
1994
+ // ── bggrep ──────────────────────────────────────────────────────────────────
1995
+
1996
+ test("bggrep: line-numbered matches; explicit pattern wins; default pattern; no-match case", async () => {
1997
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1998
+ process.env.PI_BGRUN_DIR = dir;
1999
+ try {
2000
+ const { pi, wakes, tools, ctx } = makeFakePi();
2001
+ await loadExtension(pi);
2002
+ const bgrun = tools.get("bgrun")!;
2003
+ const bggrep = tools.get("bggrep")!;
2004
+
2005
+ const res = await bgrun.execute(
2006
+ "c1",
2007
+ { command: "printf 'alpha\\nerror: boom BANANA\\nomega\\n'" },
2008
+ undefined,
2009
+ undefined,
2010
+ ctx,
2011
+ );
2012
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2013
+ [])[1];
2014
+ assert.ok(id, "got a job id");
2015
+ await waitForWakes(wakes, 1);
2016
+
2017
+ // explicit pattern → only matching lines, with line numbers
2018
+ const g = await bggrep.execute(
2019
+ "c2",
2020
+ { id, pattern: "BANANA" },
2021
+ undefined,
2022
+ undefined,
2023
+ ctx,
2024
+ );
2025
+ assert.equal(g.details.matches, 1);
2026
+ assert.equal(g.details.notFound, false);
2027
+ assert.match(g.content[0].text as string, /L2: error: boom BANANA/);
2028
+ assert.doesNotMatch(g.content[0].text as string, /alpha|omega/);
2029
+
2030
+ // default pattern (no pattern passed) catches the failure signature
2031
+ const g2 = await bggrep.execute("c3", { id }, undefined, undefined, ctx);
2032
+ assert.equal(g2.details.matches, 1);
2033
+ assert.match(g2.content[0].text as string, /1 match for \//);
2034
+ assert.equal(
2035
+ g2.details.pattern,
2036
+ "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖",
2037
+ );
2038
+
2039
+ // a log with no failure signatures → clean no-match (not an error)
2040
+ const res2 = await bgrun.execute(
2041
+ "c4",
2042
+ { command: "echo all clear, nothing to see" },
2043
+ undefined,
2044
+ undefined,
2045
+ ctx,
2046
+ );
2047
+ const id2 = ((res2.content[0].text as string).match(/^started: ([^\n]+)/) ||
2048
+ [])[1];
2049
+ await waitForWakes(wakes, 2);
2050
+ const g3 = await bggrep.execute(
2051
+ "c5",
2052
+ { id: id2 },
2053
+ undefined,
2054
+ undefined,
2055
+ ctx,
2056
+ );
2057
+ assert.equal(g3.details.matches, 0);
2058
+ assert.equal(g3.isError, undefined);
2059
+ assert.match(g3.content[0].text as string, /— none/);
2060
+ } finally {
2061
+ delete process.env.PI_BGRUN_DIR;
2062
+ rmSync(dir, { recursive: true, force: true });
2063
+ }
2064
+ });
2065
+
2066
+ test("bggrep: context lines with gap markers between distant matches", async () => {
2067
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2068
+ process.env.PI_BGRUN_DIR = dir;
2069
+ try {
2070
+ const { pi, wakes, tools, ctx } = makeFakePi();
2071
+ await loadExtension(pi);
2072
+ const bgrun = tools.get("bgrun")!;
2073
+ const bggrep = tools.get("bggrep")!;
2074
+
2075
+ const res = await bgrun.execute(
2076
+ "c1",
2077
+ {
2078
+ command:
2079
+ "printf 'l1\\nMATCH one\\nl3\\nl4\\nl5\\nl6\\nl7\\nMATCH two\\nl9\\n'",
2080
+ },
2081
+ undefined,
2082
+ undefined,
2083
+ ctx,
2084
+ );
2085
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2086
+ [])[1];
2087
+ assert.ok(id, "got a job id");
2088
+ await waitForWakes(wakes, 1);
2089
+
2090
+ const g = await bggrep.execute(
2091
+ "c2",
2092
+ { id, pattern: "MATCH", context: 1 },
2093
+ undefined,
2094
+ undefined,
2095
+ ctx,
2096
+ );
2097
+ assert.equal(g.details.matches, 2);
2098
+ const text = g.content[0].text as string;
2099
+ assert.match(text, /L2: MATCH one/);
2100
+ assert.match(text, /L1: l1/); // context before
2101
+ assert.match(text, /L8: MATCH two/);
2102
+ assert.match(text, /L9: l9/); // context after
2103
+ assert.match(text, /…\[3 lines skipped\]…/); // l4-l6 between the windows
2104
+ } finally {
2105
+ delete process.env.PI_BGRUN_DIR;
2106
+ rmSync(dir, { recursive: true, force: true });
2107
+ }
2108
+ });
2109
+
2110
+ test("bggrep: invalid pattern errors clearly", async () => {
2111
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2112
+ process.env.PI_BGRUN_DIR = dir;
2113
+ try {
2114
+ const { pi, wakes, tools, ctx } = makeFakePi();
2115
+ await loadExtension(pi);
2116
+ const bgrun = tools.get("bgrun")!;
2117
+ const bggrep = tools.get("bggrep")!;
2118
+ const res = await bgrun.execute(
2119
+ "c1",
2120
+ { command: "echo hi" },
2121
+ undefined,
2122
+ undefined,
2123
+ ctx,
2124
+ );
2125
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2126
+ [])[1];
2127
+ await waitForWakes(wakes, 1);
2128
+ await assert.rejects(
2129
+ bggrep.execute(
2130
+ "c2",
2131
+ { id, pattern: "([unclosed" },
2132
+ undefined,
2133
+ undefined,
2134
+ ctx,
2135
+ ),
2136
+ /bggrep: invalid pattern/,
2137
+ );
2138
+ } finally {
2139
+ delete process.env.PI_BGRUN_DIR;
2140
+ rmSync(dir, { recursive: true, force: true });
2141
+ }
2142
+ });
2143
+
2144
+ test("bggrep: caps at 50 matches with a not-shown note", async () => {
2145
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2146
+ process.env.PI_BGRUN_DIR = dir;
2147
+ try {
2148
+ const { pi, wakes, tools, ctx } = makeFakePi();
2149
+ await loadExtension(pi);
2150
+ const bgrun = tools.get("bgrun")!;
2151
+ const bggrep = tools.get("bggrep")!;
2152
+ const res = await bgrun.execute(
2153
+ "c1",
2154
+ { command: 'for i in $(seq 1 60); do echo "boom $i"; done' },
2155
+ undefined,
2156
+ undefined,
2157
+ ctx,
2158
+ );
2159
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2160
+ [])[1];
2161
+ await waitForWakes(wakes, 1);
2162
+ const g = await bggrep.execute(
2163
+ "c2",
2164
+ { id, pattern: "boom" },
2165
+ undefined,
2166
+ undefined,
2167
+ ctx,
2168
+ );
2169
+ assert.equal(g.details.matches, 60);
2170
+ assert.equal(g.details.capped, true);
2171
+ assert.match(
2172
+ g.content[0].text as string,
2173
+ /showing first 50; 10 more not shown/,
2174
+ );
2175
+ assert.match(g.content[0].text as string, /L50: boom 50/);
2176
+ assert.doesNotMatch(g.content[0].text as string, /L51: boom 51/);
2177
+ } finally {
2178
+ delete process.env.PI_BGRUN_DIR;
2179
+ rmSync(dir, { recursive: true, force: true });
2180
+ }
2181
+ });
2182
+
2183
+ test("bggrep: prefers the session record's logPath when the jobsDir config changes", async () => {
2184
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
2185
+ delete process.env.PI_BGRUN_DIR;
2186
+ try {
2187
+ mkdirSync(join(proj, ".git"), { recursive: true });
2188
+ mkdirSync(join(proj, ".pi"), { recursive: true });
2189
+ writeFileSync(
2190
+ join(proj, ".pi", "pi-bgrun.json"),
2191
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
2192
+ );
2193
+ const { pi, wakes, tools, ctx } = makeFakePi({
2194
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
2195
+ });
2196
+ await loadExtension(pi);
2197
+ const bgrun = tools.get("bgrun")!;
2198
+ const bggrep = tools.get("bggrep")!;
2199
+ const res = await bgrun.execute(
2200
+ "c1",
2201
+ { command: "echo pattern-target-line" },
2202
+ undefined,
2203
+ undefined,
2204
+ ctx,
2205
+ );
2206
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2207
+ [])[1];
2208
+ await waitForWakes(wakes, 1);
2209
+
2210
+ const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
2211
+ const g = await bggrep.execute(
2212
+ "c2",
2213
+ { id, pattern: "pattern-target" },
2214
+ undefined,
2215
+ undefined,
2216
+ plainCtx,
2217
+ );
2218
+ assert.equal(g.details.notFound, false);
2219
+ assert.equal(g.details.matches, 1);
2220
+ } finally {
2221
+ delete process.env.PI_BGRUN_DIR;
2222
+ rmSync(proj, { recursive: true, force: true });
2223
+ }
2224
+ });
2225
+
2226
+ // ── bgtail delta tailing ────────────────────────────────────────────────────
2227
+
2228
+ test("bgtail: delta tailing — first read full tail, then only new lines, then none", async () => {
2229
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2230
+ process.env.PI_BGRUN_DIR = dir;
2231
+ try {
2232
+ const { pi, wakes, tools, ctx } = makeFakePi();
2233
+ await loadExtension(pi);
2234
+ const bgrun = tools.get("bgrun")!;
2235
+ const bgtail = tools.get("bgtail")!;
2236
+ const res = await bgrun.execute(
2237
+ "c1",
2238
+ { command: "echo first line" },
2239
+ undefined,
2240
+ undefined,
2241
+ ctx,
2242
+ );
2243
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2244
+ [])[1];
2245
+ assert.ok(id, "got a job id");
2246
+ await waitForWakes(wakes, 1);
2247
+ const logPath = join(dir, `${id}.log`);
2248
+
2249
+ // First read: full tail, no delta header
2250
+ const t1 = await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2251
+ assert.match(t1.content[0].text as string, /first line/);
2252
+ assert.equal(t1.details.newLines, undefined);
2253
+ assert.doesNotMatch(
2254
+ t1.content[0].text as string,
2255
+ /new lines since last read/,
2256
+ );
2257
+
2258
+ // Log grows: only the new lines come back, with a +N header
2259
+ appendFileSync(logPath, "appended-A\nappended-B\n");
2260
+ const t2 = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2261
+ const text2 = t2.content[0].text as string;
2262
+ assert.match(text2, /\+2 new lines since last read/);
2263
+ assert.match(text2, /appended-A/);
2264
+ assert.match(text2, /appended-B/);
2265
+ assert.doesNotMatch(text2, /first line/);
2266
+ assert.equal(t2.details.newLines, 2);
2267
+
2268
+ // Nothing new: a tiny no-new-lines response (cheap polling)
2269
+ const t3 = await bgtail.execute("c4", { id }, undefined, undefined, ctx);
2270
+ assert.match(t3.content[0].text as string, /no new lines since last read/);
2271
+ assert.equal(t3.details.linesShown, 0);
2272
+ } finally {
2273
+ delete process.env.PI_BGRUN_DIR;
2274
+ rmSync(dir, { recursive: true, force: true });
2275
+ }
2276
+ });
2277
+
2278
+ test("bgtail: raw:true keeps the verbatim window but still advances the bookmark", async () => {
2279
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2280
+ process.env.PI_BGRUN_DIR = dir;
2281
+ try {
2282
+ const { pi, wakes, tools, ctx } = makeFakePi();
2283
+ await loadExtension(pi);
2284
+ const bgrun = tools.get("bgrun")!;
2285
+ const bgtail = tools.get("bgtail")!;
2286
+ const res = await bgrun.execute(
2287
+ "c1",
2288
+ { command: "echo baseline" },
2289
+ undefined,
2290
+ undefined,
2291
+ ctx,
2292
+ );
2293
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2294
+ [])[1];
2295
+ await waitForWakes(wakes, 1);
2296
+ const logPath = join(dir, `${id}.log`);
2297
+
2298
+ appendFileSync(logPath, "post-raw line\n");
2299
+ const r = await bgtail.execute(
2300
+ "c2",
2301
+ { id, lines: 3, raw: true },
2302
+ undefined,
2303
+ undefined,
2304
+ ctx,
2305
+ );
2306
+ assert.match(r.content[0].text as string, /post-raw line/);
2307
+ assert.equal(r.details.condensed, false);
2308
+
2309
+ // The raw read advanced the bookmark → the next condensed read is empty
2310
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2311
+ assert.match(t.content[0].text as string, /no new lines since last read/);
2312
+ } finally {
2313
+ delete process.env.PI_BGRUN_DIR;
2314
+ rmSync(dir, { recursive: true, force: true });
2315
+ }
2316
+ });
2317
+
2318
+ test("bgtail: a shrunken log resets to a full tail with a note", async () => {
2319
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2320
+ process.env.PI_BGRUN_DIR = dir;
2321
+ try {
2322
+ const { pi, wakes, tools, ctx } = makeFakePi();
2323
+ await loadExtension(pi);
2324
+ const bgrun = tools.get("bgrun")!;
2325
+ const bgtail = tools.get("bgtail")!;
2326
+ const res = await bgrun.execute(
2327
+ "c1",
2328
+ { command: "echo long original content line" },
2329
+ undefined,
2330
+ undefined,
2331
+ ctx,
2332
+ );
2333
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2334
+ [])[1];
2335
+ await waitForWakes(wakes, 1);
2336
+ const logPath = join(dir, `${id}.log`);
2337
+
2338
+ // First read sets the bookmark; then the log is replaced by a shorter one
2339
+ await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2340
+ writeFileSync(logPath, "tiny replacement\n");
2341
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2342
+ const text = t.content[0].text as string;
2343
+ assert.match(text, /log shrank since last read — showing full tail/);
2344
+ assert.match(text, /tiny replacement/);
2345
+ } finally {
2346
+ delete process.env.PI_BGRUN_DIR;
2347
+ rmSync(dir, { recursive: true, force: true });
2348
+ }
2349
+ });
2350
+
2351
+ test("bgtail: a replaced log with the same line count resets to a full tail", async () => {
2352
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2353
+ process.env.PI_BGRUN_DIR = dir;
2354
+ try {
2355
+ const { pi, wakes, tools, ctx } = makeFakePi();
2356
+ await loadExtension(pi);
2357
+ const bgrun = tools.get("bgrun")!;
2358
+ const bgtail = tools.get("bgtail")!;
2359
+ const res = await bgrun.execute(
2360
+ "c1",
2361
+ { command: "printf 'aaaa\\nbbbb\\ncccc\\n'" },
2362
+ undefined,
2363
+ undefined,
2364
+ ctx,
2365
+ );
2366
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2367
+ [])[1];
2368
+ await waitForWakes(wakes, 1);
2369
+ const logPath = join(dir, `${id}.log`);
2370
+
2371
+ // First read sets the bookmark (3 content lines, first line "aaaa")
2372
+ await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2373
+ // Replacement: SAME line count, LARGER byte size (so the shrink checks
2374
+ // cannot fire), different first line — only the first-line detector
2375
+ // (append-only logs never mutate line 0) can catch this.
2376
+ writeFileSync(
2377
+ logPath,
2378
+ "xxxxxxxxxxxxxxxxxx\nyyyyyyyyyyyyyyyyyy\nzzzzzzzzzzzzzzzzzz\n",
2379
+ );
2380
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2381
+ const text = t.content[0].text as string;
2382
+ assert.match(text, /log was replaced since last read — showing full tail/);
2383
+ assert.match(text, /xxxxxxxxxxxxxxxxxx/);
2384
+ } finally {
2385
+ delete process.env.PI_BGRUN_DIR;
2386
+ rmSync(dir, { recursive: true, force: true });
2387
+ }
2388
+ });
2389
+
2390
+ test("bggrep and bgtail normalize CRLF logs", async () => {
2391
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2392
+ process.env.PI_BGRUN_DIR = dir;
2393
+ try {
2394
+ const { pi, wakes, tools, ctx } = makeFakePi();
2395
+ await loadExtension(pi);
2396
+ const bgrun = tools.get("bgrun")!;
2397
+ const bgtail = tools.get("bgtail")!;
2398
+ const bggrep = tools.get("bggrep")!;
2399
+ const res = await bgrun.execute(
2400
+ "c1",
2401
+ { command: "echo something" },
2402
+ undefined,
2403
+ undefined,
2404
+ ctx,
2405
+ );
2406
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2407
+ [])[1];
2408
+ await waitForWakes(wakes, 1);
2409
+ const logPath = join(dir, `${id}.log`);
2410
+
2411
+ writeFileSync(logPath, "alpha\r\nerror: boom\r\nomega\r\n");
2412
+ // A $-anchored pattern must match despite the CRLF source
2413
+ const g = await bggrep.execute(
2414
+ "c2",
2415
+ { id, pattern: "boom$" },
2416
+ undefined,
2417
+ undefined,
2418
+ ctx,
2419
+ );
2420
+ assert.match(g.content[0].text as string, /L2: error: boom/);
2421
+ // And no stray \r leaks into either tool's output
2422
+ assert.ok(!(g.content[0].text as string).includes("\r"));
2423
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2424
+ assert.ok(!(t.content[0].text as string).includes("\r"));
2425
+ assert.match(t.content[0].text as string, /error: boom/);
2426
+ } finally {
2427
+ delete process.env.PI_BGRUN_DIR;
2428
+ rmSync(dir, { recursive: true, force: true });
2429
+ }
2430
+ });
2431
+
2432
+ test("bggrep: empty log reports zero lines, and a missing log is notFound", async () => {
2433
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2434
+ process.env.PI_BGRUN_DIR = dir;
2435
+ try {
2436
+ const { pi, wakes, tools, ctx } = makeFakePi();
2437
+ await loadExtension(pi);
2438
+ const bgrun = tools.get("bgrun")!;
2439
+ const bggrep = tools.get("bggrep")!;
2440
+ const res = await bgrun.execute(
2441
+ "c1",
2442
+ { command: "echo x" },
2443
+ undefined,
2444
+ undefined,
2445
+ ctx,
2446
+ );
2447
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2448
+ [])[1];
2449
+ await waitForWakes(wakes, 1);
2450
+
2451
+ writeFileSync(join(dir, `${id}.log`), "");
2452
+ const g = await bggrep.execute(
2453
+ "c2",
2454
+ { id, pattern: "Error:" },
2455
+ undefined,
2456
+ undefined,
2457
+ ctx,
2458
+ );
2459
+ assert.match(
2460
+ g.content[0].text as string,
2461
+ /0 matches for \/Error:\/ in 0 lines — none/,
2462
+ );
2463
+
2464
+ const missing = await bggrep.execute(
2465
+ "c3",
2466
+ { id: "no-such-job-123", pattern: "x" },
2467
+ undefined,
2468
+ undefined,
2469
+ ctx,
2470
+ );
2471
+ assert.equal(missing.isError, true);
2472
+ assert.equal(missing.details.notFound, true);
2473
+ } finally {
2474
+ delete process.env.PI_BGRUN_DIR;
2475
+ rmSync(dir, { recursive: true, force: true });
2476
+ }
2477
+ });
2478
+
2479
+ test("bggrep: context windows combine with the 50-match cap", async () => {
2480
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2481
+ process.env.PI_BGRUN_DIR = dir;
2482
+ try {
2483
+ const { pi, wakes, tools, ctx } = makeFakePi();
2484
+ await loadExtension(pi);
2485
+ const bgrun = tools.get("bgrun")!;
2486
+ const bggrep = tools.get("bggrep")!;
2487
+ const res = await bgrun.execute(
2488
+ "c1",
2489
+ { command: "echo x" },
2490
+ undefined,
2491
+ undefined,
2492
+ ctx,
2493
+ );
2494
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2495
+ [])[1];
2496
+ await waitForWakes(wakes, 1);
2497
+
2498
+ // 240 lines, a hit every 4th line → 60 matches (cap 50); with context: 1
2499
+ // each window is [i-1, i+1] and consecutive windows leave a 1-line gap.
2500
+ const lines: string[] = [];
2501
+ for (let i = 1; i <= 240; i++) {
2502
+ lines.push(i % 4 === 0 ? `hit ${i}` : `filler ${i}`);
2503
+ }
2504
+ writeFileSync(join(dir, `${id}.log`), lines.join("\n") + "\n");
2505
+ const r = await bggrep.execute(
2506
+ "c2",
2507
+ { id, pattern: "^hit", context: 1 },
2508
+ undefined,
2509
+ undefined,
2510
+ ctx,
2511
+ );
2512
+ const text = r.content[0].text as string;
2513
+ assert.equal(r.details.matches, 60);
2514
+ assert.equal(r.details.capped, true);
2515
+ assert.match(text, /showing first 50; 10 more not shown/);
2516
+ assert.match(text, /L4: hit 4/);
2517
+ assert.match(text, /…\[1 line skipped\]…/);
2518
+ } finally {
2519
+ delete process.env.PI_BGRUN_DIR;
2520
+ rmSync(dir, { recursive: true, force: true });
2521
+ }
2522
+ });
2523
+
2524
+ test("bgtail and bggrep clamp nonsensical numeric params", async () => {
2525
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2526
+ process.env.PI_BGRUN_DIR = dir;
2527
+ try {
2528
+ const { pi, wakes, tools, ctx } = makeFakePi();
2529
+ await loadExtension(pi);
2530
+ const bgrun = tools.get("bgrun")!;
2531
+ const bgtail = tools.get("bgtail")!;
2532
+ const bggrep = tools.get("bggrep")!;
2533
+ const res = await bgrun.execute(
2534
+ "c1",
2535
+ { command: "printf 'one\\ntwo\\nthree\\nfour\\nfive\\n'" },
2536
+ undefined,
2537
+ undefined,
2538
+ ctx,
2539
+ );
2540
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2541
+ [])[1];
2542
+ await waitForWakes(wakes, 1);
2543
+
2544
+ // lines: 0 must not mean "everything" (slice(-0) pitfall) — clamps to 1
2545
+ const t = await bgtail.execute(
2546
+ "c2",
2547
+ { id, lines: 0 },
2548
+ undefined,
2549
+ undefined,
2550
+ ctx,
2551
+ );
2552
+ assert.equal(t.details.linesShown, 1);
2553
+ assert.match(t.content[0].text as string, /five/);
2554
+ assert.ok(!(t.content[0].text as string).includes("four"));
2555
+
2556
+ // negative context must not drop the match lines themselves — clamps to 0
2557
+ const g = await bggrep.execute(
2558
+ "c3",
2559
+ { id, pattern: "^three", context: -1 },
2560
+ undefined,
2561
+ undefined,
2562
+ ctx,
2563
+ );
2564
+ assert.match(g.content[0].text as string, /L3: three/);
1295
2565
  } finally {
1296
2566
  delete process.env.PI_BGRUN_DIR;
1297
2567
  rmSync(dir, { recursive: true, force: true });