pi-background-run 0.2.1 → 0.4.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.
@@ -35,6 +35,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
35
35
  wakes: CapturedWake[];
36
36
  entries: any[];
37
37
  tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
38
+ commands: Map<
39
+ string,
40
+ { description?: string; handler: (...args: any[]) => Promise<void> }
41
+ >;
38
42
  ctx: any;
39
43
  handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
40
44
  fireSessionStart: () => Promise<void>;
@@ -45,6 +49,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
45
49
  string,
46
50
  { execute: (...args: any[]) => Promise<any> }
47
51
  >();
52
+ const commands = new Map<
53
+ string,
54
+ { description?: string; handler: (...args: any[]) => Promise<void> }
55
+ >();
48
56
  const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
49
57
  const idle = opts.idle ?? true;
50
58
  const ctx = {
@@ -64,6 +72,9 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
64
72
  registerTool(def: any) {
65
73
  tools.set(def.name, def);
66
74
  },
75
+ registerCommand(name: string, def: any) {
76
+ commands.set(name, def);
77
+ },
67
78
  on(event: string, handler: (...args: any[]) => Promise<any>) {
68
79
  const list = handlers.get(event) ?? [];
69
80
  list.push(handler);
@@ -75,7 +86,16 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
75
86
  await h({ reason: "startup" }, ctx);
76
87
  }
77
88
  };
78
- return { pi, wakes, entries, tools, ctx, handlers, fireSessionStart };
89
+ return {
90
+ pi,
91
+ wakes,
92
+ entries,
93
+ tools,
94
+ commands,
95
+ ctx,
96
+ handlers,
97
+ fireSessionStart,
98
+ };
79
99
  }
80
100
 
81
101
  async function loadExtension(
@@ -234,6 +254,143 @@ test("bgtail: returns last N lines, strips the exit marker", async () => {
234
254
  }
235
255
  });
236
256
 
