@narumitw/pi-subagents 0.43.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,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
+ }