pi-agent-squad 0.7.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 +205 -0
- package/agents/actor.md +51 -0
- package/agents/planner.md +57 -0
- package/agents/reviewer.md +40 -0
- package/agents.ts +87 -0
- package/index.ts +1204 -0
- package/message.ts +572 -0
- package/orchestrator.md +131 -0
- package/package.json +36 -0
- package/pool.ts +578 -0
- package/session-ui.ts +648 -0
- package/session.ts +8 -0
- package/spawn.ts +457 -0
- package/wait-graph.ts +56 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
Key,
|
|
9
|
+
Markdown,
|
|
10
|
+
matchesKey,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
15
|
+
import {
|
|
16
|
+
getCompactMarkdownTheme,
|
|
17
|
+
normalizeCompactCodeBlockLines,
|
|
18
|
+
} from "pi-compact-ui";
|
|
19
|
+
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
20
|
+
import {
|
|
21
|
+
createMessageRouter,
|
|
22
|
+
ENV_ROLE,
|
|
23
|
+
MAIN_AGENT,
|
|
24
|
+
registerChildMessaging,
|
|
25
|
+
writeReply,
|
|
26
|
+
channelDir,
|
|
27
|
+
removeRequestFile,
|
|
28
|
+
type MessageRequest,
|
|
29
|
+
} from "./message.ts";
|
|
30
|
+
import { SubagentPool } from "./pool.ts";
|
|
31
|
+
import type { SubagentSessionHandle } from "./session.ts";
|
|
32
|
+
import { openSubagentSessionOverlay } from "./session-ui.ts";
|
|
33
|
+
import { getFinalOutput, spawnInteractiveSubagent } from "./spawn.ts";
|
|
34
|
+
import { deadlockMessage, MessageWaitGraph } from "./wait-graph.ts";
|
|
35
|
+
|
|
36
|
+
const MESSAGE_ROOT_BASE = "/tmp/pi-subagents-messages";
|
|
37
|
+
const DEFAULT_SUBAGENT_TIMEOUT_SECONDS = 6 * 60 * 60;
|
|
38
|
+
const MIN_SUBAGENT_TIMEOUT_SECONDS = 10;
|
|
39
|
+
const MAX_SUBAGENT_TIMEOUT_SECONDS = 3 * 24 * 60 * 60;
|
|
40
|
+
const RUNNING_WIDGET_KEY = "subagents-running";
|
|
41
|
+
const RUNNING_WIDGET_MAX_ITEMS = 4;
|
|
42
|
+
const RUNNING_WIDGET_SUMMARY_MAX_WIDTH = 40;
|
|
43
|
+
const RUNNING_WIDGET_TICK_MS = 1000;
|
|
44
|
+
const RUNNING_WIDGET_NAV_DEBOUNCE_MS = 150;
|
|
45
|
+
const RUNNING_WIDGET_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
46
|
+
const INCOMING_MESSAGE_TYPE = "pi_message_request";
|
|
47
|
+
const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
|
|
48
|
+
|
|
49
|
+
interface IncomingMessageDetails {
|
|
50
|
+
id?: string;
|
|
51
|
+
from?: string;
|
|
52
|
+
expectsReply?: boolean;
|
|
53
|
+
content?: string;
|
|
54
|
+
createdAt?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface BackgroundEventDetails {
|
|
58
|
+
agent?: string;
|
|
59
|
+
status?: "done" | "error";
|
|
60
|
+
body?: string;
|
|
61
|
+
elapsedMs?: number;
|
|
62
|
+
runId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type TranscriptEventKind = "message" | "done" | "error";
|
|
66
|
+
|
|
67
|
+
function customMessageText(content: unknown): string {
|
|
68
|
+
if (typeof content === "string") return content;
|
|
69
|
+
if (!Array.isArray(content)) return "";
|
|
70
|
+
return content
|
|
71
|
+
.map((part: any) => (part?.type === "text" ? String(part.text ?? "") : ""))
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function legacyIncomingMessageBody(content: unknown): string {
|
|
77
|
+
const text = customMessageText(content).trim();
|
|
78
|
+
if (!text) return "";
|
|
79
|
+
return text
|
|
80
|
+
.replace(/^\[message from [^\]]+\]\s+message_id=[^\n]+\n*/i, "")
|
|
81
|
+
.replace(/\n*Reply with the reply_message tool, using message_id=[^\n.]+\.?\s*$/i, "")
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatEventDuration(elapsedMs: number): string {
|
|
86
|
+
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
|
87
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
88
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
89
|
+
const seconds = totalSeconds % 60;
|
|
90
|
+
if (hours > 0) return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
|
|
91
|
+
if (minutes > 0) return `${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
|
|
92
|
+
return `${seconds}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class SubagentTranscriptEventComponent implements Component {
|
|
96
|
+
private readonly markdown: Markdown | undefined;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly kind: TranscriptEventKind,
|
|
100
|
+
private readonly agent: string,
|
|
101
|
+
private readonly body: string,
|
|
102
|
+
private readonly theme: any,
|
|
103
|
+
private readonly replyRequested = false,
|
|
104
|
+
private readonly elapsedMs?: number,
|
|
105
|
+
) {
|
|
106
|
+
const content = body.trim();
|
|
107
|
+
if (content) {
|
|
108
|
+
this.markdown = new Markdown(
|
|
109
|
+
content,
|
|
110
|
+
0,
|
|
111
|
+
0,
|
|
112
|
+
getCompactMarkdownTheme(),
|
|
113
|
+
{ color: (text) => this.theme?.fg?.("text", text) ?? text },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private renderHeader(width: number): string {
|
|
119
|
+
const fg = (color: string, text: string) => this.theme?.fg?.(color, text) ?? text;
|
|
120
|
+
const bold = this.theme?.bold ? this.theme.bold.bind(this.theme) : (text: string) => text;
|
|
121
|
+
const icon = this.kind === "message" ? "←" : this.kind === "done" ? "✓" : "✗";
|
|
122
|
+
const iconColor = this.kind === "message" ? "accent" : this.kind === "done" ? "success" : "error";
|
|
123
|
+
const mode = this.kind === "message" ? "[msg]" : "[bg]";
|
|
124
|
+
const status =
|
|
125
|
+
this.kind === "message"
|
|
126
|
+
? this.replyRequested
|
|
127
|
+
? "reply requested"
|
|
128
|
+
: "message"
|
|
129
|
+
: this.kind === "done"
|
|
130
|
+
? "completed"
|
|
131
|
+
: "failed";
|
|
132
|
+
const statusColor =
|
|
133
|
+
this.kind === "message"
|
|
134
|
+
? this.replyRequested
|
|
135
|
+
? "warning"
|
|
136
|
+
: "muted"
|
|
137
|
+
: iconColor;
|
|
138
|
+
const elapsed =
|
|
139
|
+
this.elapsedMs === undefined
|
|
140
|
+
? ""
|
|
141
|
+
: fg("muted", ` · ${formatEventDuration(this.elapsedMs)}`);
|
|
142
|
+
const header =
|
|
143
|
+
`${fg(iconColor, icon)} ${fg("toolTitle", bold(this.agent))}` +
|
|
144
|
+
`${fg("muted", ` ${mode} `)}${fg(statusColor, `• ${status}`)}${elapsed}`;
|
|
145
|
+
return truncateToWidth(header, Math.max(1, width), "…");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
render(width: number): string[] {
|
|
149
|
+
const padding = " ".repeat(Math.min(1, Math.max(0, width - 1)));
|
|
150
|
+
const contentWidth = Math.max(1, width - padding.length);
|
|
151
|
+
const lines = [padding + this.renderHeader(contentWidth)];
|
|
152
|
+
if (this.markdown) {
|
|
153
|
+
const railWidth = contentWidth >= 2 ? 1 : 0;
|
|
154
|
+
const railGap = contentWidth >= 3 ? 1 : 0;
|
|
155
|
+
const bodyWidth = Math.max(1, contentWidth - railWidth - railGap);
|
|
156
|
+
const bodyLines = normalizeCompactCodeBlockLines(
|
|
157
|
+
this.markdown.render(bodyWidth),
|
|
158
|
+
bodyWidth,
|
|
159
|
+
0,
|
|
160
|
+
);
|
|
161
|
+
for (let index = 0; index < bodyLines.length; index++) {
|
|
162
|
+
const connector = index === bodyLines.length - 1 ? "└" : "│";
|
|
163
|
+
const rail =
|
|
164
|
+
railWidth > 0
|
|
165
|
+
? `${this.theme?.fg?.("dim", connector) ?? connector}${" ".repeat(railGap)}`
|
|
166
|
+
: "";
|
|
167
|
+
lines.push(padding + rail + truncateToWidth(bodyLines[index]!, bodyWidth, "…"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void {
|
|
174
|
+
this.markdown?.invalidate();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sessionRoot(sessionId: string): string {
|
|
179
|
+
return `${MESSAGE_ROOT_BASE}/${sessionId.replace(/[^\w.-]+/g, "_")}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Orchestrator mode (/orchestrate command)
|
|
184
|
+
// ============================================================================
|
|
185
|
+
|
|
186
|
+
const ORCHESTRATOR_MODE_ENTRY = "orchestrator-mode";
|
|
187
|
+
const ORCHESTRATOR_PROMPT_MARKER = "# You are the Orchestrator";
|
|
188
|
+
const SUBAGENT_USAGE_GUARD = [
|
|
189
|
+
"# Subagent Usage Gate",
|
|
190
|
+
"",
|
|
191
|
+
"Do not use subagents unless at least one of these conditions is true:",
|
|
192
|
+
"1. The user's current request explicitly asks you to use, call, spawn, delegate to, or communicate with a subagent.",
|
|
193
|
+
"2. Orchestrator mode is enabled.",
|
|
194
|
+
"",
|
|
195
|
+
"When neither condition is true:",
|
|
196
|
+
"- Do not call the `subagent` tool.",
|
|
197
|
+
"- Do not call `send_message` to contact or assign work to a subagent.",
|
|
198
|
+
"- Do not initiate or continue a planner/actor/reviewer workflow.",
|
|
199
|
+
"- Perform the task yourself using the normal tools available to the main agent.",
|
|
200
|
+
"",
|
|
201
|
+
"Task complexity, convenience, a desire for planning/review, the availability of subagent tools, or prior subagent use are not authorization.",
|
|
202
|
+
"A generic request to plan, review, test, or implement something is not authorization unless the user explicitly requests subagent involvement.",
|
|
203
|
+
].join("\n");
|
|
204
|
+
|
|
205
|
+
function orchestratorPromptPath(): string {
|
|
206
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
207
|
+
return path.join(here, "orchestrator.md");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let orchestratorPromptCache: string | undefined;
|
|
211
|
+
function readOrchestratorPrompt(): string {
|
|
212
|
+
if (orchestratorPromptCache !== undefined) return orchestratorPromptCache;
|
|
213
|
+
orchestratorPromptCache = "";
|
|
214
|
+
try {
|
|
215
|
+
const content = fs.readFileSync(orchestratorPromptPath(), "utf-8");
|
|
216
|
+
// strip YAML frontmatter, keep only the prompt body
|
|
217
|
+
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
|
|
218
|
+
orchestratorPromptCache = (match ? match[1] : content).trim();
|
|
219
|
+
} catch {
|
|
220
|
+
/* ignore */
|
|
221
|
+
}
|
|
222
|
+
return orchestratorPromptCache;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Read the current session's orchestrator-mode flag (the last entry wins) */
|
|
226
|
+
function isOrchestratorMode(ctx: { sessionManager?: { getEntries?: () => unknown[] } }): boolean {
|
|
227
|
+
try {
|
|
228
|
+
const entries = (ctx.sessionManager?.getEntries?.() ?? []) as Array<{
|
|
229
|
+
type?: string;
|
|
230
|
+
customType?: string;
|
|
231
|
+
data?: { enabled?: boolean };
|
|
232
|
+
}>;
|
|
233
|
+
let enabled = false;
|
|
234
|
+
for (const e of entries) {
|
|
235
|
+
if (e.type === "custom" && e.customType === ORCHESTRATOR_MODE_ENTRY && typeof e.data?.enabled === "boolean") {
|
|
236
|
+
enabled = e.data.enabled;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return enabled;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Path of a built-in agent definition file */
|
|
246
|
+
function agentFilePath(agentName: string): string {
|
|
247
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
248
|
+
return path.join(here, "agents", `${agentName}.md`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Available model ids (provider/model) for the current session */
|
|
252
|
+
function availableModelIds(ctx: {
|
|
253
|
+
scopedModels?: Array<{ model?: { provider?: string; id?: string } }>;
|
|
254
|
+
modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string }> };
|
|
255
|
+
}): string[] {
|
|
256
|
+
const scoped = ctx.scopedModels;
|
|
257
|
+
if (Array.isArray(scoped) && scoped.length > 0) {
|
|
258
|
+
return scoped
|
|
259
|
+
.map((e) => (e.model?.provider && e.model?.id ? `${e.model.provider}/${e.model.id}` : ""))
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
}
|
|
262
|
+
const avail = ctx.modelRegistry?.getAvailable?.() ?? [];
|
|
263
|
+
return avail
|
|
264
|
+
.map((m) => (m.provider && m.id ? `${m.provider}/${m.id}` : ""))
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Update a `model` or `thinking` line in an agent's frontmatter */
|
|
269
|
+
function updateAgentConfig(agentName: string, key: "model" | "thinking", value: string): boolean {
|
|
270
|
+
const file = agentFilePath(agentName);
|
|
271
|
+
if (!fs.existsSync(file)) return false;
|
|
272
|
+
try {
|
|
273
|
+
let content = fs.readFileSync(file, "utf-8");
|
|
274
|
+
const lineRe = new RegExp(`^(${key}:).*$`, "m");
|
|
275
|
+
if (lineRe.test(content)) {
|
|
276
|
+
content = content.replace(lineRe, `${key}: ${value}`);
|
|
277
|
+
} else {
|
|
278
|
+
// insert after the opening frontmatter marker
|
|
279
|
+
content = content.replace(/^---\r?\n/, `---\n${key}: ${value}\n`);
|
|
280
|
+
}
|
|
281
|
+
fs.writeFileSync(file, content, "utf-8");
|
|
282
|
+
return true;
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// Background async tasks (one-shot process per task; result injected on completion)
|
|
290
|
+
// ============================================================================
|
|
291
|
+
|
|
292
|
+
interface AsyncTask {
|
|
293
|
+
runId: string;
|
|
294
|
+
agent: string;
|
|
295
|
+
status: "running" | "done" | "error";
|
|
296
|
+
startedAt: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface RunningSubagentActivity {
|
|
300
|
+
id: string;
|
|
301
|
+
agent: string;
|
|
302
|
+
summary: string;
|
|
303
|
+
mode: "task" | "background" | "message";
|
|
304
|
+
startedAt: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function summarizeActivity(value: string): string {
|
|
308
|
+
const summary = String(value ?? "")
|
|
309
|
+
.replace(/```[\w-]*\s*/g, "")
|
|
310
|
+
.replace(/[`*_#>]+/g, "")
|
|
311
|
+
.replace(/^Task:\s*/i, "")
|
|
312
|
+
.replace(/\s+/g, " ")
|
|
313
|
+
.trim();
|
|
314
|
+
if (!summary) return "working…";
|
|
315
|
+
return summary.length > 500 ? `${summary.slice(0, 499)}…` : summary;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function formatActivityElapsed(startedAt: number): string {
|
|
319
|
+
const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
320
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
321
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
322
|
+
const seconds = totalSeconds % 60;
|
|
323
|
+
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
324
|
+
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
325
|
+
return `${seconds}s`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export class RunningSubagentWidgetController {
|
|
329
|
+
private activities = new Map<string, RunningSubagentActivity>();
|
|
330
|
+
private selectedId: string | undefined;
|
|
331
|
+
private ui: any;
|
|
332
|
+
private tui: any;
|
|
333
|
+
private installed = false;
|
|
334
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
335
|
+
|
|
336
|
+
get size(): number {
|
|
337
|
+
return this.activities.size;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getSelected(): RunningSubagentActivity | undefined {
|
|
341
|
+
return this.selectedId ? this.activities.get(this.selectedId) : undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
moveSelection(delta: -1 | 1): RunningSubagentActivity | undefined {
|
|
345
|
+
const activities = [...this.activities.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
346
|
+
if (activities.length === 0) {
|
|
347
|
+
this.selectedId = undefined;
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
const current = activities.findIndex((activity) => activity.id === this.selectedId);
|
|
351
|
+
const next =
|
|
352
|
+
current < 0
|
|
353
|
+
? delta > 0
|
|
354
|
+
? 0
|
|
355
|
+
: activities.length - 1
|
|
356
|
+
: (current + delta + activities.length) % activities.length;
|
|
357
|
+
this.selectedId = activities[next]!.id;
|
|
358
|
+
this.tui?.requestRender?.();
|
|
359
|
+
return activities[next];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
clearSelection(): void {
|
|
363
|
+
if (!this.selectedId) return;
|
|
364
|
+
this.selectedId = undefined;
|
|
365
|
+
this.tui?.requestRender?.();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
attach(ctx: { mode?: string; ui?: any }): void {
|
|
369
|
+
this.removeWidget();
|
|
370
|
+
this.ui = ctx.mode === "tui" ? ctx.ui : undefined;
|
|
371
|
+
if (this.activities.size > 0) this.ensureWidget();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
shutdown(): void {
|
|
375
|
+
this.activities.clear();
|
|
376
|
+
this.selectedId = undefined;
|
|
377
|
+
this.removeWidget();
|
|
378
|
+
this.ui = undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
start(id: string, agent: string, task: string, mode: RunningSubagentActivity["mode"]): void {
|
|
382
|
+
this.activities.set(id, {
|
|
383
|
+
id,
|
|
384
|
+
agent,
|
|
385
|
+
summary: summarizeActivity(task),
|
|
386
|
+
mode,
|
|
387
|
+
startedAt: Date.now(),
|
|
388
|
+
});
|
|
389
|
+
this.ensureWidget();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
finish(id: string): void {
|
|
393
|
+
const before = [...this.activities.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
394
|
+
const removedIndex = before.findIndex((activity) => activity.id === id);
|
|
395
|
+
this.activities.delete(id);
|
|
396
|
+
if (this.selectedId === id) {
|
|
397
|
+
const after = before.filter((activity) => activity.id !== id);
|
|
398
|
+
this.selectedId = after.length > 0 ? after[Math.min(Math.max(0, removedIndex), after.length - 1)]!.id : undefined;
|
|
399
|
+
}
|
|
400
|
+
if (this.activities.size === 0) this.removeWidget();
|
|
401
|
+
else {
|
|
402
|
+
this.ensureWidget();
|
|
403
|
+
this.tui?.requestRender?.();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private stopTimer(): void {
|
|
408
|
+
if (this.timer) clearInterval(this.timer);
|
|
409
|
+
this.timer = undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private removeWidget(): void {
|
|
413
|
+
this.stopTimer();
|
|
414
|
+
this.tui = undefined;
|
|
415
|
+
if (this.ui && this.installed) {
|
|
416
|
+
try {
|
|
417
|
+
this.ui.setWidget(RUNNING_WIDGET_KEY, undefined);
|
|
418
|
+
} catch {
|
|
419
|
+
/* session may already be closed */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.installed = false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private ensureWidget(): void {
|
|
426
|
+
if (!this.ui || this.activities.size === 0) {
|
|
427
|
+
if (this.activities.size === 0) this.removeWidget();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!this.installed) {
|
|
431
|
+
try {
|
|
432
|
+
this.ui.setWidget(
|
|
433
|
+
RUNNING_WIDGET_KEY,
|
|
434
|
+
(tui: any, theme: any) => {
|
|
435
|
+
this.tui = tui;
|
|
436
|
+
return {
|
|
437
|
+
render: (width: number): string[] => this.render(width, theme),
|
|
438
|
+
invalidate(): void {},
|
|
439
|
+
};
|
|
440
|
+
},
|
|
441
|
+
{ placement: "aboveEditor" },
|
|
442
|
+
);
|
|
443
|
+
this.installed = true;
|
|
444
|
+
} catch {
|
|
445
|
+
// A missing/closing TUI must never prevent a subagent from running.
|
|
446
|
+
this.installed = false;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!this.timer) {
|
|
451
|
+
this.timer = setInterval(() => {
|
|
452
|
+
if (this.activities.size === 0) {
|
|
453
|
+
this.removeWidget();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.tui?.requestRender?.();
|
|
457
|
+
}, RUNNING_WIDGET_TICK_MS);
|
|
458
|
+
this.timer.unref?.();
|
|
459
|
+
}
|
|
460
|
+
this.tui?.requestRender?.();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private render(width: number, theme: any): string[] {
|
|
464
|
+
const safeWidth = Math.max(1, width);
|
|
465
|
+
const padding = safeWidth > 1 ? " " : "";
|
|
466
|
+
const contentWidth = Math.max(1, safeWidth - padding.length);
|
|
467
|
+
const activities = [...this.activities.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
468
|
+
if (activities.length === 0) return [];
|
|
469
|
+
const frame =
|
|
470
|
+
RUNNING_WIDGET_SPINNER[
|
|
471
|
+
Math.floor(Date.now() / RUNNING_WIDGET_TICK_MS) % RUNNING_WIDGET_SPINNER.length
|
|
472
|
+
]!;
|
|
473
|
+
const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
|
|
474
|
+
const bold = theme?.bold ? (text: string) => theme.bold(text) : (text: string) => text;
|
|
475
|
+
const title =
|
|
476
|
+
`${fg("accent", frame)} ${fg("accent", bold("Subagents"))} ` +
|
|
477
|
+
fg("muted", `· ${activities.length} running`);
|
|
478
|
+
const hasSelection = this.selectedId !== undefined;
|
|
479
|
+
const shortcutCandidates = hasSelection
|
|
480
|
+
? ["⇧+↑/↓ move · Enter open · Esc clear", "⇧+↑/↓ · Enter · Esc", "⇧+↑/↓"]
|
|
481
|
+
: ["⇧+↑/↓ select · Enter open", "⇧+↑/↓ · Enter", "⇧+↑/↓"];
|
|
482
|
+
let titleWithShortcuts = title;
|
|
483
|
+
for (const shortcut of shortcutCandidates) {
|
|
484
|
+
const suffix = fg("dim", ` · ${shortcut}`);
|
|
485
|
+
if (visibleWidth(title + suffix) <= contentWidth) {
|
|
486
|
+
titleWithShortcuts = title + suffix;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const lines = [titleWithShortcuts];
|
|
491
|
+
const selectedIndex = activities.findIndex((activity) => activity.id === this.selectedId);
|
|
492
|
+
const windowStart =
|
|
493
|
+
selectedIndex >= RUNNING_WIDGET_MAX_ITEMS
|
|
494
|
+
? Math.min(
|
|
495
|
+
selectedIndex - RUNNING_WIDGET_MAX_ITEMS + 1,
|
|
496
|
+
Math.max(0, activities.length - RUNNING_WIDGET_MAX_ITEMS),
|
|
497
|
+
)
|
|
498
|
+
: 0;
|
|
499
|
+
const visible = activities.slice(windowStart, windowStart + RUNNING_WIDGET_MAX_ITEMS);
|
|
500
|
+
for (let index = 0; index < visible.length; index++) {
|
|
501
|
+
const activity = visible[index]!;
|
|
502
|
+
const isLast = index === visible.length - 1 && activities.length <= RUNNING_WIDGET_MAX_ITEMS;
|
|
503
|
+
const rail = isLast ? "└" : "├";
|
|
504
|
+
const selected = activity.id === this.selectedId;
|
|
505
|
+
const indicator = selected ? fg("accent", "›") : fg("dim", rail);
|
|
506
|
+
const mode = activity.mode === "background" ? " [bg]" : activity.mode === "message" ? " [msg]" : "";
|
|
507
|
+
const prefix = `${indicator} ${fg("toolTitle", bold(activity.agent))}${fg("muted", mode)} ${fg("muted", `· ${formatActivityElapsed(activity.startedAt)} ·`)}`;
|
|
508
|
+
const availableSummaryWidth = Math.max(
|
|
509
|
+
0,
|
|
510
|
+
Math.min(RUNNING_WIDGET_SUMMARY_MAX_WIDTH, contentWidth - visibleWidth(prefix) - 1),
|
|
511
|
+
);
|
|
512
|
+
const summary =
|
|
513
|
+
availableSummaryWidth > 0 ? truncateToWidth(activity.summary, availableSummaryWidth, "…") : "";
|
|
514
|
+
lines.push(`${prefix}${summary ? ` ${fg("dim", summary)}` : ""}`);
|
|
515
|
+
}
|
|
516
|
+
if (activities.length > RUNNING_WIDGET_MAX_ITEMS) {
|
|
517
|
+
lines.push(fg("muted", `└ … +${activities.length - RUNNING_WIDGET_MAX_ITEMS} more`));
|
|
518
|
+
}
|
|
519
|
+
return lines.map((line) => padding + truncateToWidth(line, contentWidth, "…"));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export default function (pi: ExtensionAPI) {
|
|
524
|
+
const isChild = process.env[ENV_ROLE] === "child";
|
|
525
|
+
|
|
526
|
+
if (isChild) {
|
|
527
|
+
// ===== subagent mode: register generic messaging tools =====
|
|
528
|
+
registerChildMessaging(pi);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ===== main-agent mode =====
|
|
533
|
+
pi.registerMessageRenderer<IncomingMessageDetails>(
|
|
534
|
+
INCOMING_MESSAGE_TYPE,
|
|
535
|
+
(message, _options, theme) => {
|
|
536
|
+
const details = message.details ?? {};
|
|
537
|
+
const agent = String(details.from || "subagent");
|
|
538
|
+
const body =
|
|
539
|
+
typeof details.content === "string"
|
|
540
|
+
? details.content
|
|
541
|
+
: legacyIncomingMessageBody(message.content);
|
|
542
|
+
return new SubagentTranscriptEventComponent(
|
|
543
|
+
"message",
|
|
544
|
+
agent,
|
|
545
|
+
body,
|
|
546
|
+
theme,
|
|
547
|
+
Boolean(details.expectsReply),
|
|
548
|
+
);
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
pi.registerMessageRenderer<BackgroundEventDetails>(
|
|
552
|
+
BACKGROUND_EVENT_TYPE,
|
|
553
|
+
(message, _options, theme) => {
|
|
554
|
+
const details = message.details ?? {};
|
|
555
|
+
const kind: TranscriptEventKind = details.status === "error" ? "error" : "done";
|
|
556
|
+
const agent = String(details.agent || "subagent");
|
|
557
|
+
const body =
|
|
558
|
+
typeof details.body === "string"
|
|
559
|
+
? details.body
|
|
560
|
+
: customMessageText(message.content);
|
|
561
|
+
const elapsedMs =
|
|
562
|
+
typeof details.elapsedMs === "number" && Number.isFinite(details.elapsedMs)
|
|
563
|
+
? Math.max(0, details.elapsedMs)
|
|
564
|
+
: undefined;
|
|
565
|
+
return new SubagentTranscriptEventComponent(
|
|
566
|
+
kind,
|
|
567
|
+
agent,
|
|
568
|
+
body,
|
|
569
|
+
theme,
|
|
570
|
+
false,
|
|
571
|
+
elapsedMs,
|
|
572
|
+
);
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
let cwd = process.cwd();
|
|
577
|
+
let messageRoot = sessionRoot("ephemeral");
|
|
578
|
+
const tasks = new Map<string, AsyncTask>();
|
|
579
|
+
const pool = new SubagentPool(messageRoot);
|
|
580
|
+
const runningWidget = new RunningSubagentWidgetController();
|
|
581
|
+
const sessionHandles = new Map<string, SubagentSessionHandle>();
|
|
582
|
+
const waitGraph = new MessageWaitGraph();
|
|
583
|
+
const pendingMainReplyEdges = new Map<
|
|
584
|
+
string,
|
|
585
|
+
{ release: () => void; timer?: ReturnType<typeof setTimeout> }
|
|
586
|
+
>();
|
|
587
|
+
let sessionContext: any;
|
|
588
|
+
let terminalInputUnsubscribe: (() => void) | undefined;
|
|
589
|
+
let sessionOverlayOpen = false;
|
|
590
|
+
let pendingOpenActivityId: string | undefined;
|
|
591
|
+
let lastNavigation: { direction: -1 | 1; at: number } | undefined;
|
|
592
|
+
|
|
593
|
+
function moveWidgetSelection(direction: -1 | 1): void {
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
if (
|
|
596
|
+
lastNavigation?.direction === direction &&
|
|
597
|
+
now - lastNavigation.at < RUNNING_WIDGET_NAV_DEBOUNCE_MS
|
|
598
|
+
) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
lastNavigation = { direction, at: now };
|
|
602
|
+
pendingOpenActivityId = undefined;
|
|
603
|
+
runningWidget.moveSelection(direction);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function registerSessionHandle(activityId: string, session: SubagentSessionHandle): void {
|
|
607
|
+
sessionHandles.set(activityId, session);
|
|
608
|
+
if (pendingOpenActivityId === activityId) {
|
|
609
|
+
queueMicrotask(() => openSelectedSession());
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function openSelectedSession(): void {
|
|
614
|
+
if (sessionOverlayOpen || !sessionContext) return;
|
|
615
|
+
const selected = runningWidget.getSelected();
|
|
616
|
+
if (!selected) return;
|
|
617
|
+
const session = sessionHandles.get(selected.id);
|
|
618
|
+
if (!session) {
|
|
619
|
+
pendingOpenActivityId = selected.id;
|
|
620
|
+
sessionContext.ui.notify(`Opening ${selected.agent} session when it is ready…`, "info");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
pendingOpenActivityId = undefined;
|
|
624
|
+
runningWidget.clearSelection();
|
|
625
|
+
sessionOverlayOpen = true;
|
|
626
|
+
void openSubagentSessionOverlay(sessionContext, session)
|
|
627
|
+
.catch((error) => {
|
|
628
|
+
sessionContext?.ui?.notify?.(
|
|
629
|
+
`Failed to open ${selected.agent} session: ${error instanceof Error ? error.message : String(error)}`,
|
|
630
|
+
"error",
|
|
631
|
+
);
|
|
632
|
+
})
|
|
633
|
+
.finally(() => {
|
|
634
|
+
sessionOverlayOpen = false;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
pi.on("session_start", (event, ctx) => {
|
|
639
|
+
cwd = ctx.cwd;
|
|
640
|
+
const sessionId =
|
|
641
|
+
ctx.sessionManager?.getSessionId?.() ??
|
|
642
|
+
(event as any).sessionId ??
|
|
643
|
+
"ephemeral";
|
|
644
|
+
messageRoot = sessionRoot(sessionId);
|
|
645
|
+
fs.mkdirSync(messageRoot, { recursive: true });
|
|
646
|
+
pool.setIntercomRoot(messageRoot);
|
|
647
|
+
pool.setWorkingDirectory(cwd);
|
|
648
|
+
runningWidget.attach(ctx);
|
|
649
|
+
sessionContext = ctx.mode === "tui" ? ctx : undefined;
|
|
650
|
+
lastNavigation = undefined;
|
|
651
|
+
terminalInputUnsubscribe?.();
|
|
652
|
+
terminalInputUnsubscribe =
|
|
653
|
+
ctx.mode === "tui"
|
|
654
|
+
? ctx.ui.onTerminalInput((data: string) => {
|
|
655
|
+
if (runningWidget.size === 0 || sessionOverlayOpen) return;
|
|
656
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
657
|
+
moveWidgetSelection(-1);
|
|
658
|
+
return { consume: true };
|
|
659
|
+
}
|
|
660
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
661
|
+
moveWidgetSelection(1);
|
|
662
|
+
return { consume: true };
|
|
663
|
+
}
|
|
664
|
+
if (matchesKey(data, Key.enter) && runningWidget.getSelected()) {
|
|
665
|
+
openSelectedSession();
|
|
666
|
+
return { consume: true };
|
|
667
|
+
}
|
|
668
|
+
if (matchesKey(data, Key.escape) && runningWidget.getSelected()) {
|
|
669
|
+
pendingOpenActivityId = undefined;
|
|
670
|
+
runningWidget.clearSelection();
|
|
671
|
+
return { consume: true };
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
: undefined;
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
pi.on("session_shutdown", () => {
|
|
678
|
+
terminalInputUnsubscribe?.();
|
|
679
|
+
terminalInputUnsubscribe = undefined;
|
|
680
|
+
sessionContext = undefined;
|
|
681
|
+
pendingOpenActivityId = undefined;
|
|
682
|
+
sessionHandles.clear();
|
|
683
|
+
for (const pending of pendingMainReplyEdges.values()) {
|
|
684
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
685
|
+
pending.release();
|
|
686
|
+
}
|
|
687
|
+
pendingMainReplyEdges.clear();
|
|
688
|
+
waitGraph.clear();
|
|
689
|
+
runningWidget.shutdown();
|
|
690
|
+
pool.dispose();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
function releaseMainReplyEdge(messageId: string): void {
|
|
694
|
+
const pending = pendingMainReplyEdges.get(messageId);
|
|
695
|
+
if (!pending) return;
|
|
696
|
+
pendingMainReplyEdges.delete(messageId);
|
|
697
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
698
|
+
pending.release();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function trackMainReplyEdge(msg: MessageRequest, release: () => void): void {
|
|
702
|
+
releaseMainReplyEdge(msg.id);
|
|
703
|
+
const deadline =
|
|
704
|
+
msg.expiresAt ??
|
|
705
|
+
Date.now() + (msg.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000);
|
|
706
|
+
const timer = setTimeout(
|
|
707
|
+
() => releaseMainReplyEdge(msg.id),
|
|
708
|
+
Math.max(1000, deadline - Date.now() + 1000),
|
|
709
|
+
);
|
|
710
|
+
timer.unref?.();
|
|
711
|
+
pendingMainReplyEdges.set(msg.id, { release, timer });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---- message router: subagent -> main injects into main session; subagent -> subagent forwards to a resident process ----
|
|
715
|
+
let router: ReturnType<typeof createMessageRouter> | undefined;
|
|
716
|
+
pi.on("session_start", () => {
|
|
717
|
+
router?.dispose();
|
|
718
|
+
router = createMessageRouter(pi, {
|
|
719
|
+
root: messageRoot,
|
|
720
|
+
matchesContext: () => true,
|
|
721
|
+
// to main: inject into the main session; the main model replies with reply_message
|
|
722
|
+
onMainMessage: (msg: MessageRequest) => {
|
|
723
|
+
let releaseWait: (() => void) | undefined;
|
|
724
|
+
if (msg.expectsReply) {
|
|
725
|
+
const acquired = waitGraph.acquire(msg.from, MAIN_AGENT);
|
|
726
|
+
if (acquired.cycle) {
|
|
727
|
+
if (!writeReplyTo(msg, deadlockMessage(acquired.cycle))) {
|
|
728
|
+
throw new Error("Failed to write deadlock-prevention reply.");
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
releaseWait = acquired.release;
|
|
733
|
+
if (releaseWait) trackMainReplyEdge(msg, releaseWait);
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const protocolInstruction = msg.expectsReply
|
|
737
|
+
? `Reply with the reply_message tool, using message_id=${msg.id}.`
|
|
738
|
+
: "This is a fire-and-forget message; no reply is required.";
|
|
739
|
+
pi.sendMessage(
|
|
740
|
+
{
|
|
741
|
+
customType: INCOMING_MESSAGE_TYPE,
|
|
742
|
+
content: [
|
|
743
|
+
`[message from ${msg.from}] message_id=${msg.id}`,
|
|
744
|
+
msg.content,
|
|
745
|
+
protocolInstruction,
|
|
746
|
+
].join("\n\n"),
|
|
747
|
+
display: true,
|
|
748
|
+
details: {
|
|
749
|
+
id: msg.id,
|
|
750
|
+
from: msg.from,
|
|
751
|
+
expectsReply: msg.expectsReply,
|
|
752
|
+
content: msg.content,
|
|
753
|
+
createdAt: msg.createdAt,
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
{ triggerTurn: true },
|
|
757
|
+
);
|
|
758
|
+
if (!msg.expectsReply) removeRequestFile(msg);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (msg.expectsReply) {
|
|
761
|
+
releaseMainReplyEdge(msg.id);
|
|
762
|
+
writeReplyTo(
|
|
763
|
+
msg,
|
|
764
|
+
`Failed to deliver message to main: ${error instanceof Error ? error.message : String(error)}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
// to another subagent: route to its resident process, collect its reply, write it back
|
|
771
|
+
onChildMessage: async (msg: MessageRequest, signal?: AbortSignal) => {
|
|
772
|
+
const agents = discoverAgents(cwd);
|
|
773
|
+
const target = agents.find((a) => a.name === msg.to);
|
|
774
|
+
if (!target) {
|
|
775
|
+
const reply = `Unknown subagent "${msg.to}"`;
|
|
776
|
+
completeRoutedMessage(msg, reply);
|
|
777
|
+
if (msg.from === MAIN_AGENT) throw new Error(reply);
|
|
778
|
+
return reply;
|
|
779
|
+
}
|
|
780
|
+
let releaseWait: (() => void) | undefined;
|
|
781
|
+
if (msg.expectsReply) {
|
|
782
|
+
const acquired = waitGraph.acquire(msg.from, msg.to);
|
|
783
|
+
if (acquired.cycle) {
|
|
784
|
+
const reply = deadlockMessage(acquired.cycle);
|
|
785
|
+
completeRoutedMessage(msg, reply);
|
|
786
|
+
if (msg.from === MAIN_AGENT) throw new Error(reply);
|
|
787
|
+
return reply;
|
|
788
|
+
}
|
|
789
|
+
releaseWait = acquired.release;
|
|
790
|
+
}
|
|
791
|
+
const activityId = `message:${msg.id}`;
|
|
792
|
+
runningWidget.start(activityId, target.name, msg.content, "message");
|
|
793
|
+
try {
|
|
794
|
+
await pool.ensureProcess(target);
|
|
795
|
+
registerSessionHandle(activityId, pool.getSessionHandle(target));
|
|
796
|
+
const timeoutMs = Math.min(
|
|
797
|
+
MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
798
|
+
Math.max(MIN_SUBAGENT_TIMEOUT_SECONDS * 1000, msg.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000),
|
|
799
|
+
);
|
|
800
|
+
const result = await pool.runTask(
|
|
801
|
+
target,
|
|
802
|
+
[
|
|
803
|
+
`You received a message from ${msg.from === MAIN_AGENT ? "the main agent" : `subagent ${msg.from}`} (message id ${msg.id}):`,
|
|
804
|
+
``,
|
|
805
|
+
msg.content,
|
|
806
|
+
``,
|
|
807
|
+
`Process this message and give your reply. Your reply will be sent back to ${msg.from === MAIN_AGENT ? "the main agent" : msg.from}.`,
|
|
808
|
+
].join("\n"),
|
|
809
|
+
timeoutMs,
|
|
810
|
+
undefined,
|
|
811
|
+
signal,
|
|
812
|
+
);
|
|
813
|
+
const replyText = getFinalOutput(result.messages) || `(subagent ${msg.to} gave no reply)`;
|
|
814
|
+
completeRoutedMessage(msg, replyText);
|
|
815
|
+
return replyText;
|
|
816
|
+
} catch (e) {
|
|
817
|
+
const reply = `Target subagent failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
|
|
818
|
+
completeRoutedMessage(msg, reply);
|
|
819
|
+
if (msg.from === MAIN_AGENT) throw new Error(reply);
|
|
820
|
+
return reply;
|
|
821
|
+
} finally {
|
|
822
|
+
releaseWait?.();
|
|
823
|
+
if (pendingOpenActivityId === activityId) pendingOpenActivityId = undefined;
|
|
824
|
+
sessionHandles.delete(activityId);
|
|
825
|
+
runningWidget.finish(activityId);
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
onMessageReplied: (msg: MessageRequest) => releaseMainReplyEdge(msg.id),
|
|
829
|
+
onMessageExpired: (msg: MessageRequest) => releaseMainReplyEdge(msg.id),
|
|
830
|
+
});
|
|
831
|
+
router.start();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
pi.on("session_shutdown", () => {
|
|
835
|
+
router?.dispose();
|
|
836
|
+
router = undefined;
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
/** Write a reply back to the message sender */
|
|
840
|
+
function writeReplyTo(msg: MessageRequest, content: string): boolean {
|
|
841
|
+
try {
|
|
842
|
+
const dir = channelDir(messageRoot, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
|
|
843
|
+
writeReply(dir, msg.id, content);
|
|
844
|
+
removeRequestFile(msg);
|
|
845
|
+
releaseMainReplyEdge(msg.id);
|
|
846
|
+
return true;
|
|
847
|
+
} catch {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function completeRoutedMessage(msg: MessageRequest, content: string) {
|
|
853
|
+
if (msg.from === MAIN_AGENT) return;
|
|
854
|
+
if (msg.expectsReply) writeReplyTo(msg, content);
|
|
855
|
+
else removeRequestFile(msg);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---- background task persistence ----
|
|
859
|
+
function persistTasks() {
|
|
860
|
+
try {
|
|
861
|
+
pi.appendEntry(
|
|
862
|
+
"subagent-async-task",
|
|
863
|
+
[...tasks.values()].map((t) => ({
|
|
864
|
+
runId: t.runId,
|
|
865
|
+
agent: t.agent,
|
|
866
|
+
status: t.status,
|
|
867
|
+
startedAt: t.startedAt,
|
|
868
|
+
})),
|
|
869
|
+
);
|
|
870
|
+
} catch {
|
|
871
|
+
/* ignore */
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function launchBackground(agent: AgentConfig, taskText: string, cwdOverride?: string, timeoutMs?: number): string {
|
|
876
|
+
const runId = randomUUID();
|
|
877
|
+
tasks.set(runId, { runId, agent: agent.name, status: "running", startedAt: Date.now() });
|
|
878
|
+
persistTasks();
|
|
879
|
+
runningWidget.start(runId, agent.name, taskText, "background");
|
|
880
|
+
|
|
881
|
+
spawnInteractiveSubagent({
|
|
882
|
+
agent,
|
|
883
|
+
task: taskText,
|
|
884
|
+
cwd: cwdOverride,
|
|
885
|
+
messageRoot,
|
|
886
|
+
runId,
|
|
887
|
+
childIndex: 0,
|
|
888
|
+
timeoutMs,
|
|
889
|
+
onSession: (session) => registerSessionHandle(runId, session),
|
|
890
|
+
})
|
|
891
|
+
.then((result) => {
|
|
892
|
+
const task = tasks.get(runId);
|
|
893
|
+
if (!task) return;
|
|
894
|
+
const finalStatus: "done" | "error" =
|
|
895
|
+
result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
|
|
896
|
+
task.status = finalStatus;
|
|
897
|
+
persistTasks();
|
|
898
|
+
const output = getFinalOutput(result.messages) || "(no text output)";
|
|
899
|
+
const failureReason =
|
|
900
|
+
result.exitCode !== 0
|
|
901
|
+
? result.stderr || result.errorMessage || `process exited with code ${result.exitCode}`
|
|
902
|
+
: result.stopReason === "error"
|
|
903
|
+
? result.errorMessage || "subagent reported an error"
|
|
904
|
+
: "";
|
|
905
|
+
const head =
|
|
906
|
+
finalStatus === "done"
|
|
907
|
+
? `Subagent ${agent.name} done (run ${runId.slice(0, 8)})`
|
|
908
|
+
: `Subagent ${agent.name} failed: ${failureReason}`;
|
|
909
|
+
const displayBody =
|
|
910
|
+
finalStatus === "done"
|
|
911
|
+
? output
|
|
912
|
+
: [
|
|
913
|
+
failureReason,
|
|
914
|
+
output === "(no text output)" ? "" : `**Partial result**\n\n${output}`,
|
|
915
|
+
]
|
|
916
|
+
.filter(Boolean)
|
|
917
|
+
.join("\n\n");
|
|
918
|
+
try {
|
|
919
|
+
pi.sendMessage(
|
|
920
|
+
{
|
|
921
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
922
|
+
content: `[background subagent ${finalStatus}] ${head}\n\n--- result ---\n${output}`,
|
|
923
|
+
display: true,
|
|
924
|
+
details: {
|
|
925
|
+
agent: agent.name,
|
|
926
|
+
status: finalStatus,
|
|
927
|
+
body: displayBody,
|
|
928
|
+
elapsedMs: Date.now() - task.startedAt,
|
|
929
|
+
runId,
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
933
|
+
);
|
|
934
|
+
} catch {
|
|
935
|
+
/* session is closed */
|
|
936
|
+
}
|
|
937
|
+
})
|
|
938
|
+
.catch((err) => {
|
|
939
|
+
const task = tasks.get(runId);
|
|
940
|
+
if (task) {
|
|
941
|
+
task.status = "error";
|
|
942
|
+
persistTasks();
|
|
943
|
+
}
|
|
944
|
+
const errorText = err instanceof Error ? err.message : String(err);
|
|
945
|
+
try {
|
|
946
|
+
pi.sendMessage(
|
|
947
|
+
{
|
|
948
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
949
|
+
content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
|
|
950
|
+
display: true,
|
|
951
|
+
details: {
|
|
952
|
+
agent: agent.name,
|
|
953
|
+
status: "error",
|
|
954
|
+
body: errorText,
|
|
955
|
+
elapsedMs: task ? Date.now() - task.startedAt : undefined,
|
|
956
|
+
runId,
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
960
|
+
);
|
|
961
|
+
} catch {
|
|
962
|
+
/* ignore */
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
.finally(() => {
|
|
966
|
+
if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
|
|
967
|
+
sessionHandles.delete(runId);
|
|
968
|
+
runningWidget.finish(runId);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
return runId;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ---- system prompt gate: subagents require explicit user authorization or orchestrator mode ----
|
|
975
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
976
|
+
// Treat an explicitly appended orchestrator prompt (CLI
|
|
977
|
+
// --append-system-prompt) as orchestrator mode too.
|
|
978
|
+
const alreadyOrchestrating = event.systemPrompt.includes(ORCHESTRATOR_PROMPT_MARKER);
|
|
979
|
+
if (isOrchestratorMode(ctx) || alreadyOrchestrating) {
|
|
980
|
+
if (alreadyOrchestrating) return;
|
|
981
|
+
const prompt = readOrchestratorPrompt();
|
|
982
|
+
if (!prompt) return;
|
|
983
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + prompt };
|
|
984
|
+
}
|
|
985
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + SUBAGENT_USAGE_GUARD };
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// ---- subagent tool ----
|
|
989
|
+
pi.registerTool({
|
|
990
|
+
name: "subagent",
|
|
991
|
+
label: "Subagent",
|
|
992
|
+
description: [
|
|
993
|
+
"Delegate a task to a subagent (isolated context, separate process).",
|
|
994
|
+
"agent: subagent name (defined in the agents directory, e.g. planner/reviewer/actor); task: task description; cwd: optional;",
|
|
995
|
+
"async=true: run in background, return a runId immediately, inject the result into the conversation when done (non-blocking);",
|
|
996
|
+
"async=false (default): wait synchronously for the result.",
|
|
997
|
+
`timeoutSeconds: optional; omit unless the user explicitly requested a time. Default ${DEFAULT_SUBAGENT_TIMEOUT_SECONDS}s (6 hours), range ${MIN_SUBAGENT_TIMEOUT_SECONDS}-${MAX_SUBAGENT_TIMEOUT_SECONDS};`,
|
|
998
|
+
"While running, a subagent may send_message (to=main) to reach you, or contact other subagents — reply promptly with reply_message.",
|
|
999
|
+
].join(" "),
|
|
1000
|
+
parameters: Type.Object({
|
|
1001
|
+
agent: Type.String({ description: "Subagent name" }),
|
|
1002
|
+
task: Type.String({ description: "Task description for the subagent" }),
|
|
1003
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the subagent" })),
|
|
1004
|
+
async: Type.Optional(Type.Boolean({ description: "true=run in background without blocking (default false)" })),
|
|
1005
|
+
timeoutSeconds: Type.Optional(
|
|
1006
|
+
Type.Integer({
|
|
1007
|
+
minimum: MIN_SUBAGENT_TIMEOUT_SECONDS,
|
|
1008
|
+
maximum: MAX_SUBAGENT_TIMEOUT_SECONDS,
|
|
1009
|
+
description: `Task timeout in seconds. Omit unless the user explicitly requested a time; default ${DEFAULT_SUBAGENT_TIMEOUT_SECONDS}s (6 hours).`,
|
|
1010
|
+
}),
|
|
1011
|
+
),
|
|
1012
|
+
}),
|
|
1013
|
+
|
|
1014
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1015
|
+
const agents = discoverAgents(ctx.cwd);
|
|
1016
|
+
const agent = agents.find((a) => a.name === params.agent);
|
|
1017
|
+
if (!agent) {
|
|
1018
|
+
const available = agents.map((a) => a.name).join(", ") || "none";
|
|
1019
|
+
return {
|
|
1020
|
+
content: [{ type: "text", text: `Unknown subagent "${params.agent}". Available: ${available}` }],
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const timeoutSeconds = Math.min(
|
|
1024
|
+
MAX_SUBAGENT_TIMEOUT_SECONDS,
|
|
1025
|
+
Math.max(MIN_SUBAGENT_TIMEOUT_SECONDS, params.timeoutSeconds ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS),
|
|
1026
|
+
);
|
|
1027
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
1028
|
+
|
|
1029
|
+
if (params.async) {
|
|
1030
|
+
try {
|
|
1031
|
+
const runId = launchBackground(agent, params.task, params.cwd, timeoutMs);
|
|
1032
|
+
return {
|
|
1033
|
+
content: [
|
|
1034
|
+
{
|
|
1035
|
+
type: "text",
|
|
1036
|
+
text: `Started background subagent ${params.agent} (run ${runId.slice(0, 8)}). The main session can keep doing other things; the result will be injected when done.`,
|
|
1037
|
+
},
|
|
1038
|
+
],
|
|
1039
|
+
details: { mode: "async", runId },
|
|
1040
|
+
};
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
return {
|
|
1043
|
+
content: [{ type: "text", text: `Failed to start background subagent: ${e instanceof Error ? e.message : String(e)}` }],
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
onUpdate?.({ content: [{ type: "text", text: `Starting ${params.agent} subagent...` }] });
|
|
1049
|
+
const runId = randomUUID();
|
|
1050
|
+
const acquired = waitGraph.acquire(MAIN_AGENT, agent.name);
|
|
1051
|
+
if (acquired.cycle) {
|
|
1052
|
+
return {
|
|
1053
|
+
content: [{ type: "text", text: deadlockMessage(acquired.cycle) }],
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
runningWidget.start(runId, agent.name, params.task, "task");
|
|
1057
|
+
const result = await spawnInteractiveSubagent({
|
|
1058
|
+
agent,
|
|
1059
|
+
task: params.task,
|
|
1060
|
+
cwd: params.cwd,
|
|
1061
|
+
messageRoot,
|
|
1062
|
+
runId,
|
|
1063
|
+
childIndex: 0,
|
|
1064
|
+
signal,
|
|
1065
|
+
timeoutMs,
|
|
1066
|
+
onSession: (session) => registerSessionHandle(runId, session),
|
|
1067
|
+
}).finally(() => {
|
|
1068
|
+
acquired.release?.();
|
|
1069
|
+
if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
|
|
1070
|
+
sessionHandles.delete(runId);
|
|
1071
|
+
runningWidget.finish(runId);
|
|
1072
|
+
});
|
|
1073
|
+
const output = getFinalOutput(result.messages);
|
|
1074
|
+
|
|
1075
|
+
if (result.exitCode !== 0) {
|
|
1076
|
+
return {
|
|
1077
|
+
content: [{ type: "text", text: `Subagent ${params.agent} failed (exit ${result.exitCode}): ${result.stderr || result.errorMessage || "unknown error"}` }],
|
|
1078
|
+
details: { result },
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
if (result.stopReason === "error") {
|
|
1082
|
+
return {
|
|
1083
|
+
content: [{ type: "text", text: `Subagent ${params.agent} errored: ${result.errorMessage || "unknown error"}` }],
|
|
1084
|
+
details: { result },
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
content: [{ type: "text", text: output || `(subagent ${params.agent} produced no text output)` }],
|
|
1089
|
+
details: { result },
|
|
1090
|
+
};
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// ---- commands ----
|
|
1095
|
+
pi.registerCommand("orchestrate", {
|
|
1096
|
+
description: "Turn multi-agent orchestration mode on/off (on|off|status, default on)",
|
|
1097
|
+
handler: async (args, ctx) => {
|
|
1098
|
+
const first = Array.isArray(args) ? (args[0] ?? "on") : String(args ?? "on").trim().split(/\s+/)[0] ?? "on";
|
|
1099
|
+
const arg = String(first).toLowerCase();
|
|
1100
|
+
|
|
1101
|
+
if (arg === "status") {
|
|
1102
|
+
const on = isOrchestratorMode(ctx);
|
|
1103
|
+
ctx.ui.notify(
|
|
1104
|
+
on
|
|
1105
|
+
? "Orchestrator mode: ON (runs as orchestrator each turn; use /orchestrate off to disable)"
|
|
1106
|
+
: "Orchestrator mode: OFF (use /orchestrate on to enable)",
|
|
1107
|
+
"info",
|
|
1108
|
+
);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const enabled = arg !== "off";
|
|
1113
|
+
try {
|
|
1114
|
+
pi.appendEntry(ORCHESTRATOR_MODE_ENTRY, { enabled });
|
|
1115
|
+
} catch {
|
|
1116
|
+
/* ignore */
|
|
1117
|
+
}
|
|
1118
|
+
ctx.ui.notify(
|
|
1119
|
+
enabled
|
|
1120
|
+
? "Orchestrator mode enabled — every turn will run as the orchestrator (triage + orchestrate subagents)"
|
|
1121
|
+
: "Orchestrator mode disabled",
|
|
1122
|
+
"info",
|
|
1123
|
+
);
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
pi.registerCommand("subagents", {
|
|
1128
|
+
description: "List available subagents",
|
|
1129
|
+
handler: async (_args, ctx) => {
|
|
1130
|
+
const agents = discoverAgents(ctx.cwd);
|
|
1131
|
+
const lines = agents.map(
|
|
1132
|
+
(a) =>
|
|
1133
|
+
`- ${a.name}: ${a.description}${a.model ? ` (${a.model})` : ""}${a.thinking ? ` [thinking: ${a.thinking}]` : ""}${a.tools ? ` [tools: ${a.tools.join(",")}]` : ""}`,
|
|
1134
|
+
);
|
|
1135
|
+
ctx.ui.notify(lines.length ? `Available subagents:\n${lines.join("\n")}` : "No subagents found", "info");
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
pi.registerCommand("subagent-config", {
|
|
1140
|
+
description: "Configure a subagent (model / thinking) via interactive menu",
|
|
1141
|
+
handler: async (_args, ctx) => {
|
|
1142
|
+
const agents = discoverAgents(ctx.cwd);
|
|
1143
|
+
if (agents.length === 0) {
|
|
1144
|
+
ctx.ui.notify("No subagents found", "info");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const agentName = await ctx.ui.select("Select subagent:", agents.map((a) => a.name));
|
|
1148
|
+
if (!agentName) return;
|
|
1149
|
+
const field = await ctx.ui.select(`Configure ${agentName}:`, ["model", "thinking"]);
|
|
1150
|
+
if (!field) return;
|
|
1151
|
+
|
|
1152
|
+
if (field === "thinking") {
|
|
1153
|
+
const level = await ctx.ui.select(
|
|
1154
|
+
`Thinking level for ${agentName}:`,
|
|
1155
|
+
["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
1156
|
+
);
|
|
1157
|
+
if (!level) return;
|
|
1158
|
+
if (!updateAgentConfig(agentName, "thinking", level)) {
|
|
1159
|
+
ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
ctx.ui.notify(`Subagent ${agentName}: thinking -> ${level}`, "info");
|
|
1163
|
+
} else {
|
|
1164
|
+
const choices = availableModelIds(ctx);
|
|
1165
|
+
if (choices.length === 0) {
|
|
1166
|
+
ctx.ui.notify("No models available", "warning");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const model = await ctx.ui.select(`Model for ${agentName}:`, choices);
|
|
1170
|
+
if (!model) return;
|
|
1171
|
+
if (!updateAgentConfig(agentName, "model", model)) {
|
|
1172
|
+
ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
ctx.ui.notify(`Subagent ${agentName}: model -> ${model}`, "info");
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
pi.registerCommand("subagent-status", {
|
|
1181
|
+
description: "Show background subagent tasks and resident processes",
|
|
1182
|
+
handler: async (_args, ctx) => {
|
|
1183
|
+
const list = [...tasks.values()];
|
|
1184
|
+
const lines = list.map((t) => {
|
|
1185
|
+
const age = Math.round((Date.now() - t.startedAt) / 1000);
|
|
1186
|
+
return `- run ${t.runId.slice(0, 8)} ${t.agent} ${t.status} (${age}s ago)`;
|
|
1187
|
+
});
|
|
1188
|
+
const alive = [...poolAliveNames(pool)];
|
|
1189
|
+
ctx.ui.notify(
|
|
1190
|
+
[
|
|
1191
|
+
lines.length ? `Background tasks:\n${lines.join("\n")}` : "Background tasks: none",
|
|
1192
|
+
`Resident processes: ${alive.length ? alive.join(", ") : "none"}`,
|
|
1193
|
+
].join("\n"),
|
|
1194
|
+
"info",
|
|
1195
|
+
);
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function* poolAliveNames(pool: SubagentPool): Generator<string> {
|
|
1201
|
+
for (const n of ["planner", "reviewer", "actor"]) {
|
|
1202
|
+
if (pool.isAlive(n)) yield n;
|
|
1203
|
+
}
|
|
1204
|
+
}
|