@d3ara1n/pi-subagent 1.5.0 → 1.6.0

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
@@ -220,7 +220,7 @@ Typical flow:
220
220
 
221
221
  Semantics worth knowing:
222
222
 
223
- - **Results are pull-only.** Nothing delivers them to the model no completion event, no notification, nothing wakes the model up. The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
223
+ - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
224
224
  - **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.
225
225
  - **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it — the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
226
226
  - **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 collect: `subagent_check` still returns the partial output once, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
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",
package/src/index.ts CHANGED
@@ -57,6 +57,8 @@ import {
57
57
  renderWaitResult,
58
58
  } from "./render-async.ts";
59
59
 
60
+ const BACKGROUND_COMPLETION_MESSAGE_TYPE = "subagent-completion";
61
+
60
62
  // ── Extension entry ────────────────────────────────────────────────
61
63
 
62
64
  export default function subagentExtension(pi: ExtensionAPI) {
@@ -107,6 +109,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
107
109
  const backgroundRuns = new Map<string, RunHandle>();
108
110
  const collectedRuns = new Map<string, CollectedRun>();
109
111
  let runCounter = 0;
112
+ let sessionGeneration = 0;
110
113
 
111
114
  // ── Live-run reaping ─────────────────────────────────────────
112
115
  // Every in-flight run (foreground and background alike), removed once
@@ -162,7 +165,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
162
165
  "BACKGROUND DELEGATION:",
163
166
  "",
164
167
  "- 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.",
165
- "- Results are pull-only — no completion event, no notification, nothing wakes you. Dispatching means owning the collection point: finish your own work, then subagent_check(id) for each result. Use subagent_wait(ids) to block until the run finish.",
168
+ "- Results are pull-only for the model a completion notice is shown to the user, but nothing wakes you or delivers the result. Dispatching means owning the collection point: finish your own work, then subagent_check(id) for each result. Use subagent_wait(ids) to block until the run finish.",
166
169
  "- 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.",
167
170
  "- Background delegation works only in the top-level session.",
168
171
  );
@@ -189,6 +192,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
189
192
  rebuildGuidelines(availableRoles);
190
193
 
191
194
  pi.on("session_start", async (_event, ctx) => {
195
+ sessionGeneration += 1;
192
196
  config = loadSubagentConfig(ctx.cwd);
193
197
  concurrencyGate = new AsyncSemaphore(config.maxConcurrency);
194
198
 
@@ -220,12 +224,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
220
224
  });
221
225
 
222
226
  pi.on("context", async (event) => {
227
+ // Completion notices are persisted custom messages so the user can see
228
+ // them in the transcript, but they are deliberately UI-only. Keep the
229
+ // model on the reminder/check path instead of duplicating the notice in
230
+ // its context.
231
+ const messages = event.messages.filter(
232
+ (message) =>
233
+ message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
234
+ );
235
+
223
236
  // The model's inbox: every unclaimed background run, injected at a
224
- // cache-stable head position before every provider call. Empty inbox
225
- // zero injection (context untouched, cache fully stable).
237
+ // cache-stable head position before every provider call. Empty inbox and
238
+ // no filtered notices → context stays untouched, cache fully stable.
226
239
  const reminder = buildInboxReminder(backgroundRuns.values());
227
- if (!reminder) return;
228
- return { messages: injectReminder(event.messages, reminder) };
240
+ if (!reminder && messages.length === event.messages.length) return;
241
+ return { messages: reminder ? injectReminder(messages, reminder) : messages };
229
242
  });
230
243
 
