@d3ara1n/pi-subagent 3.3.0 → 3.3.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/README.md CHANGED
@@ -242,7 +242,7 @@ Three execution properties, kept separate:
242
242
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
243
243
  | `subagent_wait(ids?)` | Block until **all** listed runs end (omit `ids` for all current background runs) | A per-run roll call — one `id (role): state (turns, elapsed, tokens, cost)` line per run; retrieve results with `subagent_check` |
244
244
  | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity, elapsed/budget, and usage so far / the **full output** with a usage footer once finished / failure reason + partial output + usage on failed or cancelled. Idempotent: re-delivers the same terminal snapshot — the run stays in the registry for the whole session |
245
- | `subagent_steer(id, message)` | Queue a mid-run correction into one running run (typically right after a check revealed it heading down a wrong path) | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
245
+ | `subagent_steer(id, message)` | Queue a correction or updated requirement into one running run | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
246
246
  | `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation — the same roll-call line wait uses, plus a pointer to check for the partial output; the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message |
247
247
 
248
248
  Example tool calls made by the main model:
@@ -260,7 +260,7 @@ The main model continues its own work while these runs execute. Before each mode
260
260
  { "id": "sub-1" }
261
261
  ```
262
262
 
263
- If the needed run is still queued or running, the model can wait for it through `subagent_wait`:
263
+ If the needed run is still queued or running and no independent work remains, the model waits for it through `subagent_wait` rather than repeatedly polling `subagent_check`:
264
264
 
265
265
  ```json
266
266
  { "ids": ["sub-1"] }
@@ -274,7 +274,7 @@ Semantics worth knowing:
274
274
  - **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
275
275
  - **Idempotent check, session-tree delivery state:** `subagent_check` re-delivers the same terminal snapshot on every call — runs stay in the registry for the whole session, so no result can ever be stranded by branch navigation or compaction. Whether a run still needs collecting is not tracked in the registry: it derives from the session tree itself. The session is append-only, so branching back past a check entry drops it from the active path — the inbox reminder re-arms and the model simply checks again (the id still resolves; the run is still there). Branching forward to the original branch restores the check entry and silences the reminder again.
276
276
  - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled` with its usage stats.
277
- - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet collected by a terminal check on the active branch (queued, running, and finished/failed-but-unchecked alike, including cancelled ones shown as `cancelled — <reason>`), injected at a cache-stable head position. Only checks that returned a terminal snapshot count a peek at a live frame never silences the inbox, so the steer flow's check steer check-later cycle cannot strand an outcome. Runs missing from the list were already checked on this branch so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
277
+ - **Inbox reminder:** each LLM request with outstanding background runs carries a `[background subagent runs]` reminder at a cache-stable head position. It separates **In progress no final results available yet** (queued/running) from **Ended — results awaiting collection with subagent_check** (finished/failed, including cancelled ones shown as `cancelled — <reason>`). Empty sections are omitted. Live runs provide status information; only terminal runs are presented as awaiting collection. A check of a live snapshot leaves the run visible, while a terminal check removes it from the reminder on the active branch. Once every result is collected, no reminder is injected. Branch navigation derives delivery from the session tree, not registry bookkeeping.
278
278
  - **Wait has no timeout parameter.** `subagent_wait` blocks until all specified runs end. Each run's own role timeout and budgets still apply; cancelling the wait leaves the runs running.
279
279
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
280
280
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
@@ -282,11 +282,11 @@ Semantics worth knowing:
282
282
 
283
283
  ### Steering a running subagent
284
284
 
285
- Steering queues a correction into a running subagent without killing it — the middle ground between waiting it out and cancelling. The message is delivered after the child finishes its current tool batch, before its next LLM call, so the run keeps its progress and can change course. It is a suggestion injected between turns, not an interrupt: the child may comply immediately, finish what it was doing first, or ignore poor instructions entirely — to actually stop a run, cancel it.
285
+ Steering queues a correction or updated requirement into a running subagent without killing it. The message is delivered after the child finishes its current tool batch, before its next LLM call, so the run keeps its progress and can change course. It is a suggestion injected between turns, not an interrupt: the child may comply immediately, finish what it was doing first, or ignore poor instructions entirely — to actually stop a run, cancel it.
286
286
 