257
+ test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => {
258
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
259
+ process.env.PI_BGRUN_DIR = dir;
260
+ try {
261
+ const { pi, wakes, tools, ctx } = makeFakePi();
262
+ await loadExtension(pi);
263
+ const bgrun = tools.get("bgrun")!;
264
+ const bgtail = tools.get("bgtail")!;
265
+
266
+ // 1 ANSI-colored line, 5 identical spinner lines, 1 huge line
267
+ const esc = "\u001b"; // literal ESC byte, safe to pass through a shell arg
268
+ const payload =
269
+ `printf "${esc}[32mOK green${esc}[0m\nwait\nwait\nwait\nwait\nwait\nline3\n"; ` +
270
+ "echo \"$(printf 'x%.0s' $(seq 1 5000))\"";
271
+ const res = await bgrun.execute(
272
+ "call-c1",
273
+ { command: payload },
274
+ undefined,
275
+ undefined,
276
+ ctx,
277
+ );
278
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
279
+ await waitForWakes(wakes, 1);
280
+
281
+ const tail = await bgtail.execute(
282
+ "call-c1",
283
+ { id, lines: 40 },
284
+ undefined,
285
+ undefined,
286
+ ctx,
287
+ );
288
+ const text = tail.content[0].text as string;
289
+ assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
290
+ assert.ok(text.includes("OK green"), "text after stripping survives");
291
+ assert.match(
292
+ text,
293
+ /wait {2}\[x5\]/,
294
+ "5 identical lines collapsed to one with count",
295
+ );
296
+ assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
297
+ assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
298
+ assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
299
+ assert.match(
300
+ text,
301
+ /1 repeated-line run collapsed/,
302
+ "notes mention run collapse",
303
+ );
304
+ assert.ok((tail.details as any).condensed === true);
305
+ } finally {
306
+ delete process.env.PI_BGRUN_DIR;
307
+ rmSync(dir, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("bgtail: raw=true skips condensing", async () => {
312
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
313
+ process.env.PI_BGRUN_DIR = dir;
314
+ try {
315
+ const { pi, wakes, tools, ctx } = makeFakePi();
316
+ await loadExtension(pi);
317
+ const bgrun = tools.get("bgrun")!;
318
+ const bgtail = tools.get("bgtail")!;
319
+
320
+ const esc = "\u001b";
321
+ const res = await bgrun.execute(
322
+ "call-c2",
323
+ { command: `printf "${esc}[31mraw-red${esc}[0m\nwait\nwait\nwait\n"` },
324
+ undefined,
325
+ undefined,
326
+ ctx,
327
+ );
328
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
329
+ await waitForWakes(wakes, 1);
330
+
331
+ const tail = await bgtail.execute(
332
+ "call-c2",
333
+ { id, raw: true },
334
+ undefined,
335
+ undefined,
336
+ ctx,
337
+ );
338
+ const text = tail.content[0].text as string;
339
+ assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
340
+ assert.ok(
341
+ text.includes("wait\nwait\nwait"),
342
+ "raw keeps repeated lines uncollapsed",
343
+ );
344
+ assert.ok((tail.details as any).condensed === false);
345
+ } finally {
346
+ delete process.env.PI_BGRUN_DIR;
347
+ rmSync(dir, { recursive: true, force: true });
348
+ }
349
+ });
350
+
351
+ test("bgtail: total cap kicks in on large output with guidance note", async () => {
352
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
353
+ process.env.PI_BGRUN_DIR = dir;
354
+ try {
355
+ const { pi, wakes, tools, ctx } = makeFakePi();
356
+ await loadExtension(pi);
357
+ const bgrun = tools.get("bgrun")!;
358
+ const bgtail = tools.get("bgtail")!;
359
+
360
+ // ~200 distinct lines x ~500 chars = ~100KB, well past the 8KB total cap
361
+ const cmd =
362
+ "for i in $(seq 1 200); do echo \"line-$i $(printf 'y%.0s' $(seq 1 500))\"; done";
363
+ const res = await bgrun.execute(
364
+ "call-c3",
365
+ { command: cmd },
366
+ undefined,
367
+ undefined,
368
+ ctx,
369
+ );
370
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
371
+ await waitForWakes(wakes, 1);
372
+
373
+ const tail = await bgtail.execute(
374
+ "call-c3",
375
+ { id, lines: 200 },
376
+ undefined,
377
+ undefined,
378
+ ctx,
379
+ );
380
+ const text = tail.content[0].text as string;
381
+ assert.ok(text.length < 10_000, "result capped well below raw size");
382
+ assert.match(
383
+ text,
384
+ /output capped at 8000 chars — 200 raw lines total/,
385
+ "cap note names the raw line count and suggests escalation paths",
386
+ );
387
+ assert.ok((tail.details as any).condenserNotes, "notes in details too");
388
+ } finally {
389
+ delete process.env.PI_BGRUN_DIR;
390
+ rmSync(dir, { recursive: true, force: true });
391
+ }
392
+ });
393
+
237
394
  test("bgstatus: shows running then done with exit code", async () => {
238
395
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
239
396
  process.env.PI_BGRUN_DIR = dir;
@@ -541,7 +698,7 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
541
698
  );
542
699
  const text = res.content[0].text as string;
543
700
  // No 'name:' line in the response.
544
- assert.ok(!/^ name:/m.test(text), "no name line when name omitted");
701
+ assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
545
702
  const id = (text.match(/^started: ([^\n]+)/) || [])[1];
546
703
  assert.ok(
547
704
  id.startsWith("echo-unnamed-job-"),
@@ -576,7 +733,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
576
733
  ctx,
577
734
  );
578
735
  assert.ok(
579
- !/^ name:/m.test(res1.content[0].text as string),
736
+ !/^ {2}name:/m.test(res1.content[0].text as string),
580
737
  "blank name ignored",
581
738
  );
582
739
 
@@ -590,7 +747,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
590
747
  ctx,
591
748
  );
592
749
  const text2 = res2.content[0].text as string;
593
- const nameLine = (text2.match(/^ name: (.+)$/m) || [])[1];
750
+ const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
594
751
  assert.equal(nameLine.length, 80, "name truncated to 80 chars");
595
752
 
596
753
  await waitForWakes(wakes, 2);
@@ -942,14 +1099,15 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
942
1099
  }
943
1100
  });
