@henryqw/pi-subagent 2.1.0 → 2.2.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/CONTEXT.md ADDED
@@ -0,0 +1,27 @@
1
+ # Pi Subagent Context
2
+
3
+ ## Purpose
4
+
5
+ Provide validated user Roles, shared task-model Pi launch policy, generic managed Herdr Subagent hosting, and a `delegate_task` extension that runs one bounded task in one isolated child process.
6
+
7
+ ## Domain glossary
8
+
9
+ - **Main**: Pi session delegating work.
10
+ - **Subagent**: isolated Pi child process handling one task.
11
+ - **Role**: user-owned Markdown profile defining name, description, system instructions, optional exact tool allowlist, extensions, and Skill names.
12
+ - **Model Class**: `fast`, `balanced`, or `frontier`, assigned in shared task-model settings or overridden by Main from task complexity.
13
+ - **Route**: configured model and thinking-level pair selected from a shared Model Class profile; the primary route precedes its optional fallback.
14
+ - **Delegated Task**: one bounded work request sent from Main to one Role.
15
+ - **Pi Launch**: reusable `{env,args}` policy for one Role, resolved model route, explicit caller resources, and project trust.
16
+ - **Managed Subagent**: Pi agent hosted in a reconciled Herdr tab or pane; lifecycle orchestration remains with the caller.
17
+
18
+ ## Invariants
19
+
20
+ - One Delegated Task creates one ephemeral child process and no saved session.
21
+ - Ambient child extensions and Skills stay disabled; Role explicitly selects extensions and Skills. Omitted Role tools use Pi's effective `defaultTools` for built-ins; an explicit list is strict.
22
+ - Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
23
+ - Main selects Role and may override Model Class per task; omitted class uses shared `pi-subagent/delegateTask` assignment, initially `balanced`. Library callers select Role plus their own shared task ID.
24
+ - The selected profile resolves primary then fallback only before launch when a route, model, or thinking level is unavailable. If neither route is usable, launch rejects with `Run /task-models`; a started child is never retried by this package.
25
+ - Role config lives only in user `config/pi-subagent` directory; model routes live in shared `config/pi-task-models.json`; repository roles do not execute.
26
+ - Numbered Codex routes prefer Main's active account slot and explicitly load the multi-Codex child extension.
27
+ - Generic Herdr host functions validate workspace ownership and provisioning identity while callers retain domain state, prompts, and lifecycle decisions.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@henryqw/pi-subagent`
2
2
 
3
- Delegate one bounded task to one isolated Pi process. Main chooses the role and may override shared task-model effort per call.
3
+ Delegate one bounded task to one isolated Pi process, or reuse validated Role launch and managed Herdr hosting for durable workers. Main chooses Role and may override shared task-model effort per call.
4
4
 
5
5
  ## Install
6
6
 
@@ -60,6 +60,34 @@ Do not edit files.
60
60
 
61
61
  Missing skills warn and skip; they do not block delegation. No repo-controlled `.pi/agents` roles. No package-local model picker.
62
62
 
63
+ ## Library API
64
+
65
+ Package root exports shared `Role` loading, Skill resolution, task-routed Pi launch, and generic managed Herdr lifecycle:
66
+
67
+ ```ts
68
+ import {
69
+ loadRoles,
70
+ resolveRoleLaunch,
71
+ managedSubagentWorkspaceId,
72
+ reconcileManagedSubagentTab,
73
+ startManagedSubagent,
74
+ } from "@henryqw/pi-subagent";
75
+
76
+ const role = loadRoles().find(({ name }) => name === "reviewer")!;
77
+ const launch = resolveRoleLaunch(pi, ctx, {
78
+ role,
79
+ taskId: "your-package/review",
80
+ extensions: [adapterExtensionPath],
81
+ tools: ["submit_review"],
82
+ });
83
+ const workspaceId = await managedSubagentWorkspaceId(ctx.cwd, mainPane, { execute });
84
+ const host = { cwd: ctx.cwd, workspaceId };
85
+ const tab = await reconcileManagedSubagentTab(host, { cwd: worktree, launch, label }, { execute });
86
+ await startManagedSubagent(host, agentName, tab.paneId, launch, { execute });
87
+ ```
88
+
89
+ `resolveRoleLaunch` uses shared task assignment and effective Pi registries. Caller tools extend explicit Role allowlists; omitted Role `tools` preserves Pi defaults. Generic host APIs contain no workflow prompts or durable state.
90
+
63
91
  ## Remove
64
92
 
65
93
  ```bash
