@pify/shell-background 0.5.1 → 0.6.1

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/README.md CHANGED
@@ -34,7 +34,7 @@ shell_kill { id: "bg-2" } # stop it and its whole process tree
34
34
 
35
35
  ## Delivery, and the headless caveat
36
36
 
37
- When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it within the same turn with `shell_status { id, wait: N }`, which blocks (up to `N` seconds, 0–300) until the command finishes rather than returning immediately. This is the same delivery rule the rest of the suite lives by. A finish that a `shell_status` wait returns is collected there and not delivered a second time, so the model never spends a turn acknowledging a result it has just reported.
37
+ When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it within the same turn with `shell_status { id, wait: N }`, which blocks (up to `N` seconds, 0–300) until the command finishes rather than returning immediately. This is the same delivery rule the rest of the suite lives by. A job's result is pushed only at the end of the current turn, not the instant the command exits, so a job you collect during the turn with `shell_status {id}` (with or without `wait`) is never also delivered as a follow-up — the model never spends a turn acknowledging a result it has just reported. A job nobody collects is delivered once when the turn ends, and a job that finishes while the agent is idle wakes it right away.
38
38
 
39
39
  ## How it works
40
40
 
@@ -55,11 +55,12 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
55
55
  "autoBackgroundMs": 30000,
56
56
  "tailBytes": 65536,
57
57
  "tailLines": 200,
58
- "maxBackground": 8
58
+ "maxBackground": 8,
59
+ "deliverResults": true
59
60
  }
