@ferris1225/pi-subagents 4.2.7 → 4.2.8

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/src/format.ts CHANGED
@@ -1,165 +1,181 @@
1
- /**
2
- * Pure formatting/result helpers shared by the subagent tool and completion
3
- * delivery: usage rendering, completion blocks, synthetic result constructors,
4
- * and run-id matching.
5
- */
6
-
7
- import type { AgentConfig } from "./agents.ts";
8
- import { formatTaskSummary } from "./monitor.ts";
9
- import { emptyUsage } from "./rpc-run.ts";
10
- import {
11
- getResultOutput,
12
- isFailedResult,
13
- truncateResultOutput,
14
- writeResultArtifact,
15
- type SingleResult,
16
- type UsageStats,
17
- } from "./spawn.ts";
18
-
19
- export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
- return {
21
- agent: agent.name,
22
- task,
23
- exitCode: -1,
24
- messages: [],
25
- stderr: "",
26
- usage: emptyUsage(),
27
- model: agent.model,
28
- ...(thinking ? { thinking } : {}),
29
- };
30
- }
31
-
32
- export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
- return {
34
- agent: agentName,
35
- task,
36
- exitCode: 1,
37
- messages: [],
38
- stderr: errorMessage,
39
- usage: emptyUsage(),
40
- errorMessage,
41
- dispatchFailed: true,
42
- };
43
- }
44
-
45
- /** Failed result for a background task that crashed with an exception (spawn
46
- * infra, delivery API, ...) instead of returning a normal result. */
47
- export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
- const errorMessage = error instanceof Error ? error.message : String(error);
49
- return {
50
- ...queuedResult(agent, task, thinking),
51
- exitCode: 1,
52
- stderr: errorMessage,
53
- stopReason: "error",
54
- errorMessage,
55
- dispatchFailed: true,
56
- };
57
- }
58
-
59
- function formatTokens(count: number): string {
60
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
- return String(count);
63
- }
64
-
65
- export function formatUsage(usage: UsageStats): string {
66
- const parts: string[] = [];
67
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
- return parts.join(" ");
74
- }
75
-
76
- export interface CompletionFormatOptions {
77
- /** Project-scoped directory the full result is written to when the output
78
- * is truncated: <projectRoot>/results. */
79
- resultRoot?: string;
80
- }
81
-
82
- export function formatCompletionBlock(
83
- result: SingleResult,
84
- maxResultLines: number,
85
- options: CompletionFormatOptions = {},
86
- ): string {
87
- const failed = isFailedResult(result);
88
- const failedTools = result.failedTools ?? [];
89
- const status = failed ? "failed" : "completed";
90
- const usage = formatUsage(result.usage);
91
- const output = getResultOutput(result);
92
- const { text, truncated } = truncateResultOutput(output, maxResultLines);const fallbackNote = result.modelFallbackFrom
93
- ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
94
- : "";
95
- const startupRetryNote = result.startupRetries
96
- ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} concurrent pi startup race)`
97
- : "";
98
- const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
99
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
100
- if (result.isolation === "worktree") {
101
- const isolation =
102
- result.integrationStatus === "integrated"
103
- ? "worktree · changes integrated into the original working tree"
104
- : result.integrationStatus === "no_changes"
105
- ? "worktree · no changes; temporary worktree removed"
106
- : result.integrationStatus === "retained"
107
- ? result.integrationApplied
108
- ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
109
- : "worktree · integration failed; recovery artifacts retained"
110
- : "worktree · isolated";
111
- lines.push(`Isolation: ${isolation}`);
112
- if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
113
- if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
114
- if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
115
- lines.push("");
116
- }
117
- lines.push(text);
118
- // Failed-tool diagnostics ride along only when the run itself failed: they
119
- // explain the failure, while on a successful run a transient failed call
120
- // (no-match grep, rejected edit) is noise the agent already worked around.
121
- if (failed && failedTools.length > 0) {
122
- lines.push(
123
- "",
124
- `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
125
- ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
126
- );
127
- }
128
- if (truncated) {
129
- // The full text lives on disk so the main agent can read it on demand.
130
- const artifact = options.resultRoot
131
- ? writeResultArtifact(output, result.agent, options.resultRoot)
132
- : "(result root unavailable)";
133
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${artifact})`);
134
- }
135
- return lines.join("\n");
136
- }
137
-
138
- /** Instruction appended to a model-level failure: the sub-agent's provider never
139
- * produced usable output (or the run stalled), so the task is handed back to the
140
- * main window instead of being left as a dead failure. When the run preserved a
141
- * session with earlier work (and the run id is known), steer the main agent to
142
- * RESUME it in-context once a model is available, instead of re-dispatching
143
- * fresh (which would re-scan everything). */
144
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
145
- const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
146
- const detail = result.errorMessage?.trim();
147
- const cause = detail
148
- ? `its model/provider call failed (${detail})`
149
- : "its model was unavailable or failed (or the run stalled)";
150
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
151
- const recovery = sessionPreserved
152
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
153
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
154
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
155
- }
156
-
157
- /** Resolve a run-id request to actual ids: an exact numeric match always wins
158
- * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
159
- * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
160
- * from returning — or, for subagent_stop, acting on a whole prefix family. */
161
- export function matchRunIds(ids: number[], requested: string): number[] {
162
- const exact = ids.filter((id) => String(id) === requested);
163
- if (exact.length > 0) return exact;
164
- return ids.filter((id) => String(id).startsWith(requested));
165
- }
1
+ /**
2
+ * Pure formatting/result helpers shared by the subagent tool and completion
3
+ * delivery: usage rendering, completion blocks, synthetic result constructors,
4
+ * and run-id matching.
5
+ */
6
+
7
+ import type { AgentConfig } from "./agents.ts";
8
+ import { runLabel, shrinkRunLabel } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
10
+ import {
11
+ getResultOutput,
12
+ isFailedResult,
13
+ truncateResultOutput,
14
+ writeResultArtifact,
15
+ type SingleResult,
16
+ type UsageStats,
17
+ } from "./spawn.ts";
18
+
19
+ export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
+ return {
21
+ agent: agent.name,
22
+ task,
23
+ exitCode: -1,
24
+ messages: [],
25
+ stderr: "",
26
+ usage: emptyUsage(),
27
+ model: agent.model,
28
+ ...(thinking ? { thinking } : {}),
29
+ };
30
+ }
31
+
32
+ export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
+ return {
34
+ agent: agentName,
35
+ task,
36
+ exitCode: 1,
37
+ messages: [],
38
+ stderr: errorMessage,
39
+ usage: emptyUsage(),
40
+ errorMessage,
41
+ dispatchFailed: true,
42
+ };
43
+ }
44
+
45
+ /** Failed result for a background task that crashed with an exception (spawn
46
+ * infra, delivery API, ...) instead of returning a normal result. */
47
+ export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
+ const errorMessage = error instanceof Error ? error.message : String(error);
49
+ return {
50
+ ...queuedResult(agent, task, thinking),
51
+ exitCode: 1,
52
+ stderr: errorMessage,
53
+ stopReason: "error",
54
+ errorMessage,
55
+ dispatchFailed: true,
56
+ };
57
+ }
58
+
59
+ function formatTokens(count: number): string {
60
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
+ return String(count);
63
+ }
64
+
65
+ export function formatUsage(usage: UsageStats): string {
66
+ const parts: string[] = [];
67
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
+ return parts.join(" ");
74
+ }
75
+
76
+ export interface CompletionFormatOptions {
77
+ /** Project-scoped directory the full result is written to when the output
78
+ * is truncated: <projectRoot>/results. */
79
+ resultRoot?: string;
80
+ }
81
+
82
+ /** Display width of the task-derived label folded into the completion heading:
83
+ * tight enough to stay a hint rather than a second copy of the task. */
84
+ const COMPLETION_LABEL_MAX = 24;
85
+
86
+ export function formatCompletionBlock(
87
+ result: SingleResult,
88
+ maxResultLines: number,
89
+ options: CompletionFormatOptions = {},
90
+ ): string {
91
+ const failed = isFailedResult(result);
92
+ const failedTools = result.failedTools ?? [];
93
+ const status = failed ? "failed" : "completed";
94
+ const usage = formatUsage(result.usage);
95
+ const output = getResultOutput(result);
96
+ const { text, truncated } = truncateResultOutput(output, maxResultLines);
97
+ const fallbackNote = result.modelFallbackFrom
98
+ ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
99
+ : "";
100
+ const startupRetryNote = result.startupRetries
101
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
102
+ : "";
103
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
104
+ // A short task-derived label, not the task itself: the parent authored the
105
+ // task and still has it in context, but a wide fan-out of same-agent runs
106
+ // needs more than a run id to tell completions apart.
107
+ const label = shrinkRunLabel(runLabel(result.task), COMPLETION_LABEL_MAX);
108
+ const lines = [
109
+ `### [${result.agent}${label ? `·${label}` : ""}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`,
110
+ "",
111
+ ];
112
+ if (result.isolation === "worktree") {
113
+ const isolation =
114
+ result.integrationStatus === "integrated"
115
+ ? "worktree · changes integrated into the original working tree"
116
+ : result.integrationStatus === "no_changes"
117
+ ? "worktree · no changes; temporary worktree removed"
118
+ : result.integrationStatus === "retained"
119
+ ? result.integrationApplied
120
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
121
+ : "worktree · integration failed; recovery artifacts retained"
122
+ : "worktree · isolated";
123
+ lines.push(`Isolation: ${isolation}`);
124
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
125
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
126
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
127
+ lines.push("");
128
+ }
129
+ lines.push(text);
130
+ // Failed-tool diagnostics ride along only when the run itself failed: they
131
+ // explain the failure, while on a successful run a transient failed call
132
+ // (no-match grep, rejected edit) is noise the agent already worked around.
133
+ if (failed && failedTools.length > 0) {
134
+ lines.push(
135
+ "",
136
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
137
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
138
+ );
139
+ }
140
+ if (truncated) {
141
+ // The full text lives on disk so the main agent can read it on demand.
142
+ const artifact = options.resultRoot
143
+ ? writeResultArtifact(output, result.agent, options.resultRoot)
144
+ : "(result root unavailable)";
145
+ // State the loss (shown vs total) and condition the read: handing the
146
+ // parent both a summary and a full-text entrance invites the same content
147
+ // into its context twice.
148
+ const totalLines = output.split("\n").length;
149
+ lines.push("", `(${maxResultLines} of ${totalLines} lines shown; full result ${artifact} — read only if these are insufficient)`);
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
154
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
155
+ * produced usable output (or the run stalled), so the task is handed back to the
156
+ * main window instead of being left as a dead failure. When the run preserved a
157
+ * session with earlier work (and the run id is known), steer the main agent to
158
+ * RESUME it in-context once a model is available, instead of re-dispatching
159
+ * fresh (which would re-scan everything). */
160
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
161
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
162
+ const detail = result.errorMessage?.trim();
163
+ const cause = detail
164
+ ? `its model/provider call failed (${detail})`
165
+ : "its model was unavailable or failed (or the run stalled)";
166
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
167
+ const recovery = sessionPreserved
168
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
169
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
170
+ return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
171
+ }
172
+
173
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
174
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
175
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
176
+ * from returning — or, for subagent_stop, acting on — a whole prefix family. */
177
+ export function matchRunIds(ids: number[], requested: string): number[] {
178
+ const exact = ids.filter((id) => String(id) === requested);
179
+ if (exact.length > 0) return exact;
180
+ return ids.filter((id) => String(id).startsWith(requested));
181
+ }
package/src/index.ts CHANGED
@@ -1,100 +1,102 @@
1
- /**
2
- * pi-subagents — focused sub-agent delegation for pi.
3
- *
4
- * Assembly point: builds the shared runtime and registers everything.
5
- * The heavy lifting lives in focused modules:
6
- * - dispatch.ts — tool contract, managed role policy, internal steps
7
- * - thread-lifecycle.ts — stable generations, controls, final integration/delivery
8
- * - tools.ts — subagent_control / subagent_stop
9
- * - announcements.ts — session-start recovery, notices, and widget install
10
- * - widget.ts — active-only TUI run status
11
- * - runtime.ts — shared per-session state
12
- *
13
- * Also registers the `/subagents-setup` command and a `before_agent_start` hook
14
- * that injects a delegation directive into the parent system prompt so the main
15
- * model uses the tool proactively.
16
- *
17
- * The tool is not registered inside child sub-agent processes, which prevents
18
- * runaway recursion and keeps child context windows clean.
19
- */
20
-
21
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
- import { Text } from "@earendil-works/pi-tui";
23
- import { discoverAgents } from "./agents.ts";
24
- import { registerAnnouncements } from "./announcements.ts";
25
- import { getConfigPath, loadConfig } from "./config.ts";
26
- import { registerSubagentTool } from "./dispatch.ts";
27
- import { matchRunIds } from "./format.ts";
28
- import { buildDelegationDirective } from "./prompt.ts";
29
- import { createRuntime } from "./runtime.ts";
30
- import { runSetup } from "./setup.ts";
31
- import { currentSubagentDepth } from "./spawn.ts";
32
- import { bootstrapDurableState } from "./thread-lifecycle.ts";
33
- import { registerLookupTools } from "./tools.ts";
34
- import { clearActiveRunsWidget } from "./widget.ts";
35
-
36
- export { matchRunIds };
37
-
38
- export default function (pi: ExtensionAPI): void {
39
- const configPath = getConfigPath(getAgentDir());
40
- const runtime = createRuntime(pi, configPath);
41
-
42
- // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
43
- // excluded from their toolset at spawn (--exclude-tools); this check is defense
44
- // in depth so a child can never expose the tool back to its model, even if
45
- // another extension ignores the depth marker.
46
- if (currentSubagentDepth() >= 1) {
47
- pi.registerCommand("subagents-setup", {
48
- description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
49
- handler: async (_args, ctx) => {
50
- ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
51
- },
52
- });
53
- return;
54
- }
55
-
56
- pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
57
- new Text(
58
- `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
59
- 0,
60
- 0,
61
- ),
62
- );
63
-
64
- pi.on("session_shutdown", async (_event, ctx) => {
65
- clearActiveRunsWidget(ctx);
66
- await runtime.shutdown();
67
- });
68
-
69
- registerSubagentTool(pi, runtime);
70
- registerLookupTools(pi, runtime);
71
-
72
- pi.registerCommand("subagents-setup", {
73
- description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
74
- handler: async (_args, ctx) => {
75
- await runSetup(ctx, configPath);
76
- },
77
- });
78
-
79
- registerAnnouncements(pi, runtime);
80
-
81
- // Durable bootstrap: restore parked threads from the manifest so a reload or
82
- // restart keeps status and resume working, then age out old records and sweep
83
- // leaked temp/state directories. Registration never blocks on it and every
84
- // stage is best-effort; the restore pass is published as runtime.durableRestore
85
- // so the tools and the session-start notice wait for it instead of racing it.
86
- void bootstrapDurableState(runtime);
87
-
88
- // Proactive dispatch: inject the delegation directive into the parent system prompt.
89
- pi.on("before_agent_start", async (event, ctx) => {
90
- const config = await loadConfig(configPath);
91
- const { agents } = discoverAgents(ctx.cwd, {
92
- scope: config.agentScope,
93
- enabledNames: config.enabledAgents,
94
- projectTrusted: ctx.isProjectTrusted?.() === true,
95
- });
96
- const directive = buildDelegationDirective(agents);
97
- if (!directive) return undefined;
98
- return { systemPrompt: `${event.systemPrompt}\n${directive}` };
99
- });
100
- }
1
+ /**
2
+ * pi-subagents — focused sub-agent delegation for pi.
3
+ *
4
+ * Assembly point: builds the shared runtime and registers everything.
5
+ * The heavy lifting lives in focused modules:
6
+ * - dispatch.ts — tool contract, managed role policy, internal steps
7
+ * - thread-lifecycle.ts — stable generations, controls, final integration/delivery
8
+ * - tools.ts — subagent_control / subagent_stop
9
+ * - announcements.ts — session-start recovery, notices, and widget install
10
+ * - widget.ts — active-only TUI run status
11
+ * - runtime.ts — shared per-session state
12
+ *
13
+ * Also registers the `/subagents-setup` command and a `before_agent_start` hook
14
+ * that injects a delegation directive into the parent system prompt so the main
15
+ * model uses the tool proactively.
16
+ *
17
+ * The tool is not registered inside child sub-agent processes, which prevents
18
+ * runaway recursion and keeps child context windows clean.
19
+ */
20
+
21
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { Text } from "@earendil-works/pi-tui";
23
+ import { discoverAgents } from "./agents.ts";
24
+ import { registerAnnouncements } from "./announcements.ts";
25
+ import { getConfigPath, loadConfig } from "./config.ts";
26
+ import { registerSubagentTool } from "./dispatch.ts";
27
+ import { matchRunIds } from "./format.ts";
28
+ import { buildDelegationDirective } from "./prompt.ts";
29
+ import { createRuntime } from "./runtime.ts";
30
+ import { runSetup } from "./setup.ts";
31
+ import { currentSubagentDepth } from "./spawn.ts";
32
+ import { clearActiveRunsStatus } from "./status.ts";
33
+ import { bootstrapDurableState } from "./thread-lifecycle.ts";
34
+ import { registerLookupTools } from "./tools.ts";
35
+ import { clearActiveRunsWidget } from "./widget.ts";
36
+
37
+ export { matchRunIds };
38
+
39
+ export default function (pi: ExtensionAPI): void {
40
+ const configPath = getConfigPath(getAgentDir());
41
+ const runtime = createRuntime(pi, configPath);
42
+
43
+ // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
44
+ // excluded from their toolset at spawn (--exclude-tools); this check is defense
45
+ // in depth so a child can never expose the tool back to its model, even if
46
+ // another extension ignores the depth marker.
47
+ if (currentSubagentDepth() >= 1) {
48
+ pi.registerCommand("subagents-setup", {
49
+ description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
50
+ handler: async (_args, ctx) => {
51
+ ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
52
+ },
53
+ });
54
+ return;
55
+ }
56
+
57
+ pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
58
+ new Text(
59
+ `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
60
+ 0,
61
+ 0,
62
+ ),
63
+ );
64
+
65
+ pi.on("session_shutdown", async (_event, ctx) => {
66
+ clearActiveRunsStatus(ctx);
67
+ clearActiveRunsWidget(ctx);
68
+ await runtime.shutdown();
69
+ });
70
+
71
+ registerSubagentTool(pi, runtime);
72
+ registerLookupTools(pi, runtime);
73
+
74
+ pi.registerCommand("subagents-setup", {
75
+ description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
76
+ handler: async (_args, ctx) => {
77
+ await runSetup(ctx, configPath);
78
+ },
79
+ });
80
+
81
+ registerAnnouncements(pi, runtime);
82
+
83
+ // Durable bootstrap: restore parked threads from the manifest so a reload or
84
+ // restart keeps status and resume working, then age out old records and sweep
85
+ // leaked temp/state directories. Registration never blocks on it and every
86
+ // stage is best-effort; the restore pass is published as runtime.durableRestore
87
+ // so the tools and the session-start notice wait for it instead of racing it.
88
+ void bootstrapDurableState(runtime);
89
+
90
+ // Proactive dispatch: inject the delegation directive into the parent system prompt.
91
+ pi.on("before_agent_start", async (event, ctx) => {
92
+ const config = await loadConfig(configPath);
93
+ const { agents } = discoverAgents(ctx.cwd, {
94
+ scope: config.agentScope,
95
+ enabledNames: config.enabledAgents,
96
+ projectTrusted: ctx.isProjectTrusted?.() === true,
97
+ });
98
+ const directive = buildDelegationDirective(agents);
99
+ if (!directive) return undefined;
100
+ return { systemPrompt: `${event.systemPrompt}\n${directive}` };
101
+ });
102
+ }
package/src/prompt.ts CHANGED
@@ -40,7 +40,7 @@ export function buildDelegationDirective(
40
40
  ];
