@narumitw/pi-subagents 0.42.0 → 0.43.1

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