@nklisch/pi-enhanced 0.2.0 → 0.2.2

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/node_modules/@nklisch/pi-background-tasks/CHANGELOG.md +7 -0
  3. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.test.ts +105 -0
  4. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts +131 -44
  5. package/node_modules/@nklisch/pi-background-tasks/package.json +1 -1
  6. package/node_modules/@nklisch/pi-background-tasks/skills/background-tasks/SKILL.md +6 -7
  7. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  8. package/node_modules/@nklisch/pi-clearance/package.json +1 -1
  9. package/node_modules/@nklisch/pi-clearance/src/config/config-command-writer.ts +34 -17
  10. package/node_modules/@nklisch/pi-clearance/src/index.ts +34 -3
  11. package/node_modules/@nklisch/pi-clearance/src/packs/package-registration.ts +63 -36
  12. package/node_modules/@nklisch/pi-clearance/src/runtime/allow-request-message.ts +40 -1
  13. package/node_modules/@nklisch/pi-clearance/src/runtime/command-registry.ts +22 -2
  14. package/node_modules/@nklisch/pi-clearance/src/runtime/command-transforms.ts +50 -29
  15. package/node_modules/@nklisch/pi-clearance/src/runtime/config-commands/settings/native-ui.ts +233 -49
  16. package/node_modules/@nklisch/pi-clearance/src/runtime/policy-cache.ts +19 -5
  17. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +48 -13
  18. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  19. package/node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts +49 -42
  20. package/node_modules/@nklisch/pi-fff-compat/extensions/finder-lifecycle.ts +92 -0
  21. package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
  22. package/node_modules/@nklisch/pi-model-modes/package.json +1 -1
  23. package/node_modules/@nklisch/pi-model-modes/src/command-message.ts +30 -0
  24. package/node_modules/@nklisch/pi-model-modes/src/commands.ts +31 -15
  25. package/node_modules/@nklisch/pi-model-modes/src/style-command.ts +25 -14
  26. package/node_modules/@nklisch/pi-plugins/README.md +2 -2
  27. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js +4 -1
  28. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js.map +1 -1
  29. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js +12 -3
  30. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js.map +1 -1
  31. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.d.ts +1 -0
  32. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js +19 -11
  33. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js.map +1 -1
  34. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js +36 -7
  35. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js.map +1 -1
  36. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js +14 -3
  37. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js.map +1 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js +153 -54
  39. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js.map +1 -1
  40. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js +35 -8
  41. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js.map +1 -1
  42. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-package.js +1 -1
  43. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-runtime.js +1 -1
  44. package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +22 -17
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/debug.ts +18 -0
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +22 -4
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +32 -8
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +8 -7
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +25 -6
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +6 -2
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +31 -16
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +70 -32
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +55 -50
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace-bracket.ts +6 -2
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/record-observer.ts +37 -34
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +15 -14
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +31 -26
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +51 -26
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +100 -46
  63. package/node_modules/@nklisch/pi-plugins/package.json +3 -3
  64. package/package.json +4 -4
  65. package/test/verify-bundle.mjs +8 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.2
4
+
5
+ ### Fixed
6
+
7
+ - Point the bundled FFF compatibility resource at its extension entrypoint instead of its source directory, preventing Pi from trying to load the finder lifecycle helper as an extension.
8
+
9
+ ## v0.2.1
10
+
11
+ ### Fixed
12
+
13
+ - Rebundle the extension reliability releases that contain stale contexts, detached promises, timers, process callbacks, UI callbacks, and cleanup failures instead of allowing them to terminate Pi.
14
+
3
15
  ## v0.2.0
4
16
 
5
17
  ### Changed
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## v0.1.7 — 2026-08-23
4
+
5
+ ### Fixed
6
+
7
+ - Contain stale session contexts, rejected wake and persistence calls, process callbacks, and monitor failures inside each job. Session replacement now cancels owned work before Pi revokes reporting access, and diagnostics remain available through job output when the session is still active.
@@ -233,6 +233,52 @@ describe("background tool", () => {
233
233
  expect(n).toBeTruthy();
234
234
  expect(n!.level).toBe("success");
235
235
  });
