@d3ara1n/pi-subagent 2.2.0 → 3.1.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 +45 -14
- package/package.json +1 -1
- package/src/config.test.ts +19 -1
- package/src/config.ts +13 -0
- package/src/history.ts +3 -0
- package/src/index.ts +93 -110
- package/src/inheritance.test.ts +217 -0
- package/src/inheritance.ts +188 -0
- package/src/reminder.test.ts +101 -52
- package/src/reminder.ts +15 -9
- package/src/render-async.ts +70 -2
- package/src/render.test.ts +63 -0
- package/src/render.ts +25 -3
- package/src/run.test.ts +51 -1
- package/src/run.ts +28 -6
- package/src/spawn.test.ts +34 -10
- package/src/spawn.ts +44 -10
- package/src/types.ts +28 -9
- package/src/utils.test.ts +51 -0
- package/src/utils.ts +48 -5
- package/src/view.ts +15 -3
package/src/index.ts
CHANGED
|
@@ -16,18 +16,14 @@
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
19
|
-
import type {
|
|
20
|
-
CollectedRun,
|
|
21
|
-
SubagentConfig,
|
|
22
|
-
SubagentResult,
|
|
23
|
-
SubagentRole,
|
|
24
|
-
} from "./types.ts";
|
|
19
|
+
import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
|
|
25
20
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
26
21
|
import { loadSubagentConfig } from "./config.ts";
|
|
27
22
|
import { BUILTIN_ROLES } from "./roles.ts";
|
|
28
23
|
import { getPiInvocation } from "./spawn.ts";
|
|
29
24
|
import {
|
|
30
25
|
AsyncSemaphore,
|
|
26
|
+
collectDeliveredIds,
|
|
31
27
|
createThrottler,
|
|
32
28
|
describeCurrentActivity,
|
|
33
29
|
formatBudgetNote,
|
|
@@ -44,9 +40,11 @@ import {
|
|
|
44
40
|
} from "./utils.ts";
|
|
45
41
|
import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
46
42
|
import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
43
|
+
import { serializeInheritedConversation } from "./inheritance.ts";
|
|
47
44
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
48
45
|
import { createViewPanel } from "./view.ts";
|
|
49
46
|
import {
|
|
47
|
+
createSteerCallRender,
|
|
50
48
|
renderBackgroundDelegateCall,
|
|
51
49
|
renderBackgroundDelegateResult,
|
|
52
50
|
renderCancelCall,
|
|
@@ -54,6 +52,7 @@ import {
|
|
|
54
52
|
renderCheckCall,
|
|
55
53
|
renderCheckResult,
|
|
56
54
|
renderCompletionNotice,
|
|
55
|
+
renderSteerResult,
|
|
57
56
|
renderWaitCall,
|
|
58
57
|
renderWaitResult,
|
|
59
58
|
} from "./render-async.ts";
|
|
@@ -101,14 +100,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
101
100
|
refreshAvailableRoles();
|
|
102
101
|
|
|
103
102
|
// ── Background run registry ────────────────────────────────────
|
|
104
|
-
// Process-lifetime map of
|
|
103
|
+
// Process-lifetime map of background runs (queued, running, and
|
|
105
104
|
// finished/failed alike). Foreground delegate runs are NOT registered —
|
|
106
|
-
// their lifecycle is the tool call itself.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
105
|
+
// their lifecycle is the tool call itself. Runs stay registered for the
|
|
106
|
+
// whole session: subagent_check is idempotent and re-delivers the terminal
|
|
107
|
+
// snapshot on every call, so branch navigation or compaction can never
|
|
108
|
+
// strand a result outside the model's reach. Whether a run still needs
|
|
109
|
+
// reminding is NOT tracked here — it derives from the session tree (see
|
|
110
|
+
// collectDeliveredIds + the context handler), the single source of truth.
|
|
110
111
|
const backgroundRuns = new Map<string, RunHandle>();
|
|
111
|
-
const collectedRuns = new Map<string, CollectedRun>();
|
|
112
112
|
let runCounter = 0;
|
|
113
113
|
let sessionGeneration = 0;
|
|
114
114
|
|
|
@@ -146,11 +146,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
146
146
|
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
|
|
147
147
|
"- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
|
|
148
148
|
"",
|
|
149
|
-
"
|
|
149
|
+
"DELEGATION CONTEXT MODES:",
|
|
150
150
|
"",
|
|
151
|
-
"-
|
|
152
|
-
|
|
153
|
-
"-
|
|
151
|
+
"- Self-contained (default): when task, context, and files contain everything the child needs, omit inheritConversation. The child receives no parent dialogue.",
|
|
152
|
+
'- Conversation-relative: when the task is intentionally written as a delta against this chat — e.g. "implement the approved approach", "review the requirements above", or "continue from our discussion" — set inheritConversation: true.',
|
|
153
|
+
"- Never send a conversation-relative task without inheritConversation: true. Either enable inheritance or rewrite the task to be self-contained.",
|
|
154
|
+
"- With inheritance enabled, keep task focused on the work to perform; do not duplicate the conversation into context. Use context for additional non-conversation background and files for source material.",
|
|
155
|
+
"- Inherited history may be compacted or truncated. If an older detail is essential and may fall outside the retained history, include it explicitly in task or context.",
|
|
154
156
|
"",
|
|
155
157
|
"AVAILABLE ROLES:",
|
|
156
158
|
...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
|
|
@@ -250,7 +252,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
250
252
|
rebuildGuidelines(availableRoles);
|
|
251
253
|
});
|
|
252
254
|
|
|
253
|
-
pi.on("context", async (event) => {
|
|
255
|
+
pi.on("context", async (event, ctx) => {
|
|
254
256
|
// Completion notices are persisted custom messages so the user can see
|
|
255
257
|
// them in the transcript, but they are deliberately UI-only. Keep the
|
|
256
258
|
// model on the reminder/check path instead of duplicating the notice in
|
|
@@ -260,10 +262,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
260
262
|
message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
|
|
261
263
|
);
|
|
262
264
|
|
|
263
|
-
// The model's inbox: every
|
|
264
|
-
// cache-stable head position before every provider
|
|
265
|
-
//
|
|
266
|
-
|
|
265
|
+
// The model's inbox: every background run not yet checked on the active
|
|
266
|
+
// branch, injected at a cache-stable head position before every provider
|
|
267
|
+
// call. Delivery state is derived from the session tree (append-only:
|
|
268
|
+
// branching away drops the check entry, branching back restores it), so
|
|
269
|
+
// the inbox re-arms itself after tree navigation. Empty inbox and no
|
|
270
|
+
// filtered notices → context stays untouched, cache fully stable.
|
|
271
|
+
const reminder = buildInboxReminder(
|
|
272
|
+
backgroundRuns.values(),
|
|
273
|
+
collectDeliveredIds(ctx.sessionManager.buildContextEntries()),
|
|
274
|
+
);
|
|
267
275
|
if (!reminder && messages.length === event.messages.length) return;
|
|
268
276
|
return { messages: reminder ? injectReminder(messages, reminder) : messages };
|
|
269
277
|
});
|
|
@@ -295,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
295
303
|
name: "subagent_delegate",
|
|
296
304
|
label: "Delegate to subagent",
|
|
297
305
|
description:
|
|
298
|
-
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents
|
|
306
|
+
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
|
|
299
307
|
promptSnippet: "Delegate tasks to specialized subagents",
|
|
300
308
|
promptGuidelines: guidelines,
|
|
301
309
|
|
|
@@ -303,7 +311,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
303
311
|
role: Type.String({ description: "Subagent role to use" }),
|
|
304
312
|
task: Type.String({
|
|
305
313
|
description:
|
|
306
|
-
"The work to do
|
|
314
|
+
"The work to do. Without conversation inheritance it must be self-contained, with every requirement or constraint restated here or in `context`; with inheritance it may be a delta against that history. Instructions only — background material belongs in `context`, reference file paths in `files`.",
|
|
307
315
|
}),
|
|
308
316
|
context: Type.Optional(
|
|
309
317
|
Type.String({
|
|
@@ -317,6 +325,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
317
325
|
'Reference file paths for the subagent to read directly (e.g. ["src/auth.ts", "docs/api.md"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context.',
|
|
318
326
|
}),
|
|
319
327
|
),
|
|
328
|
+
inheritConversation: Type.Optional(
|
|
329
|
+
Type.Boolean({
|
|
330
|
+
description:
|
|
331
|
+
"Opt in to a text-only, compaction-aware snapshot of the active parent conversation. Omit or false for isolation; true lets task be a delta against inherited history, which may be filtered or truncated.",
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
320
334
|
background: Type.Optional(
|
|
321
335
|
Type.Boolean({
|
|
322
336
|
description:
|
|
@@ -361,6 +375,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
361
375
|
);
|
|
362
376
|
}
|
|
363
377
|
|
|
378
|
+
const inheritedConversation = params.inheritConversation
|
|
379
|
+
? serializeInheritedConversation(
|
|
380
|
+
ctx.sessionManager.buildContextEntries(),
|
|
381
|
+
config.inheritance.maxChars,
|
|
382
|
+
)
|
|
383
|
+
: undefined;
|
|
384
|
+
|
|
364
385
|
const run = startSubagentRun({
|
|
365
386
|
id: `sub-${++runCounter}`,
|
|
366
387
|
toolCallId: _toolCallId,
|
|
@@ -369,6 +390,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
369
390
|
task: params.task,
|
|
370
391
|
context: params.context,
|
|
371
392
|
files: params.files,
|
|
393
|
+
inheritConversation: params.inheritConversation === true,
|
|
394
|
+
inheritedConversation: inheritedConversation?.text,
|
|
395
|
+
inheritedConversationTruncated: inheritedConversation?.truncated,
|
|
372
396
|
cwd: params.cwd ?? ctx.cwd,
|
|
373
397
|
depth: CURRENT_DEPTH + 1,
|
|
374
398
|
// Foreground runs die with the tool call; background runs outlive the turn.
|
|
@@ -386,9 +410,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
386
410
|
backgroundRuns.set(run.id, run);
|
|
387
411
|
const runGeneration = sessionGeneration;
|
|
388
412
|
void run.promise.then((result) => {
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
413
|
+
// Never publish completions from an old session. Within a session
|
|
414
|
+
// every completion emits its UI-only notice card once — check
|
|
415
|
+
// results never suppress it (they are not LLM-visible either way).
|
|
416
|
+
if (runGeneration !== sessionGeneration) return;
|
|
392
417
|
|
|
393
418
|
const outcome = isFailedResult(result)
|
|
394
419
|
? "failed"
|
|
@@ -420,6 +445,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
420
445
|
task: params.task,
|
|
421
446
|
context: params.context,
|
|
422
447
|
files: params.files,
|
|
448
|
+
inheritConversation: params.inheritConversation === true,
|
|
449
|
+
inheritedConversationChars: inheritedConversation?.text.length,
|
|
450
|
+
inheritedConversationTruncated: inheritedConversation?.truncated,
|
|
423
451
|
},
|
|
424
452
|
};
|
|
425
453
|
}
|
|
@@ -543,16 +571,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
543
571
|
const unknown = ids.filter((id) => !backgroundRuns.has(id));
|
|
544
572
|
if (unknown.length > 0) {
|
|
545
573
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
546
|
-
|
|
547
|
-
.
|
|
548
|
-
|
|
549
|
-
const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
|
|
550
|
-
const parts = [
|
|
551
|
-
`Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
|
|
552
|
-
collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
|
|
553
|
-
`Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
554
|
-
].filter(Boolean);
|
|
555
|
-
throw new Error(parts.join(" "));
|
|
574
|
+
throw new Error(
|
|
575
|
+
`Unknown subagent id(s): ${unknown.join(", ")}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
576
|
+
);
|
|
556
577
|
}
|
|
557
578
|
const runs = ids.map((id) => backgroundRuns.get(id)!);
|
|
558
579
|
const timeoutMs =
|
|
@@ -659,7 +680,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
659
680
|
name: "subagent_check",
|
|
660
681
|
label: "Check a background subagent",
|
|
661
682
|
description:
|
|
662
|
-
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that.
|
|
683
|
+
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
|
|
663
684
|
promptSnippet: "Inspect a background subagent run",
|
|
664
685
|
parameters: Type.Object({
|
|
665
686
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -668,12 +689,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
668
689
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
669
690
|
const run = backgroundRuns.get(params.id);
|
|
670
691
|
if (!run) {
|
|
671
|
-
const collected = collectedRuns.get(params.id);
|
|
672
|
-
if (collected) {
|
|
673
|
-
throw new Error(
|
|
674
|
-
`${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
692
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
678
693
|
throw new Error(
|
|
679
694
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
@@ -683,19 +698,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
683
698
|
// Freeze live frames so the snapshot's elapsed time stays static.
|
|
684
699
|
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
685
700
|
|
|
686
|
-
// Read-once collection: a terminal check returns the result AND frees
|
|
687
|
-
// the run — the output now lives in the conversation history, so the
|
|
688
|
-
// registry keeps only a lightweight tombstone for id resolution.
|
|
689
|
-
if (run.state === "finished" || run.state === "failed") {
|
|
690
|
-
backgroundRuns.delete(run.id);
|
|
691
|
-
collectedRuns.set(run.id, {
|
|
692
|
-
id: run.id,
|
|
693
|
-
role: run.role,
|
|
694
|
-
task: taskPreview(run.task),
|
|
695
|
-
state: run.state,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
701
|
return {
|
|
700
702
|
content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
|
|
701
703
|
details: { id: run.id, role: run.role, result: snap },
|
|
@@ -710,7 +712,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
710
712
|
name: "subagent_steer",
|
|
711
713
|
label: "Steer a running background subagent",
|
|
712
714
|
description:
|
|
713
|
-
"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
|
|
715
|
+
"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.",
|
|
714
716
|
promptSnippet: "Send a mid-run correction to a background subagent",
|
|
715
717
|
parameters: Type.Object({
|
|
716
718
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -723,12 +725,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
723
725
|
async execute(_toolCallId, params) {
|
|
724
726
|
const run = backgroundRuns.get(params.id);
|
|
725
727
|
if (!run) {
|
|
726
|
-
const collected = collectedRuns.get(params.id);
|
|
727
|
-
if (collected) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
728
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
733
729
|
throw new Error(
|
|
734
730
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
@@ -752,16 +748,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
752
748
|
text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
|
|
753
749
|
},
|
|
754
750
|
],
|
|
755
|
-
details: { id: params.id, role: run.role },
|
|
751
|
+
details: { id: params.id, role: run.role, message: params.message },
|
|
756
752
|
};
|
|
757
753
|
},
|
|
754
|
+
|
|
755
|
+
renderCall: createSteerCallRender((id) => backgroundRuns.get(id)?.role),
|
|
756
|
+
renderResult: renderSteerResult,
|
|
758
757
|
});
|
|
759
758
|
|
|
760
759
|
pi.registerTool({
|
|
761
760
|
name: "subagent_cancel",
|
|
762
761
|
label: "Cancel a background subagent",
|
|
763
762
|
description:
|
|
764
|
-
"Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not
|
|
763
|
+
"Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not remove the run — check still returns the partial output. A finished/failed run cannot be cancelled; check it instead.",
|
|
765
764
|
promptSnippet: "Cancel a background subagent run",
|
|
766
765
|
parameters: Type.Object({
|
|
767
766
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -776,21 +775,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
776
775
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
777
776
|
const run = backgroundRuns.get(params.id);
|
|
778
777
|
if (!run) {
|
|
779
|
-
const collected = collectedRuns.get(params.id);
|
|
780
|
-
if (collected) {
|
|
781
|
-
throw new Error(
|
|
782
|
-
`${params.id} (${collected.role}) was already collected — its result is in your conversation history. There is nothing left to cancel.`,
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
778
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
786
779
|
throw new Error(
|
|
787
780
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
788
781
|
);
|
|
789
782
|
}
|
|
790
783
|
|
|
791
|
-
// Terminal runs cannot be cancelled — point at
|
|
784
|
+
// Terminal runs cannot be cancelled — point at check instead.
|
|
792
785
|
if (run.state === "finished" || run.state === "failed") {
|
|
793
|
-
const what =
|
|
786
|
+
const what =
|
|
787
|
+
run.state === "finished" ? "its result" : "the failure reason and partial output";
|
|
794
788
|
const text =
|
|
795
789
|
`${params.id} (${run.role}) already ${run.state} — nothing to cancel. ` +
|
|
796
790
|
`subagent_check(${params.id}) returns ${what}.`;
|
|
@@ -822,8 +816,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
822
816
|
pi.registerCommand("subagent:view", {
|
|
823
817
|
description: "Open the live subagent activity view (watch progress, steer runs)",
|
|
824
818
|
handler: async (_args, ctx) => {
|
|
825
|
-
// Union of every known run: background registry
|
|
826
|
-
//
|
|
819
|
+
// Union of every known run: the background registry plus live
|
|
820
|
+
// in-flight runs (foreground delegate calls included). Dedupe by id —
|
|
827
821
|
// background runs appear in both.
|
|
828
822
|
const runsProvider = () => {
|
|
829
823
|
const seen = new Set<string>();
|
|
@@ -942,40 +936,31 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
942
936
|
pi.registerCommand("subagent:status", {
|
|
943
937
|
description: "List background subagent runs and their current state",
|
|
944
938
|
handler: async (_args, ctx) => {
|
|
945
|
-
if (backgroundRuns.size === 0
|
|
939
|
+
if (backgroundRuns.size === 0) {
|
|
946
940
|
ctx.ui.notify("No background runs.", "info");
|
|
947
941
|
return;
|
|
948
942
|
}
|
|
949
943
|
const lines: string[] = [];
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
969
|
-
}
|
|
970
|
-
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
if (collectedRuns.size > 0) {
|
|
974
|
-
if (lines.length > 0) lines.push("");
|
|
975
|
-
lines.push("Collected (result already returned via subagent_check):");
|
|
976
|
-
for (const c of collectedRuns.values()) {
|
|
977
|
-
lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
|
|
944
|
+
lines.push("Runs:");
|
|
945
|
+
for (const run of backgroundRuns.values()) {
|
|
946
|
+
// Freeze live frames so elapsed-dependent details don't drift in the listing.
|
|
947
|
+
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
948
|
+
let icon: string;
|
|
949
|
+
let detail: string;
|
|
950
|
+
if (run.state === "failed") {
|
|
951
|
+
icon = "\u2717";
|
|
952
|
+
detail = snap.errorMessage || "unknown error";
|
|
953
|
+
} else if (run.state === "finished") {
|
|
954
|
+
icon = "\u2713";
|
|
955
|
+
detail = snap.summary || taskPreview(snap.output) || "(no output)";
|
|
956
|
+
} else if (run.state === "queued") {
|
|
957
|
+
icon = "\u23F8";
|
|
958
|
+
detail = "queued — waiting for a concurrency slot";
|
|
959
|
+
} else {
|
|
960
|
+
icon = "\u23F3";
|
|
961
|
+
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
978
962
|
}
|
|
963
|
+
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
979
964
|
}
|
|
980
965
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
981
966
|
},
|
|
@@ -1018,13 +1003,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1018
1003
|
}
|
|
1019
1004
|
|
|
1020
1005
|
if (target !== "all" && !backgroundRuns.has(target)) {
|
|
1021
|
-
const collected = collectedRuns.get(target);
|
|
1022
1006
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
1023
1007
|
ctx.ui.notify(
|
|
1024
|
-
(
|
|
1025
|
-
? `${target} (${collected.role}) was already collected — nothing to cancel.`
|
|
1026
|
-
: `Unknown subagent id: ${target}.`) +
|
|
1027
|
-
` Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
1008
|
+
`Unknown subagent id: ${target}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
1028
1009
|
"error",
|
|
1029
1010
|
);
|
|
1030
1011
|
return;
|
|
@@ -1034,7 +1015,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1034
1015
|
// reported as already-terminal instead of cancelled.
|
|
1035
1016
|
const targets =
|
|
1036
1017
|
target === "all"
|
|
1037
|
-
? [...backgroundRuns.values()].filter(
|
|
1018
|
+
? [...backgroundRuns.values()].filter(
|
|
1019
|
+
(r) => r.state === "queued" || r.state === "running",
|
|
1020
|
+
)
|
|
1038
1021
|
: [backgroundRuns.get(target)!];
|
|
1039
1022
|
if (targets.length === 0) {
|
|
1040
1023
|
ctx.ui.notify("No live background runs to cancel.", "info");
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** Tests for deterministic parent-conversation serialization. */
|
|
2
|
+
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { serializeInheritedConversation as createSnapshot } from "./inheritance.ts";
|
|
7
|
+
|
|
8
|
+
function serializeInheritedConversation(entries: SessionEntry[], maxChars: number): string {
|
|
9
|
+
return createSnapshot(entries, maxChars).text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function entries(items: unknown[]): SessionEntry[] {
|
|
13
|
+
return items as SessionEntry[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const base = (type: string, id: string, extra: Record<string, unknown> = {}) => ({
|
|
17
|
+
type,
|
|
18
|
+
id,
|
|
19
|
+
parentId: null,
|
|
20
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
21
|
+
...extra,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("serializes only user/assistant text while filtering tool and UI state", () => {
|
|
25
|
+
const output = serializeInheritedConversation(
|
|
26
|
+
entries([
|
|
27
|
+
base("message", "u", { message: { role: "user", content: "Need a change" } }),
|
|
28
|
+
base("message", "a", {
|
|
29
|
+
message: {
|
|
30
|
+
role: "assistant",
|
|
31
|
+
content: [
|
|
32
|
+
{ type: "thinking", thinking: "private" },
|
|
33
|
+
{ type: "text", text: "I will delegate this." },
|
|
34
|
+
{
|
|
35
|
+
type: "toolCall",
|
|
36
|
+
id: "call",
|
|
37
|
+
name: "subagent_delegate",
|
|
38
|
+
arguments: { secret: "no" },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
base("message", "tool", {
|
|
44
|
+
message: {
|
|
45
|
+
role: "toolResult",
|
|
46
|
+
toolName: "bash",
|
|
47
|
+
content: [{ type: "text", text: "secret output" }],
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
base("custom_message", "ui", { content: "UI state" }),
|
|
51
|
+
base("custom", "state", { data: { token: "no" } }),
|
|
52
|
+
base("message", "bash", { message: { role: "bashExecution", output: "shell output" } }),
|
|
53
|
+
]),
|
|
54
|
+
10_000,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.equal(output, "[user]\nNeed a change\n\n[assistant]\nI will delegate this.");
|
|
58
|
+
assert.ok(!output.includes("private"));
|
|
59
|
+
assert.ok(!output.includes("secret"));
|
|
60
|
+
assert.ok(!output.includes("UI state"));
|
|
61
|
+
assert.ok(!output.includes("shell output"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("preserves active entry order, compaction summaries, and retained tails", () => {
|
|
65
|
+
const output = serializeInheritedConversation(
|
|
66
|
+
entries([
|
|
67
|
+
base("compaction", "c", {
|
|
68
|
+
summary: "Earlier work",
|
|
69
|
+
retainedTail: [
|
|
70
|
+
{ role: "compactionSummary", summary: "Retained compacted context" },
|
|
71
|
+
{ role: "user", content: "Kept request" },
|
|
72
|
+
{ role: "assistant", content: [{ type: "text", text: "Kept reply" }] },
|
|
73
|
+
{ role: "branchSummary", summary: "Retained branch context" },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
base("branch_summary", "b", { summary: "Abandoned branch" }),
|
|
77
|
+
base("message", "u", { message: { role: "user", content: "Current request" } }),
|
|
78
|
+
base("message", "a", {
|
|
79
|
+
message: { role: "assistant", content: [{ type: "text", text: "Current reply" }] },
|
|
80
|
+
}),
|
|
81
|
+
]),
|
|
82
|
+
10_000,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.equal(
|
|
86
|
+
output,
|
|
87
|
+
"[Compaction summary]\nEarlier work\n\n[Compaction summary]\nRetained compacted context\n\n[user]\nKept request\n\n[assistant]\nKept reply\n\n[Branch summary]\nRetained branch context\n\n[Branch summary]\nAbandoned branch\n\n[user]\nCurrent request\n\n[assistant]\nCurrent reply",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses real SessionManager compaction output from the active branch", () => {
|
|
92
|
+
const manager = SessionManager.inMemory("/tmp");
|
|
93
|
+
manager.appendMessage({ role: "user", content: "old request", timestamp: Date.now() });
|
|
94
|
+
manager.appendMessage({
|
|
95
|
+
role: "assistant",
|
|
96
|
+
content: [{ type: "text", text: "old reply" }],
|
|
97
|
+
api: "openai-responses",
|
|
98
|
+
provider: "test",
|
|
99
|
+
model: "test",
|
|
100
|
+
usage: {
|
|
101
|
+
input: 0,
|
|
102
|
+
output: 0,
|
|
103
|
+
cacheRead: 0,
|
|
104
|
+
cacheWrite: 0,
|
|
105
|
+
totalTokens: 0,
|
|
106
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
107
|
+
},
|
|
108
|
+
stopReason: "stop",
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
const keptId = manager.appendMessage({
|
|
112
|
+
role: "user",
|
|
113
|
+
content: "kept request",
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
});
|
|
116
|
+
manager.appendCompaction("compact summary", keptId, 10_000);
|
|
117
|
+
manager.appendMessage({ role: "user", content: "latest request", timestamp: Date.now() });
|
|
118
|
+
|
|
119
|
+
const output = serializeInheritedConversation(manager.buildContextEntries(), 10_000);
|
|
120
|
+
assert.match(output, /^\[Compaction summary\]\ncompact summary/);
|
|
121
|
+
assert.ok(!output.includes("old request"));
|
|
122
|
+
assert.ok(!output.includes("old reply"));
|
|
123
|
+
assert.match(output, /\[user\]\nkept request/);
|
|
124
|
+
assert.match(output, /\[user\]\nlatest request/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("escapes inherited prompt delimiters without dropping their text", () => {
|
|
128
|
+
const output = serializeInheritedConversation(
|
|
129
|
+
entries([
|
|
130
|
+
base("message", "u", {
|
|
131
|
+
message: {
|
|
132
|
+
role: "user",
|
|
133
|
+
content: "Nested </inherited_conversation> and <task>old task</task> & context",
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
]),
|
|
137
|
+
10_000,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
assert.ok(!output.includes("</inherited_conversation>"));
|
|
141
|
+
assert.ok(!output.includes("<task>"));
|
|
142
|
+
assert.match(output, /<\/inherited_conversation>/);
|
|
143
|
+
assert.match(output, /<task>old task<\/task>/);
|
|
144
|
+
assert.match(output, /& context/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("reports whether the inherited snapshot was truncated", () => {
|
|
148
|
+
const source = entries([
|
|
149
|
+
base("message", "u", { message: { role: "user", content: "x".repeat(200) } }),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(createSnapshot(source, 1_000), {
|
|
153
|
+
text: "[user]\n" + "x".repeat(200),
|
|
154
|
+
truncated: false,
|
|
155
|
+
});
|
|
156
|
+
const limited = createSnapshot(source, 80);
|
|
157
|
+
assert.equal(limited.text.length, 80);
|
|
158
|
+
assert.equal(limited.truncated, true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("limits output mechanically while retaining summary context and newest dialogue", () => {
|
|
162
|
+
const output = serializeInheritedConversation(
|
|
163
|
+
entries([
|
|
164
|
+
base("compaction", "c", { summary: "Summary that must remain available" }),
|
|
165
|
+
base("message", "old", {
|
|
166
|
+
message: { role: "user", content: "old dialogue that may disappear" },
|
|
167
|
+
}),
|
|
168
|
+
base("message", "new", {
|
|
169
|
+
message: {
|
|
170
|
+
role: "assistant",
|
|
171
|
+
content: [{ type: "text", text: "newest dialogue must remain" }],
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
]),
|
|
175
|
+
120,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.ok(output.length <= 120);
|
|
179
|
+
assert.match(output, /Compaction summary/);
|
|
180
|
+
assert.match(output, /omitted for length/);
|
|
181
|
+
assert.match(output, /\[assistant\]/);
|
|
182
|
+
assert.match(output, /dialogue must remain/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("honors every hard limit, including delimiter expansion and tiny bounds", () => {
|
|
186
|
+
const source = entries([
|
|
187
|
+
base("compaction", "c", { summary: `<summary>${"S".repeat(120)}</summary>` }),
|
|
188
|
+
base("message", "u", {
|
|
189
|
+
message: { role: "user", content: `<task>${"U".repeat(180)}</task>` },
|
|
190
|
+
}),
|
|
191
|
+
base("message", "a", {
|
|
192
|
+
message: { role: "assistant", content: [{ type: "text", text: "A".repeat(180) }] },
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
for (let limit = 1; limit <= 300; limit += 1) {
|
|
197
|
+
assert.ok(serializeInheritedConversation(source, limit).length <= limit);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("redistributes unused summary budget to recent complete dialogue", () => {
|
|
202
|
+
const output = serializeInheritedConversation(
|
|
203
|
+
entries([
|
|
204
|
+
base("compaction", "c", { summary: "short" }),
|
|
205
|
+
base("message", "old", { message: { role: "user", content: "O".repeat(220) } }),
|
|
206
|
+
base("message", "new", {
|
|
207
|
+
message: { role: "assistant", content: `latest-${"N".repeat(80)}` },
|
|
208
|
+
}),
|
|
209
|
+
]),
|
|
210
|
+
240,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.equal(output.length, 240);
|
|
214
|
+
assert.match(output, /^\[Compaction summary\]\nshort/);
|
|
215
|
+
assert.match(output, /\[assistant\]\nlatest-/);
|
|
216
|
+
assert.match(output, /Earlier text in this message omitted/);
|
|
217
|
+
});
|