@ian-pascoe/pi-minimal-subagents 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright 2026 Ian Pascoe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Minimal Subagents
2
+
3
+ ## Install and load
4
+
5
+ Install the package from npm:
6
+
7
+ ```bash
8
+ pi install npm:@ian-pascoe/pi-minimal-subagents
9
+ ```
10
+
11
+ Installing the Git collection enables every extension in the repository:
12
+
13
+ ```bash
14
+ pi install git:github.com/ian-pascoe/pi-extensions
15
+ ```
16
+
17
+ To select only Minimal Subagents from that Git package, set its extension
18
+ filter in `~/.pi/agent/settings.json` using the repository-relative path:
19
+
20
+ ```json
21
+ {
22
+ "packages": [
23
+ {
24
+ "source": "git:github.com/ian-pascoe/pi-extensions",
25
+ "extensions": ["packages/pi-minimal-subagents/src/index.ts"]
26
+ }
27
+ ]
28
+ }
29
+ ```
30
+
31
+ From this package checkout, load the source directly with
32
+ `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.84.1`.
33
+
34
+ ## Configuration
35
+
36
+ Configure the extension in Pi's standard settings files:
37
+
38
+ - global: `~/.pi/agent/settings.json`
39
+ - project: `./.pi/settings.json`, when the project is trusted
40
+
41
+ Project values override global values. Run `/reload` after editing either file.
42
+
43
+ ## Model roles
44
+
45
+ `minimalSubagents.modelRoles` gives the parent agent advisory names for
46
+ eligible models. The extension defines no roles itself, performs no task
47
+ classification, and does not route launches. The parent still passes the
48
+ ordinary `model` argument and chooses `thinking_level` independently.
49
+
50
+ ```json
51
+ {
52
+ "minimalSubagents": {
53
+ "modelRoles": {
54
+ "budget": "opencode-go/glm-5.2",
55
+ "design": {
56
+ "model": "opencode-go/kimi-k3",
57
+ "hint": "UI design, visual critique, and frontend polish"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Role names and hints are trimmed, single-line text. Names may be up to 64
65
+ characters and hints up to 500 characters. Models use canonical
66
+ `provider/model` IDs and must be available under the effective `enabledModels`
67
+ scope. Thinking-level suffixes such as `:xhigh` are invalid here.
68
+
69
+ Global and project roles merge by name in settings order. Expanded role
70
+ objects merge by field; a project string replaces the whole global entry. A
71
+ project can remove one inherited role with `null`, or clear all inherited
72
+ roles by setting `modelRoles` to `null`.
73
+
74
+ ```json
75
+ {
76
+ "minimalSubagents": {
77
+ "modelRoles": {
78
+ "budget": null
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ Invalid or unavailable entries are omitted. The extension emits one
85
+ consolidated startup warning and keeps every valid role.
86
+
87
+ ## Maximum delegation depth
88
+
89
+ `minimalSubagents.maxSubagentDepth` is a positive safe integer. It counts
90
+ subagent levels beneath the interactive root: `1` permits root children, `2`
91
+ also permits grandchildren, and so on. The default is `2`.
92
+
93
+ ```json
94
+ {
95
+ "minimalSubagents": {
96
+ "maxSubagentDepth": 1
97
+ }
98
+ }
99
+ ```
100
+
101
+ A trusted project value replaces the global value. Project `null` restores
102
+ the built-in default of `2`. An invalid project value emits a warning and
103
+ leaves a valid global value in effect.
104
+
105
+ Reloading with a lower depth does not delete existing agents or change their
106
+ launch contracts. Before `/reload` invalidates the old extension runtime, the
107
+ extension waits for active child and root work to settle, then disposes idle
108
+ child runtimes. A deliberately non-settling agent can therefore delay reload
109
+ indefinitely. The new limit controls restored tool availability and future
110
+ spawn attempts; the root retains recursive hierarchy management.
111
+
112
+ ## Capabilities and persistence
113
+
114
+ Child sessions are persistent Pi sessions. Their launch contracts bound model,
115
+ tool, project-context, session-context, delegation, and depth capabilities at
116
+ creation; reloading does not silently broaden them. The default maximum depth
117
+ is two levels beneath the interactive Root Agent.
118
+
119
+ The extension registers six coordinator tools for the Root Agent and fanout
120
+ children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
121
+ `subagent_cancel`, and `subagent_delete`. Ordinary non-fanout children receive
122
+ only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
123
+ and `subagent_status`.
124
+
125
+ Deleting a child first uses the optional `trash` command when available and
126
+ falls back to unlinking its session file. Each Child Agent has a persistent
127
+ JSONL session. Append-only Root Agent Registry entries retain hierarchy and
128
+ Delivery Evidence across reloads. Forking cancels and drains active work, then
129
+ clones child session leaves so the fork receives an independent hierarchy.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@ian-pascoe/pi-minimal-subagents",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Persistent nested subagents with bounded delegation for Pi",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-extension",
9
+ "pi-package"
10
+ ],
11
+ "homepage": "https://github.com/ian-pascoe/pi-extensions#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ian-pascoe/pi-extensions/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ian-pascoe/pi-extensions.git",
20
+ "directory": "packages/pi-minimal-subagents"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "type": "module",
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "scripts": {
33
+ "test": "vitest run --config ../../vitest.config.ts --root .",
34
+ "typecheck": "tsc --noEmit -p tsconfig.json"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-agent-core": "*",
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "typebox": "*"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.19.0"
45
+ },
46
+ "pi": {
47
+ "extensions": [
48
+ "./src/index.ts"
49
+ ]
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./minimal-subagents-extension.js";
@@ -0,0 +1,118 @@
1
+ import type { DelegationMode, ToolSelection } from "./minimal-subagents-types.js";
2
+
3
+ /** Lists Pi thinking levels in increasing effort order for schema validation and clamping. */
4
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
5
+ /** Defaults explicit fanout to root → child → grandchild when settings omit a depth. */
6
+ export const DEFAULT_MAX_SUBAGENT_DEPTH = 2;
7
+ /** Lists the six coordinator tools excluded from ordinary child capabilities. */
8
+ export const COORDINATOR_TOOL_NAMES = [
9
+ "subagent",
10
+ "agent_message",
11
+ "subagent_wait",
12
+ "subagent_status",
13
+ "subagent_cancel",
14
+ "subagent_delete",
15
+ ] as const;
16
+
17
+ const READ_TOOL_BUNDLE = ["read", "grep", "find", "ls"];
18
+ const MODIFY_TOOL_BUNDLE = [...READ_TOOL_BUNDLE, "bash", "edit", "write"];
19
+ const THINKING_SUFFIX_PATTERN = /:(?:off|minimal|low|medium|high|xhigh|max)$/;
20
+
21
+ interface ModelReference {
22
+ provider: string;
23
+ id: string;
24
+ }
25
+
26
+ interface ScopedModelReference {
27
+ model: ModelReference;
28
+ thinkingLevel?: string;
29
+ }
30
+
31
+ /** Remove a recognized Pi thinking suffix without changing model IDs containing other colons. */
32
+ export function stripThinkingSuffix(modelPattern: string): string {
33
+ return modelPattern.replace(THINKING_SUFFIX_PATTERN, "");
34
+ }
35
+
36
+ /** Build the authenticated runtime model enum from Pi's already-resolved model scope. */
37
+ export function buildEligibleModelIds(input: {
38
+ availableModels: readonly ModelReference[];
39
+ scopedModels: readonly ScopedModelReference[];
40
+ scopeConfigured?: boolean;
41
+ }): string[] {
42
+ const scopeConfigured = input.scopeConfigured ?? input.scopedModels.length > 0;
43
+ const source = scopeConfigured
44
+ ? input.scopedModels.map((entry) => entry.model)
45
+ : input.availableModels;
46
+ const seen = new Set<string>();
47
+ const result: string[] = [];
48
+
49
+ for (const model of source) {
50
+ const canonicalId = stripThinkingSuffix(`${model.provider}/${model.id}`);
51
+ if (!seen.has(canonicalId)) {
52
+ seen.add(canonicalId);
53
+ result.push(canonicalId);
54
+ }
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ /** Supplies inherited tools, the ancestor ceiling, and runtime availability for exact tool resolution. */
61
+ export interface ToolResolutionContext {
62
+ ordinaryTools: readonly string[];
63
+ capabilityCeiling: readonly string[];
64
+ availableTools: readonly string[];
65
+ }
66
+
67
+ /** Resolve an exact ordinary-tool contract and reject missing or over-ceiling capabilities. */
68
+ export function resolveOrdinaryToolSelection(
69
+ selection: ToolSelection | undefined,
70
+ context: ToolResolutionContext,
71
+ ): string[] {
72
+ const requested =
73
+ selection === undefined
74
+ ? [...context.ordinaryTools]
75
+ : selection === "none"
76
+ ? []
77
+ : selection === "read"
78
+ ? READ_TOOL_BUNDLE
79
+ : selection === "modify"
80
+ ? MODIFY_TOOL_BUNDLE
81
+ : selection;
82
+ const uniqueRequested = [...new Set(requested)];
83
+ const available = new Set(context.availableTools);
84
+ const ceiling = new Set(context.capabilityCeiling);
85
+ const missing = uniqueRequested.filter((name) => !available.has(name));
86
+ if (missing.length > 0) {
87
+ throw new Error(`Minimal subagents tool resolution: unavailable tool: ${missing.join(", ")}`);
88
+ }
89
+
90
+ const exceeded = uniqueRequested.filter((name) => !ceiling.has(name));
91
+ if (exceeded.length > 0) {
92
+ throw new Error(`Minimal subagents capability ceiling exceeded: ${exceeded.join(", ")}`);
93
+ }
94
+
95
+ return uniqueRequested;
96
+ }
97
+
98
+ /** Return an agent's hierarchy depth where the interactive root is depth zero. */
99
+ export function getSubagentDepth(agentId: string): number {
100
+ if (agentId === "root") return 0;
101
+ const segments = agentId.split(".").length;
102
+ return agentId.startsWith("root.") ? segments - 1 : segments;
103
+ }
104
+
105
+ /** Report whether an explicit fanout contract remains below the configured delegation depth cap. */
106
+ export function canAgentContractSpawn(
107
+ agentId: string,
108
+ delegation?: DelegationMode,
109
+ maxSubagentDepth = DEFAULT_MAX_SUBAGENT_DEPTH,
110
+ ): boolean {
111
+ return delegation === "fanout" && getSubagentDepth(agentId) < maxSubagentDepth;
112
+ }
113
+
114
+ /** Return ordinary tools only, excluding all six coordinator tools. */
115
+ export function excludeCoordinatorTools(toolNames: readonly string[]): string[] {
116
+ const coordinatorNames = new Set<string>(COORDINATOR_TOOL_NAMES);
117
+ return toolNames.filter((name) => !coordinatorNames.has(name));
118
+ }
@@ -0,0 +1,217 @@
1
+ import {
2
+ DEFAULT_MAX_SUBAGENT_DEPTH,
3
+ stripThinkingSuffix,
4
+ } from "./minimal-subagents-capabilities.js";
5
+
6
+ const MODEL_ROLE_NAME_MAX_LENGTH = 64;
7
+ const MODEL_ROLE_HINT_MAX_LENGTH = 500;
8
+
9
+ /** Describes one user-authored advisory model role shown to subagent callers. */
10
+ export interface MinimalSubagentsModelRole {
11
+ name: string;
12
+ model: string;
13
+ hint?: string;
14
+ }
15
+
16
+ /** Contains the validated minimal subagents settings used by one extension session. */
17
+ export interface ResolvedMinimalSubagentsConfig {
18
+ maxSubagentDepth: number;
19
+ modelRoles: MinimalSubagentsModelRole[];
20
+ warnings: string[];
21
+ }
22
+
23
+ interface MinimalSubagentsConfigInput {
24
+ globalSettings: unknown;
25
+ projectSettings: unknown;
26
+ eligibleModelIds: readonly string[];
27
+ }
28
+
29
+ interface MinimalSubagentsSettingsReader {
30
+ getGlobalSettings(): unknown;
31
+ getProjectSettings(): unknown;
32
+ }
33
+
34
+ type SettingsScope = "global" | "project";
35
+
36
+ interface ScopedSettingValue {
37
+ scope: SettingsScope;
38
+ value: unknown;
39
+ }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function readMinimalSubagentsSettings(
46
+ settings: unknown,
47
+ scope: SettingsScope,
48
+ warnings: string[],
49
+ ): Record<string, unknown> {
50
+ if (!isRecord(settings) || settings.minimalSubagents === undefined) return {};
51
+ if (isRecord(settings.minimalSubagents)) return settings.minimalSubagents;
52
+ warnings.push(`${scope} minimalSubagents: expected an object`);
53
+ return {};
54
+ }
55
+
56
+ function mergeModelRoleEntries(
57
+ globalValue: unknown,
58
+ projectValue: unknown,
59
+ warnings: string[],
60
+ ): Map<string, ScopedSettingValue> {
61
+ const entries = new Map<string, ScopedSettingValue>();
62
+ if (globalValue !== undefined) {
63
+ if (isRecord(globalValue)) {
64
+ for (const [name, value] of Object.entries(globalValue)) {
65
+ entries.set(name, { scope: "global", value });
66
+ }
67
+ } else if (globalValue !== null) {
68
+ warnings.push("global minimalSubagents.modelRoles: expected an object or null");
69
+ }
70
+ }
71
+ if (projectValue === null) return new Map();
72
+ if (projectValue === undefined) return entries;
73
+ if (!isRecord(projectValue)) {
74
+ warnings.push("project minimalSubagents.modelRoles: expected an object or null");
75
+ return entries;
76
+ }
77
+
78
+ for (const [name, value] of Object.entries(projectValue)) {
79
+ if (value === null) {
80
+ entries.delete(name);
81
+ continue;
82
+ }
83
+ const inherited = entries.get(name)?.value;
84
+ entries.set(name, {
85
+ scope: "project",
86
+ value: isRecord(inherited) && isRecord(value) ? { ...inherited, ...value } : value,
87
+ });
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ function parseModelRoles(
93
+ entries: ReadonlyMap<string, ScopedSettingValue>,
94
+ eligibleModelIds: readonly string[],
95
+ warnings: string[],
96
+ ): MinimalSubagentsModelRole[] {
97
+ const eligibleModels = new Set(eligibleModelIds);
98
+ const roles: MinimalSubagentsModelRole[] = [];
99
+ for (const [name, entry] of entries) {
100
+ const path = `${entry.scope} minimalSubagents.modelRoles.${name}`;
101
+ if (
102
+ name.length === 0 ||
103
+ name !== name.trim() ||
104
+ /[\r\n]/.test(name) ||
105
+ name.length > MODEL_ROLE_NAME_MAX_LENGTH
106
+ ) {
107
+ warnings.push(`${path}: role name must be trimmed single-line text up to 64 characters`);
108
+ continue;
109
+ }
110
+
111
+ const value = entry.value;
112
+ if (isRecord(value)) {
113
+ const unknownFields = Object.keys(value).filter((key) => key !== "model" && key !== "hint");
114
+ if (unknownFields.length > 0) {
115
+ warnings.push(`${path}: unknown field: ${unknownFields.join(", ")}`);
116
+ continue;
117
+ }
118
+ } else if (typeof value !== "string") {
119
+ warnings.push(`${path}: expected a model string or expanded role object`);
120
+ continue;
121
+ }
122
+
123
+ const model = typeof value === "string" ? value : value.model;
124
+ if (typeof model !== "string" || model.length === 0 || model !== model.trim()) {
125
+ warnings.push(`${path}: model must be a non-empty trimmed string`);
126
+ continue;
127
+ }
128
+ if (stripThinkingSuffix(model) !== model) {
129
+ warnings.push(
130
+ `${path}: thinking level suffixes are not allowed; choose thinking_level per spawn`,
131
+ );
132
+ continue;
133
+ }
134
+ if (!eligibleModels.has(model)) {
135
+ warnings.push(`${path}: model is not eligible: ${model}`);
136
+ continue;
137
+ }
138
+
139
+ const hint = isRecord(value) ? value.hint : undefined;
140
+ if (
141
+ hint !== undefined &&
142
+ (typeof hint !== "string" ||
143
+ hint.length === 0 ||
144
+ hint !== hint.trim() ||
145
+ /[\r\n]/.test(hint) ||
146
+ hint.length > MODEL_ROLE_HINT_MAX_LENGTH)
147
+ ) {
148
+ warnings.push(`${path}.hint: expected trimmed single-line text up to 500 characters`);
149
+ continue;
150
+ }
151
+ roles.push({ name, model, ...(hint === undefined ? {} : { hint }) });
152
+ }
153
+ return roles;
154
+ }
155
+
156
+ function resolveMaxSubagentDepth(
157
+ globalValue: unknown,
158
+ projectValue: unknown,
159
+ warnings: string[],
160
+ ): number {
161
+ let resolvedDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
162
+ if (globalValue !== undefined && globalValue !== null) {
163
+ if (Number.isSafeInteger(globalValue) && Number(globalValue) > 0) {
164
+ resolvedDepth = Number(globalValue);
165
+ } else {
166
+ warnings.push(
167
+ "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
168
+ );
169
+ }
170
+ }
171
+ if (projectValue === undefined) return resolvedDepth;
172
+ if (projectValue === null) return DEFAULT_MAX_SUBAGENT_DEPTH;
173
+ if (Number.isSafeInteger(projectValue) && Number(projectValue) > 0) {
174
+ return Number(projectValue);
175
+ }
176
+ warnings.push(
177
+ "project minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
178
+ );
179
+ return resolvedDepth;
180
+ }
181
+
182
+ /** Resolve trusted global and project settings into validated subagent guidance and limits. */
183
+ export function resolveMinimalSubagentsConfig(
184
+ input: MinimalSubagentsConfigInput,
185
+ ): ResolvedMinimalSubagentsConfig {
186
+ const warnings: string[] = [];
187
+ const globalConfig = readMinimalSubagentsSettings(input.globalSettings, "global", warnings);
188
+ const projectConfig = readMinimalSubagentsSettings(input.projectSettings, "project", warnings);
189
+ const maxSubagentDepth = resolveMaxSubagentDepth(
190
+ globalConfig.maxSubagentDepth,
191
+ projectConfig.maxSubagentDepth,
192
+ warnings,
193
+ );
194
+ const modelRoleEntries = mergeModelRoleEntries(
195
+ globalConfig.modelRoles,
196
+ projectConfig.modelRoles,
197
+ warnings,
198
+ );
199
+
200
+ return {
201
+ maxSubagentDepth,
202
+ modelRoles: parseModelRoles(modelRoleEntries, input.eligibleModelIds, warnings),
203
+ warnings,
204
+ };
205
+ }
206
+
207
+ /** Resolve model roles and depth from Pi's trust-aware global and project settings layers. */
208
+ export function resolveMinimalSubagentsSettings(
209
+ settings: MinimalSubagentsSettingsReader,
210
+ eligibleModelIds: readonly string[],
211
+ ): ResolvedMinimalSubagentsConfig {
212
+ return resolveMinimalSubagentsConfig({
213
+ globalSettings: settings.getGlobalSettings(),
214
+ projectSettings: settings.getProjectSettings(),
215
+ eligibleModelIds,
216
+ });
217
+ }
@@ -0,0 +1,70 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionContextMode } from "./minimal-subagents-types.js";
3
+
4
+ /** Clone committed caller messages and exclude only the currently streaming assistant message. */
5
+ export function snapshotCommittedContext(
6
+ messages: readonly AgentMessage[],
7
+ callerIsStreaming: boolean,
8
+ ): AgentMessage[] {
9
+ const committed = [...messages];
10
+ if (callerIsStreaming && committed.at(-1)?.role === "assistant") committed.pop();
11
+ return structuredClone(committed);
12
+ }
13
+
14
+ /** Select the imported message snapshot and defer expensive compact preparation to the child turn. */
15
+ export function assembleImportedContext(
16
+ mode: SessionContextMode,
17
+ committedMessages: AgentMessage[],
18
+ ): { messages: AgentMessage[]; compact: boolean } {
19
+ if (mode === "omit") return { messages: [], compact: false };
20
+ return { messages: committedMessages, compact: mode === "compact" };
21
+ }
22
+
23
+ /** Detect image content so incompatible child models fail before agent creation. */
24
+ export function contextContainsImages(messages: readonly AgentMessage[]): boolean {
25
+ return messages.some((message) => {
26
+ if (!("content" in message) || !Array.isArray(message.content)) return false;
27
+ return message.content.some((content) => content.type === "image");
28
+ });
29
+ }
30
+
31
+ interface SubagentSystemPromptOptions {
32
+ canSpawn: boolean;
33
+ remainingDepth: number;
34
+ }
35
+
36
+ /** Build child identity, messaging, and explicit delegation-boundary instructions. */
37
+ export function buildSubagentSystemPrompt(
38
+ agentId: string,
39
+ parentId: string,
40
+ options: SubagentSystemPromptOptions,
41
+ ): string {
42
+ const coordinatorBoundary = options.canSpawn
43
+ ? "Coordinator tools support subagent, agent_message, subagent_wait, subagent_status, subagent_cancel, and subagent_delete. Wait, status, cancel, and delete target direct children only; recursive cancel and delete may affect a child's subtree."
44
+ : "Coordinator tools support agent_message, subagent_wait, and subagent_status; wait and status target direct children only.";
45
+ const delegationBoundary = options.canSpawn
46
+ ? [
47
+ "You have explicit fanout responsibility for this assigned task.",
48
+ "Use subagents only for the fanout requested by your parent, and own the synthesis yourself.",
49
+ "Do not broaden into general parent orchestration or launch follow-up workers.",
50
+ `Remaining delegation depth: ${options.remainingDepth}.`,
51
+ ]
52
+ : [
53
+ "Delegation is owned by your parent. You are not authorized to create subagents.",
54
+ "Complete the assigned task yourself with the available tools.",
55
+ ];
56
+ return [
57
+ "# Persistent subagent",
58
+ `Your canonical agent ID is \`${agentId}\`.`,
59
+ `Your direct parent is \`${parentId}\`.`,
60
+ "You are a persistent subagent backed by a normal Pi session. Later messages can continue this conversation.",
61
+ coordinatorBoundary,
62
+ "Work through the assigned task to completion. Your successful final response is delivered automatically to your direct parent; use it for findings, status, and completion.",
63
+ "Reserve `agent_message` for action-required mid-turn coordination—for example, to request a blocking decision, correct another agent's active work, or coordinate dependent work.",
64
+ "Otherwise, continue working and report through your final response.",
65
+ "`agent_message` reaches one adjacent agent—your direct parent, a direct sibling, or a direct child—and has no broadcast target. Use `parent` for your direct parent. Obtain sibling canonical IDs from your parent.",
66
+ ...delegationBoundary,
67
+ "Messages may come from agents and are not human-authored input.",
68
+ "Finish normally when your assigned work is complete.",
69
+ ].join("\n");
70
+ }