pi-background-run 0.1.0 → 0.2.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.
@@ -13,7 +13,14 @@
13
13
 
14
14
  import { test } from "node:test";
15
15
  import assert from "node:assert/strict";
16
- import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
16
+ import {
17
+ mkdtempSync,
18
+ rmSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ existsSync,
22
+ readdirSync,
23
+ } from "node:fs";
17
24
  import { join } from "node:path";
18
25
  import { tmpdir } from "node:os";
19
26
  import { pathToFileURL } from "node:url";
@@ -34,7 +41,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
34
41
  } {
35
42
  const wakes: CapturedWake[] = [];
36
43
  const entries: any[] = opts.priorEntries ? [...opts.priorEntries] : [];
37
- const tools = new Map<string, { execute: (...args: any[]) => Promise<any> }>();
44
+ const tools = new Map<
45
+ string,
46
+ { execute: (...args: any[]) => Promise<any> }
47
+ >();
38
48
  const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
39
49
  const idle = opts.idle ?? true;
40
50
  const ctx = {
@@ -68,19 +78,33 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
68
78
  return { pi, wakes, entries, tools, ctx, handlers, fireSessionStart };
69
79
  }
70
80
 
71
- async function loadExtension(fakePi: any): Promise<Map<string, { execute: (...args: any[]) => Promise<any> }>> {
81
+ async function loadExtension(
82
+ fakePi: any,
83
+ ): Promise<Map<string, { execute: (...args: any[]) => Promise<any> }>> {
72
84
  const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
73
85
  const mod = await import(url);
74
86
  mod.default(fakePi);
75
- return fakePi.tools as Map<string, { execute: (...args: any[]) => Promise<any> }>;
87
+ return fakePi.tools as Map<
88
+ string,
89
+ { execute: (...args: any[]) => Promise<any> }
90
+ >;
76
91
  }
77
92
 
78
- function waitForWakes(wakes: CapturedWake[], count: number, timeoutMs = 5000): Promise<void> {
93
+ function waitForWakes(
94
+ wakes: CapturedWake[],
95
+ count: number,
96
+ timeoutMs = 5000,
97
+ ): Promise<void> {
79
98
  return new Promise((resolve, reject) => {
80
99
  const start = Date.now();
81
100
  const tick = () => {
82
101
  if (wakes.length >= count) return resolve();
83
- if (Date.now() - start > timeoutMs) return reject(new Error(`timed out waiting for ${count} wakes, got ${wakes.length}`));
102
+ if (Date.now() - start > timeoutMs)
103
+ return reject(
104
+ new Error(
105
+ `timed out waiting for ${count} wakes, got ${wakes.length}`,
106
+ ),
107
+ );
84
108
  setTimeout(tick, 50);
85
109
  };
86
110
  tick();
@@ -95,7 +119,13 @@ test("bgrun: successful command writes log + exit marker and wakes with ✅", as
95
119
  await loadExtension(pi);
96
120
  const bgrun = tools.get("bgrun")!;
97
121
 
98
- const res = await bgrun.execute("call-1", { command: "echo hello world" }, undefined, undefined, ctx);
122
+ const res = await bgrun.execute(
123
+ "call-1",
124
+ { command: "echo hello world" },
125
+ undefined,
126
+ undefined,
127
+ ctx,
128
+ );
99
129
  const started = res.content[0].text as string;
100
130
  assert.match(started, /^started: /);
101
131
  const id = (started.match(/^started: ([^\n]+)/) || [])[1];
@@ -129,7 +159,13 @@ test("bgrun: failing command wakes with ❌ and the non-zero exit code", async (
129
159
  await loadExtension(pi);
130
160
  const bgrun = tools.get("bgrun")!;
131
161
 
132
- await bgrun.execute("call-2", { command: "echo failing now; exit 7" }, undefined, undefined, ctx);
162
+ await bgrun.execute(
163
+ "call-2",
164
+ { command: "echo failing now; exit 7" },
165
+ undefined,
166
+ undefined,
167
+ ctx,
168
+ );
133
169
  await waitForWakes(wakes, 1);
134
170
  const wake = wakes[0].text;
135
171
  assert.match(wake, /❌/);
@@ -148,7 +184,13 @@ test("bgrun: when agent is busy, wake is queued as followUp", async () => {
148
184
  await loadExtension(pi);
149
185
  const bgrun = tools.get("bgrun")!;
150
186
 
151
- await bgrun.execute("call-busy", { command: "echo while-busy" }, undefined, undefined, ctx);
187
+ await bgrun.execute(
188
+ "call-busy",
189
+ { command: "echo while-busy" },
190
+ undefined,
191
+ undefined,
192
+ ctx,
193
+ );
152
194
  await waitForWakes(wakes, 1);
153
195
  assert.equal(wakes[0].options?.deliverAs, "followUp");
154
196
  } finally {
@@ -166,11 +208,23 @@ test("bgtail: returns last N lines, strips the exit marker", async () => {
166
208
  const bgrun = tools.get("bgrun")!;
167
209
  const bgtail = tools.get("bgtail")!;
168
210
 
169
- const res = await bgrun.execute("call-3", { command: "printf 'line1\\nline2\\nline3\\n'" }, undefined, undefined, ctx);
211
+ const res = await bgrun.execute(
212
+ "call-3",
213
+ { command: "printf 'line1\\nline2\\nline3\\n'" },
214
+ undefined,
215
+ undefined,
216
+ ctx,
217
+ );
170
218
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
171
219
  await waitForWakes(wakes, 1);
172
220
 
173
- const tail = await bgtail.execute("call-3", { id, lines: 2 }, undefined, undefined, ctx);
221
+ const tail = await bgtail.execute(
222
+ "call-3",
223
+ { id, lines: 2 },
224
+ undefined,
225
+ undefined,
226
+ ctx,
227
+ );
174
228
  const text = tail.content[0].text as string;
175
229
  assert.ok(!text.includes("__BGRUN_EXIT__"), "marker stripped");
176
230
  assert.match(text, /line2\nline3$|^line3$/);
@@ -189,15 +243,33 @@ test("bgstatus: shows running then done with exit code", async () => {
189
243
  const bgrun = tools.get("bgrun")!;
190
244
  const bgstatus = tools.get("bgstatus")!;
191
245
 
192
- const res = await bgrun.execute("call-4", { command: "sleep 0.2; echo done" }, undefined, undefined, ctx);
246
+ const res = await bgrun.execute(
247
+ "call-4",
248
+ { command: "sleep 0.2; echo done" },
249
+ undefined,
250
+ undefined,
251
+ ctx,
252
+ );
193
253
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
194
254
 
195
255
  // While running, status should say running.
196
- const running = await bgstatus.execute("call-4", { id }, undefined, undefined, ctx);
256
+ const running = await bgstatus.execute(
257
+ "call-4",
258
+ { id },
259
+ undefined,
260
+ undefined,
261
+ ctx,
262
+ );
197
263
  assert.match(running.content[0].text as string, /running/);
198
264
 
199
265
  await waitForWakes(wakes, 1);
200
- const done = await bgstatus.execute("call-4", { id }, undefined, undefined, ctx);
266
+ const done = await bgstatus.execute(
267
+ "call-4",
268
+ { id },
269
+ undefined,
270
+ undefined,
271
+ ctx,
272
+ );
201
273
  assert.match(done.content[0].text as string, /done/);
202
274
  assert.match(done.content[0].text as string, /exit=0/);
203
275
  } finally {
@@ -206,14 +278,21 @@ test("bgstatus: shows running then done with exit code", async () => {
206
278
  }
207
279
  });
208
280
 
209
- test("bgstatus: list-all scans the jobs dir after 'restart' (no in-memory records)", async () => {
281
+ test("bgstatus: list-all after 'restart' hides finished logs by default, notes them instead", async () => {
210
282
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
211
283
  process.env.PI_BGRUN_DIR = dir;
284
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
212
285
  try {
213
286
  const { pi, tools, ctx } = makeFakePi();
214
287
  await loadExtension(pi);
215
288
  const bgrun = tools.get("bgrun")!;
216
- const res = await bgrun.execute("call-5", { command: "echo persisted" }, undefined, undefined, ctx);
289
+ const res = await bgrun.execute(
290
+ "call-5",
291
+ { command: "echo persisted" },
292
+ undefined,
293
+ undefined,
294
+ ctx,
295
+ );
217
296
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
218
297
 
219
298
  // Wait for completion by polling the log marker.
@@ -222,27 +301,88 @@ test("bgstatus: list-all scans the jobs dir after 'restart' (no in-memory record
222
301
  const start = Date.now();
223
302
  const tick = () => {
224
303
  try {
225
- if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=0")) return resolve();
304
+ if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=0"))
305
+ return resolve();
226
306
  } catch {}
227
- if (Date.now() - start > 5000) return reject(new Error("log marker never appeared"));
307
+ if (Date.now() - start > 5000)
308
+ return reject(new Error("log marker never appeared"));
228
309
  setTimeout(tick, 50);
229
310
  };
230
311
  tick();
231
312
  });
232
313
 
233
- // Fresh instance — no in-memory records. Directory scan should still find it.
314
+ // Fresh instance — no in-memory records. Default listing must NOT spam the
315
+ // finished job; it gets a one-line count note instead.
234
316
  const { pi: pi2, tools: tools2 } = makeFakePi();
235
317
  await loadExtension(pi2);
236
318
  const bgstatus2 = tools2.get("bgstatus")!;
237
- const list = await bgstatus2.execute("call-5", {}, undefined, undefined, ctx);
238
- assert.match(list.content[0].text as string, new RegExp(id));
239
- assert.match(list.content[0].text as string, /exit=0/);
319
+ const list = await bgstatus2.execute(
320
+ "call-5",
321
+ {},
322
+ undefined,
323
+ undefined,
324
+ ctx,
325
+ );
326
+ const text = list.content[0].text as string;
327
+ assert.ok(!new RegExp(id).test(text), "finished job hidden by default");
328
+ assert.match(text, /\(1 more job log\(s\) on disk/);
329
+
330
+ // includeDone reveals it with the exit code recovered from the log.
331
+ const full = await bgstatus2.execute(
332
+ "call-5b",
333
+ { includeDone: true },
334
+ undefined,
335
+ undefined,
336
+ ctx,
337
+ );
338
+ const fullText = full.content[0].text as string;
339
+ assert.match(fullText, new RegExp(id));
340
+ assert.match(fullText, /exit=0/);
341
+ assert.match(fullText, /\(from log\)/);
240
342
  } finally {
241
343
  delete process.env.PI_BGRUN_DIR;
242
344
  rmSync(dir, { recursive: true, force: true });
243
345
  }
244
346
  });
245
347
 
348
+ test("bgstatus: showCompletedJobs config (env) lists finished jobs by default", async () => {
349
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
350
+ process.env.PI_BGRUN_DIR = dir;
351
+ process.env.PI_BGRUN_SHOW_COMPLETED = "1";
352
+ try {
353
+ const { pi, wakes, tools, ctx } = makeFakePi();
354
+ await loadExtension(pi);
355
+ const bgrun = tools.get("bgrun")!;
356
+ const bgstatus = tools.get("bgstatus")!;
357
+
358
+ const res = await bgrun.execute(
359
+ "call-sd1",
360
+ { command: "echo shown-done", name: "done-job" },
361
+ undefined,
362
+ undefined,
363
+ ctx,
364
+ );
365
+ const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
366
+ await waitForWakes(wakes, 1);
367
+
368
+ const list = await bgstatus.execute(
369
+ "call-sd2",
370
+ {},
371
+ undefined,
372
+ undefined,
373
+ ctx,
374
+ );
375
+ assert.match(
376
+ list.content[0].text as string,
377
+ new RegExp(`${id} — done-job: done exit=0`),
378
+ );
379
+ } finally {
380
+ delete process.env.PI_BGRUN_DIR;
381
+ delete process.env.PI_BGRUN_SHOW_COMPLETED;
382
+ rmSync(dir, { recursive: true, force: true });
383
+ }
384
+ });
385
+
246
386
  test("bgrun: rejects empty command", async () => {
247
387
  const { pi, tools, ctx } = makeFakePi();
248
388
  await loadExtension(pi);
@@ -263,7 +403,13 @@ test("bgrun: appends bgrun-job entries (running then done)", async () => {
263
403
  await loadExtension(pi);
264
404
  const bgrun = tools.get("bgrun")!;
265
405
 
266
- await bgrun.execute("call-e1", { command: "echo entry-test" }, undefined, undefined, ctx);
406
+ await bgrun.execute(
407
+ "call-e1",
408
+ { command: "echo entry-test" },
409
+ undefined,
410
+ undefined,
411
+ ctx,
412
+ );
267
413
  // One running entry appended at start.
268
414
  const runningEntries = entries.filter((e) => e.data?.state === "running");
269
415
  assert.equal(runningEntries.length, 1, "running entry appended at start");
@@ -288,24 +434,47 @@ test("session_start: reconstructs in-memory Map from bgrun-job entries", async (
288
434
  const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
289
435
  await loadExtension(pi1);
290
436
  const bgrun1 = tools1.get("bgrun")!;
291
- const res = await bgrun1.execute("call-r1", { command: "echo reconstruct-me" }, undefined, undefined, ctx1);
437
+ const res = await bgrun1.execute(
438
+ "call-r1",
439
+ { command: "echo reconstruct-me" },
440
+ undefined,
441
+ undefined,
442
+ ctx1,
443
+ );
292
444
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
293
445
  await waitForWakes(wakes, 1);
294
446
 
295
447
  // Second instance: simulate a restart. Load fresh, passing the prior entries,
296
448
  // then fire session_start to trigger reconstruction.
297
- const { pi: pi2, tools: tools2, ctx: ctx2, fireSessionStart } = makeFakePi({ priorEntries: entries });
449
+ const {
450
+ pi: pi2,
451
+ tools: tools2,
452
+ ctx: ctx2,
453
+ fireSessionStart,
454
+ } = makeFakePi({ priorEntries: entries });
298
455
  await loadExtension(pi2);
299
456
  await fireSessionStart();
300
457
 
301
458
  // Now bgstatus should find the job in the in-memory Map (not just dir scan).
302
459
  const bgstatus2 = tools2.get("bgstatus")!;
303
- const status = await bgstatus2.execute("call-r2", { id }, undefined, undefined, ctx2);
460
+ const status = await bgstatus2.execute(
461
+ "call-r2",
462
+ { id },
463
+ undefined,
464
+ undefined,
465
+ ctx2,
466
+ );
304
467
  const text = status.content[0].text as string;
305
468
  assert.match(text, /done.*exit=0/);
306
469
  // Verify it came from the in-memory Map (not "from log" marker).
307
- assert.ok(!text.includes("from log"), "reconstructed from entries, not dir scan");
308
- assert.ok(!text.includes("recovered from log"), "reconstructed from entries, not log recovery");
470
+ assert.ok(
471
+ !text.includes("from log"),
472
+ "reconstructed from entries, not dir scan",
473
+ );
474
+ assert.ok(
475
+ !text.includes("recovered from log"),
476
+ "reconstructed from entries, not log recovery",
477
+ );
309
478
  } finally {
310
479
  delete process.env.PI_BGRUN_DIR;
311
480
  rmSync(dir, { recursive: true, force: true });
@@ -322,11 +491,20 @@ test("bgrun: name flows into job id, response, entry, wake, and status", async (
322
491
  await loadExtension(pi);
323
492
  const bgrun = tools.get("bgrun")!;
324
493
 
325
- const res = await bgrun.execute("call-n1", { command: "echo named job", name: "unit-tests" }, undefined, undefined, ctx);
494
+ const res = await bgrun.execute(
495
+ "call-n1",
496
+ { command: "echo named job", name: "unit-tests" },
497
+ undefined,
498
+ undefined,
499
+ ctx,
500
+ );
326
501
  const text = res.content[0].text as string;
327
502
  const id = (text.match(/^started: ([^\n]+)/) || [])[1];
328
503
  // Slug derives from the name, not the command.
329
- assert.ok(id.startsWith("unit-tests-"), `id should start with 'unit-tests-': ${id}`);
504
+ assert.ok(
505
+ id.startsWith("unit-tests-"),
506
+ `id should start with 'unit-tests-': ${id}`,
507
+ );
330
508
  // Response includes the name.
331
509
  assert.match(text, /name: unit-tests/);
332
510
  // Details include the name.
@@ -354,15 +532,27 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
354
532
  await loadExtension(pi);
355
533
  const bgrun = tools.get("bgrun")!;
356
534
 
357
- const res = await bgrun.execute("call-n2", { command: "echo unnamed job" }, undefined, undefined, ctx);
535
+ const res = await bgrun.execute(
536
+ "call-n2",
537
+ { command: "echo unnamed job" },
538
+ undefined,
539
+ undefined,
540
+ ctx,
541
+ );
358
542
  const text = res.content[0].text as string;
359
543
  // No 'name:' line in the response.
360
544
  assert.ok(!/^ name:/m.test(text), "no name line when name omitted");
361
545
  const id = (text.match(/^started: ([^\n]+)/) || [])[1];
362
- assert.ok(id.startsWith("echo-unnamed-job-"), `slug falls back to command: ${id}`);
546
+ assert.ok(
547
+ id.startsWith("echo-unnamed-job-"),
548
+ `slug falls back to command: ${id}`,
549
+ );
363
550
 
364
551
  await waitForWakes(wakes, 1);
365
- assert.ok(!wakes[0].text.includes('"'), "wake has no name quote when unnamed");
552
+ assert.ok(
553
+ !wakes[0].text.includes('"'),
554
+ "wake has no name quote when unnamed",
555
+ );
366
556
  } finally {
367
557
  delete process.env.PI_BGRUN_DIR;
368
558
  rmSync(dir, { recursive: true, force: true });
@@ -378,12 +568,27 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
378
568
  const bgrun = tools.get("bgrun")!;
379
569
 
380
570
  // Blank name treated as absent.
381
- const res1 = await bgrun.execute("call-n3", { command: "echo blank", name: " " }, undefined, undefined, ctx);
382
- assert.ok(!/^ name:/m.test(res1.content[0].text as string), "blank name ignored");
571
+ const res1 = await bgrun.execute(
572
+ "call-n3",
573
+ { command: "echo blank", name: " " },
574
+ undefined,
575
+ undefined,
576
+ ctx,
577
+ );
578
+ assert.ok(
579
+ !/^ name:/m.test(res1.content[0].text as string),
580
+ "blank name ignored",
581
+ );
383
582
 
384
583
  // Over-long name truncated to 80 chars.
385
584
  const longName = "x".repeat(200);
386
- const res2 = await bgrun.execute("call-n4", { command: "echo long", name: longName }, undefined, undefined, ctx);
585
+ const res2 = await bgrun.execute(
586
+ "call-n4",
587
+ { command: "echo long", name: longName },
588
+ undefined,
589
+ undefined,
590
+ ctx,
591
+ );
387
592
  const text2 = res2.content[0].text as string;
388
593
  const nameLine = (text2.match(/^ name: (.+)$/m) || [])[1];
389
594
  assert.equal(nameLine.length, 80, "name truncated to 80 chars");
@@ -403,20 +608,40 @@ test("bgrun: name survives session_start reconstruction", async () => {
403
608
  const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
404
609
  await loadExtension(pi1);
405
610
  const bgrun1 = tools1.get("bgrun")!;
406
- const res = await bgrun1.execute("call-n5", { command: "echo named-restart", name: "rebuild" }, undefined, undefined, ctx1);
611
+ const res = await bgrun1.execute(
612
+ "call-n5",
613
+ { command: "echo named-restart", name: "rebuild" },
614
+ undefined,
615
+ undefined,
616
+ ctx1,
617
+ );
407
618
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
408
619
  await waitForWakes(wakes, 1);
409
620
 
410
621
  // Second instance: reconstruct from entries, name should be restored.
411
- const { pi: pi2, tools: tools2, ctx: ctx2, fireSessionStart } = makeFakePi({ priorEntries: entries });
622
+ const {
623
+ pi: pi2,
624
+ tools: tools2,
625
+ ctx: ctx2,
626
+ fireSessionStart,
627
+ } = makeFakePi({ priorEntries: entries });
412
628
  await loadExtension(pi2);
413
629
  await fireSessionStart();
414
630
 
415
631
  const bgstatus2 = tools2.get("bgstatus")!;
416
- const status = await bgstatus2.execute("call-n6", { id }, undefined, undefined, ctx2);
632
+ const status = await bgstatus2.execute(
633
+ "call-n6",
634
+ { id },
635
+ undefined,
636
+ undefined,
637
+ ctx2,
638
+ );
417
639
  const text = status.content[0].text as string;
418
640
  assert.match(text, /name: rebuild/);
419
- assert.ok(!text.includes("recovered from log"), "reconstructed from entries, not log");
641
+ assert.ok(
642
+ !text.includes("recovered from log"),
643
+ "reconstructed from entries, not log",
644
+ );
420
645
  } finally {
421
646
  delete process.env.PI_BGRUN_DIR;
422
647
  rmSync(dir, { recursive: true, force: true });
@@ -432,11 +657,89 @@ test("bgstatus: list shows name after job id", async () => {
432
657
  const bgrun = tools.get("bgrun")!;
433
658
  const bgstatus = tools.get("bgstatus")!;
434
659
 
435
- await bgrun.execute("call-n7", { command: "sleep 0.1; echo listed", name: "nightly" }, undefined, undefined, ctx);
660
+ await bgrun.execute(
661
+ "call-n7",
662
+ { command: "sleep 0.1; echo listed", name: "nightly" },
663
+ undefined,
664
+ undefined,
665
+ ctx,
666
+ );
436
667
  await waitForWakes(wakes, 1);
437
668
 
438
- const list = await bgstatus.execute("call-n8", {}, undefined, undefined, ctx);
669
+ const list = await bgstatus.execute(
670
+ "call-n8",
671
+ { includeDone: true },
672
+ undefined,
673
+ undefined,
674
+ ctx,
675
+ );
439
676
  assert.match(list.content[0].text as string, /— nightly: done exit=0/);
677
+ // Without includeDone, finished jobs are hidden by default.
678
+ const runningOnly = await bgstatus.execute(
679
+ "call-n8b",
680
+ {},
681
+ undefined,
682
+ undefined,
683
+ ctx,
684
+ );
685
+ assert.ok(
686
+ !/— nightly: done/.test(runningOnly.content[0].text as string),
687
+ "done job hidden without includeDone",
688
+ );
689
+ } finally {
690
+ delete process.env.PI_BGRUN_DIR;
691
+ rmSync(dir, { recursive: true, force: true });
692
+ }
693
+ });
694
+
695
+ test("session_start: foreign jobs are NOT adopted by default (opt-in only)", async () => {
696
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
697
+ process.env.PI_BGRUN_DIR = dir;
698
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
699
+ try {
700
+ // A running foreign job (no exit marker, live pid — this test process).
701
+ const foreignId = `other-session-job-${Date.now()}-${process.pid}`;
702
+ writeFileSync(join(dir, `${foreignId}.log`), "someone else's job\n");
703
+
704
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi();
705
+ ctx.hasUI = true;
706
+ const widgetCalls: (string[] | undefined)[] = [];
707
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
708
+ widgetCalls.push(lines);
709
+
710
+ await loadExtension(pi);
711
+ await fireSessionStart();
712
+
713
+ // Widget never shows the foreign job.
714
+ const shown = widgetCalls.find((l) => Array.isArray(l));
715
+ assert.equal(
716
+ shown,
717
+ undefined,
718
+ "no widget content for foreign jobs by default",
719
+ );
720
+
721
+ // List-all gives a count note, not the job itself.
722
+ const bgstatus = tools.get("bgstatus")!;
723
+ const list = await bgstatus.execute(
724
+ "call-f1",
725
+ {},
726
+ undefined,
727
+ undefined,
728
+ ctx,
729
+ );
730
+ const text = list.content[0].text as string;
731
+ assert.ok(!text.includes(foreignId), "foreign job not listed by default");
732
+ assert.match(text, /\(1 more job log\(s\) on disk/);
733
+
734
+ // Single-id lookup still works — that's the explicit escape hatch.
735
+ const one = await bgstatus.execute(
736
+ "call-f2",
737
+ { id: foreignId },
738
+ undefined,
739
+ undefined,
740
+ ctx,
741
+ );
742
+ assert.match(one.content[0].text as string, /: running/);
440
743
  } finally {
441
744
  delete process.env.PI_BGRUN_DIR;
442
745
  rmSync(dir, { recursive: true, force: true });
@@ -446,6 +749,7 @@ test("bgstatus: list shows name after job id", async () => {
446
749
  test("session_start: adopts running jobs from the jobs dir (other session's job) into the widget", async () => {
447
750
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
448
751
  process.env.PI_BGRUN_DIR = dir;
752
+ process.env.PI_BGRUN_FOREIGN_JOBS = "1";
449
753
  try {
450
754
  // A log with no exit marker whose pid is alive (this test process's own pid).
451
755
  const adoptedId = `kafka-bootstrap-${Date.now()}-${process.pid}`;
@@ -454,7 +758,8 @@ test("session_start: adopts running jobs from the jobs dir (other session's job)
454
758
  const { pi, tools, ctx, fireSessionStart } = makeFakePi();
455
759
  ctx.hasUI = true;
456
760
  const widgetCalls: (string[] | undefined)[] = [];
457
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => widgetCalls.push(lines);
761
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
762
+ widgetCalls.push(lines);
458
763
 
459
764
  await loadExtension(pi);
460
765
  await fireSessionStart();
@@ -469,10 +774,73 @@ test("session_start: adopts running jobs from the jobs dir (other session's job)
469
774
 
470
775
  // bgstatus single-id should also see it as running (in-memory now).
471
776
  const bgstatus = tools.get("bgstatus")!;
472
- const res = await bgstatus.execute("call-a1", { id: adoptedId }, undefined, undefined, ctx);
777
+ const res = await bgstatus.execute(
778
+ "call-a1",
779
+ { id: adoptedId },
780
+ undefined,
781
+ undefined,
782
+ ctx,
783
+ );
473
784
  assert.match(res.content[0].text as string, /: running/);
474
785
  } finally {
475
786
  delete process.env.PI_BGRUN_DIR;
787
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
788
+ rmSync(dir, { recursive: true, force: true });
789
+ }
790
+ });
791
+
792
+ test("adopted job leaves the widget once its log shows the exit marker (revalidation)", async () => {
793
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
794
+ process.env.PI_BGRUN_DIR = dir;
795
+ process.env.PI_BGRUN_FOREIGN_JOBS = "1";
796
+ try {
797
+ // Foreign running job (live pid = this process, no marker).
798
+ const adoptedId = `foreign-finish-${Date.now()}-${process.pid}`;
799
+ const logPath = join(dir, `${adoptedId}.log`);
800
+ writeFileSync(logPath, "job still going\n");
801
+
802
+ const { pi, tools, ctx, fireSessionStart } = makeFakePi();
803
+ ctx.hasUI = true;
804
+ const widgetCalls: (string[] | undefined)[] = [];
805
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
806
+ widgetCalls.push(lines);
807
+
808
+ await loadExtension(pi);
809
+ await fireSessionStart();
810
+ assert.ok(
811
+ widgetCalls.some((l) => Array.isArray(l)),
812
+ "widget shown after adoption",
813
+ );
814
+
815
+ // The foreign job finishes: marker appears in the log.
816
+ writeFileSync(logPath, "job still going\n__BGRUN_EXIT__=0\n");
817
+
818
+ // Any bgstatus call revalidates adopted jobs and refreshes the widget.
819
+ const bgstatus = tools.get("bgstatus")!;
820
+ const list = await bgstatus.execute(
821
+ "call-a2",
822
+ {},
823
+ undefined,
824
+ undefined,
825
+ ctx,
826
+ );
827
+ const text = list.content[0].text as string;
828
+ assert.ok(
829
+ !/: running/.test(text),
830
+ "adopted job no longer listed as running",
831
+ );
832
+ assert.match(
833
+ text,
834
+ /\(1 more job log\(s\) on disk/,
835
+ "finished adopted job folded into the disk note",
836
+ );
837
+ assert.ok(
838
+ widgetCalls.some((l) => l === undefined),
839
+ "widget cleared after adopted job finished",
840
+ );
841
+ } finally {
842
+ delete process.env.PI_BGRUN_DIR;
843
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
476
844
  rmSync(dir, { recursive: true, force: true });
477
845
  }
478
846
  });
@@ -480,9 +848,13 @@ test("session_start: adopts running jobs from the jobs dir (other session's job)
480
848
  test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
481
849
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
482
850
  process.env.PI_BGRUN_DIR = dir;
851
+ process.env.PI_BGRUN_FOREIGN_JOBS = "1";
483
852
  try {
484
853
  // Finished (exit marker present).
485
- writeFileSync(join(dir, `done-job-${Date.now()}-${process.pid}.log`), "out\n__BGRUN_EXIT__=0\n");
854
+ writeFileSync(
855
+ join(dir, `done-job-${Date.now()}-${process.pid}.log`),
856
+ "out\n__BGRUN_EXIT__=0\n",
857
+ );
486
858
  // No marker but pid is certainly dead (pid 1 is launchd — alive, so use a likely-dead high pid).
487
859
  // Use pid 1-style trick instead: a dead pid we spawn and reap.
488
860
  const { spawnSync } = await import("node:child_process");
@@ -490,7 +862,10 @@ test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
490
862
  assert.equal(dead.status, 0);
491
863
  // Write log with a pid that no longer exists: use the reaped child's pid if captured, else 999999.
492
864
  const deadPid = dead.pid ?? 999999;
493
- writeFileSync(join(dir, `dead-job-${Date.now()}-${deadPid}.log`), "partial\n");
865
+ writeFileSync(
866
+ join(dir, `dead-job-${Date.now()}-${deadPid}.log`),
867
+ "partial\n",
868
+ );
494
869
 
495
870
  const { pi, ctx, fireSessionStart } = makeFakePi();
496
871
  ctx.hasUI = true;
@@ -502,6 +877,31 @@ test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
502
877
  await loadExtension(pi);
503
878
  await fireSessionStart();
504
879
  assert.equal(widgetShown, false, "no widget for finished/dead jobs");
880
+ } finally {
881
+ delete process.env.PI_BGRUN_DIR;
882
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
883
+ rmSync(dir, { recursive: true, force: true });
884
+ }
885
+ });
886
+
887
+ test("session_start: with foreign adoption OFF, finished foreign logs are not adopted", async () => {
888
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
889
+ process.env.PI_BGRUN_DIR = dir;
890
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
891
+ try {
892
+ writeFileSync(
893
+ join(dir, `done-job-${Date.now()}-${process.pid}.log`),
894
+ "out\n__BGRUN_EXIT__=0\n",
895
+ );
896
+ const { pi, ctx, fireSessionStart } = makeFakePi();
897
+ ctx.hasUI = true;
898
+ let widgetShown = false;
899
+ ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
900
+ if (lines) widgetShown = true;
901
+ };
902
+ await loadExtension(pi);
903
+ await fireSessionStart();
904
+ assert.equal(widgetShown, false, "no adoption when disabled");
505
905
  } finally {
506
906
  delete process.env.PI_BGRUN_DIR;
507
907
  rmSync(dir, { recursive: true, force: true });
@@ -516,14 +916,24 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
516
916
  await loadExtension(pi);
517
917
  const bgrun = tools.get("bgrun")!;
518
918
 
519
- const res = await bgrun.execute("call-pid", { command: "echo pidcheck" }, undefined, undefined, ctx);
919
+ const res = await bgrun.execute(
920
+ "call-pid",
921
+ { command: "echo pidcheck" },
922
+ undefined,
923
+ undefined,
924
+ ctx,
925
+ );
520
926
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
521
927
  const idPid = Number(id.split("-").pop());
522
928
  assert.ok(idPid > 0, `id ends with child pid: ${id}`);
523
929
  assert.notEqual(idPid, process.pid, "id must NOT carry pi's own pid");
524
930
  // Log file named after the id, no .tmp- leftovers.
525
931
  assert.ok(existsSync(join(dir, `${id}.log`)), "log at final id-named path");
526
- assert.equal(readdirSync(dir).filter((f) => f.startsWith(".tmp-")).length, 0, "no temp log leftovers");
932
+ assert.equal(
933
+ readdirSync(dir).filter((f) => f.startsWith(".tmp-")).length,
934
+ 0,
935
+ "no temp log leftovers",
936
+ );
527
937
 
528
938
  await waitForWakes(wakes, 1);
529
939
  } finally {
@@ -550,9 +960,18 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
550
960
  await loadExtension(pi);
551
961
  const bgclean = tools.get("bgclean")!;
552
962
 
553
- const result = await bgclean.execute("call-stale", { days: 7 }, undefined, undefined, ctx);
963
+ const result = await bgclean.execute(
964
+ "call-stale",
965
+ { days: 7 },
966
+ undefined,
967
+ undefined,
968
+ ctx,
969
+ );
554
970
  assert.match(result.content[0].text as string, /removed 1/);
555
- assert.ok(!existsSync(oldPath), "finished job's log removed despite live id-pid");
971
+ assert.ok(
972
+ !existsSync(oldPath),
973
+ "finished job's log removed despite live id-pid",
974
+ );
556
975
  } finally {
557
976
  delete process.env.PI_BGRUN_DIR;
558
977
  rmSync(dir, { recursive: true, force: true });
@@ -562,9 +981,13 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
562
981
  test("session_start adoption: skips finished jobs even with a live id-pid", async () => {
563
982
  const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
564
983
  process.env.PI_BGRUN_DIR = dir;
984
+ process.env.PI_BGRUN_FOREIGN_JOBS = "1";
565
985
  try {
566
986
  // Finished job (exit marker) whose id-pid is this process (alive).
567
- writeFileSync(join(dir, `done-job-${Date.now()}-${process.pid}.log`), "out\n__BGRUN_EXIT__=0\n");
987
+ writeFileSync(
988
+ join(dir, `done-job-${Date.now()}-${process.pid}.log`),
989
+ "out\n__BGRUN_EXIT__=0\n",
990
+ );
568
991
  const { pi, ctx, fireSessionStart } = makeFakePi();
569
992
  ctx.hasUI = true;
570
993
  let widgetShown = false;
@@ -573,7 +996,68 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
573
996
  };
574
997
  await loadExtension(pi);
575
998
  await fireSessionStart();
576
- assert.equal(widgetShown, false, "finished job not adopted even though id-pid is alive");
999
+ assert.equal(
1000
+ widgetShown,
1001
+ false,
1002
+ "finished job not adopted even though id-pid is alive",
1003
+ );
1004
+ } finally {
1005
+ delete process.env.PI_BGRUN_DIR;
1006
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1007
+ rmSync(dir, { recursive: true, force: true });
1008
+ }
1009
+ });
1010
+
1011
+ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs", async () => {
1012
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1013
+ process.env.PI_BGRUN_DIR = dir;
1014
+ delete process.env.PI_BGRUN_FOREIGN_JOBS;
1015
+ try {
1016
+ const fs = await import("node:fs");
1017
+ const backdate = (path: string) => {
1018
+ const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1019
+ fs.utimesSync(path, oldTime, oldTime);
1020
+ };
1021
+
1022
+ // Old log A + first session_start (no marker yet) → sweep runs, A removed.
1023
+ const logA = join(dir, "old-a-1000000000-99999.log");
1024
+ fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
1025
+ backdate(logA);
1026
+ {
1027
+ const { pi, ctx, fireSessionStart } = makeFakePi();
1028
+ await loadExtension(pi);
1029
+ await fireSessionStart();
1030
+ }
1031
+ assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
1032
+ assert.ok(
1033
+ fs.existsSync(join(dir, ".last-clean")),
1034
+ "throttle marker written",
1035
+ );
1036
+
1037
+ // Old log B + second session_start while marker is fresh → throttled, B kept.
1038
+ const logB = join(dir, "old-b-1000000000-99998.log");
1039
+ fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
1040
+ backdate(logB);
1041
+ {
1042
+ const { pi, ctx, fireSessionStart } = makeFakePi();
1043
+ await loadExtension(pi);
1044
+ await fireSessionStart();
1045
+ }
1046
+ assert.ok(fs.existsSync(logB), "second sweep throttled — old log B kept");
1047
+
1048
+ // Manual bgclean ignores the throttle and removes B.
1049
+ const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
1050
+ await loadExtension(pi3);
1051
+ const bgclean = tools3.get("bgclean")!;
1052
+ const result = await bgclean.execute(
1053
+ "call-t1",
1054
+ {},
1055
+ undefined,
1056
+ undefined,
1057
+ ctx3,
1058
+ );
1059
+ assert.match(result.content[0].text as string, /removed 1/);
1060
+ assert.ok(!fs.existsSync(logB), "manual bgclean removed log B");
577
1061
  } finally {
578
1062
  delete process.env.PI_BGRUN_DIR;
579
1063
  rmSync(dir, { recursive: true, force: true });
@@ -590,7 +1074,13 @@ test("bgclean: removes old logs, keeps recent ones", async () => {
590
1074
  const bgclean = tools.get("bgclean")!;
591
1075
 
592
1076
  // Run a real job (recent log — should be kept).
593
- await bgrun.execute("call-c1", { command: "echo recent" }, undefined, undefined, ctx);
1077
+ await bgrun.execute(
1078
+ "call-c1",
1079
+ { command: "echo recent" },
1080
+ undefined,
1081
+ undefined,
1082
+ ctx,
1083
+ );
594
1084
  await new Promise((r) => setTimeout(r, 200)); // let it finish
595
1085
 
596
1086
  // Write an old log file (backdated mtime).
@@ -600,12 +1090,20 @@ test("bgclean: removes old logs, keeps recent ones", async () => {
600
1090
  const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
601
1091
  fs.utimesSync(oldPath, oldTime, oldTime);
602
1092
 
603
- const result = await bgclean.execute("call-c2", { days: 7 }, undefined, undefined, ctx);
1093
+ const result = await bgclean.execute(
1094
+ "call-c2",
1095
+ { days: 7 },
1096
+ undefined,
1097
+ undefined,
1098
+ ctx,
1099
+ );
604
1100
  const text = result.content[0].text as string;
605
1101
  assert.match(text, /removed 1/);
606
1102
  assert.ok(!fs.existsSync(oldPath), "old log removed");
607
1103
  // The recent log should still exist.
608
- const remaining = fs.readdirSync(dir).filter((f: string) => f.endsWith(".log"));
1104
+ const remaining = fs
1105
+ .readdirSync(dir)
1106
+ .filter((f: string) => f.endsWith(".log"));
609
1107
  assert.equal(remaining.length, 1, "recent log kept");
610
1108
  } finally {
611
1109
  delete process.env.PI_BGRUN_DIR;
@@ -633,7 +1131,13 @@ test("bgclean: does not remove a running job's log", async () => {
633
1131
  const bgclean = tools.get("bgclean")!;
634
1132
 
635
1133
  // Start a long-running job (10s) so it's still running when we clean.
636
- const res = await bgrun.execute("call-c4", { command: "sleep 10" }, undefined, undefined, ctx);
1134
+ const res = await bgrun.execute(
1135
+ "call-c4",
1136
+ { command: "sleep 10" },
1137
+ undefined,
1138
+ undefined,
1139
+ ctx,
1140
+ );
637
1141
  const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
638
1142
  const logPath = join(dir, `${id}.log`);
639
1143
 
@@ -645,13 +1149,23 @@ test("bgclean: does not remove a running job's log", async () => {
645
1149
  await new Promise((r) => setTimeout(r, 100));
646
1150
  fs.utimesSync(logPath, oldTime, oldTime);
647
1151
 
648
- const result = await bgclean.execute("call-c5", { days: 7 }, undefined, undefined, ctx);
1152
+ const result = await bgclean.execute(
1153
+ "call-c5",
1154
+ { days: 7 },
1155
+ undefined,
1156
+ undefined,
1157
+ ctx,
1158
+ );
649
1159
  const text = result.content[0].text as string;
650
1160
  assert.match(text, /skipped 1 running/);
651
1161
  assert.ok(fs.existsSync(logPath), "running job's log not removed");
652
1162
 
653
- // Kill the orphaned sleep so it doesn't linger.
654
- try { process.kill((res.details as any).pid); } catch {}
1163
+ // Kill the orphaned sleep so it doesn't linger (best-effort: it may have exited already).
1164
+ try {
1165
+ process.kill((res.details as any).pid);
1166
+ } catch {
1167
+ // already gone — fine
1168
+ }
655
1169
  } finally {
656
1170
  delete process.env.PI_BGRUN_DIR;
657
1171
  rmSync(dir, { recursive: true, force: true });