@pify/shell-background 0.6.0 → 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
 
@@ -242,6 +242,47 @@ export default function shellBackground(pi: ExtensionAPI) {
242
242
  const waiters = new Map<string, number>();
243
243
  const awaited = (job: Job): boolean => (waiters.get(job.id) ?? 0) > 0;
244
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
+
245
286
  /** Deliver a finished background job into the conversation, once. */
246
287
  function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
247
288
  exit
@@ -254,10 +295,7 @@ export default function shellBackground(pi: ExtensionAPI) {
254
295
  registry?.persist(job);
255
296
  return;
256
297
  }
257
- if (job.delivered || awaited(job)) return;
258
- job.delivered = true;
259
- registry?.persist(job);
260
- deliver(job);
298
+ enqueueDelivery(job);
261
299
  })
262
300
  .catch(() => {
263
301
  // A /reload can make captured handles throw; delivery is a convenience,
@@ -544,19 +582,17 @@ export default function shellBackground(pi: ExtensionAPI) {
544
582
  }
545
583
  if (sig?.aborted) {
546
584
  // The model never sees an aborted tool result. If the job finished
547
- // while this wait held the delivery path back, deliver it now.
548
- if (isFinished(job) && !job.delivered) {
549
- job.delivered = true;
550
- registry.persist(job);
551
- deliver(job);
552
- }
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);
553
588
  return { content: [{ type: "text", text: `shell_status ${job.id}: wait aborted` }], details: { id: job.id }, isError: true };
554
589
  }
555
- // Reading a finished job marks it collected so it will not also be
556
- // delivered unasked. A still-running poll must never do this: setting
557
- // 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.
558
593
  if (isFinished(job)) {
559
594
  job.delivered = true;
595
+ pending.delete(job);
560
596
  registry.persist(job);
561
597
  }
562
598
  return {
@@ -642,11 +678,7 @@ export default function shellBackground(pi: ExtensionAPI) {
642
678
  }
643
679
  }
644
680
  if (job.status === "running") anyRunning = true;
645
- else if (!job.delivered && !awaited(job)) {
646
- job.delivered = true;
647
- registry.persist(job);
648
- deliver(job);
649
- }
681
+ else enqueueDelivery(job);
650
682
  }
651
683
  renderWidget(ctx);
652
684
  if (!anyRunning) clearInterval(timer);
@@ -654,6 +686,19 @@ export default function shellBackground(pi: ExtensionAPI) {
654
686
  timer.unref?.();
655
687
  }
656
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
+
657
702
  pi.on("session_start", async (_event, ctx) => {
658
703
  const warnings = loadSettings(ctx.cwd);
659
704
  const key = sessionKey(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.6.0",
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",