@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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +52 -6
- package/src/reminder.test.ts +1 -1
- package/src/reminder.ts +1 -1
- package/src/utils.test.ts +23 -0
- package/src/utils.ts +21 -1
package/README.md
CHANGED
|
@@ -220,7 +220,7 @@ Typical flow:
|
|
|
220
220
|
|
|
221
221
|
Semantics worth knowing:
|
|
222
222
|
|
|
223
|
-
- **Results are pull-only
|
|
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.
|
|
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 —
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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}).` },
|
package/src/reminder.test.ts
CHANGED
|
@@ -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:
|
|
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.
|