@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.
- package/CHANGELOG.md +12 -0
- package/node_modules/@nklisch/pi-background-tasks/CHANGELOG.md +7 -0
- package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.test.ts +105 -0
- package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts +131 -44
- package/node_modules/@nklisch/pi-background-tasks/package.json +1 -1
- package/node_modules/@nklisch/pi-background-tasks/skills/background-tasks/SKILL.md +6 -7
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-clearance/package.json +1 -1
- package/node_modules/@nklisch/pi-clearance/src/config/config-command-writer.ts +34 -17
- package/node_modules/@nklisch/pi-clearance/src/index.ts +34 -3
- package/node_modules/@nklisch/pi-clearance/src/packs/package-registration.ts +63 -36
- package/node_modules/@nklisch/pi-clearance/src/runtime/allow-request-message.ts +40 -1
- package/node_modules/@nklisch/pi-clearance/src/runtime/command-registry.ts +22 -2
- package/node_modules/@nklisch/pi-clearance/src/runtime/command-transforms.ts +50 -29
- package/node_modules/@nklisch/pi-clearance/src/runtime/config-commands/settings/native-ui.ts +233 -49
- package/node_modules/@nklisch/pi-clearance/src/runtime/policy-cache.ts +19 -5
- package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +48 -13
- package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
- package/node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts +49 -42
- package/node_modules/@nklisch/pi-fff-compat/extensions/finder-lifecycle.ts +92 -0
- package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
- package/node_modules/@nklisch/pi-model-modes/package.json +1 -1
- package/node_modules/@nklisch/pi-model-modes/src/command-message.ts +30 -0
- package/node_modules/@nklisch/pi-model-modes/src/commands.ts +31 -15
- package/node_modules/@nklisch/pi-model-modes/src/style-command.ts +25 -14
- package/node_modules/@nklisch/pi-plugins/README.md +2 -2
- package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js +4 -1
- package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js +12 -3
- package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.d.ts +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js +19 -11
- package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js +36 -7
- package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js +14 -3
- package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js +153 -54
- package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js +35 -8
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-package.js +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-runtime.js +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +22 -17
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/debug.ts +18 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +22 -4
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +32 -8
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +8 -7
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +25 -6
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +6 -2
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +31 -16
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +70 -32
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +55 -50
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace-bracket.ts +6 -2
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/record-observer.ts +37 -34
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +15 -14
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +31 -26
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +51 -26
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +100 -46
- package/node_modules/@nklisch/pi-plugins/package.json +3 -3
- package/package.json +4 -4
- 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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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((
|
|
390
|
-
|
|
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 (
|
|
398
|
-
|
|
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
|
-
|
|
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", (
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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.
|
|
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
|
-
**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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;
|