944
1101
 
945
- test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
1102
+ test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
946
1103
  // Regression: exit marker must win over pid liveness. Old code checked
947
1104
  // pid first, so any log whose id-pid happened to be a live process (e.g.
948
1105
  // pi's own pid from the old id bug, or pid reuse) was kept forever.
949
1106
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
950
1107
  process.env.PI_BGRUN_DIR = dir;
951
1108
  try {
952
- // Old finished log whose id-pid is THIS process (alive!) — must still be removed.
1109
+ // Old finished foreign log whose id-pid is THIS process (alive!) — must
1110
+ // still be removed by an explicit global sweep.
953
1111
  const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
954
1112
  writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
955
1113
  const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
@@ -960,14 +1118,31 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
960
1118
  await loadExtension(pi);
961
1119
  const bgclean = tools.get("bgclean")!;
962
1120
 
1121
+ // Default scope: this session only — the foreign log is untouched.
1122
+ const scoped = await bgclean.execute(
1123
+ "call-stale-scoped",
1124
+ { days: 7 },
1125
+ undefined,
1126
+ undefined,
1127
+ ctx,
1128
+ );
1129
+ assert.match(scoped.content[0].text as string, /removed 0/);
1130
+ assert.ok(
1131
+ existsSync(oldPath),
1132
+ "foreign log untouched by session-scoped bgclean",
1133
+ );
1134
+
963
1135
  const result = await bgclean.execute(
964
1136
  "call-stale",
965
- { days: 7 },
1137
+ { days: 7, all: true },
966
1138
  undefined,
967
1139
  undefined,
968
1140
  ctx,
969
1141
  );
970
- assert.match(result.content[0].text as string, /removed 1/);
1142
+ assert.match(
1143
+ result.content[0].text as string,
1144
+ /removed 1 job log\(s\) \(all sessions\)/,
1145
+ );
971
1146
  assert.ok(
972
1147
  !existsSync(oldPath),
973
1148
  "finished job's log removed despite live id-pid",
@@ -1008,10 +1183,267 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
1008
1183
  }
1009
1184
  });
1010
1185
 