60
61
  ```
61
62
 
62
- `autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result reads; `tailLines` bounds how many lines of it the model is shown (0 disables the line shrink). `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
63
+ `autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result reads; `tailLines` bounds how many lines of it the model is shown (0 disables the line shrink). `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. `deliverResults` (boolean, default `true`): when a background job finishes, its result is pushed into the conversation and wakes the agent for a new turn. Set it to `false` to turn that off — the widget still updates and `shell_status {id}` still collects the result, but nothing is injected and the agent is not woken. Bad values fall back to the defaults with a warning rather than taking the tool down.
63
64
 
64
65
  Since pi 0.99 the bash tool also returns a machine-readable result — `structuredContent` — which codemode scripts and tools calling `ctx.executeTool("bash", …)` receive instead of the text. This package keeps pi's shape so such a script reads either tool unchanged: `output` (the whole log, up to 1 MiB, head and tail around an omission marker), `truncated`, `full_output_path`, `exit_code`, `wall_time_seconds` — plus `status` and `job_id`, because a command here can still be running when the tool returns; then `exit_code` is absent and `shell_status {id}` collects it, with the same structured result.
65
66
 
@@ -218,6 +218,7 @@ export default function shellBackground(pi: ExtensionAPI) {
218
218
  /** Push a finished job's result into the conversation through the live pi. */
219
219
  function deliver(job: Job): void {
220
220
  renderWidget();
221
+ if (!settings.deliverResults) return;
221
222
  pi.sendMessage(
222
223
  {
223
224
  customType: DELIVERY_TYPE,
@@ -241,6 +242,47 @@ export default function shellBackground(pi: ExtensionAPI) {
241
242
  const waiters = new Map<string, number>();
242
243
  const awaited = (job: Job): boolean => (waiters.get(job.id) ?? 0) > 0;
243
244
 
245
+ // A finished job's result must reach the model exactly once, and NOT while
246
+ // the model may still collect it itself. The old code sent the follow-up the
247
+ // instant the process exited, so a `shell_status {id}` that collected the
248
+ // same job later in that turn got the result a second time (issue #2): the
249
+ // message was already queued when the job ended, and marking it collected
250
+ // afterwards was too late. So a finished job is held in `pending` and
251
+ // delivered only at a turn boundary (agent_settled) — or right away while
252
+ // idle, so a job finishing between turns still wakes the agent — and the
253
+ // drain skips any job a `shell_status` read marked `delivered` meanwhile.
254
+ // `waiters` still covers an ACTIVE wait, which returns and collects the
255
+ // result itself and so must never also be sent.
256
+ const pending = new Set<Job>();
257
+ let turnActive = false;
258
+
259
+ function enqueueDelivery(job: Job): void {
260
+ if (job.delivered) return;
261
+ pending.add(job);
262
+ maybeDrainDeliveries();
263
+ }
264
+
265
+ /** Drain now only while idle; during a turn the model may still collect a job, so wait for agent_settled. */
266
+ function maybeDrainDeliveries(): void {
267
+ if (!turnActive) drainDeliveries();
268
+ }
269
+
270
+ function drainDeliveries(): void {
271
+ for (const job of [...pending]) {
272
+ // Collected (by a shell_status read) since it was queued — never send it.
273
+ if (job.delivered) {
274
+ pending.delete(job);
275
+ continue;
276
+ }
277
+ // An active shell_status wait will return and collect this job itself.
278
+ if (awaited(job)) continue;
279
+ pending.delete(job);
280
+ job.delivered = true;
281
+ registry?.persist(job);
282
+ deliver(job);
283
+ }
284
+ }
285
+
244
286
  /** Deliver a finished background job into the conversation, once. */
245
287
  function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
246
288
  exit
@@ -253,10 +295,7 @@ export default function shellBackground(pi: ExtensionAPI) {
253
295
  registry?.persist(job);
254
296
  return;
255
297
  }
256
- if (job.delivered || awaited(job)) return;
257
- job.delivered = true;
258
- registry?.persist(job);
259
- deliver(job);
298
+ enqueueDelivery(job);
260
299
  })
261
300
  .catch(() => {
262
301
  // A /reload can make captured handles throw; delivery is a convenience,
@@ -543,19 +582,17 @@ export default function shellBackground(pi: ExtensionAPI) {
543
582
  }
544
583
  if (sig?.aborted) {
545
584
  // The model never sees an aborted tool result. If the job finished
546
- // while this wait held the delivery path back, deliver it now.
547
- if (isFinished(job) && !job.delivered) {
548
- job.delivered = true;
549
- registry.persist(job);
550
- deliver(job);
551
- }
585
+ // while the wait held delivery back, hand it to the turn-boundary drain
586
+ // (it was not collected here) rather than dropping it.
587
+ if (isFinished(job) && !job.delivered) enqueueDelivery(job);
552
588
  return { content: [{ type: "text", text: `shell_status ${job.id}: wait aborted` }], details: { id: job.id }, isError: true };
553
589
  }
554
- // Reading a finished job marks it collected so it will not also be
555
- // delivered unasked. A still-running poll must never do this: setting
556
- // delivered here would permanently cancel the promised auto-delivery.
590
+ // Reading a finished job marks it collected so the turn-boundary drain
591
+ // never also sends it (issue #2). A still-running poll must never do this:
592
+ // setting delivered here would permanently cancel the promised delivery.
557
593
  if (isFinished(job)) {
558
594
  job.delivered = true;
595
+ pending.delete(job);
559
596
  registry.persist(job);
560
597
  }
561
598
  return {
@@ -641,11 +678,7 @@ export default function shellBackground(pi: ExtensionAPI) {
641
678
  }
642
679
  }
643
680
  if (job.status === "running") anyRunning = true;
644
- else if (!job.delivered && !awaited(job)) {
645
- job.delivered = true;
646
- registry.persist(job);
647
- deliver(job);
648
- }
681
+ else enqueueDelivery(job);
649
682
  }
650
683
  renderWidget(ctx);
651
684
  if (!anyRunning) clearInterval(timer);
@@ -653,6 +686,19 @@ export default function shellBackground(pi: ExtensionAPI) {
653
686
  timer.unref?.();
654
687
  }
655
688
 
689
+ // Turn boundaries drive delivery: a finished job is pushed only when the
690
+ // current turn ends, so a job the model collects during the turn is never
691
+ // also delivered as a follow-up (issue #2).
692
+ pi.on("agent_start", async (_event, ctx) => {
693
+ turnActive = true;
694
+ lastUiCtx = ctx;
695
+ });
696
+ pi.on("agent_settled", async (_event, ctx) => {
697
+ turnActive = false;
698
+ lastUiCtx = ctx;
699
+ drainDeliveries();
700
+ });
701
+
656
702
  pi.on("session_start", async (_event, ctx) => {
657
703
  const warnings = loadSettings(ctx.cwd);
658
704
  const key = sessionKey(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -29,6 +29,13 @@ export interface ShellBgSettings {
29
29
  * — a command itself is never refused.
30
30
  */
31
31
  maxBackground: number;
32
+ /**
33
+ * Whether a finished background job pushes its result into the conversation
34
+ * (and triggers a new agent turn that explains it). false: the job still
35
+ * finishes, the widget updates, and shell_status collects the result, but
36
+ * nothing is injected and the agent is not woken.
37
+ */
38
+ deliverResults: boolean;
32
39
  }
33
40
 
34
41
  export const DEFAULT_SETTINGS: ShellBgSettings = {
@@ -36,9 +43,12 @@ export const DEFAULT_SETTINGS: ShellBgSettings = {
36
43
  tailBytes: 64 * 1024,
37
44
  tailLines: 200,
38
45
  maxBackground: 8,
46
+ deliverResults: true,
39
47
  };
40
48
 
41
- const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
49
+ type NumericKey = Exclude<keyof ShellBgSettings, "deliverResults">;
50
+
51
+ const LIMITS: Record<NumericKey, { min: number; max: number }> = {
42
52
  // 0 is allowed (disable); otherwise at least 1s so a typo of "30" (=30ms)
43
53
  // does not make every command look long-running.
44
54
  autoBackgroundMs: { min: 0, max: 3_600_000 },
@@ -63,7 +73,12 @@ export function resolveSettings(
63
73
  warnings.push(`unknown setting "${key}"`);
64
74
  continue;
65
75
  }
66
- const name = key as keyof ShellBgSettings;
76
+ if (key === "deliverResults") {
77
+ if (typeof value === "boolean") settings.deliverResults = value;
78
+ else warnings.push(`"deliverResults" must be true or false — using ${DEFAULT_SETTINGS.deliverResults}`);
79
+ continue;
80
+ }
81
+ const name = key as NumericKey;
67
82
  if (typeof value !== "number" || !Number.isFinite(value)) {
68
83
  warnings.push(`"${key}" must be a number — using ${DEFAULT_SETTINGS[name]}`);
69
84
  continue;
@@ -83,7 +98,7 @@ export function resolveSettings(
83
98
  return { settings, warnings };
84
99
  }
85
100
 
86
- function clamp(name: keyof ShellBgSettings, value: number, warnings: string[]): number {
101
+ function clamp(name: NumericKey, value: number, warnings: string[]): number {
87
102
  const { min, max } = LIMITS[name];
88
103
  const c = Math.round(Math.min(max, Math.max(min, value)));
89
104
  if (c !== value) warnings.push(`"${name}" clamped to ${c} (allowed ${min}–${max})`);