@@ -0,0 +1,79 @@
1
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type HerdrExecutor } from "@henryqw/pi-herdr";
3
+ import { type AvailableModel, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
4
+ export interface Role {
5
+ name: string;
6
+ description: string;
7
+ tools?: string[];
8
+ extensions: string[];
9
+ skills: string[];
10
+ systemPrompt: string;
11
+ }
12
+ export interface PiLaunch {
13
+ env: Record<string, string>;
14
+ args: string[];
15
+ }
16
+ export interface ResolvedRoleLaunch extends PiLaunch {
17
+ model: AvailableModel;
18
+ thinkingLevel: ThinkingLevel;
19
+ missingSkills: string[];
20
+ }
21
+ export interface CreateRoleLaunchInput {
22
+ role: Role;
23
+ route: ResolvedTaskRoute;
24
+ extensions?: readonly string[];
25
+ tools?: readonly string[];
26
+ env?: Readonly<Record<string, string>>;
27
+ }
28
+ export interface ResolveRoleLaunchInput extends Omit<CreateRoleLaunchInput, "route"> {
29
+ taskId: string;
30
+ agentDir?: string;
31
+ }
32
+ export interface ResolvedRoleSkills {
33
+ paths: string[];
34
+ missing: string[];
35
+ }
36
+ export declare const isProfileName: (value: unknown) => value is ProfileName;
37
+ export declare function loadRoles(agentDir?: string): Role[];
38
+ export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string): ResolvedTaskRoute;
39
+ export declare function resolveRoleSkills(pi: Pick<ExtensionAPI, "getCommands">, role: Role): ResolvedRoleSkills;
40
+ export declare function createRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: Pick<ExtensionContext, "isProjectTrusted">, input: CreateRoleLaunchInput): ResolvedRoleLaunch;
41
+ export declare function resolveRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): ResolvedRoleLaunch;
42
+ export interface ManagedSubagentHost {
43
+ cwd: string;
44
+ workspaceId: string;
45
+ }
46
+ export interface ManagedSubagentCommandOptions {
47
+ cwd: string;
48
+ }
49
+ export type ManagedSubagentExecutor = HerdrExecutor<ManagedSubagentCommandOptions>;
50
+ export interface ManagedSubagentHostOptions {
51
+ execute: ManagedSubagentExecutor;
52
+ delay?: (milliseconds: number) => Promise<void>;
53
+ }
54
+ export interface ManagedSubagentTab {
55
+ tabId: string;
56
+ paneId: string;
57
+ }
58
+ export declare function launchEnvironmentArgs(launch: PiLaunch): string[];
59
+ export declare function managedSubagentName(workspaceId: string, ...identity: string[]): string;
60
+ export declare function managedSubagentWorkspaceId(cwd: string, mainPane: string, options: ManagedSubagentHostOptions): Promise<string>;
61
+ /** Returns pane ID to Herdr status for agents owned by this workspace. */
62
+ export declare function listManagedSubagents(host: ManagedSubagentHost, options: ManagedSubagentHostOptions): Promise<Map<string, string>>;
63
+ export declare function createManagedSubagentTab(host: ManagedSubagentHost, cwd: string, launch: PiLaunch, label: string, options: ManagedSubagentHostOptions): Promise<ManagedSubagentTab>;
64
+ export declare function reconcileManagedSubagentTab(host: ManagedSubagentHost, input: {
65
+ tabId?: string;
66
+ paneId?: string;
67
+ cwd: string;
68
+ launch: PiLaunch;
69
+ label: string;
70
+ }, options: ManagedSubagentHostOptions): Promise<ManagedSubagentTab>;
71
+ export declare function findManagedSubagentTab(host: ManagedSubagentHost, label: string, options: ManagedSubagentHostOptions): Promise<ManagedSubagentTab | undefined>;
72
+ export declare function managedSubagentTabExists(host: ManagedSubagentHost, tabId: string, options: ManagedSubagentHostOptions): Promise<boolean>;
73
+ export declare function reconcileManagedSubagentPane(host: ManagedSubagentHost, tabId: string, rootPaneId: string, cwd: string, launch: PiLaunch, label: string, options: ManagedSubagentHostOptions): Promise<string>;
74
+ export declare function startManagedSubagent(host: ManagedSubagentHost, agent: string, pane: string, launch: PiLaunch, options: ManagedSubagentHostOptions, hooks?: {
75
+ beforeStart?: () => Promise<void>;
76
+ onStarted?: () => Promise<void>;
77
+ }): Promise<"existing" | "started">;
78
+ export declare function promptManagedSubagent(host: ManagedSubagentHost, agent: string, prompt: string | Record<string, unknown>, options: ManagedSubagentHostOptions): Promise<void>;
79
+ export declare function retireManagedSubagentTab(host: ManagedSubagentHost, tabId: string, options: ManagedSubagentHostOptions): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,396 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
6
+ import { createHerdrClient, herdrCommandFailure, hasHerdrErrorCode } from "@henryqw/pi-herdr";
7
+ import { modelReference, orderedProfileRoutes, PROFILE_NAMES, readTaskModelsConfig, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
8
+ const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
9
+ const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
10
+ export const isProfileName = (value) => typeof value === "string" && PROFILE_NAMES.includes(value);
11
+ const cleanText = (value, field, source) => {
12
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
13
+ throw new Error(`${source}: ${field} must be non-empty text.`);
14
+ }
15
+ return value.trim();
16
+ };
17
+ const stringList = (value, field, source, required = false) => {
18
+ if (value === undefined) {
19
+ if (required)
20
+ throw new Error(`${source}: ${field} is required.`);
21
+ return [];
22
+ }
23
+ const values = typeof value === "string" ? value.split(",") : value;
24
+ if (!Array.isArray(values) || values.some((item) => typeof item !== "string" || !item.trim() || item.includes("\0"))) {
25
+ throw new Error(`${source}: ${field} must be an array of strings.`);
26
+ }
27
+ return values.map((item) => item.trim());
28
+ };
29
+ function validateExtension(extension, source) {
30
+ const value = cleanText(extension, "extension", source);
31
+ const packageSource = /^(?:npm|git|github|https?|ssh):/.test(value);
32
+ const userPath = isAbsolute(value) || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("file://");
33
+ if (!packageSource && !userPath) {
34
+ throw new Error(`${source}: extensions entries must be absolute paths or package sources.`);
35
+ }
36
+ return value;
37
+ }
38
+ function extensionList(value, source) {
39
+ return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
40
+ }
41
+ export function loadRoles(agentDir = getAgentDir()) {
42
+ const dir = join(agentDir, "config", "pi-subagent");
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(dir, { withFileTypes: true });
46
+ }
47
+ catch (error) {
48
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
49
+ return [];
50
+ throw error;
51
+ }
52
+ const roles = entries
53
+ .filter((entry) => entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()))
54
+ .sort((a, b) => a.name.localeCompare(b.name))
55
+ .map((entry) => {
56
+ const file = join(dir, entry.name);
57
+ let parsed;
58
+ try {
59
+ parsed = parseFrontmatter(readFileSync(file, "utf8"));
60
+ }
61
+ catch (error) {
62
+ throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ const frontmatter = parsed.frontmatter;
65
+ return {
66
+ name: cleanText(frontmatter.name, "name", file),
67
+ description: cleanText(frontmatter.description, "description", file),
68
+ tools: frontmatter.tools === undefined ? undefined : stringList(frontmatter.tools, "tools", file, true),
69
+ extensions: extensionList(frontmatter.extensions, file),
70
+ skills: stringList(frontmatter.skills, "skills", file),
71
+ systemPrompt: cleanText(parsed.body, "system prompt", file),
72
+ };
73
+ });
74
+ const names = new Set();
75
+ for (const role of roles) {
76
+ if (names.has(role.name))
77
+ throw new Error(`Duplicate Subagent role: ${role.name}.`);
78
+ names.add(role.name);
79
+ }
80
+ return roles;
81
+ }
82
+ export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
83
+ let config;
84
+ try {
85
+ config = readTaskModelsConfig(agentDir);
86
+ }
87
+ catch {
88
+ throw new Error("Couldn't read task model config. Run /task-models.");
89
+ }
90
+ return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName]);
91
+ }
92
+ function resolveConfiguredRoute(ctx, profileName, profile) {
93
+ if (!profile)
94
+ throw new Error(`No ${profileName} task model profile is configured. Run /task-models.`);
95
+ for (const route of orderedProfileRoutes(profile)) {
96
+ const resolved = resolveTaskModelRoute(ctx, route);
97
+ if (resolved)
98
+ return resolved;
99
+ }
100
+ throw new Error(`No usable ${profileName} task model route. Run /task-models.`);
101
+ }
102
+ export function resolveRoleSkills(pi, role) {
103
+ const skills = new Map(pi.getCommands()
104
+ .filter((command) => command.source === "skill")
105
+ .map((command) => [command.name, command.sourceInfo.path]));
106
+ const paths = [];
107
+ const missing = [];
108
+ for (const name of role.skills) {
109
+ const path = skills.get(`skill:${name}`);
110
+ if (path)
111
+ paths.push(path);
112
+ else
113
+ missing.push(name);
114
+ }
115
+ return { paths, missing };
116
+ }
117
+ export function createRoleLaunch(pi, ctx, input) {
118
+ const role = input.role;
119
+ const skills = resolveRoleSkills(pi, role);
120
+ const extensions = [
121
+ ...role.extensions,
122
+ ...(input.extensions ?? []),
123
+ ...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
124
+ ].map((extension) => validateExtension(extension, `Role ${role.name}`));
125
+ const tools = role.tools === undefined
126
+ ? undefined
127
+ : [...new Set([...role.tools, ...(input.tools ?? [])].map((tool) => cleanText(tool, "tool", `Role ${role.name}`)))];
128
+ const env = Object.fromEntries(Object.entries(input.env ?? {}).map(([key, value]) => {
129
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
130
+ throw new Error(`Invalid launch environment name: ${key}`);
131
+ if (typeof value !== "string" || value.includes("\0"))
132
+ throw new Error(`Invalid launch environment value: ${key}`);
133
+ return [key, value];
134
+ }));
135
+ const args = ["--no-session", "--no-extensions", "--no-skills"];
136
+ for (const extension of new Set(extensions))
137
+ args.push("--extension", extension);
138
+ for (const skill of skills.paths)
139
+ args.push("--skill", skill);
140
+ if (tools !== undefined) {
141
+ if (tools.length)
142
+ args.push("--tools", tools.join(","));
143
+ else
144
+ args.push("--no-tools");
145
+ }
146
+ args.push("--model", modelReference(input.route.model));
147
+ if (input.route.thinkingLevel)
148
+ args.push("--thinking", input.route.thinkingLevel);
149
+ args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
150
+ args.push("--append-system-prompt", cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`));
151
+ return {
152
+ env,
153
+ args,
154
+ model: input.route.model,
155
+ thinkingLevel: input.route.thinkingLevel,
156
+ missingSkills: skills.missing,
157
+ };
158
+ }
159
+ export function resolveRoleLaunch(pi, ctx, input) {
160
+ const taskId = cleanText(input.taskId, "task ID", "Role launch");
161
+ return createRoleLaunch(pi, ctx, {
162
+ ...input,
163
+ route: resolveConfiguredTaskRoute(ctx, taskId, input.agentDir),
164
+ });
165
+ }
166
+ export function launchEnvironmentArgs(launch) {
167
+ return Object.entries(launch.env).flatMap(([key, value]) => {
168
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== "string" || value.includes("\0")) {
169
+ throw new Error(`Invalid launch environment: ${key}`);
170
+ }
171
+ return ["--env", `${key}=${value}`];
172
+ });
173
+ }
174
+ export function managedSubagentName(workspaceId, ...identity) {
175
+ const parts = [nonEmptyString(workspaceId, "Herdr workspace id"), ...identity.map((part, index) => nonEmptyString(part, `Subagent identity ${index}`))];
176
+ if (!identity.length)
177
+ throw new Error("Managed Subagent identity is required");
178
+ return `subagent-${createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 23)}`;
179
+ }
180
+ export async function managedSubagentWorkspaceId(cwd, mainPane, options) {
181
+ const paneId = nonEmptyString(mainPane, "recorded main Herdr pane");
182
+ const pane = (await listPanes(cwd, options))
183
+ .map((entry, index) => object(entry, `Herdr pane ${index}`))
184
+ .find((entry) => entry.pane_id === paneId);
185
+ if (!pane)
186
+ throw new Error(`Recorded main Herdr pane is missing: ${paneId}`);
187
+ return nonEmptyString(pane.workspace_id, "recorded main Herdr workspace");
188
+ }
189
+ /** Returns pane ID to Herdr status for agents owned by this workspace. */
190
+ export async function listManagedSubagents(host, options) {
191
+ const workspaceId = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
192
+ const response = object(await createHerdrClient(options.execute).json(["agent", "list"], { cwd: host.cwd }), "Herdr agent list response");
193
+ const result = object(response.result, "Herdr agent list result");
194
+ return new Map(array(result.agents, "Herdr agents").flatMap((entry, index) => {
195
+ const agent = object(entry, `Herdr agent ${index}`);
196
+ if (nonEmptyString(agent.workspace_id, `Herdr agent ${index} workspace`) !== workspaceId)
197
+ return [];
198
+ return [[
199
+ nonEmptyString(agent.pane_id, `Herdr agent ${index} pane`),
200
+ nonEmptyString(agent.agent_status, `Herdr agent ${index} status`),
201
+ ]];
202
+ }));
203
+ }
204
+ export async function createManagedSubagentTab(host, cwd, launch, label, options) {
205
+ const response = await createHerdrClient(options.execute).json([
206
+ "tab", "create", "--workspace", nonEmptyString(host.workspaceId, "recorded Herdr workspace"), "--cwd", cwd,
207
+ ...launchEnvironmentArgs(launch), "--label", nonEmptyString(label, "Herdr tab label"), "--no-focus",
208
+ ], { cwd: host.cwd });
209
+ const result = object(object(response, "Herdr tab response").result, "Herdr tab result");
210
+ return {
211
+ tabId: nonEmptyString(object(result.tab, "Herdr tab").tab_id, "Herdr tab id"),
212
+ paneId: nonEmptyString(object(result.root_pane, "Herdr root pane").pane_id, "Herdr root pane id"),
213
+ };
214
+ }
215
+ export async function reconcileManagedSubagentTab(host, input, options) {
216
+ if (input.tabId && input.paneId && await managedSubagentTabExists(host, input.tabId, options)) {
217
+ return { tabId: input.tabId, paneId: input.paneId };
218
+ }
219
+ return await findManagedSubagentTab(host, input.label, options)
220
+ ?? await createManagedSubagentTab(host, input.cwd, input.launch, input.label, options);
221
+ }
222
+ export async function findManagedSubagentTab(host, label, options) {
223
+ const workspaceId = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
224
+ const matches = (await listTabs(host.cwd, options))
225
+ .map((entry, index) => object(entry, `Herdr tab ${index}`))
226
+ .filter((tab, index) => nonEmptyString(tab.workspace_id, `Herdr tab ${index} workspace`) === workspaceId)
227
+ .filter((tab) => tab.label === label);
228
+ if (matches.length > 1)
229
+ throw new Error(`Multiple Herdr tabs match provisioning identity: ${label}`);
230
+ if (!matches.length)
231
+ return undefined;
232
+ const tabId = nonEmptyString(object(matches[0], "Herdr tab").tab_id, "Herdr tab id");
233
+ const panes = (await listPanes(host.cwd, options))
234
+ .filter((entry, index) => object(entry, `Herdr pane ${index}`).tab_id === tabId);
235
+ if (panes.length !== 1)
236
+ throw new Error(`Provisioned Herdr tab ${tabId} must contain exactly one root pane`);
237
+ return { tabId, paneId: nonEmptyString(object(panes[0], "Herdr pane").pane_id, "Herdr pane id") };
238
+ }
239
+ export async function managedSubagentTabExists(host, tabId, options) {
240
+ const expected = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
241
+ const id = nonEmptyString(tabId, "Herdr tab id");
242
+ const tab = (await listTabs(host.cwd, options))
243
+ .map((entry, index) => object(entry, `Herdr tab ${index}`))
244
+ .find((entry) => entry.tab_id === id);
245
+ if (!tab)
246
+ return false;
247
+ const actual = nonEmptyString(tab.workspace_id, `Herdr tab ${id} workspace`);
248
+ if (actual !== expected)
249
+ throw new Error(`Herdr tab ${id} belongs to workspace ${actual}, expected initiating workspace ${expected}`);
250
+ return true;
251
+ }
252
+ export async function reconcileManagedSubagentPane(host, tabId, rootPaneId, cwd, launch, label, options) {
253
+ const tab = nonEmptyString(tabId, "Herdr tab id");
254
+ const workspace = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
255
+ const ownerTab = (await listTabs(host.cwd, options))
256
+ .map((entry, index) => object(entry, `Herdr tab ${index}`))
257
+ .find((entry) => entry.tab_id === tab);
258
+ if (!ownerTab)
259
+ throw new Error(`Herdr tab is missing: ${tab}`);
260
+ const ownerWorkspace = nonEmptyString(ownerTab.workspace_id, `Herdr tab ${tab} workspace`);
261
+ if (ownerWorkspace !== workspace)
262
+ throw new Error(`Herdr tab ${tab} belongs to workspace ${ownerWorkspace}, expected initiating workspace ${workspace}`);
263
+ const root = nonEmptyString(rootPaneId, "Herdr root pane");
264
+ const panes = (await listPanes(host.cwd, options)).map((entry, index) => object(entry, `Herdr pane ${index}`));
265
+ const owner = panes.find((pane) => pane.pane_id === root);
266
+ if (!owner)
267
+ throw new Error(`Herdr root pane is missing: ${root}`);
268
+ if (owner.tab_id !== tab)
269
+ throw new Error(`Herdr root pane ${root} does not belong to tab ${tab}`);
270
+ const siblings = panes.filter((pane) => pane.tab_id === tab && pane.pane_id !== root);
271
+ const named = siblings.filter((pane) => pane.label === label);
272
+ if (named.length > 1)
273
+ throw new Error(`Multiple Herdr panes match provisioning identity: ${label}`);
274
+ if (named.length)
275
+ return nonEmptyString(named[0].pane_id, "Herdr Subagent pane id");
276
+ if (siblings.length > 1)
277
+ throw new Error(`Provisioned Herdr tab ${tab} has multiple Subagent panes`);
278
+ if (siblings.length)
279
+ return nonEmptyString(siblings[0].pane_id, "Herdr Subagent pane id");
280
+ const herdr = createHerdrClient(options.execute);
281
+ const response = await herdr.json([
282
+ "pane", "split", "--pane", root, "--direction", "right", "--cwd", cwd,
283
+ ...launchEnvironmentArgs(launch), "--no-focus",
284
+ ], { cwd: host.cwd });
285
+ const result = object(object(response, "Herdr pane response").result, "Herdr pane result");
286
+ const pane = nonEmptyString(object(result.pane, "Herdr Subagent pane").pane_id, "Herdr Subagent pane id");
287
+ await herdr.run(["pane", "rename", pane, nonEmptyString(label, "Herdr pane label")], { cwd: host.cwd });
288
+ return pane;
289
+ }
290
+ export async function startManagedSubagent(host, agent, pane, launch, options, hooks = {}) {
291
+ assertAgentName(agent);
292
+ const name = nonEmptyString(agent, "Herdr agent name");
293
+ const paneId = nonEmptyString(pane, "Herdr agent pane");
294
+ const existing = await getManagedSubagent(host, name, options);
295
+ if (existing) {
296
+ assertAgentPane(name, paneId, existing);
297
+ return "existing";
298
+ }
299
+ await hooks.beforeStart?.();
300
+ const arguments_ = ["agent", "start", name, "--kind", "pi", "--pane", paneId, "--", ...launch.args];
301
+ const herdr = createHerdrClient(options.execute);
302
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
303
+ const result = await herdr.exec(arguments_, { cwd: host.cwd });
304
+ if (result.code === 0 && !result.killed) {
305
+ await hooks.onStarted?.();
306
+ return "started";
307
+ }
308
+ if (hasHerdrErrorCode(result, "agent_name_taken")) {
309
+ const raced = await getManagedSubagent(host, name, options);
310
+ if (!raced)
311
+ throw new Error(`Herdr agent ${name} reported agent_name_taken but could not be found; refusing to start a duplicate`);
312
+ assertAgentPane(name, paneId, raced);
313
+ return "existing";
314
+ }
315
+ if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
316
+ throw new Error(herdrCommandFailure(arguments_, result));
317
+ }
318
+ await (options.delay ?? delay)(250);
319
+ }
320
+ throw new Error(`Herdr agent ${name} could not be started`);
321
+ }
322
+ export async function promptManagedSubagent(host, agent, prompt, options) {
323
+ assertAgentName(agent);
324
+ const text = typeof prompt === "string" ? nonEmptyString(prompt, "Subagent prompt") : JSON.stringify(prompt);
325
+ await createHerdrClient(options.execute).run(["agent", "prompt", agent, text], { cwd: host.cwd });
326
+ }
327
+ export async function retireManagedSubagentTab(host, tabId, options) {
328
+ const id = nonEmptyString(tabId, "Herdr tab id");
329
+ try {
330
+ if (!(await managedSubagentTabExists(host, id, options)))
331
+ return;
332
+ await createHerdrClient(options.execute).run(["tab", "close", id], { cwd: host.cwd });
333
+ }
334
+ catch (error) {
335
+ if (!(await confirmsTabAbsent(host, id, options)))
336
+ throw error;
337
+ }
338
+ }
339
+ function assertAgentName(agent) {
340
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(agent))
341
+ throw new Error(`Invalid Herdr agent name: ${agent}`);
342
+ }
343
+ function object(value, label) {
344
+ if (!value || typeof value !== "object" || Array.isArray(value))
345
+ throw new Error(`${label} must be an object`);
346
+ return value;
347
+ }
348
+ function array(value, label) {
349
+ if (!Array.isArray(value))
350
+ throw new Error(`${label} must be an array`);
351
+ return value;
352
+ }
353
+ function nonEmptyString(value, label) {
354
+ if (typeof value !== "string")
355
+ throw new Error(`${label} must be a string`);
356
+ if (!value.trim() || value.includes("\0"))
357
+ throw new Error(`${label} must not be empty`);
358
+ return value;
359
+ }
360
+ async function listTabs(cwd, options) {
361
+ const response = await createHerdrClient(options.execute).json(["tab", "list"], { cwd });
362
+ return array(object(object(response, "Herdr tab list response").result, "Herdr tab list result").tabs, "Herdr tabs");
363
+ }
364
+ async function listPanes(cwd, options) {
365
+ const response = await createHerdrClient(options.execute).json(["pane", "list"], { cwd });
366
+ return array(object(object(response, "Herdr pane list response").result, "Herdr pane list result").panes, "Herdr panes");
367
+ }
368
+ async function getManagedSubagent(host, name, options) {
369
+ const arguments_ = ["agent", "get", name];
370
+ const result = await createHerdrClient(options.execute).exec(arguments_, { cwd: host.cwd });
371
+ if (result.code !== 0 || result.killed) {
372
+ if (hasHerdrErrorCode(result, "agent_not_found"))
373
+ return undefined;
374
+ throw new Error(herdrCommandFailure(arguments_, result));
375
+ }
376
+ const response = object(JSON.parse(result.stdout), "Herdr agent get response");
377
+ return object(object(response.result, "Herdr agent get result").agent, `Herdr agent ${name}`);
378
+ }
379
+ function assertAgentPane(name, expected, agent) {
380
+ const actual = typeof agent.pane_id === "string" ? agent.pane_id : "missing";
381
+ if (actual !== expected) {
382
+ throw new Error(`Herdr agent name collision for ${name}: expected pane ${expected}, found ${actual}; refusing to reuse or replace it`);
383
+ }
384
+ }
385
+ async function confirmsTabAbsent(host, tabId, options) {
386
+ try {
387
+ const result = await createHerdrClient(options.execute).exec(["tab", "get", tabId], { cwd: host.cwd });
388
+ return !result.killed && result.code !== 0 && hasHerdrErrorCode(result, "tab_not_found");
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ }
394
+ async function delay(milliseconds) {
395
+ await new Promise((done) => { setTimeout(done, milliseconds); });
396
+ }
@@ -1,29 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { basename, isAbsolute, join } from "node:path";
6
- import { fileURLToPath } from "node:url";
2
+ import { existsSync } from "node:fs";
3
+ import { basename } from "node:path";
7
4
  import { StringEnum } from "@earendil-works/pi-ai";
8
- import { type ExtensionAPI, type ExtensionContext, getAgentDir, parseFrontmatter, type Theme } from "@earendil-works/pi-coding-agent";
5
+ import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
9
6
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
10
- import {
11
- DEFAULT_TASK_ASSIGNMENTS,
12
- modelReference,
13
- orderedProfileRoutes,
14
- PROFILE_NAMES,
15
- readTaskModelsConfig,
16
- resolveTaskModelRoute,
17
- type ProfileName,
18
- type ResolvedTaskRoute,
19
- } from "@henryqw/pi-task-models";
7
+ import { modelReference, PROFILE_NAMES, type ProfileName } from "@henryqw/pi-task-models";
20
8
  import { Type } from "typebox";
9
+ import { createRoleLaunch, isProfileName, loadRoles, resolveRoleLaunch, resolveTaskRoute } from "@henryqw/pi-subagent";
21
10
 
22
11
  const MODEL_CLASSES = PROFILE_NAMES;
23
12
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
24
- const DEFAULT_MODEL_CLASS = DEFAULT_TASK_ASSIGNMENTS[SUBAGENT_TASK];
25
- const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
26
- const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
27
13
  const MAX_OUTPUT_BYTES = 50 * 1024;
28
14
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
29
15
  const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
@@ -35,14 +21,6 @@ const MAX_WIDGET_ROWS = 8;
35
21
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
22
 
37
23
  type ModelClass = ProfileName;
38
- type Role = {
39
- name: string;
40
- description: string;
41
- tools?: string[];
42
- extensions: string[];
43
- skills: string[];
44
- systemPrompt: string;
45
- };
46
24
  type ChildResult = {
47
25
  exitCode: number;
48
26
  output: string;
@@ -61,27 +39,7 @@ type WidgetItem = {
61
39
  removeAt?: number;
62
40
  };
63
41
 
64
- const isModelClass = (value: unknown): value is ModelClass =>
65
- typeof value === "string" && MODEL_CLASSES.includes(value as ModelClass);
66
- const isNumberedCodexProvider = (provider: string): boolean => CODEX_ALIAS.test(provider);
67
-
68
- function resolveTaskRoute(ctx: ExtensionContext, modelClass?: ModelClass): ResolvedTaskRoute {
69
- let config;
70
- try {
71
- config = readTaskModelsConfig();
72
- } catch {
73
- throw new Error("Couldn't read task model config. Run /task-models.");
74
- }
75
-
76
- const profileName = modelClass ?? config.tasks[SUBAGENT_TASK] ?? DEFAULT_MODEL_CLASS;
77
- const profile = config.profiles[profileName];
78
- if (!profile) throw new Error(`No ${profileName} task model profile is configured. Run /task-models.`);
79
- for (const route of orderedProfileRoutes(profile)) {
80
- const resolved = resolveTaskModelRoute(ctx, route);
81
- if (resolved) return resolved;
82
- }
83
- throw new Error(`No usable ${profileName} task model route. Run /task-models.`);
84
- }
42
+ const isModelClass = isProfileName;
85
43
 
86
44
  const cleanText = (value: unknown, field: string, file: string): string => {
87
45
  if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
@@ -90,70 +48,6 @@ const cleanText = (value: unknown, field: string, file: string): string => {
90
48
  return value.trim();
91
49
  };
92
50
 
93
- const stringList = (value: unknown, field: string, file: string, required = false): string[] => {
94
- if (value === undefined) {
95
- if (required) throw new Error(`${file}: ${field} is required.`);
96
- return [];
97
- }
98
- const values = typeof value === "string" ? value.split(",") : value;
99
- if (!Array.isArray(values) || values.some((item) => typeof item !== "string" || !item.trim() || item.includes("\0"))) {
100
- throw new Error(`${file}: ${field} must be an array of strings.`);
101
- }
102
- return values.map((item) => item.trim());
103
- };
104
-
105
- const extensionList = (value: unknown, file: string): string[] => {
106
- const extensions = stringList(value, "extensions", file);
107
- for (const extension of extensions) {
108
- const packageSource = /^(?:npm|git|github|https?|ssh):/.test(extension);
109
- const userPath = isAbsolute(extension) || extension.startsWith("~/") || extension.startsWith("~\\") || extension.startsWith("file://");
110
- if (!packageSource && !userPath) {
111
- throw new Error(`${file}: extensions entries must be absolute paths or package sources.`);
112
- }
113
- }
114
- return extensions;
115
- };
116
-
117
- export function loadRoles(agentDir = getAgentDir()): Role[] {
118
- const dir = join(agentDir, "config", "pi-subagent");
119
- let entries;
120
- try {
121
- entries = readdirSync(dir, { withFileTypes: true });
122
- } catch (error: unknown) {
123
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return [];
124
- throw error;
125
- }
126
-
127
- const roles = entries
128
- .filter((entry) => entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()))
129
- .sort((a, b) => a.name.localeCompare(b.name))
130
- .map((entry): Role => {
131
- const file = join(dir, entry.name);
132
- let parsed: ReturnType<typeof parseFrontmatter>;
133
- try {
134
- parsed = parseFrontmatter(readFileSync(file, "utf8"));
135
- } catch (error) {
136
- throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
137
- }
138
- const frontmatter = parsed.frontmatter;
139
- return {
140
- name: cleanText(frontmatter.name, "name", file),
141
- description: cleanText(frontmatter.description, "description", file),
142
- tools: frontmatter.tools === undefined ? undefined : stringList(frontmatter.tools, "tools", file, true),
143
- extensions: extensionList(frontmatter.extensions, file),
144
- skills: stringList(frontmatter.skills, "skills", file),
145
- systemPrompt: cleanText(parsed.body, "system prompt", file),
146
- };
147
- });
148
-
149
- const names = new Set<string>();
150
- for (const role of roles) {
151
- if (names.has(role.name)) throw new Error(`Duplicate Subagent role: ${role.name}.`);
152
- names.add(role.name);
153
- }
154
- return roles;
155
- }
156
-
157
51
  function piInvocation(args: string[]): { command: string; args: string[] } {
158
52
  const currentScript = process.argv[1];
159
53
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
@@ -471,20 +365,6 @@ const roleSummary = (): string => {
471
365
  }
472
366
  };
473
367
 
474
- function resolveSkillPaths(pi: ExtensionAPI, names: string[]): { paths: string[]; missing: string[] } {
475
- const skills = new Map(pi.getCommands()
476
- .filter((command) => command.source === "skill")
477
- .map((command) => [command.name, command.sourceInfo.path]));
478
- const paths: string[] = [];
479
- const missing: string[] = [];
480
- for (const name of names) {
481
- const path = skills.get(`skill:${name}`);
482
- if (path) paths.push(path);
483
- else missing.push(name);
484
- }
485
- return { paths, missing };
486
- }
487
-
488
368
  export default function subagentExtension(pi: ExtensionAPI): void {
489
369
  const widgetItems = new Map<string, WidgetItem>();
490
370
  let widgetInstalled = false;
@@ -590,40 +470,22 @@ export default function subagentExtension(pi: ExtensionAPI): void {
590
470
  if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
591
471
  throw new Error("delegate_task modelClass must be fast, balanced, or frontier.");
592
472
  }
593
- const resolvedRoute = resolveTaskRoute(ctx, params.modelClass);
594
- const model = resolvedRoute.model;
595
- const modelReferenceValue = modelReference(model);
596
- const thinkingLevel = resolvedRoute.thinkingLevel;
597
-
598
- const resolvedSkills = resolveSkillPaths(pi, role.skills);
599
- if (resolvedSkills.missing.length) {
473
+ const launch = params.modelClass === undefined
474
+ ? resolveRoleLaunch(pi, ctx, { role, taskId: SUBAGENT_TASK })
475
+ : createRoleLaunch(pi, ctx, { role, route: resolveTaskRoute(ctx, params.modelClass) });
476
+ const modelReferenceValue = modelReference(launch.model);
477
+ const thinkingLevel = launch.thinkingLevel;
478
+ if (launch.missingSkills.length) {
600
479
  ctx.ui.notify(
601
- `Subagent role ${role.name} skipped unavailable Pi skills: ${resolvedSkills.missing.join(", ")}.`,
480
+ `Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
602
481
  "warning",
603
482
  );