287
287
  There are two channels into the same mechanism:
288
288
 
289
- - **The model** calls `subagent_steer(id, message)` typically right after a `subagent_check` snapshot revealed the run heading down a wrong path (check steer check again later).
289
+ - **The model** calls `subagent_steer(id, message)` when there is a concrete deviation from the delegated task or new information changes its requirements. A running status or repeated checks without a result does not establish a wrong direction or a stall; steering must not ask the child to wrap up or return early merely to avoid waiting.
290
290
  - **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed, so whoever watches the view sees what was injected and when.
291
291
 
292
292
  Steering messages appear in the delegate, wait, check, and live-view activity streams. Pending entries use `↩ steer (queued):`, stay below current activity, and count toward the same five-item collapsed limit. Consumption removes the queued marker without moving the row; `↩ steer:` remains accent-colored in history. Expanded views retain the full activity history. This is TUI presentation only: wait's model-facing response remains a status roll call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
@@ -42,11 +42,10 @@ test("queued items share the visible limit and consumption changes only the mark
42
42
  assert.equal(buildDisplayItems(log).length, 7);
43
43
  });
44
44
 
45
- test("queued rendering uses status for every display type and steers retain accent", () => {
45
+ test("queued rendering uses status for every display type and steer color follows delivery status", () => {
46
46
  const color = (name: string, text: string) => `<${name}>${text}</${name}>`;
47
- for (const status of ["queued", "done"] as const) {
48
- assert.match(formatDisplayItem({ type: "steer", status, text: "input" }, color), /^<accent>/);
49
- }
47
+ assert.match(formatDisplayItem({ type: "steer", status: "queued", text: "input" }, color), /^<accent>/);
48
+ assert.match(formatDisplayItem({ type: "steer", status: "done", text: "input" }, color), /^<dim>/);
50
49
  assert.equal(formatDisplayItem({ type: "steer", status: "done", text: "queued" }, plain), "\u21a9 steer: queued");
51
50
  assert.match(formatDisplayItem({ type: "thinking", status: "queued" }, plain), /\(queued\)/);
52
51
  assert.match(formatDisplayItem({ type: "toolCall", name: "read", args: {}, status: "queued" }, plain), /\(queued\)/);
package/src/index.ts CHANGED
@@ -179,7 +179,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
179
179
  "BACKGROUND DELEGATION:",
180
180
  "",
181
181
  "- Use it only when you have your own work this turn (including an ongoing discussion with the user) while the run executes; otherwise let the call block and return the result directly.",
182
- "- When you need a background run's result, collect it with subagent_check if the run has ended; otherwise use subagent_wait, then check to collect it.",
182
+ "- While a background run is queued or running, continue independent work. When its result is needed and no independent work remains, use subagent_wait, then subagent_check to collect it; if it has already ended, collect it directly. Do not use repeated subagent_check calls as a substitute for waiting.",
183
+ "- Use subagent_steer for a concrete deviation from the delegated task or new information that changes its requirements. A running status or several checks without a result is not evidence of a wrong direction or a stall. Do not ask the child to wrap up or return early merely to avoid waiting.",
183
184
  "- Cancel a run you no longer need with subagent_cancel(id) — the child stops and its partial output stays in the registry for subagent_check to collect.",
184
185
  "- Background delegation works only in the top-level session.",
185
186
  );
@@ -707,13 +708,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
707
708
  name: "subagent_steer",
708
709
  label: "Steer a running background subagent",
709
710
  description:
710
- "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and check is the tool for terminal runs. Typical flow: check → steer → check again later.",
711
+ "Queue a correction or updated requirement into ONE running background subagent. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress. Only running runs accept steering; queued and terminal runs reject it.",
711
712
  promptSnippet: "Send a mid-run correction to a background subagent",
712
713
  parameters: Type.Object({
713
714
  id: Type.String({ description: "Run id returned by a background delegate call" }),
714
715
  message: Type.String({
715
716
  description:
716
- "The correction. Concise and imperative it lands mid-run, between the child's turns.",
717
+ "The concrete correction or updated requirement for the child to follow.",
717
718
  }),
718
719
  }),
719
720
 
@@ -740,7 +741,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
740
741
  content: [
741
742
  {
742
743
  type: "text",
743
- text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
744
+ text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch.`,
744
745
  },
745
746
  ],
746
747
  details: { id: params.id, role: run.role, message: params.message },
@@ -143,10 +143,53 @@ describe("buildInboxReminder", () => {
143
143
  assert.ok(text.includes(`"${"x".repeat(70)}..."`));
144
144
  });
145
145
 
146
- test("header identifies results awaiting collection with check", () => {
147
- const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
148
- assert.match(text, /^\[background subagent runs results not yet collected with subagent_check/);
149
- assert.match(text, /already checked on this branch\]/);
146
+ test("live-only reminders report progress without requesting collection", () => {
147
+ const text = buildInboxReminder([
148
+ entry({ id: "sub-1", state: "running" }),
149
+ entry({ id: "sub-2", state: "queued" }),
150
+ ], new Set())!;
151
+ assert.match(text, /^\[background subagent runs\]/);
152
+ assert.match(text, /In progress — no final results available yet:/);
153
+ assert.doesNotMatch(text, /awaiting collection|subagent_check|Ended/);
154
+ });
155
+
156
+ test("mixed reminders separate live runs from every terminal outcome", () => {
157
+ const text = buildInboxReminder([
158
+ entry({ id: "sub-1", state: "finished" }),
159
+ entry({ id: "sub-2", state: "running" }),
160
+ entry({ id: "sub-3", state: "failed", snapshot: frame({ exitCode: 1 }) }),
161
+ entry({ id: "sub-4", state: "queued" }),
162
+ entry({ id: "sub-5", state: "failed", snapshot: frame({ exitCode: 1, stopReason: "cancelled" }) }),
163
+ ], new Set())!;
164
+ const sections = text.split("\n\n");
165
+ assert.equal(sections.length, 3);
166
+ assert.match(sections[1], /^In progress/);
167
+ assert.deepEqual([...sections[1].matchAll(/- (sub-\d+)/g)].map((match) => match[1]), ["sub-2", "sub-4"]);
168
+ assert.match(sections[2], /^Ended — results awaiting collection with subagent_check/);
169
+ assert.deepEqual([...sections[2].matchAll(/- (sub-\d+)/g)].map((match) => match[1]), ["sub-1", "sub-3", "sub-5"]);
170
+ });
171
+
172
+ test("completion makes a live run collectible until its terminal result is delivered", () => {
173
+ const run = entry({ id: "sub-1", state: "running" });
174
+ const running = buildInboxReminder([run], new Set())!;
175
+ assert.doesNotMatch(running, /awaiting collection/);
176
+
177
+ run.state = "finished";
178
+ run.snapshot = frame({ elapsedMs: 42_000 });
179
+ const finished = buildInboxReminder([run], new Set())!;
180
+ assert.match(finished, /Ended — results awaiting collection with subagent_check/);
181
+ assert.match(finished, /sub-1 \(worker\) — finished \(ran 42s\)/);
182
+ assert.doesNotMatch(finished, /In progress/);
183
+ assert.equal(buildInboxReminder([run], new Set([run.id])), undefined);
184
+ });
185
+
186
+ test("collecting terminal results leaves live runs without a collection section", () => {
187
+ const text = buildInboxReminder([
188
+ entry({ id: "sub-1", state: "finished" }),
189
+ entry({ id: "sub-2", state: "running" }),
190
+ ], new Set(["sub-1"]))!;
191
+ assert.match(text, /sub-2 \(worker\) — running/);
192
+ assert.doesNotMatch(text, /sub-1|awaiting collection|subagent_check|Ended/);
150
193
  });
151
194
 
152
195
  test("terminal rows in the delivered set drop out of the inbox", () => {
@@ -163,7 +206,7 @@ describe("buildInboxReminder", () => {
163
206
 
164
207
  test("live rows stay listed even when their id is in the delivered set", () => {
165
208
  // A live frame checked mid-run does not count as delivery — the result
166
- // was not final yet, so the run keeps nagging until a terminal check.
209
+ // was not final yet, so the run remains visible as progress information.
167
210
  const text = buildInboxReminder(
168
211
  [
169
212
  entry({ id: "sub-1", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
package/src/reminder.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * The model's inbox of background subagent runs.
3
3
  *
4
4
  * Injected into the LLM context before every provider call via the `context`
5
- * event. The reminder lists every delegated run not yet delivered by a
6
- * subagent_check of a terminal snapshot on the active branch queued,
7
- * running, and finished/failed alike so the model cannot forget about
8
- * them. Delivery state is derived from the session tree (see
5
+ * event. The reminder separates queued/running work from terminal results
6
+ * awaiting collection by subagent_check on the active branch. Live runs
7
+ * stay visible as status information without implying a result is ready.
8
+ * Delivery state is derived from the session tree (see
9
9
  * collectDeliveredIds), not tracked in the registry: branching past a check
10
10
  * re-arms the inbox, branching back silences it, and compaction un-delivers
11
11
  * naturally.
@@ -30,8 +30,9 @@ export interface InboxEntry {
30
30
  snapshot: SubagentResult;
31
31
  }
32
32
 
33
- const INBOX_HEADER =
34
- "[background subagent runs results not yet collected with subagent_check; a terminal check removes a run from this list; runs missing here were already checked on this branch]";
33
+ const INBOX_HEADER = "[background subagent runs]";
34
+ const LIVE_HEADER = "In progress no final results available yet:";
35
+ const TERMINAL_HEADER = "Ended — results awaiting collection with subagent_check; a terminal check removes a run from this section:";
35
36
 
36
37
  /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
37
38
  function formatDuration(totalSec: number): string {
@@ -71,13 +72,19 @@ function inboxStatus(entry: InboxEntry): string {
71
72
  * live-frame checks), so a past peek at a running run never silences it.
72
73
  */
73
74
  export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
74
- const rows: string[] = [];
75
+ const liveRows: string[] = [];
76
+ const terminalRows: string[] = [];
75
77
  for (const entry of entries) {
76
- if (entry.state !== "queued" && entry.state !== "running" && delivered.has(entry.id)) continue;
78
+ const live = entry.state === "queued" || entry.state === "running";
79
+ if (!live && delivered.has(entry.id)) continue;
80
+ const rows = live ? liveRows : terminalRows;
77
81
  rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
78
82
  }
79
- if (rows.length === 0) return undefined;
80
- return `${INBOX_HEADER}\n${rows.join("\n")}`;
83
+ if (liveRows.length === 0 && terminalRows.length === 0) return undefined;
84
+ const sections = [INBOX_HEADER];
85
+ if (liveRows.length > 0) sections.push(`${LIVE_HEADER}\n${liveRows.join("\n")}`);
86
+ if (terminalRows.length > 0) sections.push(`${TERMINAL_HEADER}\n${terminalRows.join("\n")}`);
87
+ return sections.join("\n\n");
81
88
  }
82
89
 
83
90
  /** Message array type of the `context` event (AgentMessage[]). */
package/src/utils.ts CHANGED
@@ -288,7 +288,10 @@ export function formatDisplayItem(
288
288
  const queued = item.status === "queued";
289
289
  const marker = queued ? " (queued)" : "";
290
290
  if (item.type === "steer") {
291
- return fg("accent", `\u21a9 steer${marker}: ${oneLine(item.text)}`);
291
+ // Color follows delivery status like every other entry (queued = accent,
292
+ // consumed = dim); the ↩ glyph alone carries the external-origin marker.
293
+ const steerColor = queued ? "accent" : "dim";
294
+ return fg(steerColor, `\u21a9 steer${marker}: ${oneLine(item.text)}`);
292
295
  }
293
296
  if (queued) {
294
297
  const body = item.type === "thinking" ? "thinking" : formatToolCall(item.name, item.args, (_c, text) => text);