236
+
237
+ test("REGRESSION: stale Pi and UI contexts cannot escape a detached process callback", async () => {
238
+ // Session replacement invalidates both the extension API and prior tool
239
+ // contexts. The Criterion completion crash escaped from child.on("exit")
240
+ // when appendEntry threw on exactly this stale-context path.
241
+ const { pi, tools } = makeFakePi();
242
+ const bg = tools.get("background")!;
243
+ const jobs = tools.get("jobs")!;
244
+ const ctx = makeContext();
245
+ pi.appendEntry = () => { throw new Error("stale appendEntry"); };
246
+ pi.sendMessage = () => { throw new Error("stale sendMessage"); };
247
+ ctx.ui!.notify = () => { throw new Error("stale notify"); };
248
+ ctx.ui!.setStatus = () => { throw new Error("stale setStatus"); };
249
+
250
+ const diagnostics: string[] = [];
251
+ const priorConsoleError = console.error;
252
+ console.error = (...args: unknown[]) => diagnostics.push(args.map(String).join(" "));
253
+ try {
254
+ const started = (await bg.execute(
255
+ "c1",
256
+ { command: "echo completes-after-context-invalidates" },
257
+ undefined,
258
+ undefined,
259
+ ctx,
260
+ )) as { details: { jobId: number } };
261
+
262
+ // Do not poll through the jobs tool yet: every tool call intentionally
263
+ // refreshes lastUi, which would replace the simulated stale context.
264
+ await waitFor(() => diagnostics.some((line) => line.includes("waking the agent failed")) || undefined);
265
+ expect(await jobStatus(tools, makeContext(), started.details.jobId)).toBe("completed");
266
+ const tail = (await jobs.execute(
267
+ "c2",
268
+ { action: "tail", jobId: started.details.jobId, lines: 100 },
269
+ undefined,
270
+ undefined,
271
+ makeContext(),
272
+ )) as { content: Array<{ text: string }> };
273
+
274
+ expect(tail.content[0].text).toContain("persisting job registry failed: stale appendEntry");
275
+ expect(tail.content[0].text).toContain("refreshing background status failed: stale setStatus");
276
+ expect(tail.content[0].text).toContain("notifying the user failed: stale notify");
277
+ expect(tail.content[0].text).toContain("waking the agent failed: stale sendMessage");
278
+ } finally {
279
+ console.error = priorConsoleError;
280
+ }
281
+ });
236
282
  });
237
283
 