1011
- test("auto-clean: throttled via .last-clean marker; manual bgclean always runs", async () => {
1186
+ test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
1187
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1188
+ process.env.PI_BGRUN_DIR = dir;
1189
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1190
+ try {
1191
+ // A job from 5 days ago whose transcript entry never got a done entry
1192
+ // (pi wasn't running when it exited), whose log is long gone and whose
1193
+ // pid is definitely dead.
1194
+ const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
1195
+ const logPath = join(dir, `${zombieId}.log`); // never created
1196
+ const priorEntries = [
1197
+ {
1198
+ type: "custom",
1199
+ customType: "bgrun-job",
1200
+ data: {
1201
+ id: zombieId,
1202
+ pid: 99999999,
1203
+ cmd: "cd /old/project && make test",
1204
+ name: undefined,
1205
+ started: Date.now() - 5 * 24 * 60 * 60 * 1000,
1206
+ logPath,
1207
+ state: "running",
1208
+ },
1209
+ },
1210
+ ];
1211
+ const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
1212
+ priorEntries,
1213
+ });
1214
+ ctx.hasUI = true;
1215
+ const widgetCalls: (string[] | undefined)[] = [];
1216
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1217
+ widgetCalls.push(lines);
1218
+
1219
+ await loadExtension(pi);
1220
+ await fireSessionStart();
1221
+
1222
+ // Revalidation runs before the widget ever renders — the zombie is
1223
+ // cleared immediately instead of showing as "running" forever.
1224
+ assert.ok(
1225
+ !widgetCalls.some((l) => Array.isArray(l)),
1226
+ "reconstructed zombie never shown in the widget",
1227
+ );
1228
+
1229
+ // A done entry is appended so future resumes reconstruct it as done.
1230
+ const doneEntry = entries.find(
1231
+ (e) =>
1232
+ e.customType === "bgrun-job" &&
1233
+ e.data?.id === zombieId &&
1234
+ e.data?.state === "done",
1235
+ );
1236
+ assert.ok(doneEntry, "done entry appended for the recovered job");
1237
+
1238
+ // Single-id lookup reports done, not running.
1239
+ const bgstatus = tools.get("bgstatus")!;
1240
+ const res = await bgstatus.execute(
1241
+ "call-z1",
1242
+ { id: zombieId },
1243
+ undefined,
1244
+ undefined,
1245
+ ctx,
1246
+ );
1247
+ assert.match(res.content[0].text as string, /: done/);
1248
+ } finally {
1249
+ delete process.env.PI_BGRUN_DIR;
1250
+ rmSync(dir, { recursive: true, force: true });
1251
+ }
1252
+ });
1253
+
1254
+ test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
1255
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1256
+ process.env.PI_BGRUN_DIR = dir;
1257
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1258
+ try {
1259
+ // Jobs killed by a signal persist state:"done" with exitCode: undefined —
1260
+ // reconstruction must honor the state field, not just the exit code.
1261
+ const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
1262
+ const logPath = join(dir, `${killedId}.log`);
1263
+ writeFileSync(logPath, "partial output\n"); // no marker — killed before it
1264
+ const priorEntries = [
1265
+ {
1266
+ type: "custom",
1267
+ customType: "bgrun-job",
1268
+ data: {
1269
+ id: killedId,
1270
+ pid: process.pid, // alive — liveness alone must not resurrect it as running
1271
+ cmd: "npm run watch",
1272
+ name: "nightly-watch",
1273
+ started: Date.now() - 60_000,
1274
+ logPath,
1275
+ state: "done",
1276
+ exitCode: undefined,
1277
+ exitedAt: Date.now() - 30_000,
1278
+ },
1279
+ },
1280
+ ];
1281
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1282
+ ctx.hasUI = true;
1283
+ const widgetCalls: (string[] | undefined)[] = [];
1284
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1285
+ widgetCalls.push(lines);
1286
+
1287
+ await loadExtension(pi);
1288
+ await fireSessionStart();
1289
+
1290
+ assert.ok(
1291
+ !widgetCalls.some((l) => Array.isArray(l)),
1292
+ "signal-killed job with a done entry is not resurrected as running",
1293
+ );
1294
+ const bgstatus = tools.get("bgstatus")!;
1295
+ const res = await bgstatus.execute(
1296
+ "call-z2",
1297
+ { id: killedId },
1298
+ undefined,
1299
+ undefined,
1300
+ ctx,
1301
+ );
1302
+ assert.match(res.content[0].text as string, /: done/);
1303
+ } finally {
1304
+ delete process.env.PI_BGRUN_DIR;
1305
+ rmSync(dir, { recursive: true, force: true });
1306
+ }
1307
+ });
1308
+
1309
+ test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
1310
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1311
+ process.env.PI_BGRUN_DIR = dir;
1312
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1313
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1314
+ try {
1315
+ const fs = await import("node:fs");
1316
+ const backdate = (path: string) => {
1317
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1318
+ fs.utimesSync(path, oldTime, oldTime);
1319
+ };
1320
+
1321
+ // This session's old done job (from the transcript) with a backdated log.
1322
+ const mineId = `my-old-job-${Date.now()}-99999999`;
1323
+ const myLog = join(dir, `${mineId}.log`);
1324
+ fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1325
+ backdate(myLog);
1326
+
1327
+ // A foreign session's week-old FINISHED log — an orphan; swept by default.
1328
+ const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1329
+ fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1330
+ backdate(orphanLog);
1331
+
1332
+ // A foreign session's RECENT finished log — within retention, kept.
1333
+ const recentForeignLog = join(
1334
+ dir,
1335
+ `foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
1336
+ );
1337
+ fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
1338
+
1339
+ // A foreign session's week-old RUNNING log (no marker, live pid) — running
1340
+ // jobs are pid-protected even when old.
1341
+ const runningForeignLog = join(
1342
+ dir,
1343
+ `foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
1344
+ );
1345
+ fs.writeFileSync(runningForeignLog, "still going\n");
1346
+ backdate(runningForeignLog);
1347
+
1348
+ const priorEntries = [
1349
+ {
1350
+ type: "custom",
1351
+ customType: "bgrun-job",
1352
+ data: {
1353
+ id: mineId,
1354
+ pid: 99999999,
1355
+ cmd: "echo mine",
1356
+ name: undefined,
1357
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1358
+ logPath: myLog,
1359
+ state: "done",
1360
+ exitCode: 0,
1361
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1362
+ },
1363
+ },
1364
+ ];
1365
+ const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1366
+ await loadExtension(pi);
1367
+ await fireSessionStart();
1368
+
1369
+ assert.ok(!fs.existsSync(myLog), "this session's old log swept");
1370
+ assert.ok(
1371
+ !fs.existsSync(orphanLog),
1372
+ "week-old finished foreign orphan swept by default",
1373
+ );
1374
+ assert.ok(
1375
+ fs.existsSync(recentForeignLog),
1376
+ "recent foreign log kept (within retention)",
1377
+ );
1378
+ assert.ok(
1379
+ fs.existsSync(runningForeignLog),
1380
+ "old but RUNNING foreign log kept (pid-protected)",
1381
+ );
1382
+ } finally {
1383
+ delete process.env.PI_BGRUN_DIR;
1384
+ rmSync(dir, { recursive: true, force: true });
1385
+ }
1386
+ });
1387
+
1388
+ test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
1389
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1390
+ process.env.PI_BGRUN_DIR = dir;
1391
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1392
+ process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1393
+ try {
1394
+ const fs = await import("node:fs");
1395
+ const backdate = (path: string) => {
1396
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1397
+ fs.utimesSync(path, oldTime, oldTime);
1398
+ };
1399
+
1400
+ const mineId = `my-old-job-${Date.now()}-99999999`;
1401
+ const myLog = join(dir, `${mineId}.log`);
1402
+ fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1403
+ backdate(myLog);
1404
+
1405
+ const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1406
+ fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1407
+ backdate(orphanLog);
1408
+
1409
+ const priorEntries = [
1410
+ {
1411
+ type: "custom",
1412
+ customType: "bgrun-job",
1413
+ data: {
1414
+ id: mineId,
1415
+ pid: 99999999,
1416
+ cmd: "echo mine",
1417
+ name: undefined,
1418
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1419
+ logPath: myLog,
1420
+ state: "done",
1421
+ exitCode: 0,
1422
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1423
+ },
1424
+ },
1425
+ ];
1426
+ const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1427
+ await loadExtension(pi);
1428
+ await fireSessionStart();
1429
+
1430
+ assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
1431
+ assert.ok(
1432
+ fs.existsSync(orphanLog),
1433
+ "foreign orphan untouched when globalAutoClean is off",
1434
+ );
1435
+ } finally {
1436
+ delete process.env.PI_BGRUN_DIR;
1437
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1438
+ rmSync(dir, { recursive: true, force: true });
1439
+ }
1440
+ });
1441
+
1442
+ test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
1012
1443
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1013
1444
  process.env.PI_BGRUN_DIR = dir;
