@narumitw/pi-subagents 0.41.0 → 0.43.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/src/consult.ts ADDED
@@ -0,0 +1,688 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
+ import { StringEnum, type Usage } from "@earendil-works/pi-ai";
5
+ import {
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ getAgentDir,
9
+ type ToolDefinition,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { type Static, Type } from "typebox";
12
+ import {
13
+ type AgentConfig,
14
+ type AgentDiscoveryResult,
15
+ type AgentScope,
16
+ type ConsultResourcePolicy,
17
+ DEFAULT_AGENT_CATALOG_MAX_ITEMS,
18
+ discoverAgents,
19
+ isThinkingLevel,
20
+ type SubagentSettings,
21
+ THINKING_LEVELS,
22
+ } from "./agents.js";
23
+ import { resolveConsultTools } from "./consult-policy.js";
24
+ import { assertSubagentDepthAllowed, resolveDefaultSubagentTimeoutMs } from "./execution.js";
25
+ import {
26
+ DEFAULT_MAX_CONTEXT_BYTES,
27
+ DEFAULT_MAX_STDERR_BYTES,
28
+ MAX_SUBAGENT_TIMEOUT_MS,
29
+ truncateUtf8,
30
+ } from "./limits.js";
31
+ import {
32
+ type ChildLaunchPolicy,
33
+ getResultFinalOutput,
34
+ isResultError,
35
+ runSingleAgent,
36
+ type SingleResult,
37
+ type SubagentDetails,
38
+ } from "./runner.js";
39
+ import { boundedPrivateText, boundText, safeDisplayPath, safeTerminalLine } from "./safe-text.js";
40
+ import { DEFAULT_CONSULT_RESOURCE_POLICY, resolveSubagentThinkingLevel } from "./settings.js";
41
+
42
+ const ConsultScopeSchema = StringEnum(["user", "project", "both"] as const, {
43
+ default: "user",
44
+ description: "Agent definition scope. Project scopes require a trusted project.",
45
+ });
46
+ const ConsultThinkingSchema = StringEnum(THINKING_LEVELS);
47
+
48
+ export const SubagentConsultParams = Type.Object(
49
+ {
50
+ agent: Type.String({ minLength: 1 }),
51
+ task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
52
+ agentScope: Type.Optional(ConsultScopeSchema),
53
+ confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
54
+ cwd: Type.Optional(Type.String({ minLength: 1 })),
55
+ timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SUBAGENT_TIMEOUT_MS })),
56
+ thinkingLevel: Type.Optional(ConsultThinkingSchema),
57
+ },
58
+ { additionalProperties: false },
59
+ );
60
+
61
+ export type SubagentConsultParams = Static<typeof SubagentConsultParams>;
62
+
63
+ export interface ConsultChildRequest {
64
+ agent: AgentConfig;
65
+ task: string;
66
+ cwd: string;
67
+ agentScope: AgentScope;
68
+ thinkingLevel?: (typeof THINKING_LEVELS)[number];
69
+ timeoutMs: number;
70
+ effectiveTools: string[];
71
+ resourcePolicy: ConsultResourcePolicy;
72
+ launchPolicy: ChildLaunchPolicy;
73
+ signal: AbortSignal;
74
+ onUpdate?: (result: SingleResult) => void;
75
+ }
76
+
77
+ export interface RegisterSubagentConsultOptions {
78
+ getSettings(): SubagentSettings | undefined;
79
+ runChild?: (request: ConsultChildRequest) => Promise<SingleResult>;
80
+ invocationOverride?: { command: string; argsPrefix?: string[] };
81
+ }
82
+
83
+ export interface ConsultDetails {
84
+ agent: string;
85
+ agentSource: string;
86
+ agentScope: AgentScope;
87
+ cwd: string;
88
+ model?: string;
89
+ thinkingLevel?: string;
90
+ timeoutMs: number;
91
+ policy: {
92
+ requestedTools: string[] | null;
93
+ effectiveTools: string[];
94
+ requestedResources: ConsultResourcePolicy;
95
+ effectiveResources: {
96
+ policy: ConsultResourcePolicy;
97
+ projectResources: boolean;
98
+ contextFiles: boolean;
99
+ skills: boolean;
100
+ promptTemplates: boolean;
101
+ };
102
+ extensions: "disabled";
103
+ sessionPersistence: "disabled";
104
+ retainedAgent: false;
105
+ };
106
+ child?: Record<string, unknown>;
107
+ cancelled?: boolean;
108
+ isError?: boolean;
109
+ truncated?: boolean;
110
+ }
111
+
112
+ const READ_ONLY_INSTRUCTION = [
113
+ "This is a read-only consultation.",
114
+ "Use only the tools made available by the executor to inspect and reason about existing content.",
115
+ "Do not claim to edit files, run shell commands, mutate state, or persist a session.",
116
+ "If the task asks for implementation, return analysis or instructions instead of claiming changes.",
117
+ ].join("\n");
118
+
119
+ const MINIMAL_CONSULT_SYSTEM_PROMPT =
120
+ "You are a read-only consultation assistant. Analyze the delegated task using only executor-provided capabilities and return a grounded answer.";
121
+ const MAX_UNKNOWN_AGENT_NAME_BYTES = 128;
122
+
123
+ export function registerSubagentConsult(
124
+ pi: ExtensionAPI,
125
+ options: RegisterSubagentConsultOptions,
126
+ ): (catalog: string) => void {
127
+ let generation = 0;
128
+ const active = new Set<AbortController>();
129
+ const activeChildren = new Set<Promise<SingleResult>>();
130
+ const cancelActive = (reason: string) => {
131
+ generation++;
132
+ for (const controller of active) {
133
+ controller.abort(new DOMException(reason, "AbortError"));
134
+ }
135
+ active.clear();
136
+ };
137
+ const cancelAndWaitForChildren = async (reason: string) => {
138
+ cancelActive(reason);
139
+ await Promise.allSettled([...activeChildren]);
140
+ };
141
+ pi.on("session_start", () => cancelAndWaitForChildren("Subagent consultation session replaced"));
142
+ pi.on("session_shutdown", () =>
143
+ cancelAndWaitForChildren("Subagent consultation session shut down"),
144
+ );
145
+
146
+ const baseDescription =
147
+ "Run one ephemeral subagent synchronously under enforced read-only tool and resource policies and return its answer. The child can use only the effective subset of Pi's built-in read, grep, find, and ls tools. Shell commands, file writes, extension tools, detached lifecycle operations, and persistent agent state are disabled.";
148
+ const definition: ToolDefinition<typeof SubagentConsultParams, ConsultDetails> = {
149
+ name: "subagent_consult",
150
+ label: "Consult Read-only Subagent",
151
+ description: baseDescription,
152
+ promptSnippet: "Consult one constrained read-only subagent and wait for its answer",
153
+ promptGuidelines: [
154
+ "Use subagent_consult for bounded reconnaissance, planning, or review whose result is required in the current turn.",
155
+ "Implementation-shaped tasks remain read-only and can return only analysis or instructions.",
156
+ ],
157
+ parameters: SubagentConsultParams,
158
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
159
+ const operation = validateConsultParams(params);
160
+ assertSubagentDepthAllowed();
161
+ if (signal?.aborted) throw abortError("Subagent consultation was aborted before start");
162
+ const ownerGeneration = generation;
163
+ const ownedController = new AbortController();
164
+ active.add(ownedController);
165
+ const combined = combineAbortSignals(signal, ownedController.signal);
166
+ try {
167
+ return await executeConsult(
168
+ operation,
169
+ ctx,
170
+ combined.signal,
171
+ options,
172
+ (partial) => {
173
+ if (ownerGeneration !== generation || combined.signal.aborted) return;
174
+ onUpdate?.(partial);
175
+ },
176
+ () => ownerGeneration === generation,
177
+ (child) => {
178
+ activeChildren.add(child);
179
+ void child.then(
180
+ () => activeChildren.delete(child),
181
+ () => activeChildren.delete(child),
182
+ );
183
+ },
184
+ );
185
+ } finally {
186
+ combined.dispose();
187
+ active.delete(ownedController);
188
+ }
189
+ },
190
+ };
191
+ pi.registerTool<typeof SubagentConsultParams, ConsultDetails>(definition);
192
+ pi.on("tool_result", (event) => {
193
+ if (event.toolName !== "subagent_consult") return;
194
+ if ((event.details as ConsultDetails | undefined)?.isError) return { isError: true };
195
+ });
196
+ return (catalog: string) => {
197
+ definition.description = catalog ? `${baseDescription}\n\n${catalog}` : baseDescription;
198
+ pi.registerTool<typeof SubagentConsultParams, ConsultDetails>(definition);
199
+ };
200
+ }
201
+
202
+ function formatAvailableConsultAgents(discovery: AgentDiscoveryResult): string {
203
+ const listed = discovery.agents.slice(0, DEFAULT_AGENT_CATALOG_MAX_ITEMS);
204
+ const labels = listed.map(
205
+ (agent) => `${safeTerminalLine(agent.name, MAX_UNKNOWN_AGENT_NAME_BYTES)} (${agent.source})`,
206
+ );
207
+ const omitted =
208
+ discovery.agents.length - listed.length + (discovery.omittedAgentDefinitions ?? 0);
209
+ const parts = [labels.join(", ") || "none"];
210
+ if (omitted > 0) {
211
+ parts.push(`[${omitted} additional agent definition${omitted === 1 ? "" : "s"} omitted.]`);
212
+ }
213
+ if (discovery.metadataDiscoveryIncomplete) {
214
+ parts.push("[Agent metadata discovery was incomplete; some definitions may be unavailable.]");
215
+ }
216
+ return parts.join(" ");
217
+ }
218
+
219
+ function validateConsultParams(
220
+ params: unknown,
221
+ ): Required<Pick<SubagentConsultParams, "agent" | "task" | "agentScope" | "confirmProjectAgents">> &
222
+ Pick<SubagentConsultParams, "cwd" | "timeoutMs" | "thinkingLevel"> {
223
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
224
+ throw new Error("subagent_consult parameters must be an object");
225
+ }
226
+ const values = params as Record<string, unknown>;
227
+ const allowed = [
228
+ "agent",
229
+ "task",
230
+ "agentScope",
231
+ "confirmProjectAgents",
232
+ "cwd",
233
+ "timeoutMs",
234
+ "thinkingLevel",
235
+ ];
236
+ const unexpected = Object.keys(values).find(
237
+ (key) => values[key] !== undefined && !allowed.includes(key),
238
+ );
239
+ if (unexpected) throw new Error(`subagent_consult does not accept ${unexpected}`);
240
+ const agent = requiredString(values.agent, "agent");
241
+ const task = requiredString(values.task, "task");
242
+ if (task.includes("\0")) throw new Error("subagent_consult task must not contain NUL bytes");
243
+ if (Buffer.byteLength(task, "utf8") > DEFAULT_MAX_CONTEXT_BYTES) {
244
+ throw new Error(
245
+ `subagent_consult task must be at most ${DEFAULT_MAX_CONTEXT_BYTES} UTF-8 bytes`,
246
+ );
247
+ }
248
+ const agentScope = optionalScope(values.agentScope);
249
+ if (
250
+ values.confirmProjectAgents !== undefined &&
251
+ typeof values.confirmProjectAgents !== "boolean"
252
+ ) {
253
+ throw new Error("subagent_consult confirmProjectAgents must be boolean");
254
+ }
255
+ if (values.cwd !== undefined && (typeof values.cwd !== "string" || !values.cwd.trim())) {
256
+ throw new Error("subagent_consult cwd must be a non-empty string");
257
+ }
258
+ if (
259
+ values.timeoutMs !== undefined &&
260
+ (typeof values.timeoutMs !== "number" ||
261
+ !Number.isFinite(values.timeoutMs) ||
262
+ values.timeoutMs < 1 ||
263
+ values.timeoutMs > MAX_SUBAGENT_TIMEOUT_MS)
264
+ ) {
265
+ throw new Error(`subagent_consult timeoutMs must be between 1 and ${MAX_SUBAGENT_TIMEOUT_MS}`);
266
+ }
267
+ if (values.thinkingLevel !== undefined && !isThinkingLevel(values.thinkingLevel)) {
268
+ throw new Error("subagent_consult thinkingLevel is invalid");
269
+ }
270
+ return {
271
+ agent,
272
+ task,
273
+ agentScope,
274
+ confirmProjectAgents: values.confirmProjectAgents !== false,
275
+ cwd: values.cwd as string | undefined,
276
+ timeoutMs: values.timeoutMs as number | undefined,
277
+ thinkingLevel: values.thinkingLevel as (typeof THINKING_LEVELS)[number] | undefined,
278
+ };
279
+ }
280
+
281
+ async function executeConsult(
282
+ operation: ReturnType<typeof validateConsultParams>,
283
+ ctx: ExtensionContext,
284
+ signal: AbortSignal,
285
+ options: RegisterSubagentConsultOptions,
286
+ emitUpdate: (partial: AgentToolResult<ConsultDetails>) => void,
287
+ isCurrent: () => boolean,
288
+ trackChild: (child: Promise<SingleResult>) => void,
289
+ ): Promise<AgentToolResult<ConsultDetails>> {
290
+ if (
291
+ (operation.agentScope === "project" || operation.agentScope === "both") &&
292
+ !ctx.isProjectTrusted()
293
+ ) {
294
+ throw new Error("Project-local subagent definitions require a trusted project");
295
+ }
296
+ const settings = options.getSettings();
297
+ const discovery = discoverAgents(ctx.cwd, operation.agentScope, settings);
298
+ const agent = discovery.agents.find((candidate) => candidate.name === operation.agent);
299
+ if (!agent) {
300
+ throw new Error(
301
+ `Unknown subagent definition: ${boundedPrivateText(operation.agent, 256)}. ` +
302
+ `Available agents for agentScope "${operation.agentScope}": ${formatAvailableConsultAgents(discovery)}`,
303
+ );
304
+ }
305
+ const setup = resolveConsultSetup(operation, agent, settings, ctx);
306
+
307
+ if (agent.source === "project" && operation.confirmProjectAgents) {
308
+ if (!ctx.hasUI) {
309
+ throw new Error(
310
+ "Project-local subagent confirmation requires UI; pass confirmProjectAgents: false explicitly in a trusted project",
311
+ );
312
+ }
313
+ const approved = await ctx.ui.confirm(
314
+ "Run project-local read-only agent?",
315
+ `Agent: ${safeTerminalLine(agent.name, 256)}\nSource: ${safeTerminalLine(path.posix.join(".pi", "agents", path.basename(agent.filePath)))}`,
316
+ );
317
+ assertCurrentRequest(signal, isCurrent);
318
+ if (!approved) {
319
+ return {
320
+ content: [{ type: "text", text: "Read-only subagent consultation cancelled." }],
321
+ details: { ...setup.details, cancelled: true },
322
+ };
323
+ }
324
+ }
325
+ assertCurrentRequest(signal, isCurrent);
326
+ const runChild = options.runChild ?? ((request) => runConsultChild(request, options));
327
+ const child = runChild({
328
+ agent: setup.agent,
329
+ task: operation.task,
330
+ cwd: setup.cwd,
331
+ agentScope: operation.agentScope,
332
+ thinkingLevel: setup.thinkingLevel,
333
+ timeoutMs: setup.timeoutMs,
334
+ effectiveTools: setup.effectiveTools,
335
+ resourcePolicy: setup.resourcePolicy,
336
+ launchPolicy: setup.launchPolicy,
337
+ signal,
338
+ onUpdate: (result) => {
339
+ if (signal.aborted || !isCurrent()) return;
340
+ emitUpdate(consultUpdate(result, setup.details));
341
+ },
342
+ });
343
+ trackChild(child);
344
+ const result = await child;
345
+ if (!isCurrent()) throw abortError("Subagent consultation owner was replaced");
346
+ if (result.aborted && !result.processStarted) {
347
+ throw abortError(result.errorMessage || "Subagent consultation was aborted before launch");
348
+ }
349
+ if (result.launchFailed) {
350
+ throw new Error(
351
+ boundedPrivateText(
352
+ result.errorMessage || result.stderr.trim() || "Subagent consultation failed to launch",
353
+ 2 * 1024,
354
+ ),
355
+ );
356
+ }
357
+ const error = isResultError(result);
358
+ const output = error
359
+ ? formatConsultFailure(result)
360
+ : getResultFinalOutput(result) || "(no output)";
361
+ const bounded = boundText(output);
362
+ const details: ConsultDetails = {
363
+ ...setup.details,
364
+ child: projectChildResult(result),
365
+ ...(error ? { isError: true } : {}),
366
+ ...(bounded.truncated ? { truncated: true } : {}),
367
+ };
368
+ return {
369
+ content: [{ type: "text", text: bounded.text }],
370
+ details,
371
+ usage: usageFromResult(result),
372
+ };
373
+ }
374
+
375
+ function resolveConsultSetup(
376
+ operation: ReturnType<typeof validateConsultParams>,
377
+ agent: AgentConfig,
378
+ settings: SubagentSettings | undefined,
379
+ ctx: ExtensionContext,
380
+ ) {
381
+ const cwd = canonicalDirectory(operation.cwd ?? ctx.cwd, "subagent consultation cwd");
382
+ const workspace = canonicalDirectory(ctx.cwd, "current workspace");
383
+ const resourcePolicy = settings?.consult?.resources ?? DEFAULT_CONSULT_RESOURCE_POLICY;
384
+ if (!isWithin(cwd, workspace) && resourcePolicy !== "none") {
385
+ throw new Error(
386
+ `Subagent consultation cwd is outside the current workspace; consult.resources must be "none": ${safeTerminalLine(cwd)}`,
387
+ );
388
+ }
389
+ const effectiveTools = resolveConsultTools(agent.tools);
390
+ const projectTrusted = ctx.isProjectTrusted();
391
+ const launchPolicy = resourceLaunchPolicy(resourcePolicy, projectTrusted, workspace);
392
+ launchPolicy.tools = effectiveTools;
393
+ const thinkingLevel = resolveSubagentThinkingLevel([agent], agent.name, operation.thinkingLevel);
394
+ const timeoutMs = operation.timeoutMs ?? agent.timeoutMs ?? resolveDefaultSubagentTimeoutMs();
395
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_SUBAGENT_TIMEOUT_MS) {
396
+ throw new Error(
397
+ `Subagent consultation timeout must be between 1 and ${MAX_SUBAGENT_TIMEOUT_MS}ms`,
398
+ );
399
+ }
400
+ const childAgent: AgentConfig = {
401
+ ...agent,
402
+ tools: effectiveTools,
403
+ systemPrompt: [agent.systemPrompt, READ_ONLY_INSTRUCTION].filter(Boolean).join("\n\n"),
404
+ };
405
+ const effectiveResources = {
406
+ policy: resourcePolicy,
407
+ projectResources: projectTrusted && resourcePolicy !== "none",
408
+ contextFiles: !launchPolicy.disableContextFiles,
409
+ skills: !launchPolicy.disableSkills,
410
+ promptTemplates: !launchPolicy.disablePromptTemplates,
411
+ };
412
+ const details: ConsultDetails = {
413
+ agent: boundedPrivateText(agent.name, 256),
414
+ agentSource: agent.source,
415
+ agentScope: operation.agentScope,
416
+ cwd: safeDisplayPath(cwd, workspace),
417
+ model: agent.model ? boundedPrivateText(agent.model, 256) : undefined,
418
+ thinkingLevel,
419
+ timeoutMs,
420
+ policy: {
421
+ requestedTools:
422
+ agent.tools === undefined
423
+ ? null
424
+ : agent.tools.slice(0, 100).map((tool) => boundedPrivateText(tool, 256)),
425
+ effectiveTools,
426
+ requestedResources: resourcePolicy,
427
+ effectiveResources,
428
+ extensions: "disabled",
429
+ sessionPersistence: "disabled",
430
+ retainedAgent: false,
431
+ },
432
+ };
433
+ return {
434
+ agent: childAgent,
435
+ cwd,
436
+ resourcePolicy,
437
+ effectiveTools,
438
+ thinkingLevel,
439
+ timeoutMs,
440
+ launchPolicy,
441
+ details,
442
+ };
443
+ }
444
+
445
+ async function runConsultChild(
446
+ request: ConsultChildRequest,
447
+ options: RegisterSubagentConsultOptions,
448
+ ): Promise<SingleResult> {
449
+ return runSingleAgent(
450
+ request.cwd,
451
+ [request.agent],
452
+ request.agent.name,
453
+ request.task,
454
+ request.cwd,
455
+ undefined,
456
+ request.signal,
457
+ request.thinkingLevel,
458
+ request.timeoutMs,
459
+ (partial) => {
460
+ const result = partial.details.results[0];
461
+ if (result) request.onUpdate?.(result);
462
+ },
463
+ (results): SubagentDetails => ({
464
+ mode: "single",
465
+ agentScope: request.agentScope,
466
+ projectAgentsDir: null,
467
+ results,
468
+ }),
469
+ options.invocationOverride,
470
+ request.launchPolicy,
471
+ );
472
+ }
473
+
474
+ function consultUpdate(
475
+ result: SingleResult,
476
+ details: ConsultDetails,
477
+ ): AgentToolResult<ConsultDetails> {
478
+ const output = boundText(getResultFinalOutput(result) || "(running...)");
479
+ return {
480
+ content: [{ type: "text", text: output.text }],
481
+ details: { ...details, child: projectChildResult(result) },
482
+ };
483
+ }
484
+
485
+ function resourceLaunchPolicy(
486
+ policy: ConsultResourcePolicy,
487
+ projectTrusted: boolean,
488
+ workspace: string,
489
+ ): ChildLaunchPolicy {
490
+ if (policy === "none") {
491
+ return {
492
+ disableExtensions: true,
493
+ disableSkills: true,
494
+ disablePromptTemplates: true,
495
+ disableContextFiles: true,
496
+ projectTrust: false,
497
+ baseSystemPrompt: MINIMAL_CONSULT_SYSTEM_PROMPT,
498
+ };
499
+ }
500
+ const baseSystemPrompt = discoverSystemPrompt(workspace, projectTrusted);
501
+ if (policy === "project-context") {
502
+ return {
503
+ disableExtensions: true,
504
+ disableSkills: true,
505
+ disablePromptTemplates: true,
506
+ disableContextFiles: !projectTrusted,
507
+ projectTrust: projectTrusted,
508
+ baseSystemPrompt,
509
+ };
510
+ }
511
+ return {
512
+ disableExtensions: true,
513
+ disableContextFiles: !projectTrusted,
514
+ projectTrust: projectTrusted,
515
+ baseSystemPrompt,
516
+ appendSystemPromptPaths: discoverAppendSystemPrompts(workspace, projectTrusted),
517
+ };
518
+ }
519
+
520
+ function discoverSystemPrompt(workspace: string, projectTrusted: boolean): string | undefined {
521
+ const candidates = [
522
+ ...(projectTrusted ? [path.join(workspace, ".pi", "SYSTEM.md")] : []),
523
+ path.join(getAgentDir(), "SYSTEM.md"),
524
+ ];
525
+ for (const candidate of candidates) {
526
+ const prompt = readBoundedOptionalPrompt(candidate);
527
+ if (prompt !== undefined) return prompt;
528
+ }
529
+ return undefined;
530
+ }
531
+
532
+ function readBoundedOptionalPrompt(filePath: string): string | undefined {
533
+ let descriptor: number | undefined;
534
+ try {
535
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
536
+ if (!fs.fstatSync(descriptor).isFile()) return undefined;
537
+ const buffer = Buffer.alloc(DEFAULT_MAX_CONTEXT_BYTES + 4);
538
+ let bytesRead = 0;
539
+ while (bytesRead < buffer.length) {
540
+ const next = fs.readSync(descriptor, buffer, bytesRead, buffer.length - bytesRead, bytesRead);
541
+ if (next === 0) break;
542
+ bytesRead += next;
543
+ }
544
+ return truncateUtf8(buffer.subarray(0, bytesRead).toString("utf8"), DEFAULT_MAX_CONTEXT_BYTES)
545
+ .text;
546
+ } catch {
547
+ // Match Pi resource discovery: an unreadable optional prompt is skipped.
548
+ return undefined;
549
+ } finally {
550
+ if (descriptor !== undefined) {
551
+ try {
552
+ fs.closeSync(descriptor);
553
+ } catch {
554
+ // Optional prompt cleanup must not make consultation discovery fail.
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ function discoverAppendSystemPrompts(cwd: string, projectTrusted: boolean): string[] {
561
+ const candidates = [
562
+ path.join(getAgentDir(), "APPEND_SYSTEM.md"),
563
+ ...(projectTrusted ? [path.join(cwd, ".pi", "APPEND_SYSTEM.md")] : []),
564
+ ];
565
+ return candidates.filter((candidate) => {
566
+ try {
567
+ return fs.statSync(candidate).isFile();
568
+ } catch {
569
+ return false;
570
+ }
571
+ });
572
+ }
573
+
574
+ function projectChildResult(result: SingleResult): Record<string, unknown> {
575
+ return {
576
+ exitCode: result.exitCode,
577
+ stopReason:
578
+ typeof result.stopReason === "string"
579
+ ? boundedPrivateText(result.stopReason, 256)
580
+ : undefined,
581
+ timedOut: result.timedOut,
582
+ aborted: result.aborted,
583
+ truncated: result.truncated,
584
+ malformedEvents: result.malformedEvents,
585
+ processStarted: result.processStarted,
586
+ actualProvider: result.actualProvider
587
+ ? boundedPrivateText(result.actualProvider, 256)
588
+ : undefined,
589
+ actualModel: result.actualModel ? boundedPrivateText(result.actualModel, 256) : undefined,
590
+ partialOutput: result.finalOutput
591
+ ? boundedPrivateText(result.finalOutput, 8 * 1024)
592
+ : undefined,
593
+ error: result.errorMessage ? boundedPrivateText(result.errorMessage, 2 * 1024) : undefined,
594
+ usage: { ...result.usage },
595
+ };
596
+ }
597
+
598
+ function formatConsultFailure(result: SingleResult): string {
599
+ const rawError = result.errorMessage || result.stderr.trim();
600
+ const error = rawError ? boundedPrivateText(rawError, DEFAULT_MAX_STDERR_BYTES) : "";
601
+ const output = getResultFinalOutput(result);
602
+ return error && output
603
+ ? `${error}\n\nPartial output:\n${output}`
604
+ : error || output || "(no output)";
605
+ }
606
+
607
+ function usageFromResult(result: SingleResult): Usage {
608
+ return {
609
+ input: result.usage.input,
610
+ output: result.usage.output,
611
+ cacheRead: result.usage.cacheRead,
612
+ cacheWrite: result.usage.cacheWrite,
613
+ totalTokens:
614
+ result.usage.totalTokens ??
615
+ result.usage.input + result.usage.output + result.usage.cacheRead + result.usage.cacheWrite,
616
+ cost: {
617
+ input: result.usage.costInput ?? 0,
618
+ output: result.usage.costOutput ?? 0,
619
+ cacheRead: result.usage.costCacheRead ?? 0,
620
+ cacheWrite: result.usage.costCacheWrite ?? 0,
621
+ total: result.usage.cost,
622
+ },
623
+ };
624
+ }
625
+
626
+ function canonicalDirectory(value: string, label: string): string {
627
+ try {
628
+ const resolved = fs.realpathSync(value);
629
+ if (!fs.statSync(resolved).isDirectory()) throw new Error("not a directory");
630
+ return resolved;
631
+ } catch (error) {
632
+ const reason = error instanceof Error ? error.message : String(error);
633
+ throw new Error(`Invalid ${label}: ${safeTerminalLine(value)} (${safeTerminalLine(reason)})`);
634
+ }
635
+ }
636
+
637
+ function isWithin(candidate: string, root: string): boolean {
638
+ const relative = path.relative(root, candidate);
639
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
640
+ }
641
+
642
+ function assertCurrentRequest(signal: AbortSignal, isCurrent: () => boolean): void {
643
+ if (signal.aborted || !isCurrent()) throw abortError("Subagent consultation owner was replaced");
644
+ }
645
+
646
+ function abortError(message: string): Error {
647
+ const error = new Error(message);
648
+ error.name = "AbortError";
649
+ return error;
650
+ }
651
+
652
+ function combineAbortSignals(
653
+ external: AbortSignal | undefined,
654
+ owned: AbortSignal,
655
+ ): { signal: AbortSignal; dispose(): void } {
656
+ const controller = new AbortController();
657
+ const signals = [external, owned].filter((value): value is AbortSignal => value !== undefined);
658
+ const abort = (signal: AbortSignal) => {
659
+ if (!controller.signal.aborted) controller.abort(signal.reason);
660
+ };
661
+ const listeners = signals.map((signal) => {
662
+ const listener = () => abort(signal);
663
+ if (signal.aborted) abort(signal);
664
+ else signal.addEventListener("abort", listener, { once: true });
665
+ return { signal, listener };
666
+ });
667
+ return {
668
+ signal: controller.signal,
669
+ dispose() {
670
+ for (const { signal, listener } of listeners) {
671
+ signal.removeEventListener("abort", listener);
672
+ }
673
+ },
674
+ };
675
+ }
676
+
677
+ function requiredString(value: unknown, name: string): string {
678
+ if (typeof value !== "string" || !value.trim()) {
679
+ throw new Error(`subagent_consult requires ${name}`);
680
+ }
681
+ return value;
682
+ }
683
+
684
+ function optionalScope(value: unknown): AgentScope {
685
+ if (value === undefined) return "user";
686
+ if (value === "user" || value === "project" || value === "both") return value;
687
+ throw new Error("subagent_consult agentScope must be user, project, or both");
688
+ }