238
284
  describe("monitor tool", () => {
@@ -302,6 +348,65 @@ describe("monitor tool", () => {
302
348
  }
303
349
  });
304
350
 
351
+ test("turns an unexpected detached poll exception into a failed job", async () => {
352
+ const hostile = {
353
+ [Symbol.toPrimitive]() { throw new Error("hostile poll output"); },
354
+ };
355
+ const { tools, wakes } = makeFakePi(async () => ({
356
+ stdout: hostile as unknown as string,
357
+ code: 0,
358
+ }));
359
+ const mon = tools.get("monitor")!;
360
+ const jobs = tools.get("jobs")!;
361
+ const ctx = makeContext();
362
+ const started = (await mon.execute(
363
+ "c1",
364
+ { command: "check", satisfy_on: "exit_zero", interval_seconds: 1, timeout_seconds: 5 },
365
+ undefined,
366
+ undefined,
367
+ ctx,
368
+ )) as { details: { jobId: number } };
369
+
370
+ const wake = await waitFor(() => wakes[0]);
371
+ expect(wake.content).toContain("internal running monitor poll failure");
372
+ expect(await jobStatus(tools, ctx, started.details.jobId)).toBe("failed");
373
+ const tail = (await jobs.execute(
374
+ "c2",
375
+ { action: "tail", jobId: started.details.jobId, lines: 100 },
376
+ undefined,
377
+ undefined,
378
+ ctx,
379
+ )) as { content: Array<{ text: string }> };
380
+ expect(tail.content[0].text).toContain("running monitor poll failed: hostile poll output");
381
+ });
382
+
383
+ test("captures cwd before delayed polls so a replaced context is never reused", async () => {
384
+ let calls = 0;
385
+ const { tools, wakes } = makeFakePi(async () => {
386
+ calls += 1;
387
+ return calls === 1
388
+ ? { stdout: "pending", code: 1 }
389
+ : { stdout: "READY", code: 0 };
390
+ });
391
+ const mon = tools.get("monitor")!;
392
+ const ctx = makeContext();
393
+ await mon.execute(
394
+ "c1",
395
+ { command: "check", satisfy_on: "stdout_matches", pattern: "READY", interval_seconds: 1, timeout_seconds: 5 },
396
+ undefined,
397
+ undefined,
398
+ ctx,
399
+ );
400
+ Object.defineProperty(ctx, "cwd", {
401
+ configurable: true,
402
+ get: () => { throw new Error("stale tool context"); },
403
+ });
404
+
405
+ const wake = await waitFor(() => wakes[0], { timeoutMs: 5000 });
406
+ expect(wake.content).toContain("satisfied");
407
+ expect(calls).toBe(2);
408
+ });
409
+
305
410
  test("times out when the condition never holds", async () => {
306
411
  const { tools, wakes } = makeFakePi();
307
412
  const mon = tools.get("monitor")!;
@@ -318,15 +318,73 @@ export default function backgroundTasksExtension(pi: PiApi): void {
318
318
  let nextId = 1;
319
319
  let shuttingDown = false;
320
320
 
321
- function snapshot(): void {
322
- pi.appendEntry?.("background-tasks", {
323
- jobs: Array.from(jobs.values()).map((j) => ({
324
- id: j.id,
325
- kind: j.kind,
326
- label: j.label,
327
- command: j.command,
328
- status: j.status,
329
- })),
321
+ function errorText(error: unknown): string {
322
+ try {
323
+ return error instanceof Error ? error.message : String(error);
324
+ } catch {
325
+ return "unprintable error";
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Detached callbacks have no host-owned tool/event promise boundary. Record
331
+ * their failures in the job output (where the agent can retrieve them) and
332
+ * stderr, but never let them escape into Node's EventEmitter/timer machinery.
333
+ */
334
+ function recordOperationalError(job: Job | undefined, operation: string, error: unknown): void {
335
+ const diagnostic = `[background-tasks] ${operation} failed: ${errorText(error)}`;
336
+ try {
337
+ if (job) appendBuffer(job, `\n${diagnostic}\n`);
338
+ } catch {
339
+ // The final containment boundary must remain total even under memory
340
+ // pressure or a damaged record.
341
+ }
342
+ try {
343
+ console.error(diagnostic);
344
+ } catch {
345
+ // stderr can itself be unavailable during process teardown.
346
+ }
347
+ }
348
+
349
+ function guarded(job: Job | undefined, operation: string, callback: () => void): void {
350
+ try {
351
+ callback();
352
+ } catch (error) {
353
+ recordOperationalError(job, operation, error);
354
+ }
355
+ }
356
+
357
+ function runDetached(job: Job | undefined, operation: string, callback: () => Promise<void>): void {
358
+ const fail = (error: unknown): void => {
359
+ recordOperationalError(job, operation, error);
360
+ if (!job || job.status !== "running") return;
361
+ job.polling = false;
362
+ job.status = "failed";
363
+ wake(
364
+ `[${job.kind} #${job.id} "${job.label}" stopped after an internal ${operation} failure]. Read the diagnostic with the jobs tool (action=tail, jobId=${job.id}).`,
365
+ {},
366
+ job,
367
+ );
368
+ finalize(job);
369
+ };
370
+ try {
371
+ void callback().catch(fail);
372
+ } catch (error) {
373
+ fail(error);
374
+ }
375
+ }
376
+
377
+ function snapshot(job?: Job): void {
378
+ guarded(job, "persisting job registry", () => {
379
+ pi.appendEntry?.("background-tasks", {
380
+ jobs: Array.from(jobs.values()).map((j) => ({
381
+ id: j.id,
382
+ kind: j.kind,
383
+ label: j.label,
384
+ command: j.command,
385
+ status: j.status,
386
+ })),
387
+ });
330
388
  });
331
389
  }
332
390
 
@@ -354,8 +412,8 @@ export default function backgroundTasksExtension(pi: PiApi): void {
354
412
  /** The last ctx.ui seen by any tool call — used to refresh visuals from async callbacks. */
355
413
  let lastUi: UiContext | undefined;
356
414
 
357
- function refreshVisuals(): void {
358
- updateStatus(lastUi);
415
+ function refreshVisuals(job?: Job): void {
416
+ guarded(job, "refreshing background status", () => updateStatus(lastUi));
359
417
  }
360
418
 
361
419
  /**
@@ -364,7 +422,7 @@ export default function backgroundTasksExtension(pi: PiApi): void {
364
422
  * which is attacker-controlled. The agent reads the actual output on demand
365
423
  * via the jobs tool (tail/view). Returns void; the wake is best-effort.
366
424
  */
367
- function wake(message: string, details: Record<string, unknown> = {}): void {
425
+ function wake(message: string, details: Record<string, unknown> = {}, job?: Job): void {
368
426
  if (shuttingDown) return; // don't trigger turns during/after shutdown
369
427
  const tail = message.split("\n")[0];
370
428
  const wakeDetails = { source: "background-tasks", trusted: true, ...details };
@@ -386,28 +444,30 @@ export default function backgroundTasksExtension(pi: PiApi): void {
386
444
  },
387
445
  { triggerTurn: true, deliverAs: "steer" },
388
446
  ),
389
- ).catch((err) => {
390
- console.error(`[background-tasks] wake failed: ${(err as Error).message}`);
447
+ ).catch((error) => {
448
+ recordOperationalError(job, "waking the agent", error);
391
449
  });
392
450
  } else if (lastUi?.notify) {
393
451
  lastUi.notify(`${tail} (auto-wake unavailable: pi.sendMessage missing)`, "info");
394
452
  } else {
395
453
  console.error(`[background-tasks] wake (no channel): ${tail}`);
396
454
  }
397
- } catch (err) {
398
- console.error(`[background-tasks] wake threw: ${(err as Error).message}`);
455
+ } catch (error) {
456
+ recordOperationalError(job, "waking the agent", error);
399
457
  }
400
458
  }
401
459
 
402
- function notify(level: "info" | "success" | "warning" | "error", message: string): void {
403
- lastUi?.notify?.(message, level);
460
+ function notify(level: "info" | "success" | "warning" | "error", message: string, job?: Job): void {
461
+ guarded(job, "notifying the user", () => lastUi?.notify?.(message, level));
404
462
  }
405
463
 
406
464
  function finalize(job: Job): void {
407
465
  job.endedAt = Date.now();
408
- snapshot();
466
+ // Session-bound Pi/UI objects are revoked at shutdown. Late process events
467
+ // may still complete plain in-memory state, but must not touch those APIs.
468
+ if (!shuttingDown) snapshot(job);
409
469
  pruneTerminal();
410
- refreshVisuals();
470
+ if (!shuttingDown) refreshVisuals(job);
411
471
  }
412
472
 
413
473
  /**
@@ -546,13 +606,15 @@ export default function backgroundTasksExtension(pi: PiApi): void {
546
606
  // Trusted wake: no command output, just id + the matched fact.
547
607
  wake(
548
608
  `[background job #${job.id} "${label}" matched its wake_on_pattern — still running]. Read output with the jobs tool (action=tail, jobId=${job.id}).`,
609
+ {},
610
+ job,
549
611
  );
550
612
  }
551
613
  };
552
- child.stdout?.on("data", handleChunk);
553
- child.stderr?.on("data", handleChunk);
614
+ child.stdout?.on("data", (data) => guarded(job, "capturing stdout", () => handleChunk(data)));
615
+ child.stderr?.on("data", (data) => guarded(job, "capturing stderr", () => handleChunk(data)));
554
616
 
555
- child.on("exit", (code, signal) => {
617
+ child.on("exit", (code, signal) => guarded(job, "handling process exit", () => {
556
618
  if (job.status === "cancelled" || job.status === "kill_failed") return; // cancellation owns terminal state
557
619
  if (job.status === "cancelling") {
558
620
  // Reaped during cancellation (SIGTERM or SIGKILL worked) — mark cancelled.
@@ -566,26 +628,30 @@ export default function backgroundTasksExtension(pi: PiApi): void {
566
628
  const ok = code === 0;
567
629
  job.status = ok ? "completed" : "failed";
568
630
  const reason = signal ? `signal ${signal}` : `exit ${code ?? "?"}`;
569
- notify(ok ? "success" : "error", `background job #${job.id} "${label}" finished: ${reason}`);
631
+ notify(ok ? "success" : "error", `background job #${job.id} "${label}" finished: ${reason}`, job);
570
632
  // Trusted wake: only id, label, and the status word. NO command output.
571
633
  wake(
572
634
  `[background job #${job.id} "${label}" finished: ${reason}]. Read its output with the jobs tool (action=tail, jobId=${job.id}).`,
635
+ {},
636
+ job,
573
637
  );
574
638
  finalize(job);
575
- });
576
- child.on("error", (err) => {
639
+ }));
640
+ child.on("error", (error) => guarded(job, "handling process error", () => {
577
641
  if (job.status !== "running" && job.status !== "cancelling") return;
578
642
  job.status = "failed";
579
- notify("error", `background job #${job.id} "${label}" failed to spawn: ${err.message}`);
643
+ notify("error", `background job #${job.id} "${label}" failed to spawn: ${error.message}`, job);
580
644
  wake(
581
- `[background job #${job.id} "${label}" failed to spawn: ${err.message}]. No command output was produced.`,
645
+ `[background job #${job.id} "${label}" failed to spawn: ${error.message}]. No command output was produced.`,
646
+ {},
647
+ job,
582
648
  );
583
649
  finalize(job);
584
- });
650
+ }));
585
651
 
586
652
  jobs.set(job.id, job);
587
- snapshot();
588
- refreshVisuals();
653
+ snapshot(job);
654
+ refreshVisuals(job);
589
655
 
590
656
  const patternNote = wakeOnPattern
591
657
  ? `, or earlier the first time output matches /${wakePatternRaw}/`
@@ -616,6 +682,9 @@ export default function backgroundTasksExtension(pi: PiApi): void {
616
682
  };
617
683
  }
618
684
  const label = params.label ? String(params.label) : slugify(command);
685
+ // Capture plain data while the tool context is current. Delayed polls must
686
+ // never dereference a session-bound context after replacement or reload.
687
+ const cwd = ctx.cwd ?? process.cwd();
619
688
  const rawInterval = Number(params.interval_seconds ?? DEFAULT_MONITOR_INTERVAL_S);
620
689
  const intervalSeconds = Number.isFinite(rawInterval) && rawInterval >= MIN_MONITOR_INTERVAL_S
621
690
  ? rawInterval
@@ -659,8 +728,8 @@ export default function backgroundTasksExtension(pi: PiApi): void {
659
728
  pattern: patternRaw,
660
729
  };
661
730
  jobs.set(job.id, job);
662
- snapshot();
663
- refreshVisuals();
731
+ snapshot(job);
732
+ refreshVisuals(job);
664
733
 
665
734
  const evaluate = (out: string, code: number | null | undefined): boolean => {
666
735
  switch (satisfyOn) {
@@ -693,7 +762,7 @@ export default function backgroundTasksExtension(pi: PiApi): void {
693
762
  // tool's `spawn(command, { shell: "/bin/sh" })`.
694
763
  result = await pi.exec!("/bin/sh", ["-c", command], {
695
764
  timeout: Math.max(5, intervalSeconds) * 1000,
696
- cwd: ctx.cwd ?? process.cwd(),
765
+ cwd,
697
766
  });
698
767
  } catch (err) {
699
768
  result = { stderr: (err as Error).message, code: null };
@@ -724,9 +793,11 @@ export default function backgroundTasksExtension(pi: PiApi): void {
724
793
  job.exitCode = result.code ?? undefined;
725
794
  job.status = "timeout";
726
795
  const hint = `every poll failed to run its command (command not found in stderr). The poll command likely references a missing/typo'd binary or tool`;
727
- notify("error", `monitor #${job.id} "${label}" aborting: ${hint}. Check the poll command.`);
796
+ notify("error", `monitor #${job.id} "${label}" aborting: ${hint}. Check the poll command.`, job);
728
797
  wake(
729
798
  `[monitor #${job.id} "${label}" aborted after ${job.pollFailures} consecutive broken polls: ${hint}. Read the last poll with the jobs tool (action=tail, jobId=${job.id}) and fix the command.`,
799
+ {},
800
+ job,
730
801
  );
731
802
  finalize(job);
732
803
  return;
@@ -735,10 +806,12 @@ export default function backgroundTasksExtension(pi: PiApi): void {
735
806
  if (evaluate(result.stdout ?? "", result.code)) {
736
807
  job.exitCode = result.code ?? undefined;
737
808
  job.status = "satisfied";
738
- notify("success", `monitor #${job.id} "${label}" satisfied (${satisfyOn})`);
809
+ notify("success", `monitor #${job.id} "${label}" satisfied (${satisfyOn})`, job);
739
810
  // Trusted wake: no command output.
740
811
  wake(
741
812
  `[monitor #${job.id} "${label}" satisfied: ${satisfyOn}, exit ${result.code ?? "?"}]. Read the result with the jobs tool (action=tail, jobId=${job.id}).`,
813
+ {},
814
+ job,
742
815
  );
743
816
  finalize(job);
744
817
  return;
@@ -746,19 +819,25 @@ export default function backgroundTasksExtension(pi: PiApi): void {
746
819
  if (Date.now() >= (job.deadline ?? 0)) {
747
820
  job.exitCode = result.code ?? undefined;
748
821
  job.status = "timeout";
749
- notify("warning", `monitor #${job.id} "${label}" timed out after ${timeoutSeconds}s`);
822
+ notify("warning", `monitor #${job.id} "${label}" timed out after ${timeoutSeconds}s`, job);
750
823
  wake(
751
824
  `[monitor #${job.id} "${label}" timed out after ${timeoutSeconds}s without satisfying ${satisfyOn}]. Read the last poll with the jobs tool (action=tail, jobId=${job.id}).`,
825
+ {},
826
+ job,
752
827
  );
753
828
  finalize(job);
754
829
  return;
755
830
  }
756
831
  // Schedule the NEXT poll only after this one finished -> no overlap.
757
- job.timer = setTimeout(() => void tick(), intervalSeconds * 1000);
832
+ job.timer = setTimeout(
833
+ () => runDetached(job, "running monitor poll", tick),
834
+ intervalSeconds * 1000,
835
+ );
758
836
  };
759
837
 
760
- // First tick immediately so short conditions resolve fast.
761
- void tick();
838
+ // First tick immediately so short conditions resolve fast. A monitor poll
839
+ // runs outside Pi's awaited tool boundary, so contain every rejection here.
840
+ runDetached(job, "running monitor poll", tick);
762
841
 
763
842
  return {
764
843
  content: [
@@ -972,12 +1051,20 @@ export default function backgroundTasksExtension(pi: PiApi): void {
972
1051
  // Await cancellations so SIGTERM/SIGKILL escalation actually completes before
973
1052
  // the process tears down (a fire-and-forget timer could be killed mid-escalate).
974
1053
  pi.on?.("session_shutdown", async () => {
1054
+ // Set the fence before the first await. Any process event that arrives
1055
+ // during teardown may finish its in-memory state, but must not start a new
1056
+ // turn. Clear the retained UI before Pi invalidates this extension context.
975
1057
  shuttingDown = true;
976
- await Promise.all(
977
- Array.from(jobs.values())
978
- .filter((j) => j.status === "running" || j.status === "cancelling")
979
- .map((j) => cancelJob(j)),
1058
+ lastUi = undefined;
1059
+ const active = Array.from(jobs.values()).filter(
1060
+ (job) => job.status === "running" || job.status === "cancelling",
980
1061
  );
1062
+ const outcomes = await Promise.allSettled(active.map((job) => cancelJob(job)));
1063
+ outcomes.forEach((outcome, index) => {
1064
+ if (outcome.status === "rejected") {
1065
+ recordOperationalError(active[index], "cancelling job during session shutdown", outcome.reason);
1066
+ }
1067
+ });
981
1068
  });
982
1069
  }
983
1070
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-background-tasks",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Pi-native runtime tools — background a long command and get woken on exit, poll a command until a condition holds, and manage the job registry.",
5
5
  "author": {
6
6
  "name": "nklisch"
@@ -129,10 +129,9 @@ manage waiting itself. This is intentional harness-specific surface — the
129
129
  portable knowledge is the when/why above; the executable ergonomics live in the
130
130
  extension.
131
131
 
132
- **Known limitation session scoping.** The job registry, wake channel, and UI
133
- handles are process-global within a single pi process, not per-session. If you
134
- switch or fork sessions while a background job is still running, that job's
135
- completion can still fire and wake/notify the *current* session rather than the
136
- one that started it. For long jobs that may outlive a session switch, prefer
137
- starting them in the session you intend to receive the wake, or cancel before
138
- switching. (Per-session scoping is a future improvement.)
132
+ **Session lifecycle.** Jobs belong to the extension instance that started them.
133
+ Session replacement, fork, reload, and shutdown cancel its running processes and
134
+ monitors before Pi revokes that instance. Detached callbacks retain only plain
135
+ job data; they never reuse an old command context, and a late process event is
136
+ contained without waking or notifying the replacement session. Start a new job
137
+ in the replacement session when work should continue there.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-clearance",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Configurable auto-reviewer Pi extension for parsed, structural command policy",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -114,6 +114,10 @@ interface AtomicConfigWriteInput {
114
114
  ) => Promise<ConfigCommandPostWriteValidationResult>;
115
115
  readonly writeFailureReason: string;
116
116
  readonly postWriteFailureReason: string;
117
+ /** Test seam for deterministic failure coverage; production uses fs rename. */
118
+ readonly renameTempFile?: (sourcePath: string, targetPath: string) => Promise<void>;
119
+ /** Test seam for deterministic cleanup failure coverage. */
120
+ readonly cleanupTempFile?: (tempPath: string) => Promise<void>;
117
121
  }
118
122
 
119
123
  /**
@@ -208,22 +212,25 @@ export async function writeConfigTargetAndValidate(
208
212
  let tempPresent = false;
209
213
  let renamed = false;
210
214
  let backupCreated = false;
215
+ let result: ConfigCommandApplyResult;
211
216
 
212
217
  try {
213
218
  await mkdir(path.dirname(input.targetPath), { recursive: true });
219
+ // Mark the path before writing: if the write rejects after creating a
220
+ // partial file, the same best-effort cleanup still runs.
221
+ tempPresent = true;
214
222
  await writeFile(
215
223
  tempPath,
216
224
  serializeSparseConfigText(input.configKind, input.value),
217
225
  "utf8",
218
226
  );
219
- tempPresent = true;
220
227
 
221
228
  if (input.hadExistingFile) {
222
229
  await copyFile(input.targetPath, backupPath);
223
230
  backupCreated = true;
224
231
  }
225
232
 
226
- await rename(tempPath, input.targetPath);
233
+ await (input.renameTempFile ?? rename)(tempPath, input.targetPath);
227
234
  tempPresent = false;
228
235
  renamed = true;
229
236
 
@@ -235,7 +242,7 @@ export async function writeConfigTargetAndValidate(
235
242
  backupPath,
236
243
  input.hadExistingFile,
237
244
  );
238
- return {
245
+ result = {
239
246
  ok: false,
240
247
  planId: input.planId,
241
248
  targetPath: input.targetPath,
@@ -244,17 +251,17 @@ export async function writeConfigTargetAndValidate(
244
251
  restored,
245
252
  errors: policyValidation.errors,
246
253
  };
254
+ } else {
255
+ result = {
256
+ ok: true,
257
+ planId: input.planId,
258
+ changed: true,
259
+ targetPath: input.targetPath,
260
+ ...(backupCreated ? { backupPath } : {}),
261
+ resolvedConfig,
262
+ warnings: policyValidation.warnings ?? [],
263
+ };
247
264
  }
248
-
249
- return {
250
- ok: true,
251
- planId: input.planId,
252
- changed: true,
253
- targetPath: input.targetPath,
254
- ...(backupCreated ? { backupPath } : {}),
255
- resolvedConfig,
256
- warnings: policyValidation.warnings ?? [],
257
- };
258
265
  } catch (error) {
259
266
  const restored = renamed
260
267
  ? await restoreAfterFailedValidation(
@@ -263,7 +270,7 @@ export async function writeConfigTargetAndValidate(
263
270
  input.hadExistingFile,
264
271
  )
265
272
  : false;
266
- return {
273
+ result = {
267
274
  ok: false,
268
275
  planId: input.planId,
269
276
  targetPath: input.targetPath,
@@ -272,11 +279,21 @@ export async function writeConfigTargetAndValidate(
272
279
  restored,
273
280
  errors: [errorMessage(error)],
274
281
  };
275
- } finally {
276
- if (tempPresent) {
277
- await unlinkIfExists(tempPath);
282
+ }
283
+
284
+ if (tempPresent) {
285
+ try {
286
+ await (input.cleanupTempFile ?? unlinkIfExists)(tempPath);
287
+ } catch (error) {
288
+ const diagnostic = `temporary config file cleanup failed: ${errorMessage(error)}`;
289
+ console.error(`Pi Clearance ${diagnostic}`);
290
+ result = result.ok
291
+ ? { ...result, warnings: [...result.warnings, diagnostic] }
292
+ : { ...result, errors: [...result.errors, diagnostic] };
278
293
  }
279
294
  }
295
+
296
+ return result;
280
297
  }
281
298
 
282
299
  export function applyJsonPatchDocument(
@@ -170,9 +170,17 @@ const piAutoApprove: ExtensionFactory = (pi: ExtensionAPI) => {
170
170
  });
171
171
  pi.on("session_start", handleSessionStart);
172
172
  pi.on("session_shutdown", (_event, ctx) => {
173
- operatorStatus.clear(ctx);
174
- packageRegistration.dispose();
175
- transformStore.dispose();
173
+ // These resources are independent. A failed unsubscribe or status cleanup
174
+ // must not strand the remaining listener on a persistent Pi event bus.
175
+ containSessionShutdownCleanup("operator status", () => {
176
+ operatorStatus.clear(ctx);
177
+ });
178
+ containSessionShutdownCleanup("package registration", () => {
179
+ packageRegistration.dispose();
180
+ });
181
+ containSessionShutdownCleanup("command transforms", () => {
182
+ transformStore.dispose();
183
+ });
176
184
  });
177
185
 
178
186
  pi.on(
@@ -223,4 +231,27 @@ function toolMetadata(pi: ExtensionAPI): {
223
231
  }
224
232
  }
225
233
 
234
+ function containSessionShutdownCleanup(
235
+ resource: string,
236
+ cleanup: () => void,
237
+ ): void {
238
+ try {
239
+ cleanup();
240
+ } catch (error) {
241
+ // Shutdown is an out-of-band lifecycle callback. Keep the failure visible,
242
+ // but continue independently through every owned resource cleanup.
243
+ console.error(
244
+ `Pi Clearance session shutdown cleanup failed for ${resource}: ${errorMessage(error)}`,
245
+ );
246
+ }
247
+ }
248
+
249
+ function errorMessage(error: unknown): string {
250
+ try {
251
+ return error instanceof Error ? error.message : String(error);
252
+ } catch {
253
+ return "unknown error";
254
+ }
255
+ }
256
+
226
257
  export default piAutoApprove;