1014
1445
  delete process.env.PI_BGRUN_FOREIGN_JOBS;
1446
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
1015
1447
  try {
1016
1448
  const fs = await import("node:fs");
1017
1449
  const backdate = (path: string) => {
@@ -1019,92 +1451,150 @@ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs",
1019
1451
  fs.utimesSync(path, oldTime, oldTime);
1020
1452
  };
1021
1453
 
1022
- // Old log A + first session_start (no marker yet) → sweep runs, A removed.
1454
+ // Old foreign log A + first session_start (no marker yet) → global sweep
1455
+ // runs, A removed.
1023
1456
  const logA = join(dir, "old-a-1000000000-99999.log");
1024
1457
  fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
1025
1458
  backdate(logA);
1026
1459
  {
1027
- const { pi, ctx, fireSessionStart } = makeFakePi();
1460
+ const { pi, fireSessionStart } = makeFakePi();
1028
1461
  await loadExtension(pi);
1029
1462
  await fireSessionStart();
1030
1463
  }
1031
- assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
1464
+ assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
1032
1465
  assert.ok(
1033
1466
  fs.existsSync(join(dir, ".last-clean")),
1034
1467
  "throttle marker written",
1035
1468
  );
1036
1469
 
1037
- // Old log B + second session_start while marker is fresh → throttled, B kept.
1470
+ // Old foreign log B + second session_start while marker is fresh →
1471
+ // throttled, B kept.
1038
1472
  const logB = join(dir, "old-b-1000000000-99998.log");
1039
1473
  fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
1040
1474
  backdate(logB);
1041
1475
  {
1042
- const { pi, ctx, fireSessionStart } = makeFakePi();
1476
+ const { pi, fireSessionStart } = makeFakePi();
1043
1477
  await loadExtension(pi);
1044
1478
  await fireSessionStart();
1045
1479
  }
1046
- assert.ok(fs.existsSync(logB), "second sweep throttled — old log B kept");
1480
+ assert.ok(
1481
+ fs.existsSync(logB),
1482
+ "second global sweep throttled — old log B kept",
1483
+ );
1047
1484
 
1048
- // Manual bgclean ignores the throttle and removes B.
1485
+ // Manual `bgclean all` ignores the throttle and removes B.
1049
1486
  const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
1050
1487
  await loadExtension(pi3);
1051
1488
  const bgclean = tools3.get("bgclean")!;
1052
1489
  const result = await bgclean.execute(
1053
1490
  "call-t1",
1054
- {},
1491
+ { all: true },
1055
1492
  undefined,
1056
1493
  undefined,
1057
1494
  ctx3,
1058
1495
  );
1059
- assert.match(result.content[0].text as string, /removed 1/);
1060
- assert.ok(!fs.existsSync(logB), "manual bgclean removed log B");
1496
+ assert.match(
1497
+ result.content[0].text as string,
1498
+ /removed 1 job log\(s\) \(all sessions\)/,
1499
+ );
1500
+ assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
1061
1501
  } finally {
1062
1502
  delete process.env.PI_BGRUN_DIR;
1503
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1063
1504
  rmSync(dir, { recursive: true, force: true });
1064
1505
  }
