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/message.ts ADDED
@@ -0,0 +1,572 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Type } from "typebox";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ // ============================================================================
8
+ // Generic messaging system (no identity concept at the bottom layer)
9
+ //
10
+ // Two parties communicate: `main` (the main agent) and any subagent. Identity
11
+ // is defined entirely by each agent's prompt.
12
+ // Messages = file channel + polling:
13
+ // send_message -> write <root>/<runId>/<agent>/<idx>/requests/<msgId>.json (to=target)
14
+ // reply_message -> write <root>/<runId>/<fromAgent>/<idx>/replies/<msgId>.json
15
+ // the waiting party polls replies/<msgId>.json
16
+ // Main-side routing: to === "main" -> inject into main session; otherwise
17
+ // forward to the target subagent's resident process.
18
+ // ============================================================================
19
+
20
+ // ---- env vars (injected when a subagent is spawned) ----
21
+ export const ENV_CHANNEL_ROOT = "PI_SUBAGENT_CHANNEL_ROOT";
22
+ export const ENV_RUN_ID = "PI_SUBAGENT_RUN_ID";
23
+ export const ENV_AGENT = "PI_SUBAGENT_AGENT";
24
+ export const ENV_CHILD_INDEX = "PI_SUBAGENT_CHILD_INDEX";
25
+ export const ENV_ROLE = "PI_SUBAGENT_ROLE";
26
+ export const ROLE_MAIN = "main";
27
+ export const ROLE_CHILD = "child";
28
+
29
+ /** Generic address of the main agent (no identity semantics, just "the main session") */
30
+ export const MAIN_AGENT = "main";
31
+
32
+ export const TOOL_SEND = "send_message";
33
+ export const TOOL_READ = "read_inbox";
34
+ export const TOOL_REPLY = "reply_message";
35
+
36
+ const REQUESTS_DIR = "requests";
37
+ const REPLIES_DIR = "replies";
38
+ const MAX_MESSAGE_BYTES = 64 * 1024;
39
+ const DEFAULT_WAIT_TIMEOUT_MS = 6 * 60 * 60 * 1000;
40
+ const DEFAULT_ROUTE_TIMEOUT_SECONDS = 6 * 60 * 60;
41
+ const MIN_ROUTE_TIMEOUT_SECONDS = 10;
42
+ const MAX_ROUTE_TIMEOUT_SECONDS = 3 * 24 * 60 * 60;
43
+ const CHANNEL_POLL_MS = 500;
44
+ const REPLY_POLL_MS = 250;
45
+
46
+ export interface MessageRequest {
47
+ type: "pi.message.request";
48
+ id: string;
49
+ createdAt: number;
50
+ from: string; // sender: main or a subagent name
51
+ to: string; // target: main or a subagent name
52
+ content: string;
53
+ expectsReply: boolean;
54
+ expiresAt?: number;
55
+ timeoutMs?: number;
56
+ requestFile: string;
57
+ // sender's channel location (so a reply can be written back)
58
+ fromRunId: string;
59
+ fromAgent: string;
60
+ fromChildIndex: number;
61
+ }
62
+
63
+ export interface MessageReply {
64
+ type: "pi.message.reply";
65
+ messageId: string;
66
+ content: string;
67
+ timestamp: number;
68
+ }
69
+
70
+ function safeSegment(value: string): string {
71
+ return value.replace(/[^\w.-]+/g, "_");
72
+ }
73
+
74
+ /** Channel directory of one agent instance */
75
+ export function channelDir(root: string, runId: string, agent: string, childIndex: number): string {
76
+ return path.join(root, safeSegment(runId), safeSegment(agent), String(childIndex));
77
+ }
78
+
79
+ function requestPath(dir: string, id: string): string {
80
+ return path.join(dir, REQUESTS_DIR, `${safeSegment(id)}.json`);
81
+ }
82
+ function replyPath(dir: string, id: string): string {
83
+ return path.join(dir, REPLIES_DIR, `${safeSegment(id)}.json`);
84
+ }
85
+
86
+ export function ensureDir(dir: string): void {
87
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
88
+ }
89
+
90
+ function writeAtomic(filePath: string, data: string): void {
91
+ fs.writeFileSync(filePath, data, { encoding: "utf-8", mode: 0o600 });
92
+ }
93
+
94
+ // ============================================================================
95
+ // Message client (shared by subagents and the main agent)
96
+ // ============================================================================
97
+
98
+ interface ClientMeta {
99
+ root: string;
100
+ runId: string;
101
+ agent: string;
102
+ childIndex: number;
103
+ }
104
+
105
+ function readClientMeta(): ClientMeta | undefined {
106
+ const root = process.env[ENV_CHANNEL_ROOT]?.trim();
107
+ const runId = process.env[ENV_RUN_ID]?.trim();
108
+ const agent = process.env[ENV_AGENT]?.trim();
109
+ const rawIndex = process.env[ENV_CHILD_INDEX]?.trim();
110
+ if (!root || !runId || !agent || rawIndex === undefined || !/^\d+$/.test(rawIndex)) return undefined;
111
+ return { root, runId, agent, childIndex: Number(rawIndex) };
112
+ }
113
+
114
+ function waitForReply(dir: string, messageId: string, deadline: number, signal?: AbortSignal): Promise<string> {
115
+ return new Promise((resolve, reject) => {
116
+ const file = replyPath(dir, messageId);
117
+ const tick = () => {
118
+ if (signal?.aborted) {
119
+ reject(new Error("Message wait cancelled."));
120
+ return;
121
+ }
122
+ if (Date.now() > deadline) {
123
+ reject(new Error("Timed out waiting for reply."));
124
+ return;
125
+ }
126
+ try {
127
+ if (fs.existsSync(file)) {
128
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as MessageReply;
129
+ if (parsed.type === "pi.message.reply" && parsed.messageId === messageId && typeof parsed.content === "string") {
130
+ try {
131
+ fs.unlinkSync(file);
132
+ } catch {
133
+ /* ignore cleanup failure */
134
+ }
135
+ resolve(parsed.content);
136
+ return;
137
+ }
138
+ }
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ setTimeout(tick, REPLY_POLL_MS);
143
+ };
144
+ tick();
145
+ });
146
+ }
147
+
148
+ /** Send a message to any target (main or subagent); wait=true blocks for a reply */
149
+ async function sendMessageInternal(
150
+ to: string,
151
+ content: string,
152
+ wait: boolean,
153
+ timeoutMs: number,
154
+ signal?: AbortSignal,
155
+ ): Promise<{ messageId: string; reply?: string }> {
156
+ const meta = readClientMeta();
157
+ if (!meta) throw new Error("Message channel is not available.");
158
+ const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
159
+ ensureDir(path.join(dir, REQUESTS_DIR));
160
+ ensureDir(path.join(dir, REPLIES_DIR));
161
+
162
+ const messageId = randomUUID();
163
+ const now = Date.now();
164
+ const deadline = now + (wait ? timeoutMs : DEFAULT_WAIT_TIMEOUT_MS);
165
+ const request: MessageRequest = {
166
+ type: "pi.message.request",
167
+ id: messageId,
168
+ createdAt: now,
169
+ from: meta.agent,
170
+ to,
171
+ content,
172
+ expectsReply: wait,
173
+ ...(wait ? { expiresAt: deadline } : {}),
174
+ timeoutMs,
175
+ requestFile: requestPath(dir, messageId),
176
+ fromRunId: meta.runId,
177
+ fromAgent: meta.agent,
178
+ fromChildIndex: meta.childIndex,
179
+ };
180
+ const serialized = JSON.stringify(request, null, "\t");
181
+ if (Buffer.byteLength(serialized, "utf-8") > MAX_MESSAGE_BYTES) throw new Error("Message is too large.");
182
+ writeAtomic(requestPath(dir, messageId), serialized);
183
+
184
+ if (!wait) return { messageId };
185
+ try {
186
+ const reply = await waitForReply(dir, messageId, deadline, signal);
187
+ return { messageId, reply };
188
+ } catch (e) {
189
+ try {
190
+ fs.unlinkSync(requestPath(dir, messageId));
191
+ } catch {
192
+ /* router may already have consumed it */
193
+ }
194
+ throw e;
195
+ }
196
+ }
197
+
198
+ /** Reply to a message (locate the sender from the message, write the reply back) */
199
+ function replyToMessage(root: string, messageId: string, content: string): boolean {
200
+ const request = findRequestInTree(root, messageId);
201
+ if (!request) return false;
202
+ const senderDir = channelDir(root, request.fromRunId, request.fromAgent, request.fromChildIndex);
203
+ ensureDir(path.join(senderDir, REPLIES_DIR));
204
+ const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
205
+ writeAtomic(replyPath(senderDir, messageId), JSON.stringify(reply, null, 2));
206
+ // clean up the replied request
207
+ if (request.requestFile) {
208
+ try {
209
+ fs.unlinkSync(request.requestFile);
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ }
214
+ return true;
215
+ }
216
+
217
+ function findRequestInTree(root: string, messageId: string): MessageRequest | undefined {
218
+ try {
219
+ if (!fs.existsSync(root)) return undefined;
220
+ for (const run of fs.readdirSync(root)) {
221
+ const runDir = path.join(root, run);
222
+ if (!fs.statSync(runDir).isDirectory()) continue;
223
+ for (const agent of fs.readdirSync(runDir)) {
224
+ const agentDir = path.join(runDir, agent);
225
+ if (!fs.statSync(agentDir).isDirectory()) continue;
226
+ for (const idx of fs.readdirSync(agentDir)) {
227
+ const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
228
+ if (!fs.existsSync(reqDir)) continue;
229
+ for (const f of fs.readdirSync(reqDir)) {
230
+ if (f !== `${safeSegment(messageId)}.json`) continue;
231
+ try {
232
+ return JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ } catch {
241
+ /* ignore */
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ /** Register the generic messaging tools for a child (subagent) */
247
+ export function registerChildMessaging(pi: ExtensionAPI): void {
248
+ pi.registerTool({
249
+ name: TOOL_SEND,
250
+ label: "Send Message",
251
+ description: [
252
+ "Send a message to any target: to='main' reaches the main agent, to=<agent name> reaches that subagent.",
253
+ "wait=true (ask): block until the other party replies before continuing; wait=false (send): fire and forget.",
254
+ "Use it to ask/confirm with the main agent or another subagent instead of leaving notes in normal output.",
255
+ ].join(" "),
256
+ parameters: Type.Object({
257
+ to: Type.String({ description: "Target: main or a subagent name" }),
258
+ content: Type.String({ description: "Message content" }),
259
+ wait: Type.Optional(
260
+ Type.Boolean({ description: "true=wait for a reply (default true)" }),
261
+ ),
262
+ timeoutSeconds: Type.Optional(
263
+ Type.Integer({
264
+ minimum: MIN_ROUTE_TIMEOUT_SECONDS,
265
+ maximum: MAX_ROUTE_TIMEOUT_SECONDS,
266
+ description: `Reply timeout in seconds. Omit unless the user explicitly requested a time; default ${DEFAULT_ROUTE_TIMEOUT_SECONDS}s (6 hours).`,
267
+ }),
268
+ ),
269
+ }),
270
+ execute: async (_id, params, signal) => {
271
+ try {
272
+ const timeoutMs =
273
+ Math.min(
274
+ MAX_ROUTE_TIMEOUT_SECONDS,
275
+ Math.max(MIN_ROUTE_TIMEOUT_SECONDS, params.timeoutSeconds ?? DEFAULT_ROUTE_TIMEOUT_SECONDS),
276
+ ) * 1000;
277
+ const { messageId, reply } = await sendMessageInternal(
278
+ params.to,
279
+ params.content,
280
+ params.wait !== false,
281
+ timeoutMs,
282
+ signal,
283
+ );
284
+ if (reply !== undefined) {
285
+ return {
286
+ content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }],
287
+ };
288
+ }
289
+ return { content: [{ type: "text", text: `Sent message to ${params.to} (id ${messageId.slice(0, 8)})` }] };
290
+ } catch (e) {
291
+ return {
292
+ content: [{ type: "text", text: `Failed to send message: ${e instanceof Error ? e.message : String(e)}` }],
293
+ };
294
+ }
295
+ },
296
+ });
297
+
298
+ pi.registerTool({
299
+ name: TOOL_READ,
300
+ label: "Read Inbox",
301
+ description: "Read messages that others sent you.",
302
+ parameters: Type.Object({}),
303
+ execute: async (_id) => {
304
+ const meta = readClientMeta();
305
+ if (!meta) return { content: [{ type: "text", text: "Message channel is not available." }] };
306
+ const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
307
+ const reqDir = path.join(dir, REQUESTS_DIR);
308
+ if (!fs.existsSync(reqDir)) return { content: [{ type: "text", text: "Inbox is empty." }] };
309
+ const files = fs.readdirSync(reqDir).filter((f) => f.endsWith(".json"));
310
+ if (files.length === 0) return { content: [{ type: "text", text: "Inbox is empty." }] };
311
+ const lines = files.map((f) => {
312
+ try {
313
+ const r = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
314
+ return `- [${r.id.slice(0, 8)}] from ${r.from}: ${r.content.slice(0, 150)}`;
315
+ } catch {
316
+ return "";
317
+ }
318
+ });
319
+ return { content: [{ type: "text", text: `Inbox (requests from other agents):\n${lines.join("\n")}` }] };
320
+ },
321
+ });
322
+
323
+ pi.registerTool({
324
+ name: TOOL_REPLY,
325
+ label: "Reply Message",
326
+ description: "Reply to a received message (message_id comes from the received message).",
327
+ parameters: Type.Object({
328
+ message_id: Type.String({ description: "The message id to reply to" }),
329
+ content: Type.String({ description: "Reply content" }),
330
+ }),
331
+ execute: async (_id, params) => {
332
+ const meta = readClientMeta();
333
+ if (!meta) return { content: [{ type: "text", text: "Message channel is not available." }] };
334
+ if (!replyToMessage(meta.root, params.message_id, params.content)) {
335
+ return { content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)}` }] };
336
+ }
337
+ return { content: [{ type: "text", text: `Replied to ${params.message_id.slice(0, 8)}` }] };
338
+ },
339
+ });
340
+ }
341
+
342
+ // ============================================================================
343
+ // Main-agent side: message router (poller)
344
+ // ============================================================================
345
+
346
+ export interface MessageRouterState {
347
+ root: string;
348
+ /** when to === "main": inject into the main session */
349
+ onMainMessage: (msg: MessageRequest) => void;
350
+ /** when to === some subagent: route to its resident process */
351
+ onChildMessage: (msg: MessageRequest, signal?: AbortSignal) => Promise<string> | string;
352
+ matchesContext: (msg: MessageRequest) => boolean;
353
+ /** notification hooks used by the main-side wait graph */
354
+ onMessageReplied?: (msg: MessageRequest) => void;
355
+ onMessageExpired?: (msg: MessageRequest) => void;
356
+ }
357
+
358
+ export interface MessageRouter {
359
+ start: () => void;
360
+ dispose: () => void;
361
+ }
362
+
363
+ /** Scan the channel tree for all pending message requests */
364
+ export function scanMessages(root: string): MessageRequest[] {
365
+ const out: MessageRequest[] = [];
366
+ try {
367
+ if (!fs.existsSync(root)) return out;
368
+ for (const run of fs.readdirSync(root)) {
369
+ const runDir = path.join(root, run);
370
+ if (!fs.statSync(runDir).isDirectory()) continue;
371
+ for (const agent of fs.readdirSync(runDir)) {
372
+ const agentDir = path.join(runDir, agent);
373
+ if (!fs.statSync(agentDir).isDirectory()) continue;
374
+ for (const idx of fs.readdirSync(agentDir)) {
375
+ const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
376
+ if (!fs.existsSync(reqDir)) continue;
377
+ for (const f of fs.readdirSync(reqDir)) {
378
+ if (!f.endsWith(".json")) continue;
379
+ try {
380
+ const req = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
381
+ if (req.type === "pi.message.request" && req.id) {
382
+ req.requestFile = path.join(reqDir, f);
383
+ out.push(req);
384
+ }
385
+ } catch {
386
+ /* ignore */
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ } catch {
393
+ /* ignore */
394
+ }
395
+ return out;
396
+ }
397
+
398
+ export function writeReply(dir: string, messageId: string, content: string): void {
399
+ ensureDir(path.join(dir, REPLIES_DIR));
400
+ const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
401
+ writeAtomic(replyPath(dir, messageId), JSON.stringify(reply, null, 2));
402
+ }
403
+
404
+ export function removeRequestFile(msg: MessageRequest): void {
405
+ try {
406
+ if (msg.requestFile) fs.unlinkSync(msg.requestFile);
407
+ } catch {
408
+ /* ignore */
409
+ }
410
+ }
411
+
412
+ /** Main agent creates a message router: poller scans, then splits main vs subagent */
413
+ export function createMessageRouter(pi: ExtensionAPI, state: MessageRouterState): MessageRouter {
414
+ const seen = new Set<string>();
415
+ const inFlight = new Set<string>();
416
+ let poller: ReturnType<typeof setInterval> | undefined;
417
+
418
+ const poll = () => {
419
+ for (const msg of scanMessages(state.root)) {
420
+ if (msg.expiresAt !== undefined && msg.expiresAt < Date.now()) {
421
+ seen.delete(msg.id);
422
+ inFlight.delete(msg.id);
423
+ state.onMessageExpired?.(msg);
424
+ removeRequestFile(msg);
425
+ continue;
426
+ }
427
+ if (seen.has(msg.id) || inFlight.has(msg.id)) continue;
428
+ if (!state.matchesContext(msg)) continue;
429
+ if (msg.to === MAIN_AGENT) {
430
+ try {
431
+ state.onMainMessage(msg);
432
+ // A request to main remains on disk until reply_message is
433
+ // called, so remember successful delivery to avoid injecting
434
+ // it on every poll. Failed delivery is deliberately retried.
435
+ seen.add(msg.id);
436
+ } catch {
437
+ /* retry on the next poll */
438
+ }
439
+ } else {
440
+ inFlight.add(msg.id);
441
+ void Promise.resolve()
442
+ .then(() => state.onChildMessage(msg))
443
+ .then(() => {
444
+ seen.add(msg.id);
445
+ })
446
+ .catch(() => {
447
+ /* retry unexpected routing failures on the next poll */
448
+ })
449
+ .finally(() => {
450
+ inFlight.delete(msg.id);
451
+ });
452
+ }
453
+ }
454
+ };
455
+
456
+ return {
457
+ start: () => {
458
+ if (poller) return;
459
+ registerMainReplyTool(pi, state);
460
+ poll();
461
+ poller = setInterval(poll, CHANNEL_POLL_MS);
462
+ poller.unref?.();
463
+ },
464
+ dispose: () => {
465
+ if (poller) clearInterval(poller);
466
+ poller = undefined;
467
+ },
468
+ };
469
+ }
470
+
471
+ /** Main agent registers the generic messaging tools (send_message + reply_message) */
472
+ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): void {
473
+ // the main agent can also proactively message subagents
474
+ pi.registerTool({
475
+ name: TOOL_SEND,
476
+ label: "Send Message",
477
+ description: [
478
+ "Send a message to a subagent: to=<agent name>. wait=true waits for its reply.",
479
+ "The subagent processes the message and replies via reply_message.",
480
+ ].join(" "),
481
+ parameters: Type.Object({
482
+ to: Type.String({ description: "Subagent name" }),
483
+ content: Type.String({ description: "Message content" }),
484
+ wait: Type.Optional(Type.Boolean({ description: "true=wait for a reply (default true)" })),
485
+ timeoutSeconds: Type.Optional(
486
+ Type.Integer({
487
+ minimum: MIN_ROUTE_TIMEOUT_SECONDS,
488
+ maximum: MAX_ROUTE_TIMEOUT_SECONDS,
489
+ description: `Task timeout in seconds. Omit unless the user explicitly requested a time; default ${DEFAULT_ROUTE_TIMEOUT_SECONDS}s (6 hours).`,
490
+ }),
491
+ ),
492
+ }),
493
+ execute: async (_id, params, signal) => {
494
+ // the main agent has no child channel location; forward via the router
495
+ const wait = params.wait !== false;
496
+ const timeoutMs =
497
+ Math.min(
498
+ MAX_ROUTE_TIMEOUT_SECONDS,
499
+ Math.max(MIN_ROUTE_TIMEOUT_SECONDS, params.timeoutSeconds ?? DEFAULT_ROUTE_TIMEOUT_SECONDS),
500
+ ) * 1000;
501
+ const msg = findOrCreateProxyRequest(state.root, params.content, wait, timeoutMs);
502
+ if (!msg) {
503
+ return { content: [{ type: "text", text: "Message channel is not ready." }] };
504
+ }
505
+ const routed = { ...msg, to: params.to };
506
+ if (!wait) {
507
+ void Promise.resolve(state.onChildMessage(routed)).catch(() => {
508
+ /* fire-and-forget failures cannot be returned to this completed tool call */
509
+ });
510
+ return { content: [{ type: "text", text: `Sent message to ${params.to}` }] };
511
+ }
512
+ try {
513
+ const reply = await state.onChildMessage(routed, signal);
514
+ return { content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }] };
515
+ } catch (e) {
516
+ return {
517
+ content: [{ type: "text", text: `Failed to message ${params.to}: ${e instanceof Error ? e.message : String(e)}` }],
518
+ };
519
+ }
520
+ },
521
+ });
522
+
523
+ pi.registerTool({
524
+ name: TOOL_REPLY,
525
+ label: "Reply Message",
526
+ description: [
527
+ "Reply to a message from a subagent. message_id comes from the received message (like message_id=xxx).",
528
+ "After receiving a subagent message, use this tool; the content is sent back to the waiting subagent.",
529
+ ].join(" "),
530
+ parameters: Type.Object({
531
+ message_id: Type.String({ description: "The message id to reply to" }),
532
+ content: Type.String({ description: "Reply content" }),
533
+ }),
534
+ execute: async (_id, params) => {
535
+ const msg = findRequestInTree(state.root, params.message_id);
536
+ if (!msg) {
537
+ return {
538
+ content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)} (may already be handled)` }],
539
+ };
540
+ }
541
+ const dir = channelDir(state.root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
542
+ writeReply(dir, msg.id, params.content);
543
+ removeRequestFile(msg);
544
+ state.onMessageReplied?.(msg);
545
+ return { content: [{ type: "text", text: `Replied to ${msg.from}` }] };
546
+ },
547
+ });
548
+ }
549
+
550
+ /** Proxy request for the main agent sending to a subagent (placeholder; routing actually delivers it) */
551
+ function findOrCreateProxyRequest(
552
+ root: string,
553
+ content: string,
554
+ expectsReply: boolean,
555
+ timeoutMs: number,
556
+ ): MessageRequest | undefined {
557
+ if (!root) return undefined;
558
+ return {
559
+ type: "pi.message.request",
560
+ id: randomUUID(),
561
+ createdAt: Date.now(),
562
+ from: MAIN_AGENT,
563
+ to: "",
564
+ content,
565
+ expectsReply,
566
+ timeoutMs,
567
+ requestFile: "",
568
+ fromRunId: "main",
569
+ fromAgent: MAIN_AGENT,
570
+ fromChildIndex: 0,
571
+ };
572
+ }
@@ -0,0 +1,131 @@
1
+ ---
2
+ name: orchestrator
3
+ description: Main agent system prompt — outcome ownership with optional specialist workflows
4
+ ---
5
+
6
+ # You are the Orchestrator
7
+
8
+ You are the primary owner of the user's outcome. Use specialists when their
9
+ distinct capability materially improves the result. Choose the smallest
10
+ workflow that delivers the outcome reliably, and remain responsible for
11
+ reconnaissance, integration, user communication, and final delivery.
12
+
13
+ ## Working model
14
+
15
+ Every task moves through some of these phases:
16
+
17
+ 1. **Discover** — understand the requested outcome and gather the facts needed to act.
18
+ 2. **Decide** — resolve consequential implementation choices that remain after discovery.
19
+ 3. **Execute** — implement a clear task brief or design decision.
20
+ 4. **Verify** — independently check completed work against the requested outcome.
21
+
22
+ Not every task needs every phase or every specialist. Task size and decision
23
+ uncertainty are separate: a large amount of clear work is execution, while a
24
+ small change with a consequential unresolved choice may need design.
25
+
26
+ ## Specialists
27
+
28
+ | Agent | Distinct capability | Model |
29
+ |---|---|---|
30
+ | `planner` | Resolve a concrete design decision and produce an actor-ready decision record | glm-5.3 |
31
+ | `actor` | Implement a clear task brief or decision record | deepseek-v4-flash |
32
+ | `reviewer` | Independently verify completed work against the requested outcome | gpt-5.6-sol |
33
+
34
+ ## Selecting the workflow
35
+
36
+ For each user request:
37
+
38
+ 1. Define the concrete deliverable.
39
+ 2. Gather enough evidence to understand the current system.
40
+ 3. Determine whether the implementation direction is already clear.
41
+ 4. Select the specialist whose distinct output is needed next.
42
+ 5. Integrate the result and advance the active workflow.
43
+
44
+ Use these workflow shapes:
45
+
46
+ - **Main directly completes the work** when the necessary context and capabilities are already available.
47
+ - **Main → actor** when the desired change, constraints, and verification criteria form a clear task brief.
48
+ - **Main → planner → actor** when discovery exposes a consequential unresolved decision that prevents a clear task brief.
49
+ - **Main or actor → reviewer** when independent correctness, regression, security, compatibility, or requirement coverage checks add meaningful value.
50
+ - **Main → planner** when the user's requested deliverable is itself a design decision or implementation plan.
51
+
52
+ ## Planner readiness: Decision Brief
53
+
54
+ Planner is the specialist for the **Decide** phase. Before invoking planner,
55
+ form a Decision Brief containing:
56
+
57
+ - **Decision to make** — one sentence naming the unresolved choice.
58
+ - **Why it matters** — how the answer changes implementation, compatibility, migration, or risk.
59
+ - **Known facts** — evidence established during discovery.
60
+ - **Constraints** — requirements the decision must satisfy.
61
+ - **Candidate approaches or unresolved boundary** — the viable directions or exact point of uncertainty.
62
+ - **Downstream use** — how the result will change actor's implementation brief.
63
+
64
+ Planner returns a decision record and an actor-ready implementation outline.
65
+ Its value comes from resolving the decision, not from restating known facts or
66
+ turning an already-clear implementation into a longer checklist.
67
+
68
+ ## Actor readiness: Task Brief
69
+
70
+ Actor can work from either a direct Task Brief or a planner Decision Record. A
71
+ separate planner result is optional.
72
+
73
+ A useful Task Brief contains:
74
+
75
+ - Desired outcome
76
+ - Relevant subsystem or files
77
+ - Required behavior
78
+ - Constraints
79
+ - Verification criteria
80
+
81
+ Actor derives local execution steps, implements the change, verifies it, and
82
+ reports changed files, results, and remaining blockers.
83
+
84
+ ## Reviewer readiness: Verification Brief
85
+
86
+ Reviewer evaluates completed work using:
87
+
88
+ - User outcome
89
+ - Task Brief
90
+ - Decision Record, when one exists
91
+ - Relevant diff or files
92
+ - Verification already performed
93
+
94
+ Reviewer returns `Approved` or a prioritized issue list. A planner document is
95
+ not required for review.
96
+
97
+ ## Workflow continuity
98
+
99
+ Each user objective defines one active workflow. Associate specialist runs and
100
+ background results with that objective.
101
+
102
+ When a specialist result arrives:
103
+
104
+ 1. Integrate it into the current workflow state.
105
+ 2. Advance to the next phase when the output is sufficient.
106
+ 3. Re-invoke a specialist when new evidence has materially changed the brief or introduced a new decision.
107
+
108
+ When the user establishes a new objective, make it the active workflow.
109
+ Results from older workflows remain context, rather than becoming commands to
110
+ resume the old workflow.
111
+
112
+ ## Background execution
113
+
114
+ Use `async: true` when the main session can make independent progress while a
115
+ specialist works. Use synchronous execution when the specialist's result is
116
+ the immediate dependency for the next action.
117
+
118
+ While a background specialist runs, gather evidence that improves the active
119
+ brief and avoid duplicating the specialist's distinct assignment.
120
+
121
+ ## Timeouts
122
+
123
+ Use the default task timeout unless the user explicitly requested a time
124
+ limit. Specialist failures are workflow evidence: integrate the failure,
125
+ continue with the available facts, and create a new run when an updated brief
126
+ provides a materially better attempt.
127
+
128
+ ## Coordination principle
129
+
130
+ The coordination cost of a specialist should be lower than the value of its
131
+ distinct output. Main remains accountable for the complete user outcome.