@narumitw/pi-subagents 0.42.0 → 0.43.1

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.
@@ -0,0 +1,35 @@
1
+ import type { CompletionDelivery } from "./agents.js";
2
+
3
+ export function createSpawnPromptGuidelines(
4
+ completionDelivery: CompletionDelivery,
5
+ blockingEnabled = true,
6
+ ): string[] {
7
+ const deliveryGuidance =
8
+ completionDelivery === "auto-resume"
9
+ ? blockingEnabled
10
+ ? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
11
+ : "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result."
12
+ : blockingEnabled
13
+ ? "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result."
14
+ : "With subagent_spawn completion delivery set to next-turn (the default), use subagent_spawn only when the current response does not depend on its result; complete final-answer-dependent work directly because an idle root is not awakened.";
15
+ const noLocalWorkGuidance =
16
+ completionDelivery === "auto-resume"
17
+ ? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
18
+ : "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response only when the current response does not depend on its result; next-turn delivery will not wake an idle root.";
19
+ return [
20
+ "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
21
+ "Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
22
+ deliveryGuidance,
23
+ "Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
24
+ ...(blockingEnabled
25
+ ? [
26
+ "Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
27
+ "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
28
+ ]
29
+ : []),
30
+ "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
31
+ noLocalWorkGuidance,
32
+ 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
33
+ 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
34
+ ];
35
+ }
@@ -0,0 +1,31 @@
1
+ import type { AgentRegistry } from "./registry.js";
2
+ import type { WorkspaceManager } from "./workspace.js";
3
+
4
+ export async function disposeStatefulRuntime(
5
+ registry: AgentRegistry | undefined,
6
+ workspaceManager: WorkspaceManager,
7
+ ): Promise<unknown[]> {
8
+ const errors: unknown[] = [];
9
+ try {
10
+ await registry?.shutdown();
11
+ } catch (error) {
12
+ errors.push(error);
13
+ }
14
+ try {
15
+ await workspaceManager.cleanupAll();
16
+ } catch (error) {
17
+ errors.push(error);
18
+ }
19
+ return errors;
20
+ }
21
+
22
+ export function assertCurrentSpawn(
23
+ signal: AbortSignal | undefined,
24
+ generation: number,
25
+ currentGeneration: number,
26
+ ): void {
27
+ if (!signal?.aborted && generation === currentGeneration) return;
28
+ const error = new Error("Subagent spawn owner was replaced or aborted");
29
+ error.name = "AbortError";
30
+ throw error;
31
+ }
@@ -0,0 +1,249 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import {
5
+ COLLAPSED_LIST_LIMIT,
6
+ expansionHint,
7
+ type RenderStatus,
8
+ recordList,
9
+ recordValue,
10
+ renderFallbackResult,
11
+ safeBlock,
12
+ safeLine,
13
+ statusBadge,
14
+ stringValue,
15
+ type ToolRendererContext,
16
+ textResult,
17
+ toolHeader,
18
+ } from "./render-common.js";
19
+
20
+ export type StatefulRenderTool = "spawn" | "send" | "manage" | "mailbox";
21
+
22
+ export function createStatefulToolRenderer(tool: StatefulRenderTool) {
23
+ return {
24
+ renderCall(args: unknown, theme: Theme) {
25
+ return renderStatefulCall(tool, recordValue(args) ?? {}, theme);
26
+ },
27
+ renderResult(
28
+ result: AgentToolResult<unknown>,
29
+ options: ToolRenderResultOptions,
30
+ theme: Theme,
31
+ context: ToolRendererContext<unknown>,
32
+ ) {
33
+ return renderStatefulResult(tool, result, options, theme, context);
34
+ },
35
+ };
36
+ }
37
+
38
+ function renderStatefulCall(tool: StatefulRenderTool, args: Record<string, unknown>, theme: Theme) {
39
+ if (tool === "spawn") {
40
+ const metadata = [
41
+ `[${safeLine(args.agentScope, "user", 64)}]`,
42
+ "detached",
43
+ safeLine(args.workspaceMode, "shared", 64),
44
+ ];
45
+ if (typeof args.thinkingLevel === "string") metadata.push(`thinking:${args.thinkingLevel}`);
46
+ return new Text(
47
+ [
48
+ toolHeader(theme, "subagent_spawn", args.agent, metadata),
49
+ ` ${theme.fg("dim", safeLine(args.task, "...", 2 * 1024))}`,
50
+ ].join("\n"),
51
+ 0,
52
+ 0,
53
+ );
54
+ }
55
+ if (tool === "send") {
56
+ return new Text(
57
+ [
58
+ toolHeader(theme, "subagent_send", args.agentId, ["follow-up"]),
59
+ ` ${theme.fg("dim", safeLine(args.task, "...", 2 * 1024))}`,
60
+ ].join("\n"),
61
+ 0,
62
+ 0,
63
+ );
64
+ }
65
+ if (tool === "manage") {
66
+ const metadata: string[] = [];
67
+ if (typeof args.agentId === "string") metadata.push(`id:${safeLine(args.agentId, "", 256)}`);
68
+ if (args.subtree === true) metadata.push("subtree");
69
+ if (args.includeClosed === true) metadata.push("include closed");
70
+ return new Text(toolHeader(theme, "subagent_manage", args.action, metadata), 0, 0);
71
+ }
72
+ const metadata = [`id:${safeLine(args.agentId, "...", 256)}`];
73
+ if (args.action === "read") {
74
+ metadata.push(args.acknowledge === false ? "leave unread" : "acknowledge");
75
+ }
76
+ const lines = [toolHeader(theme, "subagent_mailbox", args.action, metadata)];
77
+ if (args.action === "send") {
78
+ lines.push(` ${theme.fg("dim", safeLine(args.message, "...", 2 * 1024))}`);
79
+ }
80
+ return new Text(lines.join("\n"), 0, 0);
81
+ }
82
+
83
+ function renderStatefulResult(
84
+ tool: StatefulRenderTool,
85
+ result: AgentToolResult<unknown>,
86
+ options: ToolRenderResultOptions,
87
+ theme: Theme,
88
+ context: ToolRendererContext<unknown>,
89
+ ) {
90
+ const details = recordValue(result.details);
91
+ const args = recordValue(context.args) ?? {};
92
+ if (!details) return renderFallbackResult(result, options, theme, context.isError);
93
+ if (tool === "spawn" || tool === "send") {
94
+ const agent = recordValue(details.agent);
95
+ if (!agent) return renderFallbackResult(result, options, theme, context.isError);
96
+ return new Text(renderAgentResult(agent, result, options.expanded, theme), 0, 0);
97
+ }
98
+ if (tool === "manage") {
99
+ return new Text(renderManageResult(args, details, result, options.expanded, theme), 0, 0);
100
+ }
101
+ return new Text(renderMailboxResult(args, details, options.expanded, theme), 0, 0);
102
+ }
103
+
104
+ function renderAgentResult(
105
+ agent: Record<string, unknown>,
106
+ result: AgentToolResult<unknown>,
107
+ expanded: boolean,
108
+ theme: Theme,
109
+ ): string {
110
+ const state = safeLine(agent.state, "unknown", 128);
111
+ const lines = [
112
+ `${statusBadge(theme, lifecycleStatus(state))} · ${theme.fg("accent", safeLine(agent.id, "agent", 256))} · ${theme.fg("toolOutput", safeLine(agent.agent, "subagent", 256))} · ${theme.fg("muted", state)}`,
113
+ ];
114
+ const thinking = stringValue(agent.thinkingLevel);
115
+ const unread = typeof agent.unreadMessages === "number" ? agent.unreadMessages : 0;
116
+ if (thinking || unread > 0) {
117
+ lines.push(
118
+ theme.fg(
119
+ "dim",
120
+ [thinking && `thinking:${safeLine(thinking, "", 128)}`, unread > 0 && `unread:${unread}`]
121
+ .filter(Boolean)
122
+ .join(" · "),
123
+ ),
124
+ );
125
+ }
126
+ if (expanded) {
127
+ const task = safeBlock(agent.currentTask, "", 2 * 1024).trim();
128
+ const error = safeBlock(agent.error, "", 2 * 1024).trim();
129
+ if (task) lines.push(theme.fg("dim", `task: ${task}`));
130
+ if (error) lines.push(theme.fg("error", `error: ${error}`));
131
+ const content = safeBlock(textResult(result), "", 8 * 1024).trim();
132
+ if (content) lines.push(theme.fg("toolOutput", content));
133
+ } else lines.push(expansionHint());
134
+ return lines.join("\n");
135
+ }
136
+
137
+ function renderManageResult(
138
+ args: Record<string, unknown>,
139
+ details: Record<string, unknown>,
140
+ result: AgentToolResult<unknown>,
141
+ expanded: boolean,
142
+ theme: Theme,
143
+ ): string {
144
+ const action = safeLine(args.action, "action", 128);
145
+ const agents = recordList(details.agents);
146
+ const primary = recordValue(details.agent);
147
+ if (action === "list") {
148
+ const lines = [
149
+ `${statusBadge(theme, "completed")} · list · ${agents.length} agent${agents.length === 1 ? "" : "s"}`,
150
+ ];
151
+ const selected = expanded ? agents : agents.slice(0, COLLAPSED_LIST_LIMIT);
152
+ for (const agent of selected) lines.push(formatAgentLine(agent, theme, expanded));
153
+ if (agents.length === 0) lines.push(theme.fg("muted", "(no retained agents)"));
154
+ if (agents.length > selected.length)
155
+ lines.push(theme.fg("muted", `… ${agents.length - selected.length} omitted`));
156
+ if (!expanded && agents.length > 0) lines.push(expansionHint());
157
+ return lines.join("\n");
158
+ }
159
+
160
+ const subtree = args.subtree === true;
161
+ const affected = subtree ? agents.length : agents.length > 0 ? agents.length : primary ? 1 : 0;
162
+ const status: RenderStatus = action === "interrupt" ? "interrupted" : "closed";
163
+ const lines = [
164
+ `${statusBadge(theme, status)} · ${affected} agent${affected === 1 ? "" : "s"}${subtree ? theme.fg("muted", " · subtree") : ""}`,
165
+ ];
166
+ const selected = agents.length > 0 ? agents : !subtree && primary ? [primary] : [];
167
+ for (const agent of expanded ? selected : selected.slice(0, COLLAPSED_LIST_LIMIT)) {
168
+ lines.push(formatAgentLine(agent, theme, expanded));
169
+ }
170
+ if (selected.length === 0) {
171
+ const content = safeBlock(textResult(result), "(no output)", 8 * 1024);
172
+ lines.push(theme.fg("toolOutput", content));
173
+ }
174
+ if (!expanded && selected.length > 0) lines.push(expansionHint());
175
+ return lines.join("\n");
176
+ }
177
+
178
+ function renderMailboxResult(
179
+ args: Record<string, unknown>,
180
+ details: Record<string, unknown>,
181
+ expanded: boolean,
182
+ theme: Theme,
183
+ ): string {
184
+ const action = safeLine(args.action, "action", 64);
185
+ if (action === "send") {
186
+ const message = recordValue(details.message);
187
+ if (!message) return `${statusBadge(theme, "completed")} · Queued message`;
188
+ const lines = [
189
+ `${theme.fg("success", "✓")} ${theme.fg("success", "Queued")} · ${theme.fg("accent", safeLine(message.id, "message", 256))} · ${theme.fg("muted", `to ${safeLine(message.recipientId, safeLine(args.agentId), 256)}`)}`,
190
+ ];
191
+ if (expanded) {
192
+ lines.push(theme.fg("toolOutput", safeBlock(message.content, "(empty message)", 2 * 1024)));
193
+ } else lines.push(expansionHint());
194
+ return lines.join("\n");
195
+ }
196
+
197
+ const messages = recordList(details.messages);
198
+ const acknowledged = args.acknowledge === false ? "left unread" : "acknowledged";
199
+ const lines = [
200
+ `${statusBadge(theme, "completed")} · ${messages.length} message${messages.length === 1 ? "" : "s"} · ${acknowledged}`,
201
+ ];
202
+ const selected = expanded ? messages : messages.slice(0, COLLAPSED_LIST_LIMIT);
203
+ for (const message of selected) {
204
+ const content = expanded
205
+ ? safeBlock(message.content, "(empty message)", 2 * 1024)
206
+ : safeLine(message.content, "(empty message)", 512);
207
+ lines.push(
208
+ `${theme.fg("muted", "• ")}${theme.fg("accent", safeLine(message.id, "message", 256))} ${theme.fg("muted", `from ${safeLine(message.senderId, "unknown", 256)}: `)}${theme.fg("toolOutput", content)}`,
209
+ );
210
+ }
211
+ if (messages.length === 0) lines.push(theme.fg("muted", "(no unread messages)"));
212
+ if (messages.length > selected.length)
213
+ lines.push(theme.fg("muted", `… ${messages.length - selected.length} omitted`));
214
+ if (!expanded && messages.length > 0) lines.push(expansionHint());
215
+ return lines.join("\n");
216
+ }
217
+
218
+ function formatAgentLine(agent: Record<string, unknown>, theme: Theme, expanded: boolean): string {
219
+ const unread = typeof agent.unreadMessages === "number" ? agent.unreadMessages : 0;
220
+ const lines = [
221
+ `${theme.fg("muted", "• ")}${theme.fg("accent", safeLine(agent.id, "agent", 256))} ${theme.fg("toolOutput", safeLine(agent.agent, "subagent", 256))} ${theme.fg("muted", safeLine(agent.state, "unknown", 128))}${unread > 0 ? theme.fg("warning", ` · unread:${unread}`) : ""}`,
222
+ ];
223
+ if (expanded) {
224
+ const task = safeBlock(agent.currentTask, "", 2 * 1024).trim();
225
+ const error = safeBlock(agent.error, "", 2 * 1024).trim();
226
+ if (task) lines.push(` ${theme.fg("dim", `task: ${task}`)}`);
227
+ if (error) lines.push(` ${theme.fg("error", `error: ${error}`)}`);
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+
232
+ function lifecycleStatus(state: string): RenderStatus {
233
+ switch (state) {
234
+ case "starting":
235
+ return "starting";
236
+ case "running":
237
+ return "running";
238
+ case "idle":
239
+ return "idle";
240
+ case "failed":
241
+ return "failed";
242
+ case "interrupted":
243
+ return "interrupted";
244
+ case "closed":
245
+ return "closed";
246
+ default:
247
+ return "completed";
248
+ }
249
+ }
@@ -0,0 +1,91 @@
1
+ import { realpathSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { type AgentScope, discoverAgents, type SubagentSettings } from "./agents.js";
5
+ import type { AgentRegistry, ManagedAgent } from "./registry.js";
6
+ import { safeTerminalLine } from "./safe-text.js";
7
+ import { readSubagentSettings } from "./settings.js";
8
+
9
+ export function assertNoSharedWriteConflict(
10
+ registry: AgentRegistry,
11
+ agentName: string,
12
+ cwd: string,
13
+ scope: AgentScope,
14
+ settings?: SubagentSettings,
15
+ ): void {
16
+ const agents = discoverAgents(cwd, scope, settings ?? readSubagentSettings()).agents;
17
+ const requested = agents.find((agent) => agent.name === agentName);
18
+ if (!isWriteCapable(requested?.tools)) return;
19
+ for (const active of registry.list()) {
20
+ if (
21
+ !isSameCwd(active.cwd, cwd) ||
22
+ (active.state !== "running" && active.state !== "starting")
23
+ ) {
24
+ continue;
25
+ }
26
+ const activeConfig = agents.find((agent) => agent.name === active.agent);
27
+ if (isWriteCapable(activeConfig?.tools)) {
28
+ throw new Error(
29
+ `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
30
+ "Prefer one subagent_spawn covering combined asynchronous work. Use the blocking subagent parallel mode only when concurrent synchronous outputs justify making the main agent unavailable. Otherwise let the active agent finish or close it; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
31
+ );
32
+ }
33
+ }
34
+ }
35
+
36
+ export function assertFollowUpWriteAllowed(
37
+ registry: AgentRegistry,
38
+ agent: ManagedAgent,
39
+ allowConcurrentWrites: boolean,
40
+ isolatedWorkspace: boolean,
41
+ settings?: SubagentSettings,
42
+ ): void {
43
+ if (allowConcurrentWrites || isolatedWorkspace) return;
44
+ assertNoSharedWriteConflict(
45
+ registry,
46
+ agent.agent,
47
+ agent.cwd,
48
+ agent.agentScope ?? "user",
49
+ settings,
50
+ );
51
+ }
52
+
53
+ export function isWriteCapable(tools: string[] | undefined): boolean {
54
+ if (!tools) return true;
55
+ return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
56
+ }
57
+
58
+ export async function confirmProjectAgent(
59
+ name: string,
60
+ scope: AgentScope,
61
+ confirm: boolean,
62
+ ctx: ExtensionContext,
63
+ cwd: string,
64
+ settings?: SubagentSettings,
65
+ ): Promise<void> {
66
+ if (scope !== "project" && scope !== "both") return;
67
+ if (!isSameCwd(cwd, ctx.cwd)) {
68
+ throw new Error("Project-local subagent definitions cannot run with an overridden cwd");
69
+ }
70
+ if (!ctx.isProjectTrusted()) {
71
+ throw new Error("Project-local subagent definitions require a trusted project");
72
+ }
73
+ const discovery = discoverAgents(cwd, scope, settings ?? readSubagentSettings());
74
+ const agent = discovery.agents.find((candidate) => candidate.name === name);
75
+ if (agent?.source !== "project") return;
76
+ if (confirm && ctx.hasUI) {
77
+ const approved = await ctx.ui.confirm(
78
+ "Run project-local agent?",
79
+ `Agent: ${safeTerminalLine(name, 256)}\nSource: ${safeTerminalLine(agent.filePath)}`,
80
+ );
81
+ if (!approved) throw new Error("Project-local subagent was not approved");
82
+ }
83
+ }
84
+
85
+ function isSameCwd(left: string, right: string): boolean {
86
+ try {
87
+ return realpathSync(left) === realpathSync(right);
88
+ } catch {
89
+ return path.resolve(left) === path.resolve(right);
90
+ }
91
+ }