@d3ara1n/pi-subagent 1.5.1 → 1.7.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.1",
3
+ "version": "1.7.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
@@ -159,10 +162,14 @@ export default function subagentExtension(pi: ExtensionAPI) {
159
162
  "- Parallel: multiple subagent_delegate calls at the same time run concurrently — foreground and background alike, no special flag.",
160
163
  "- Background (background: true): non-blocking — returns an id immediately so you can do your own work while the run executes.",
161
164
  "",
165
+ "RUN HISTORY:",
166
+ "",
167
+ "- Every spawned run is audited to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json (toolCallId = the delegate call's id). The file holds the FULL raw output even when you received a compressed/truncated version, plus the task, activity log, and usage.",
168
+ "",
162
169
  "BACKGROUND DELEGATION:",
163
170
  "",
164
171
  "- 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.",
172
+ "- 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
173
  "- 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
174
  "- Background delegation works only in the top-level session.",
168
175
  );
@@ -189,6 +196,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
189
196
  rebuildGuidelines(availableRoles);
190
197
 
191
198
  pi.on("session_start", async (_event, ctx) => {
199
+ sessionGeneration += 1;
192
200
  config = loadSubagentConfig(ctx.cwd);
193
201
  concurrencyGate = new AsyncSemaphore(config.maxConcurrency);
194
202
 
@@ -220,12 +228,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
220
228
  });
221
229
 
222
230
  pi.on("context", async (event) => {
231
+ // Completion notices are persisted custom messages so the user can see
232
+ // them in the transcript, but they are deliberately UI-only. Keep the
233
+ // model on the reminder/check path instead of duplicating the notice in
234
+ // its context.
235
+ const messages = event.messages.filter(
236
+ (message) =>
237
+ message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
238
+ );
239
+
223
240
  // 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).
241
+ // cache-stable head position before every provider call. Empty inbox and
242
+ // no filtered notices → context stays untouched, cache fully stable.
226
243
  const reminder = buildInboxReminder(backgroundRuns.values());
227
- if (!reminder) return;
228
- return { messages: injectReminder(event.messages, reminder) };
244
+ if (!reminder && messages.length === event.messages.length) return;
245
+ return { messages: reminder ? injectReminder(messages, reminder) : messages };
229
246
  });
230
247
 