1065
1506
  });
1066
1507
 
1067
- test("bgclean: removes old logs, keeps recent ones", async () => {
1508
+ test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
1068
1509
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1069
1510
  process.env.PI_BGRUN_DIR = dir;
1511
+ // Isolate bgclean's scoping from the global orphan auto-sweep (default on)
1512
+ // so the foreign log survives session_start for bgclean to (not) act on.
1513
+ process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1070
1514
  try {
1071
- const { pi, tools, ctx } = makeFakePi();
1515
+ const fs = await import("node:fs");
1516
+ const backdate = (path: string) => {
1517
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1518
+ fs.utimesSync(path, oldTime, oldTime);
1519
+ };
1520
+
1521
+ const mkEntry = (
1522
+ id: string,
1523
+ logPath: string,
1524
+ extra: Record<string, unknown> = {},
1525
+ ) => ({
1526
+ type: "custom",
1527
+ customType: "bgrun-job",
1528
+ data: {
1529
+ id,
1530
+ pid: 99999999,
1531
+ cmd: `echo ${id}`,
1532
+ name: undefined,
1533
+ started: Date.now() - 60_000,
1534
+ logPath,
1535
+ state: "done",
1536
+ exitCode: 0,
1537
+ exitedAt: Date.now() - 30_000,
1538
+ ...extra,
1539
+ },
1540
+ });
1541
+
1542
+ // This session's recent done job (fresh log — kept).
1543
+ const recentId = `recent-job-${Date.now()}-99999998`;
1544
+ const recentLog = join(dir, `${recentId}.log`);
1545
+ fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
1546
+
1547
+ // This session's old done job (backdated log — removed by default scope).
1548
+ const oldId = `old-session-job-${Date.now()}-99999997`;
1549
+ const oldLog = join(dir, `${oldId}.log`);
1550
+ fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
1551
+ backdate(oldLog);
1552
+
1553
+ // A foreign session's old log — untouched by default, removed with all.
1554
+ const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
1555
+ fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
1556
+ backdate(foreignLog);
1557
+
1558
+ const priorEntries = [
1559
+ mkEntry(recentId, recentLog),
1560
+ mkEntry(oldId, oldLog, {
1561
+ started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1562
+ exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1563
+ }),
1564
+ ];
1565
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1072
1566
  await loadExtension(pi);
1073
- const bgrun = tools.get("bgrun")!;
1567
+ await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
1568
+
1074
1569
  const bgclean = tools.get("bgclean")!;
1075
1570
 
1076
- // Run a real job (recent log — should be kept).
1077
- await bgrun.execute(
1078
- "call-c1",
1079
- { command: "echo recent" },
1571
+ // Default: this session only.
1572
+ const scoped = await bgclean.execute(
1573
+ "call-c2",
1574
+ { days: 7 },
1080
1575
  undefined,
1081
1576
  undefined,
1082
1577
  ctx,
1083
1578
  );
1084
- await new Promise((r) => setTimeout(r, 200)); // let it finish
1085
-
1086
- // Write an old log file (backdated mtime).
1087
- const oldPath = join(dir, "old-job-1000000000-99999.log");
1088
- const fs = await import("node:fs");
1089
- fs.writeFileSync(oldPath, "old output\n__BGRUN_EXIT__=0\n");
1090
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
1091
- fs.utimesSync(oldPath, oldTime, oldTime);
1579
+ assert.match(scoped.content[0].text as string, /\(this session\)/);
1580
+ assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
1581
+ assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
1582
+ assert.ok(
1583
+ fs.existsSync(foreignLog),
1584
+ "foreign log untouched by session-scoped bgclean",
1585
+ );
1092
1586
 
1093
- const result = await bgclean.execute(
1094
- "call-c2",
1095
- { days: 7 },
1587
+ // all: true sweeps the shared dir.
1588
+ const global = await bgclean.execute(
1589
+ "call-c3",
1590
+ { days: 7, all: true },
1096
1591
  undefined,
1097
1592
  undefined,
1098
1593
  ctx,
1099
1594
  );
1100
- const text = result.content[0].text as string;
1101
- assert.match(text, /removed 1/);
1102
- assert.ok(!fs.existsSync(oldPath), "old log removed");
1103
- // The recent log should still exist.
1104
- const remaining = fs
1105
- .readdirSync(dir)
1106
- .filter((f: string) => f.endsWith(".log"));
1107
- assert.equal(remaining.length, 1, "recent log kept");
1595
+ assert.match(global.content[0].text as string, /\(all sessions\)/);
1596
+ assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
1597
+ assert.ok(fs.existsSync(recentLog), "recent log still kept");
1108
1598
  } finally {
1109
1599
  delete process.env.PI_BGRUN_DIR;
1110
1600
  rmSync(dir, { recursive: true, force: true });
@@ -1168,6 +1658,115 @@ test("bgclean: does not remove a running job's log", async () => {
1168
1658
  }
1169
1659
  } finally {
1170
1660
  delete process.env.PI_BGRUN_DIR;
1661
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1171
1662
  rmSync(dir, { recursive: true, force: true });
1172
1663
  }
1173
1664
  });
1665
+
1666
+ test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
1667
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1668
+ process.env.PI_BGRUN_DIR = dir;
1669
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1670
+ delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1671
+ try {
1672
+ const { pi, wakes, tools, commands, ctx } = makeFakePi();
1673
+ ctx.hasUI = true;
1674
+ const notes: { text: string; kind: string }[] = [];
1675
+ ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
1676
+
1677
+ await loadExtension(pi);
1678
+
1679
+ // All three human-facing commands are registered (/bgrun is agent-only).
1680
+ assert.ok(commands.has("bgstatus"), "/bgstatus registered");
1681
+ assert.ok(commands.has("bgtail"), "/bgtail registered");
1682
+ assert.ok(commands.has("bgclean"), "/bgclean registered");
1683
+ assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
1684
+
1685
+ // Run a real job to completion so there's something to inspect.
1686
+ const bgrun = tools.get("bgrun")!;
1687
+ const res = await bgrun.execute(
1688
+ "call-cmd1",
1689
+ { command: "echo cmd-mirror", name: "mirror-job" },
1690
+ undefined,
1691
+ undefined,
1692
+ ctx,
1693
+ );
1694
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
1695
+ await waitForWakes(wakes, 1);
1696
+
1697
+ // /bgstatus <id> → single-job status via notify.
1698
+ await commands.get("bgstatus")!.handler(id, ctx);
1699
+ assert.ok(
1700
+ notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
1701
+ "/bgstatus <id> notifies job status",
1702
+ );
1703
+
1704
+ // /bgstatus done → listing includes the finished job.
1705
+ await commands.get("bgstatus")!.handler("done", ctx);
1706
+ assert.ok(
1707
+ notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
1708
+ "/bgstatus done lists finished jobs",
1709
+ );
1710
+
1711
+ // /bgtail <id> <lines> → condensed tail via notify.
1712
+ await commands.get("bgtail")!.handler(`${id} 5`, ctx);
1713
+ assert.ok(
1714
+ notes.some((n) => n.text.includes("cmd-mirror")),
1715
+ "/bgtail notifies the log tail",
1716
+ );
1717
+
1718
+ // /bgtail with no args → usage error.
1719
+ await commands.get("bgtail")!.handler("", ctx);
1720
+ assert.ok(
1721
+ notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
1722
+ "/bgtail without id shows usage",
1723
+ );
1724
+
1725
+ // /bgclean (no args) → session-scoped summary via notify.
1726
+ await commands.get("bgclean")!.handler("", ctx);
1727
+ assert.ok(
1728
+ notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
1729
+ "/bgclean notifies the session-scoped summary",
1730
+ );
1731
+
1732
+ // /bgclean 7 all → global scope.
1733
+ await commands.get("bgclean")!.handler("7 all", ctx);
1734
+ assert.ok(
1735
+ notes.some((n) => /\(all sessions\)/.test(n.text)),
1736
+ "/bgclean all notifies the global summary",
1737
+ );
1738
+ } finally {
1739
+ delete process.env.PI_BGRUN_DIR;
1740
+ rmSync(dir, { recursive: true, force: true });
1741
+ }
1742
+ });
1743
+
1744
+ test("formatSince: same-day shows time only; older days include the date", async () => {
1745
+ const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
1746
+ const mod: any = await import(url);
1747
+ assert.equal(typeof mod.formatSince, "function");
1748
+
1749
+ const now = new Date("2026-09-09T10:00:00").getTime();
1750
+ const sameDay = new Date("2026-09-09T06:30:12").getTime();
1751
+ const prevDay = new Date("2026-09-04T15:05:40").getTime();
1752
+ const prevMonth = new Date("2026-08-12T23:59:59").getTime();
1753
+ const prevYear = new Date("2025-12-30T08:00:00").getTime();
1754
+
1755
+ // Same calendar day → time only (unchanged display).
1756
+ assert.equal(mod.formatSince(sameDay, now), "06:30:12");
1757
+
1758
+ // Different day, same year → date + time.
1759
+ const prevDayStr = mod.formatSince(prevDay, now);
1760
+ assert.match(prevDayStr, /Sep 4/);
1761
+ assert.match(prevDayStr, /15:05:40/);
1762
+
1763
+ const prevMonthStr = mod.formatSince(prevMonth, now);
1764
+ assert.match(prevMonthStr, /Aug 12/);
1765
+ assert.match(prevMonthStr, /23:59:59/);
1766
+
1767
+ // Different year → date includes the year.
1768
+ const prevYearStr = mod.formatSince(prevYear, now);
1769
+ assert.match(prevYearStr, /2025/);
1770
+ assert.match(prevYearStr, /Dec 30/);
1771
+ assert.match(prevYearStr, /08:00:00/);
1772
+ });