41
41
 
42
42
  const handoffRules = [
43
- "Dispatch never blocks or ends your turn — keep working; each completion resumes you automatically. Never sleep or poll for it.",
43
+ "Dispatch never blocks or ends your turn — keep working, but only on what the children are not: never re-read a scope you just delegated. Each completion resumes you automatically; never sleep or poll for it.",
44
44
  "Results are already shown; add only your conclusion or next action, never a restatement.",
45
45
  "Never declare the overall task done while a dispatched run is still active.",
46
46
  ];
package/src/runtime.ts CHANGED
@@ -131,6 +131,12 @@ export interface SubagentRuntime {
131
131
 
132
132
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
133
133
  const backgroundQueue = new BackgroundTaskQueue(resolveSubagentConcurrency());
134
+ // Compaction reads the whole history, summarizes it, and replaces it. A
135
+ // completion injected into that window can be swallowed by the summary,
136
+ // silently losing a result a child spent minutes producing — so delivery is
137
+ // held until compaction settles (either outcome) instead.
138
+ let compactionInFlight = false;
139
+ let heldCompletions: CompletionMessageItem[] = [];
134
140
 
135
141
  const runtime: SubagentRuntime = {
136
142
  configPath,
@@ -142,6 +148,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
142
148
  restoredNotified: false,
143
149
  sendCompletionGroup: (items) => {
144
150
  if (!runtime.sessionActive || items.length === 0) return;
151
+ if (compactionInFlight) {
152
+ heldCompletions.push(...items);
153
+ return;
154
+ }
145
155
  // A result arriving for one run does not mean sibling runs are done.
146
156
  // Computing this at delivery (emit) time — not when the item was
147
157
  // pushed — reflects the current monitor state, since finishing runs
@@ -242,6 +252,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
242
252
  }
243
253
  const preflights = [...runtime.preflightOperations];
244
254
  runtime.completionBatcher.dispose();
255
+ // Held items are dropped exactly like the batcher's abandoned ones: the
256
+ // session is gone, so there is no window left to deliver them into.
257
+ heldCompletions = [];
258
+ compactionInFlight = false;
245
259
  runtime.backgroundQueue.cancelAll();
246
260
  // Await live RPC process-tree cleanup and continuation preflight rollback
247
261
  // before persisting records or releasing ownership maps.
@@ -308,6 +322,25 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
308
322
  },
309
323
  };
310
324
 
325
+ // Hold delivery across compaction. `session_before_compact` opens the
326
+ // window; BOTH terminal events close it, because a failed or aborted
327
+ // compaction that never released would strand every held result forever.
328
+ pi.on("session_before_compact", () => {
329
+ compactionInFlight = true;
330
+ });
331
+ const releaseHeldCompletions = (): void => {
332
+ compactionInFlight = false;
333
+ if (heldCompletions.length === 0) return;
334
+ const items = heldCompletions;
335
+ heldCompletions = [];
336
+ // Re-enter the normal path now that the gate is open: the active-runs
337
+ // footer and the steer/nextTurn choice must reflect delivery time, not
338
+ // the moment the items were held.
339
+ runtime.sendCompletionGroup(items);
340
+ };
341
+ pi.on("session_compact", releaseHeldCompletions);
342
+ pi.on("session_compact_failed", releaseHeldCompletions);
343
+
311
344
  runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
312
345
  emit: runtime.sendCompletionGroup,
313
346
  });