604
483
  }
605
484
 
606
- const tempDir = await mkdtemp(join(tmpdir(), "pi-subagent-"));
607
- const promptPath = join(tempDir, "system.md");
608
485
  let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
609
486
  try {
610
- await writeFile(promptPath, role.systemPrompt, { encoding: "utf8", mode: 0o600 });
611
- const args = ["--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
612
- const extensions = isNumberedCodexProvider(model.provider)
613
- ? [...role.extensions, MULTI_CODEX_EXTENSION]
614
- : role.extensions;
615
- for (const extension of new Set(extensions)) args.push("--extension", extension);
616
- for (const skill of resolvedSkills.paths) args.push("--skill", skill);
617
- if (role.tools !== undefined) {
618
- if (role.tools.length) args.push("--tools", role.tools.join(","));
619
- else args.push("--no-tools");
620
- }
621
- args.push("--model", modelReferenceValue);
622
- if (thinkingLevel) args.push("--thinking", thinkingLevel);
623
- args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
624
- args.push("--append-system-prompt", promptPath, `Task: ${task}`);
625
-
626
- startWidgetItem(toolCallId, role.name, model.id, thinkingLevel, task, ctx);
487
+ const args = ["--mode", "json", "-p", ...launch.args, `Task: ${task}`];
488
+ startWidgetItem(toolCallId, role.name, launch.model.id, thinkingLevel, task, ctx);
627
489
  const details = { role: role.name, model: modelReferenceValue, thinkingLevel };
628
490
  const result = await runPi(
629
491
  args,
@@ -642,11 +504,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
642
504
  if (signal?.aborted) widgetStatus = "aborted";
643
505
  throw error;
644
506
  } finally {
645
- try {
646
- await rm(tempDir, { recursive: true, force: true });
647
- } finally {
648
- finishWidgetItem(toolCallId, widgetStatus);
649
- }
507
+ finishWidgetItem(toolCallId, widgetStatus);
650
508
  }
651
509
  },
652
510
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -14,13 +14,24 @@
14
14
  },
15
15
  "license": "MIT",
16
16
  "files": [
17
+ "dist",
17
18
  "extensions",
18
19
  "README.md",
20
+ "CONTEXT.md",
19
21
  "LICENSE"
20
22
  ],
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
21
30
  "scripts": {
22
- "test": "node --test test/*.test.ts",
23
- "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/subagent.ts test/*.test.ts",
31
+ "build": "tsc --project tsconfig.build.json",
32
+ "test": "npm run build && node --test test/*.test.ts",
33
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck src/*.ts extensions/*.ts test/*.test.ts",
34
+ "prepack": "npm run build",
24
35
  "pack:check": "npm pack --dry-run"
25
36
  },
26
37
  "peerDependencies": {
@@ -46,7 +57,8 @@
46
57
  ]
47
58
  },
48
59
  "dependencies": {
60
+ "@henryqw/pi-herdr": "^0.1.1",
49
61
  "@henryqw/pi-multi-codex": "^0.3.8",
50
- "@henryqw/pi-task-models": "^0.2.0"
62
+ "@henryqw/pi-task-models": "^0.3.0"
51
63
  }
52
64
  }