@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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +48 -6
- package/src/reminder.test.ts +1 -1
- package/src/reminder.ts +1 -1
- package/src/utils.test.ts +4 -7
- package/src/utils.ts +7 -11
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.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 —
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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}).` },
|
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
|
@@ -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
|
|
253
|
+
test("command is preserved for viewport-aware truncation", () => {
|
|
254
254
|
const long = "x".repeat(70);
|
|
255
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
275
|
-
*
|
|
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
|
|
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
|
|
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
|
|
596
|
-
|
|
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 ─────────────────────────────────────
|