231
244
  pi.on("tool_result", (event) => {
@@ -243,6 +256,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
243
256
  // runs are audited to history, gates release. Without this, background
244
257
  // children would burn tokens as unwaitable orphans after /reload or /new.
245
258
  pi.on("session_shutdown", () => {
259
+ sessionGeneration += 1;
246
260
  for (const run of liveRuns) run.abort("session shutdown");
247
261
  });
248
262
 
@@ -275,7 +289,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
275
289
  background: Type.Optional(
276
290
  Type.Boolean({
277
291
  description:
278
- "Non-blocking: returns an id immediately so you can do your own work (or keep discussing with the user) while the run executes — not for parallelism (several foreground calls in one turn already run concurrently). Results are pull-only: nothing delivers them to you or wakes you; fetch with subagent_wait/subagent_check when your own work is done. If the next thing you'd do is wait for the result, omit this and let the call block.",
292
+ "Non-blocking: returns an id immediately so you can do your own work (or keep discussing with the user) while the run executes — not for parallelism (several foreground calls in one turn already run concurrently). Results are pull-only for the model: a completion notice is shown to the user, but nothing delivers the result or wakes you; fetch with subagent_wait/subagent_check when your own work is done. If the next thing you'd do is wait for the result, omit this and let the call block.",
279
293
  }),
280
294
  ),
281
295
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
@@ -339,6 +353,34 @@ export default function subagentExtension(pi: ExtensionAPI) {
339
353
  // ── Background: return the id immediately; the pipeline keeps running. ──
340
354
  if (params.background) {
341
355
  backgroundRuns.set(run.id, run);
356
+ const runGeneration = sessionGeneration;
357
+ void run.promise.then((result) => {
358
+ // A collected run already has a visible check result. Do not emit a
359
+ // second notice, and never publish completions from an old session.
360
+ if (!backgroundRuns.has(run.id) || runGeneration !== sessionGeneration) return;
361
+
362
+ const outcome = isFailedResult(result)
363
+ ? "failed"
364
+ : result.stopReason === "cancelled"
365
+ ? "cancelled"
366
+ : "finished";
367
+ const detail =
368
+ outcome === "failed"
369
+ ? result.errorMessage || result.stderr
370
+ : outcome === "cancelled"
371
+ ? result.errorMessage
372
+ : result.summary;
373
+ const detailText = detail?.trim() ? ` — ${taskPreview(detail)}` : "";
374
+ pi.sendMessage(
375
+ {
376
+ customType: BACKGROUND_COMPLETION_MESSAGE_TYPE,
377
+ content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"${detailText}`,
378
+ display: true,
379
+ details: { id: run.id, role: run.role, outcome },
380
+ },
381
+ { triggerTurn: false },
382
+ );
383
+ });
342
384
  return {
343
385
  content: [
344
386
  { type: "text", text: `Background subagent started — id: ${run.id} (${params.role}).` },
@@ -129,7 +129,7 @@ describe("buildInboxReminder", () => {
129
129
 
130
130
  test("header explains pull-only collection semantics", () => {
131
131
  const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
132
- assert.match(text, /^\[background subagent runs — results are pull-only/);
132
+ assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
133
133
  assert.match(text, /already collected\]/);
134
134
  });
135
135
  });
package/src/reminder.ts CHANGED
@@ -28,7 +28,7 @@ export interface InboxEntry {
28
28
  }
29
29
 
30
30
  const INBOX_HEADER =
31
- "[background subagent runs — results are pull-only: nothing notifies you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already collected]";
31
+ "[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already collected]";
32
32
 
33
33
  /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
34
34
  function formatDuration(totalSec: number): string {
package/src/utils.test.ts CHANGED
@@ -250,21 +250,18 @@ describe("previewArgs", () => {
250
250
  test("command -> $ prefix", () => {
251
251
  assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
252
252
  });
253
- test("command truncated at 60 chars", () => {
253
+ test("command is preserved for viewport-aware truncation", () => {
254
254
  const long = "x".repeat(70);
255
- const r = previewArgs({ command: long });
256
- assert.ok(r.startsWith("$ "));
257
- assert.ok(r.endsWith("..."));
258
- assert.ok(r.length < long.length);
255
+ assert.equal(previewArgs({ command: long }), `$ ${long}`);
259
256
  });
260
257
  test("file_path is shortened (home -> ~)", () => {
261
258
  const r = previewArgs({ file_path: "/home/user/foo.ts" });
262
259
  assert.ok(r.includes("foo.ts"));
263
260
  });
264
- test("url passthrough (truncated when long)", () => {
261
+ test("url is preserved for viewport-aware truncation", () => {
265
262
  assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
266
263
  const longUrl = "https://" + "x".repeat(70);
267
- assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
264
+ assert.equal(previewArgs({ url: longUrl }), longUrl);
268
265
  });
269
266
  test("query/pattern/regex/search -> /.../ form", () => {
270
267
  assert.equal(previewArgs({ query: "foo" }), "/foo/");
package/src/utils.ts CHANGED
@@ -149,8 +149,7 @@ export function formatToolCall(
149
149
  }
150
150
  case "bash": {
151
151
  const command = (args.command as string) || "...";
152
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
153
- return fg("muted", "$ ") + fg("toolOutput", preview);
152
+ return fg("muted", "$ ") + fg("toolOutput", command);
154
153
  }
155
154
  case "read": {
156
155
  const rawPath = (args.file_path || args.path || "...") as string;
@@ -271,10 +270,8 @@ export function taskPreview(task: string): string {
271
270
  * Width-aware collapsed-view component: renders each line truncated with "…"
272
271
  * to the actual viewport width (never wraps), padded full-width like Text(0,0).
273
272
  *
274
- * The char caps in taskPreview/formatToolCall/etc. stay as they are — they are
275
- * content limits shared with the LLM-facing text (check output, error
276
- * messages), which has no viewport semantics. This component is the TUI-side
277
- * final guard, applied where the folding affordance exists.
273
+ * Content formatters leave tool-call arguments intact; this component is the
274
+ * TUI-side final guard, applied where the folding affordance exists.
278
275
  */
279
276
  export function collapsedText(text: string): Component {
280
277
  const lines = text.split("\n");
@@ -586,15 +583,14 @@ export function freezeFrame(r: SubagentResult): SubagentResult {
586
583
  */
587
584
  export function previewArgs(args: Record<string, unknown>): string {
588
585
  const command = args.command as string | undefined;
589
- if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
586
+ if (command) return `$ ${command}`;
590
587
  const fp = (args.file_path || args.path) as string | undefined;
591
588
  if (fp) return shortenPath(fp);
592
589
  const url = args.url as string | undefined;
593
- if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
590
+ if (url) return url;
594
591
  const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
595
- if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
596
- const argsStr = JSON.stringify(args);
597
- return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
592
+ if (query) return `/${query}/`;
593
+ return JSON.stringify(args);
598
594
  }
599
595
 
600
596
  // ── Numeric configuration ─────────────────────────────────────