231
248
  pi.on("tool_result", (event) => {
@@ -243,6 +260,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
243
260
  // runs are audited to history, gates release. Without this, background
244
261
  // children would burn tokens as unwaitable orphans after /reload or /new.
245
262
  pi.on("session_shutdown", () => {
263
+ sessionGeneration += 1;
246
264
  for (const run of liveRuns) run.abort("session shutdown");
247
265
  });
248
266
 
@@ -275,7 +293,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
275
293
  background: Type.Optional(
276
294
  Type.Boolean({
277
295
  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.",
296
+ "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
297
  }),
280
298
  ),
281
299
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
@@ -339,6 +357,34 @@ export default function subagentExtension(pi: ExtensionAPI) {
339
357
  // ── Background: return the id immediately; the pipeline keeps running. ──
340
358
  if (params.background) {
341
359
  backgroundRuns.set(run.id, run);
360
+ const runGeneration = sessionGeneration;
361
+ void run.promise.then((result) => {
362
+ // A collected run already has a visible check result. Do not emit a
363
+ // second notice, and never publish completions from an old session.
364
+ if (!backgroundRuns.has(run.id) || runGeneration !== sessionGeneration) return;
365
+
366
+ const outcome = isFailedResult(result)
367
+ ? "failed"
368
+ : result.stopReason === "cancelled"
369
+ ? "cancelled"
370
+ : "finished";
371
+ const detail =
372
+ outcome === "failed"
373
+ ? result.errorMessage || result.stderr
374
+ : outcome === "cancelled"
375
+ ? result.errorMessage
376
+ : result.summary;
377
+ const detailText = detail?.trim() ? ` — ${taskPreview(detail)}` : "";
378
+ pi.sendMessage(
379
+ {
380
+ customType: BACKGROUND_COMPLETION_MESSAGE_TYPE,
381
+ content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"${detailText}`,
382
+ display: true,
383
+ details: { id: run.id, role: run.role, outcome },
384
+ },
385
+ { triggerTurn: false },
386
+ );
387
+ });
342
388
  return {
343
389
  content: [
344
390
  { 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
@@ -41,6 +41,7 @@ import {
41
41
  createThrottler,
42
42
  terminalResultLine,
43
43
  buildDisplayItems,
44
+ formatToolCall,
44
45
  } from "./utils.ts";
45
46
  import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
46
47
 
@@ -427,6 +428,28 @@ describe("terminalResultLine", () => {
427
428
  test("finishedText replaces the success chain (wait's status-only line)", () => {
428
429
  assert.equal(terminalResultLine(baseResult(), id, "finished"), "\u2713 finished");
429
430
  });
431
+ test("error message newlines are flattened to one line", () => {
432
+ assert.equal(
433
+ terminalResultLine(baseResult({ exitCode: 1, errorMessage: "boom\n at frame 2\nat frame 3" }), id),
434
+ "\u2717 boom at frame 2 at frame 3",
435
+ );
436
+ });
437
+ });
438
+
439
+ // ── formatToolCall: single-line guarantee for TUI rows ──
440
+ describe("formatToolCall newline sanitization", () => {
441
+ const id = (_color: string, text: string) => text;
442
+
443
+ test("multi-line bash command renders as one line", () => {
444
+ const out = formatToolCall("bash", { command: "echo a\necho b\n echo c" }, id);
445
+ assert.ok(!out.includes("\n"));
446
+ assert.equal(out, "$ echo a echo b echo c");
447
+ });
448
+ test("default branch preview flattens embedded newlines", () => {
449
+ const out = formatToolCall("fetch", { url: "https://x.test/a\n/b" }, id);
450
+ assert.ok(!out.includes("\n"));
451
+ assert.ok(out.includes("https://x.test/a /b"));
452
+ });
430
453
  });
431
454
 
432
455
  // ── createThrottler: burst coalescing for onUpdate ──
package/src/utils.ts CHANGED
@@ -136,10 +136,30 @@ export function shortenPath(p: string): string {
136
136
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
137
137
  }
138
138
 
139
+ /** Flatten embedded newlines (multi-line bash commands, patterns, error
140
+ * messages) into single spaces so a row never spans multiple terminal lines.
141
+ * Every caller renders the result as ONE TUI row — inline rows join it with
142
+ * "\n" separators and the :view overlay wraps each row in a border frame. */
143
+ function oneLine(s: string): string {
144
+ return s.replace(/\s*\r?\n\s*/g, " ");
145
+ }
146
+
147
+ /**
148
+ * One-line tool-call row for TUI display. Newline sanitization happens here
149
+ * at the single choke point so every tool branch is covered.
150
+ */
139
151
  export function formatToolCall(
140
152
  toolName: string,
141
153
  args: Record<string, unknown>,
142
154
  fg: (color: string, text: string) => string,
155
+ ): string {
156
+ return oneLine(renderToolCall(toolName, args, fg));
157
+ }
158
+
159
+ function renderToolCall(
160
+ toolName: string,
161
+ args: Record<string, unknown>,
162
+ fg: (color: string, text: string) => string,
143
163
  ): string {
144
164
  switch (toolName) {
145
165
  case "subagent_delegate": {
@@ -306,7 +326,7 @@ function failureResultText(r: {
306
326
  const isCancelled = r.stopReason === "cancelled";
307
327
  return {
308
328
  content:
309
- r.errorMessage ||
329
+ oneLine(r.errorMessage || "") ||
310
330
  (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : isCancelled ? "Cancelled" : "failed"),
311
331
  // Timeout/budget/cancel are intentional stops with partial output —
312
332
  // warning, not the error red reserved for real failures.