@henryqw/pi-subagent 2.1.0 → 2.3.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/CONTEXT.md +27 -0
- package/README.md +33 -5
- package/dist/index.d.ts +79 -0
- package/dist/index.js +395 -0
- package/extensions/role-tools.ts +33 -0
- package/extensions/subagent.ts +16 -158
- package/package.json +16 -4
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 extension sources and named Skills. Pi loads Skills supplied by those extension packages or their resource discovery. Omitted Role tools use Pi's effective `defaultTools`; an explicit list sets base tools while loaded extension tools activate automatically.
|
|
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
|
|
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
|
|
|
@@ -25,7 +25,7 @@ pi install npm:@henryqw/pi-subagent
|
|
|
25
25
|
|
|
26
26
|
`modelClass` is `fast`, `balanced`, or `frontier`. Omitted class uses the shared `pi-subagent/delegateTask` assignment, which defaults to `balanced`. Primary route is resolved against current scoped text models; fallback is tried only before launch. If no route is usable, delegation rejects with `Run /task-models`. A started child is never retried.
|
|
27
27
|
|
|
28
|
-
Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and
|
|
28
|
+
Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and Skills are off. Role/caller extensions load; those packages' tools and Skills auto-load, plus any extra `skills` names. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. Streaming output is capped at 50 KiB. Unused JSON event types are discarded before payload buffering; consumed or unclassifiable events above 1 MiB fail delegation.
|
|
29
29
|
|
|
30
30
|
TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
|
|
31
31
|
|
|
@@ -53,13 +53,41 @@ Do not edit files.
|
|
|
53
53
|
| --- | --- | --- |
|
|
54
54
|
| `name` | yes | Role selected by Main |
|
|
55
55
|
| `description` | yes | Tells Main when to use the role |
|
|
56
|
-
| `tools` | no | Omit for Pi
|
|
57
|
-
| `extensions` | no | Absolute/`~/` paths or package sources. Repository-relative paths are rejected. |
|
|
58
|
-
| `skills` | no |
|
|
56
|
+
| `tools` | no | Omit for Pi defaults; when present, base tools are listed and every loaded Role/caller extension tool is added automatically. `[]` leaves extension tools only. |
|
|
57
|
+
| `extensions` | no | Absolute/`~/` paths or package sources. Package-declared Skills and Pi `resources_discover` Skill paths load automatically. Repository-relative paths are rejected. |
|
|
58
|
+
| `skills` | no | Additional effective Pi Skill names, resolved from Main's registry |
|
|
59
59
|
| Markdown body | yes | Role system instructions |
|
|
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 Role base tools; Role and caller extension tools activate automatically. 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
|
package/dist/index.d.ts
ADDED
|
@@ -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,395 @@
|
|
|
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
|
+
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
11
|
+
const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
12
|
+
export const isProfileName = (value) => typeof value === "string" && PROFILE_NAMES.includes(value);
|
|
13
|
+
const cleanText = (value, field, source) => {
|
|
14
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
15
|
+
throw new Error(`${source}: ${field} must be non-empty text.`);
|
|
16
|
+
}
|
|
17
|
+
return value.trim();
|
|
18
|
+
};
|
|
19
|
+
const stringList = (value, field, source, required = false) => {
|
|
20
|
+
if (value === undefined) {
|
|
21
|
+
if (required)
|
|
22
|
+
throw new Error(`${source}: ${field} is required.`);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const values = typeof value === "string" ? value.split(",") : value;
|
|
26
|
+
if (!Array.isArray(values) || values.some((item) => typeof item !== "string" || !item.trim() || item.includes("\0"))) {
|
|
27
|
+
throw new Error(`${source}: ${field} must be an array of strings.`);
|
|
28
|
+
}
|
|
29
|
+
return values.map((item) => item.trim());
|
|
30
|
+
};
|
|
31
|
+
function validateExtension(extension, source) {
|
|
32
|
+
const value = cleanText(extension, "extension", source);
|
|
33
|
+
const packageSource = /^(?:npm|git|github|https?|ssh):/.test(value);
|
|
34
|
+
const userPath = isAbsolute(value) || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("file://");
|
|
35
|
+
if (!packageSource && !userPath) {
|
|
36
|
+
throw new Error(`${source}: extensions entries must be absolute paths or package sources.`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function extensionList(value, source) {
|
|
41
|
+
return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
|
|
42
|
+
}
|
|
43
|
+
export function loadRoles(agentDir = getAgentDir()) {
|
|
44
|
+
const dir = join(agentDir, "config", "pi-subagent");
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
51
|
+
return [];
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const roles = entries
|
|
55
|
+
.filter((entry) => entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()))
|
|
56
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const file = join(dir, entry.name);
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = parseFrontmatter(readFileSync(file, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
const frontmatter = parsed.frontmatter;
|
|
67
|
+
return {
|
|
68
|
+
name: cleanText(frontmatter.name, "name", file),
|
|
69
|
+
description: cleanText(frontmatter.description, "description", file),
|
|
70
|
+
tools: frontmatter.tools === undefined ? undefined : stringList(frontmatter.tools, "tools", file, true),
|
|
71
|
+
extensions: extensionList(frontmatter.extensions, file),
|
|
72
|
+
skills: stringList(frontmatter.skills, "skills", file),
|
|
73
|
+
systemPrompt: cleanText(parsed.body, "system prompt", file),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const names = new Set();
|
|
77
|
+
for (const role of roles) {
|
|
78
|
+
if (names.has(role.name))
|
|
79
|
+
throw new Error(`Duplicate Subagent role: ${role.name}.`);
|
|
80
|
+
names.add(role.name);
|
|
81
|
+
}
|
|
82
|
+
return roles;
|
|
83
|
+
}
|
|
84
|
+
export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
|
|
85
|
+
let config;
|
|
86
|
+
try {
|
|
87
|
+
config = readTaskModelsConfig(agentDir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error("Couldn't read task model config. Run /task-models.");
|
|
91
|
+
}
|
|
92
|
+
return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName]);
|
|
93
|
+
}
|
|
94
|
+
function resolveConfiguredRoute(ctx, profileName, profile) {
|
|
95
|
+
if (!profile)
|
|
96
|
+
throw new Error(`No ${profileName} task model profile is configured. Run /task-models.`);
|
|
97
|
+
for (const route of orderedProfileRoutes(profile)) {
|
|
98
|
+
const resolved = resolveTaskModelRoute(ctx, route);
|
|
99
|
+
if (resolved)
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`No usable ${profileName} task model route. Run /task-models.`);
|
|
103
|
+
}
|
|
104
|
+
export function resolveRoleSkills(pi, role) {
|
|
105
|
+
const skills = new Map(pi.getCommands()
|
|
106
|
+
.filter((command) => command.source === "skill")
|
|
107
|
+
.map((command) => [command.name, command.sourceInfo.path]));
|
|
108
|
+
const paths = [];
|
|
109
|
+
const missing = [];
|
|
110
|
+
for (const name of role.skills) {
|
|
111
|
+
const path = skills.get(`skill:${name}`);
|
|
112
|
+
if (path)
|
|
113
|
+
paths.push(path);
|
|
114
|
+
else
|
|
115
|
+
missing.push(name);
|
|
116
|
+
}
|
|
117
|
+
return { paths, missing };
|
|
118
|
+
}
|
|
119
|
+
export function createRoleLaunch(pi, ctx, input) {
|
|
120
|
+
const role = input.role;
|
|
121
|
+
const skills = resolveRoleSkills(pi, role);
|
|
122
|
+
const tools = role.tools === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: [...new Set([...role.tools, ...(input.tools ?? [])].map((tool) => cleanText(tool, "tool", `Role ${role.name}`)))];
|
|
125
|
+
const extensions = [
|
|
126
|
+
...role.extensions,
|
|
127
|
+
...(input.extensions ?? []),
|
|
128
|
+
...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
|
|
129
|
+
...(tools === undefined ? [] : [ROLE_TOOLS_EXTENSION]),
|
|
130
|
+
].map((extension) => validateExtension(extension, `Role ${role.name}`));
|
|
131
|
+
const env = Object.fromEntries(Object.entries(input.env ?? {}).map(([key, value]) => {
|
|
132
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
133
|
+
throw new Error(`Invalid launch environment name: ${key}`);
|
|
134
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
135
|
+
throw new Error(`Invalid launch environment value: ${key}`);
|
|
136
|
+
return [key, value];
|
|
137
|
+
}));
|
|
138
|
+
const args = ["--no-session", "--no-extensions", "--no-skills"];
|
|
139
|
+
for (const extension of new Set(extensions))
|
|
140
|
+
args.push("--extension", extension);
|
|
141
|
+
for (const skill of skills.paths)
|
|
142
|
+
args.push("--skill", skill);
|
|
143
|
+
if (tools !== undefined)
|
|
144
|
+
args.push(`--${ROLE_TOOL_POLICY_FLAG}`, JSON.stringify(tools));
|
|
145
|
+
args.push("--model", modelReference(input.route.model));
|
|
146
|
+
if (input.route.thinkingLevel)
|
|
147
|
+
args.push("--thinking", input.route.thinkingLevel);
|
|
148
|
+
args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
|
|
149
|
+
args.push("--append-system-prompt", cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`));
|
|
150
|
+
return {
|
|
151
|
+
env,
|
|
152
|
+
args,
|
|
153
|
+
model: input.route.model,
|
|
154
|
+
thinkingLevel: input.route.thinkingLevel,
|
|
155
|
+
missingSkills: skills.missing,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function resolveRoleLaunch(pi, ctx, input) {
|
|
159
|
+
const taskId = cleanText(input.taskId, "task ID", "Role launch");
|
|
160
|
+
return createRoleLaunch(pi, ctx, {
|
|
161
|
+
...input,
|
|
162
|
+
route: resolveConfiguredTaskRoute(ctx, taskId, input.agentDir),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function launchEnvironmentArgs(launch) {
|
|
166
|
+
return Object.entries(launch.env).flatMap(([key, value]) => {
|
|
167
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== "string" || value.includes("\0")) {
|
|
168
|
+
throw new Error(`Invalid launch environment: ${key}`);
|
|
169
|
+
}
|
|
170
|
+
return ["--env", `${key}=${value}`];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export function managedSubagentName(workspaceId, ...identity) {
|
|
174
|
+
const parts = [nonEmptyString(workspaceId, "Herdr workspace id"), ...identity.map((part, index) => nonEmptyString(part, `Subagent identity ${index}`))];
|
|
175
|
+
if (!identity.length)
|
|
176
|
+
throw new Error("Managed Subagent identity is required");
|
|
177
|
+
return `subagent-${createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 23)}`;
|
|
178
|
+
}
|
|
179
|
+
export async function managedSubagentWorkspaceId(cwd, mainPane, options) {
|
|
180
|
+
const paneId = nonEmptyString(mainPane, "recorded main Herdr pane");
|
|
181
|
+
const pane = (await listPanes(cwd, options))
|
|
182
|
+
.map((entry, index) => object(entry, `Herdr pane ${index}`))
|
|
183
|
+
.find((entry) => entry.pane_id === paneId);
|
|
184
|
+
if (!pane)
|
|
185
|
+
throw new Error(`Recorded main Herdr pane is missing: ${paneId}`);
|
|
186
|
+
return nonEmptyString(pane.workspace_id, "recorded main Herdr workspace");
|
|
187
|
+
}
|
|
188
|
+
/** Returns pane ID to Herdr status for agents owned by this workspace. */
|
|
189
|
+
export async function listManagedSubagents(host, options) {
|
|
190
|
+
const workspaceId = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
|
|
191
|
+
const response = object(await createHerdrClient(options.execute).json(["agent", "list"], { cwd: host.cwd }), "Herdr agent list response");
|
|
192
|
+
const result = object(response.result, "Herdr agent list result");
|
|
193
|
+
return new Map(array(result.agents, "Herdr agents").flatMap((entry, index) => {
|
|
194
|
+
const agent = object(entry, `Herdr agent ${index}`);
|
|
195
|
+
if (nonEmptyString(agent.workspace_id, `Herdr agent ${index} workspace`) !== workspaceId)
|
|
196
|
+
return [];
|
|
197
|
+
return [[
|
|
198
|
+
nonEmptyString(agent.pane_id, `Herdr agent ${index} pane`),
|
|
199
|
+
nonEmptyString(agent.agent_status, `Herdr agent ${index} status`),
|
|
200
|
+
]];
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
export async function createManagedSubagentTab(host, cwd, launch, label, options) {
|
|
204
|
+
const response = await createHerdrClient(options.execute).json([
|
|
205
|
+
"tab", "create", "--workspace", nonEmptyString(host.workspaceId, "recorded Herdr workspace"), "--cwd", cwd,
|
|
206
|
+
...launchEnvironmentArgs(launch), "--label", nonEmptyString(label, "Herdr tab label"), "--no-focus",
|
|
207
|
+
], { cwd: host.cwd });
|
|
208
|
+
const result = object(object(response, "Herdr tab response").result, "Herdr tab result");
|
|
209
|
+
return {
|
|
210
|
+
tabId: nonEmptyString(object(result.tab, "Herdr tab").tab_id, "Herdr tab id"),
|
|
211
|
+
paneId: nonEmptyString(object(result.root_pane, "Herdr root pane").pane_id, "Herdr root pane id"),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export async function reconcileManagedSubagentTab(host, input, options) {
|
|
215
|
+
if (input.tabId && input.paneId && await managedSubagentTabExists(host, input.tabId, options)) {
|
|
216
|
+
return { tabId: input.tabId, paneId: input.paneId };
|
|
217
|
+
}
|
|
218
|
+
return await findManagedSubagentTab(host, input.label, options)
|
|
219
|
+
?? await createManagedSubagentTab(host, input.cwd, input.launch, input.label, options);
|
|
220
|
+
}
|
|
221
|
+
export async function findManagedSubagentTab(host, label, options) {
|
|
222
|
+
const workspaceId = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
|
|
223
|
+
const matches = (await listTabs(host.cwd, options))
|
|
224
|
+
.map((entry, index) => object(entry, `Herdr tab ${index}`))
|
|
225
|
+
.filter((tab, index) => nonEmptyString(tab.workspace_id, `Herdr tab ${index} workspace`) === workspaceId)
|
|
226
|
+
.filter((tab) => tab.label === label);
|
|
227
|
+
if (matches.length > 1)
|
|
228
|
+
throw new Error(`Multiple Herdr tabs match provisioning identity: ${label}`);
|
|
229
|
+
if (!matches.length)
|
|
230
|
+
return undefined;
|
|
231
|
+
const tabId = nonEmptyString(object(matches[0], "Herdr tab").tab_id, "Herdr tab id");
|
|
232
|
+
const panes = (await listPanes(host.cwd, options))
|
|
233
|
+
.filter((entry, index) => object(entry, `Herdr pane ${index}`).tab_id === tabId);
|
|
234
|
+
if (panes.length !== 1)
|
|
235
|
+
throw new Error(`Provisioned Herdr tab ${tabId} must contain exactly one root pane`);
|
|
236
|
+
return { tabId, paneId: nonEmptyString(object(panes[0], "Herdr pane").pane_id, "Herdr pane id") };
|
|
237
|
+
}
|
|
238
|
+
export async function managedSubagentTabExists(host, tabId, options) {
|
|
239
|
+
const expected = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
|
|
240
|
+
const id = nonEmptyString(tabId, "Herdr tab id");
|
|
241
|
+
const tab = (await listTabs(host.cwd, options))
|
|
242
|
+
.map((entry, index) => object(entry, `Herdr tab ${index}`))
|
|
243
|
+
.find((entry) => entry.tab_id === id);
|
|
244
|
+
if (!tab)
|
|
245
|
+
return false;
|
|
246
|
+
const actual = nonEmptyString(tab.workspace_id, `Herdr tab ${id} workspace`);
|
|
247
|
+
if (actual !== expected)
|
|
248
|
+
throw new Error(`Herdr tab ${id} belongs to workspace ${actual}, expected initiating workspace ${expected}`);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
export async function reconcileManagedSubagentPane(host, tabId, rootPaneId, cwd, launch, label, options) {
|
|
252
|
+
const tab = nonEmptyString(tabId, "Herdr tab id");
|
|
253
|
+
const workspace = nonEmptyString(host.workspaceId, "recorded Herdr workspace");
|
|
254
|
+
const ownerTab = (await listTabs(host.cwd, options))
|
|
255
|
+
.map((entry, index) => object(entry, `Herdr tab ${index}`))
|
|
256
|
+
.find((entry) => entry.tab_id === tab);
|
|
257
|
+
if (!ownerTab)
|
|
258
|
+
throw new Error(`Herdr tab is missing: ${tab}`);
|
|
259
|
+
const ownerWorkspace = nonEmptyString(ownerTab.workspace_id, `Herdr tab ${tab} workspace`);
|
|
260
|
+
if (ownerWorkspace !== workspace)
|
|
261
|
+
throw new Error(`Herdr tab ${tab} belongs to workspace ${ownerWorkspace}, expected initiating workspace ${workspace}`);
|
|
262
|
+
const root = nonEmptyString(rootPaneId, "Herdr root pane");
|
|
263
|
+
const panes = (await listPanes(host.cwd, options)).map((entry, index) => object(entry, `Herdr pane ${index}`));
|
|
264
|
+
const owner = panes.find((pane) => pane.pane_id === root);
|
|
265
|
+
if (!owner)
|
|
266
|
+
throw new Error(`Herdr root pane is missing: ${root}`);
|
|
267
|
+
if (owner.tab_id !== tab)
|
|
268
|
+
throw new Error(`Herdr root pane ${root} does not belong to tab ${tab}`);
|
|
269
|
+
const siblings = panes.filter((pane) => pane.tab_id === tab && pane.pane_id !== root);
|
|
270
|
+
const named = siblings.filter((pane) => pane.label === label);
|
|
271
|
+
if (named.length > 1)
|
|
272
|
+
throw new Error(`Multiple Herdr panes match provisioning identity: ${label}`);
|
|
273
|
+
if (named.length)
|
|
274
|
+
return nonEmptyString(named[0].pane_id, "Herdr Subagent pane id");
|
|
275
|
+
if (siblings.length > 1)
|
|
276
|
+
throw new Error(`Provisioned Herdr tab ${tab} has multiple Subagent panes`);
|
|
277
|
+
if (siblings.length)
|
|
278
|
+
return nonEmptyString(siblings[0].pane_id, "Herdr Subagent pane id");
|
|
279
|
+
const herdr = createHerdrClient(options.execute);
|
|
280
|
+
const response = await herdr.json([
|
|
281
|
+
"pane", "split", "--pane", root, "--direction", "right", "--cwd", cwd,
|
|
282
|
+
...launchEnvironmentArgs(launch), "--no-focus",
|
|
283
|
+
], { cwd: host.cwd });
|
|
284
|
+
const result = object(object(response, "Herdr pane response").result, "Herdr pane result");
|
|
285
|
+
const pane = nonEmptyString(object(result.pane, "Herdr Subagent pane").pane_id, "Herdr Subagent pane id");
|
|
286
|
+
await herdr.run(["pane", "rename", pane, nonEmptyString(label, "Herdr pane label")], { cwd: host.cwd });
|
|
287
|
+
return pane;
|
|
288
|
+
}
|
|
289
|
+
export async function startManagedSubagent(host, agent, pane, launch, options, hooks = {}) {
|
|
290
|
+
assertAgentName(agent);
|
|
291
|
+
const name = nonEmptyString(agent, "Herdr agent name");
|
|
292
|
+
const paneId = nonEmptyString(pane, "Herdr agent pane");
|
|
293
|
+
const existing = await getManagedSubagent(host, name, options);
|
|
294
|
+
if (existing) {
|
|
295
|
+
assertAgentPane(name, paneId, existing);
|
|
296
|
+
return "existing";
|
|
297
|
+
}
|
|
298
|
+
await hooks.beforeStart?.();
|
|
299
|
+
const arguments_ = ["agent", "start", name, "--kind", "pi", "--pane", paneId, "--", ...launch.args];
|
|
300
|
+
const herdr = createHerdrClient(options.execute);
|
|
301
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
302
|
+
const result = await herdr.exec(arguments_, { cwd: host.cwd });
|
|
303
|
+
if (result.code === 0 && !result.killed) {
|
|
304
|
+
await hooks.onStarted?.();
|
|
305
|
+
return "started";
|
|
306
|
+
}
|
|
307
|
+
if (hasHerdrErrorCode(result, "agent_name_taken")) {
|
|
308
|
+
const raced = await getManagedSubagent(host, name, options);
|
|
309
|
+
if (!raced)
|
|
310
|
+
throw new Error(`Herdr agent ${name} reported agent_name_taken but could not be found; refusing to start a duplicate`);
|
|
311
|
+
assertAgentPane(name, paneId, raced);
|
|
312
|
+
return "existing";
|
|
313
|
+
}
|
|
314
|
+
if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
|
|
315
|
+
throw new Error(herdrCommandFailure(arguments_, result));
|
|
316
|
+
}
|
|
317
|
+
await (options.delay ?? delay)(250);
|
|
318
|
+
}
|
|
319
|
+
throw new Error(`Herdr agent ${name} could not be started`);
|
|
320
|
+
}
|
|
321
|
+
export async function promptManagedSubagent(host, agent, prompt, options) {
|
|
322
|
+
assertAgentName(agent);
|
|
323
|
+
const text = typeof prompt === "string" ? nonEmptyString(prompt, "Subagent prompt") : JSON.stringify(prompt);
|
|
324
|
+
await createHerdrClient(options.execute).run(["agent", "prompt", agent, text], { cwd: host.cwd });
|
|
325
|
+
}
|
|
326
|
+
export async function retireManagedSubagentTab(host, tabId, options) {
|
|
327
|
+
const id = nonEmptyString(tabId, "Herdr tab id");
|
|
328
|
+
try {
|
|
329
|
+
if (!(await managedSubagentTabExists(host, id, options)))
|
|
330
|
+
return;
|
|
331
|
+
await createHerdrClient(options.execute).run(["tab", "close", id], { cwd: host.cwd });
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (!(await confirmsTabAbsent(host, id, options)))
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function assertAgentName(agent) {
|
|
339
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(agent))
|
|
340
|
+
throw new Error(`Invalid Herdr agent name: ${agent}`);
|
|
341
|
+
}
|
|
342
|
+
function object(value, label) {
|
|
343
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
344
|
+
throw new Error(`${label} must be an object`);
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
function array(value, label) {
|
|
348
|
+
if (!Array.isArray(value))
|
|
349
|
+
throw new Error(`${label} must be an array`);
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
function nonEmptyString(value, label) {
|
|
353
|
+
if (typeof value !== "string")
|
|
354
|
+
throw new Error(`${label} must be a string`);
|
|
355
|
+
if (!value.trim() || value.includes("\0"))
|
|
356
|
+
throw new Error(`${label} must not be empty`);
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
async function listTabs(cwd, options) {
|
|
360
|
+
const response = await createHerdrClient(options.execute).json(["tab", "list"], { cwd });
|
|
361
|
+
return array(object(object(response, "Herdr tab list response").result, "Herdr tab list result").tabs, "Herdr tabs");
|
|
362
|
+
}
|
|
363
|
+
async function listPanes(cwd, options) {
|
|
364
|
+
const response = await createHerdrClient(options.execute).json(["pane", "list"], { cwd });
|
|
365
|
+
return array(object(object(response, "Herdr pane list response").result, "Herdr pane list result").panes, "Herdr panes");
|
|
366
|
+
}
|
|
367
|
+
async function getManagedSubagent(host, name, options) {
|
|
368
|
+
const arguments_ = ["agent", "get", name];
|
|
369
|
+
const result = await createHerdrClient(options.execute).exec(arguments_, { cwd: host.cwd });
|
|
370
|
+
if (result.code !== 0 || result.killed) {
|
|
371
|
+
if (hasHerdrErrorCode(result, "agent_not_found"))
|
|
372
|
+
return undefined;
|
|
373
|
+
throw new Error(herdrCommandFailure(arguments_, result));
|
|
374
|
+
}
|
|
375
|
+
const response = object(JSON.parse(result.stdout), "Herdr agent get response");
|
|
376
|
+
return object(object(response.result, "Herdr agent get result").agent, `Herdr agent ${name}`);
|
|
377
|
+
}
|
|
378
|
+
function assertAgentPane(name, expected, agent) {
|
|
379
|
+
const actual = typeof agent.pane_id === "string" ? agent.pane_id : "missing";
|
|
380
|
+
if (actual !== expected) {
|
|
381
|
+
throw new Error(`Herdr agent name collision for ${name}: expected pane ${expected}, found ${actual}; refusing to reuse or replace it`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function confirmsTabAbsent(host, tabId, options) {
|
|
385
|
+
try {
|
|
386
|
+
const result = await createHerdrClient(options.execute).exec(["tab", "get", tabId], { cwd: host.cwd });
|
|
387
|
+
return !result.killed && result.code !== 0 && hasHerdrErrorCode(result, "tab_not_found");
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function delay(milliseconds) {
|
|
394
|
+
await new Promise((done) => { setTimeout(done, milliseconds); });
|
|
395
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
4
|
+
|
|
5
|
+
function configuredTools(value: unknown): string[] | undefined {
|
|
6
|
+
if (value === undefined) return;
|
|
7
|
+
if (typeof value !== "string") throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
8
|
+
let parsed: unknown;
|
|
9
|
+
try {
|
|
10
|
+
parsed = JSON.parse(value);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
15
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
16
|
+
}
|
|
17
|
+
return [...new Set(parsed.map((name) => name.trim()))];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function roleTools(pi: ExtensionAPI): void {
|
|
21
|
+
pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
|
|
22
|
+
description: "Internal Pi Subagent Role tool policy",
|
|
23
|
+
type: "string",
|
|
24
|
+
});
|
|
25
|
+
pi.on("session_start", () => {
|
|
26
|
+
const selected = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
27
|
+
if (!selected) return;
|
|
28
|
+
const extensionTools = pi.getAllTools()
|
|
29
|
+
.filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source))
|
|
30
|
+
.map((tool) => tool.name);
|
|
31
|
+
pi.setActiveTools([...new Set([...selected, ...extensionTools])]);
|
|
32
|
+
});
|
|
33
|
+
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync
|
|
3
|
-
import {
|
|
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,
|
|
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 =
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
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: ${
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
"version": "2.3.1",
|
|
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
|
-
"
|
|
23
|
-
"
|
|
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.
|
|
62
|
+
"@henryqw/pi-task-models": "^0.3.0"
|
|
51
63
|
}
|
|
52
64
|
}
|