@hheei/pi-subagents 0.2.3 → 0.3.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.
Files changed (44) hide show
  1. package/README.md +32 -17
  2. package/dist/agent-resolver.d.ts +9 -0
  3. package/dist/agent-resolver.js +278 -51
  4. package/dist/bridge-client.d.ts +5 -0
  5. package/dist/bridge-client.js +6 -0
  6. package/dist/builtin-agents.js +50 -37
  7. package/dist/child-bridge.d.ts +0 -8
  8. package/dist/child-bridge.js +60 -43
  9. package/dist/child-control.d.ts +5 -3
  10. package/dist/child-control.js +176 -35
  11. package/dist/commands.d.ts +1 -4
  12. package/dist/commands.js +1 -52
  13. package/dist/domain.d.ts +12 -12
  14. package/dist/domain.js +2 -1
  15. package/dist/extension.d.ts +5 -3
  16. package/dist/extension.js +42 -44
  17. package/dist/host-adapter.js +1 -1
  18. package/dist/info.d.ts +9 -0
  19. package/dist/info.js +54 -0
  20. package/dist/launch-spec.d.ts +0 -1
  21. package/dist/launch-spec.js +38 -18
  22. package/dist/manager.d.ts +4 -2
  23. package/dist/manager.js +299 -114
  24. package/dist/registry.js +19 -5
  25. package/dist/rpc-events.d.ts +1 -1
  26. package/dist/rpc-events.js +0 -1
  27. package/dist/runtime.js +8 -0
  28. package/dist/session-bootstrap.d.ts +2 -1
  29. package/dist/session-bootstrap.js +22 -14
  30. package/dist/state.d.ts +13 -0
  31. package/dist/state.js +59 -16
  32. package/dist/status.d.ts +14 -0
  33. package/dist/status.js +36 -0
  34. package/dist/tool-activation.d.ts +3 -4
  35. package/dist/tool-activation.js +13 -215
  36. package/dist/tool-presentation.d.ts +17 -0
  37. package/dist/tool-presentation.js +160 -0
  38. package/dist/tools.d.ts +6 -1
  39. package/dist/tools.js +133 -180
  40. package/package.json +6 -6
  41. package/dist/session-fork.d.ts +0 -16
  42. package/dist/session-fork.js +0 -67
  43. package/dist/widget.d.ts +0 -12
  44. package/dist/widget.js +0 -149
package/README.md CHANGED
@@ -5,7 +5,7 @@ file, its own runtime, and its own durable identity.
5
5
 
6
6
  The package is a single concrete extension with one `pi.extensions` entry
7
7
  (`dist/extension.js`). The same entry runs on both sides: the parent branch registers the
8
- model-facing tools, commands, and widget; the child branch registers only the reporting
8
+ model-facing tools, commands, compact footer status, and info entries; the child branch registers only the reporting
9
9
  bridge and session-leave reporting.
10
10
 
11
11
  ## Parent tools
@@ -15,16 +15,20 @@ children and `/subagents stop [id]` to stop one without conflicting with other e
15
15
 
16
16
  | Tool | Purpose |
17
17
  | --- | --- |
18
- | `subagent_enable({})` | Enable interactive agent tools (`spawn_agent`, `send_agent`, `get_agent`, `stop_agent`) on demand. Deactivated by default in new sessions to save tokens; appends available interactive agents if any are defined, and enabled tools become available on the next model request. |
19
- | `spawn_agent({ task, agent, cwd?, title? })` | Start one child and return when its bridge is ready and the initial task was delivered. Runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing subagents** via `send_agent` for related/follow-up work to maintain context and maximize prompt cache and memory efficiency. `title` (<= 60 chars) names the child's Pi session, shown as `🤖 <title>`. Requires `subagent_enable`. Do not poll `get`/`list` for completion; reports arrive automatically as `subagent-report` messages. Do not pause or freeze other subagents before spawning. |
20
- | `send_agent({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle or completed (`done`/`blocked`) children resume their existing session automatically with historical context intact, maximizing cache and memory efficiency. Do not wait for or ask workers to 'freeze': idle subagents are completely dormant and touch nothing. Requires `subagent_enable`. |
21
- | `get_agent({ id })` | Inspect one child: state (`running` / `done` / `blocked`), presentation (`panel`/`background`), session, summary, usage, runtime freshness, and model/thinking. Requires `subagent_enable`. |
22
- | `list_agents({})` | List available interactive agent definitions and running subagents owned by this parent session. |
23
- | `stop_agent({ id })` | Persist a stopped intent, then end the runtime: a background child is killed, a panel child has its panel closed and verified gone. Requires `subagent_enable`. |
18
+ | `agent_spawn({ task, agent, cwd?, title? })` | Spawn an independent background subagent task. Runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing subagents** via `agent_send` for related/follow-up work to maintain context and maximize prompt cache and memory efficiency. Tool description lists available agent names and roles, excluding disabled or hidden definitions. Deferred by default; discover via `tool_search({ query: "agent" })`. Do not poll for completion; results return automatically. |
19
+ | `agent_send({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle, blocked, done, or errored children resume their existing session automatically with historical context intact. Deferred by default; discover via `tool_search({ query: "agent" })`. |
20
+ | `agent_get({ id, keys? })` | Inspect one subagent by id. `keys` is a comma-separated selection of `status` (default), `progress` (default), and `diagnostic`; unknown keys fail. The view never returns the child's task parameters or terminal result, which arrive automatically as reports. From the second successful inspection of the same child in this parent runtime, the result adds a short reminder that results return automatically and must not be polled. Deferred by default; discover via `tool_search({ query: "agent" })`. |
21
+ | `agent_stop({ id })` | Stop one subagent's runtime by id. The child keeps its session, so a later `agent_send` can resume it. Deferred by default; discover via `tool_search({ query: "agent" })`. |
24
22
 
25
23
  ### Unified Task Management
26
24
 
27
- Subagent execution tasks automatically register with ext-core's `TaskRegistry`. Background bash jobs and subagents are uniformly tracked and waited upon using `wait_jobs`, eliminating fragmented or redundant task tools.
25
+ `agent_spawn` returns JSON `{ id, path }` on success. `path` is the absolute child session file path
26
+ assigned by Pi; its first write may still be pending. `agent_get` exposes the same `progress.path`.
27
+ `agent_send` and `agent_stop` return one confirmation
28
+ sentence. Failures return only their reason. Operation results omit session IDs, presentation, and
29
+ state snapshots; use `agent_get` for runtime inspection.
30
+
31
+ Subagent execution tasks register with ext-core's `TaskRegistry` when pi-ext-tools provides it, alongside background Bash jobs. Child reports arrive automatically; do not poll status to detect completion. Bash uses separate `bg_bash_*` tools. Deferred subagent tools are loaded via `tool_search`; activation supplies `subagent_guidance`. Both get tools return the same `status`, `progress`, and optional `diagnostic` envelope. Diagnostics describe blockers, failed turns, or uncertain runtime state.
28
32
 
29
33
  ## Child execution and reporting
30
34
 
@@ -35,20 +39,18 @@ Subagents communicate with the parent over a dedicated Unix domain socket bridge
35
39
  - **Blockers & Decisions**: The child registers `contact_parent({ message, reason? })` strictly for reporting when it is blocked or urgently requires a parent decision midway (`reason` defaults to `'blocked'`). Blocked reports wake the parent immediately.
36
40
  - **Automatic Retry**: If a subagent encounters a transient error, the harness allows one automatic retry before marking the task failed or delivering a blocked notification.
37
41
  - **Panel Failure Auto-Close**: When a subagent running in a panel encounters a fatal error or reports a blocker, the manager starts a 15-second countdown after notifying the parent, automatically closing the panel tab to avoid workspace clutter unless new instructions are dispatched.
38
- - **Session Reference (`forkFrom`)**: `spawn_agent` can reference past conversation via `forkFrom: 'parent'` or a subagent id like `agent-1` (cannot be `'current'`). Instead of injecting the entire transcript into the subagent's prompt, the subagent's instructions reference the source session JSONL file path, allowing the model to inspect earlier context on demand using `read` or `grep` without token bloat.
42
+ ## Compact status and reports
39
43
 
40
- ## Three visual states
44
+ Running children use the native Pi status line: `🤖 󰪠#21 #22 #23`. With pi-status installed, this appears at the bottom right before the existing TODO segment, separated by ` ‧ `. Display shorthand `#21` refers to tool ID `agent-21`. No running children means no agent segment; no multi-line parent widget is mounted.
41
45
 
42
- Subagent lifecycle is streamlined into three visual states in the TUI widget and status line:
46
+ Confirmed done, error, and blocked reports appear once as `subagent-info` transcript entries. Collapsed entries show the outcome, child ID, and agent name; expand to read the complete report. They survive resume and do not enter model context. The original automatic `subagent-report` still reaches the model, with its duplicate transcript display hidden. Transient errors remain subject to settlement and retry checks before reporting.
43
47
 
44
- - **`running`** (blue/amber): Starting, executing turns, auto-retrying, or waiting out the 5-second settlement debounce.
45
- - **`done`** (green checkmark `✓`): Succeeded and automatically reported final assistant text back to the parent.
46
- - **`blocked`** (red/dim `!`): Failed, interrupted, stopped, or explicitly blocked via `contact_parent`.
48
+ Use `/subagents` for complete status and management. `agent_send` can resume a done, blocked, errored, or stopped child with its session context intact; retrying does not guarantee the underlying failure is resolved.
47
49
 
48
50
  ## Host Integration and Isolation
49
51
 
50
52
  - **Herdr Workspace Affinity**: When spawning in Herdr, tabs are explicitly created in the parent process's current workspace (`--workspace <WORKSPACE_ID>`), preventing child tabs from jumping to whichever workspace happens to have user focus.
51
- - **Hindsight Memory Isolation**: Subagents are completely isolated from the parent's long-term Hindsight memory. `PI_HINDSIGHT_DISABLE=1` is set in the child environment, hindsight tools are excluded via `--exclude-tools`, and any memory recall prompt blocks are stripped to protect memory banks from subagent noise.
53
+ - **Hindsight Memory Isolation**: `PI_HINDSIGHT_DISABLE=1` is set in the child environment and memory auto-registration/recall/writeback are disabled. The child bridge blocks all `mcp__hindsight__*` calls (including nested codemode calls and future server tools), even if that server is configured in a file. Memory recall prompt blocks are stripped. This boundary applies to the reserved `hindsight` server name, not arbitrary external servers registered under other names.
52
54
 
53
55
  ## Agent definitions
54
56
 
@@ -56,11 +58,24 @@ Agent Markdown files are discovered in this order, most specific first, by `name
56
58
 
57
59
  ```text
58
60
  <cwd>/.pi/agents/*.md
59
- <cwd>/.agents/agents/*.md
61
+ <cwd>/.pi/agents/*.md
60
62
  ~/.pi/agent/agents/*.md
61
63
  ```
62
64
 
63
- Frontmatter is parsed with Pi's `parseFrontmatter` and validated against this package's schema. Supported fields: `name`, `display_name`, `description`, `hidden`, `model`, `thinking`, `tools`, `exclude_tools`, `extensions`, `skills`, `interactive`.
65
+ Frontmatter is parsed with Pi's `parseFrontmatter` and validated against this package's schema. Supported fields: `name`, `display_name`, `description`, `hidden`, `model`, `thinking`, `tools`, `exclude_tools`, `extensions`, `skills`, `mcp`, `codemode`.
66
+
67
+ ### Unified Resource Selection (Pi Semantics)
68
+
69
+ `tools`, `skills`, and `mcp` follow Pi's unified selection semantics:
70
+
71
+ - **`boolean` toggle**:
72
+ - `true` (or omitted): Full inheritance from host defaults.
73
+ - `false`: Completely disabled (passes `--no-mcp`, `--no-skills`, `--no-tools` respectively; `--no-mcp` physically shuts down the MCP subsystem so no server processes or network connections start).
74
+ - MCP selection (`mcp: [server1]`) acts at the tool exposure and execution boundary via `--tools` / `--exclude-tools` patterns and child-bridge tool call gating. Non-whitelisted or excluded server tools cannot be viewed or called.
75
+ - **Exact list (Whitelist / Replace)**:
76
+ - `[item1, item2]`: Activates only the named entries.
77
+ - **Incremental modifiers (`+` / `-`)**:
78
+ - `["+itemA", "-itemB"]`: Adds or excludes entries relative to base defaults. Exact names and modifiers cannot be mixed.
64
79
 
65
80
  ### Built-in Agents
66
81
 
@@ -47,6 +47,15 @@ export interface DiscoveredAgent {
47
47
  readonly body: string;
48
48
  readonly enabled?: boolean;
49
49
  }
50
+ export type PiSelectionType = "all" | "none" | "whitelist" | "modifier";
51
+ export interface ParsedPiSelection {
52
+ readonly type: PiSelectionType;
53
+ readonly names?: readonly string[];
54
+ readonly modifiers?: readonly string[];
55
+ readonly add?: readonly string[];
56
+ readonly remove?: readonly string[];
57
+ }
58
+ export declare function parsePiSelection(value: unknown, field: string, path: string): ParsedPiSelection;
50
59
  export declare function discoverAgents(cwd: string, homeDirectory?: string): Promise<readonly DiscoveredAgent[]>;
51
60
  /**
52
61
  * Resolves one agent definition into an immutable, non-secret policy snapshot.
@@ -22,8 +22,8 @@ const SUPPORTED_FIELDS = {
22
22
  exclude_tools: true,
23
23
  extensions: true,
24
24
  skills: true,
25
- interactive: true,
26
25
  codemode: true,
26
+ mcp: true,
27
27
  };
28
28
  /** Detects whether codemode is configured with mode: "only" in settings or environment. */
29
29
  export function detectCodemodeOnly(cwd, homeDirectory) {
@@ -35,18 +35,27 @@ export function detectCodemodeOnly(cwd, homeDirectory) {
35
35
  : homeDirectory !== undefined
36
36
  ? join(homeDirectory, ".pi", "agent")
37
37
  : getAgentDir();
38
- const candidates = [
39
- join(agentDir, "settings.json"),
40
- ...(cwd ? [join(cwd, ".pi", "settings.json")] : []),
41
- ];
42
- for (const candidate of candidates) {
43
- if (existsSync(candidate)) {
44
- const content = JSON.parse(readFileSync(candidate, "utf8"));
45
- if (isRecord(content) && isRecord(content.codemode) && content.codemode.mode === "only") {
46
- return true;
38
+ // Project settings (.pi/settings.json in cwd) take precedence over user settings (.pi/agent/settings.json)
39
+ if (cwd) {
40
+ const projectSettings = join(cwd, ".pi", "settings.json");
41
+ if (existsSync(projectSettings)) {
42
+ const content = JSON.parse(readFileSync(projectSettings, "utf8"));
43
+ if (isRecord(content) &&
44
+ isRecord(content.codemode) &&
45
+ typeof content.codemode.mode === "string") {
46
+ return content.codemode.mode === "only";
47
47
  }
48
48
  }
49
49
  }
50
+ const userSettings = join(agentDir, "settings.json");
51
+ if (existsSync(userSettings)) {
52
+ const content = JSON.parse(readFileSync(userSettings, "utf8"));
53
+ if (isRecord(content) &&
54
+ isRecord(content.codemode) &&
55
+ typeof content.codemode.mode === "string") {
56
+ return content.codemode.mode === "only";
57
+ }
58
+ }
50
59
  }
51
60
  catch {
52
61
  // Ignore filesystem or JSON parse failures
@@ -104,6 +113,161 @@ function resolveResourcePaths(value, field, path, homeDirectory) {
104
113
  return resolve(dirname(path), trimmed);
105
114
  });
106
115
  }
116
+ const DEFAULT_PI_TOOLS = ["read", "bash", "edit", "write"];
117
+ function isModifier(entry) {
118
+ return entry.startsWith("+") || entry.startsWith("-");
119
+ }
120
+ export function parsePiSelection(value, field, path) {
121
+ if (value === undefined || value === true || value === "all") {
122
+ return { type: "all" };
123
+ }
124
+ if (value === false || value === "none") {
125
+ return { type: "none" };
126
+ }
127
+ const raw = typeof value === "string" ? value.split(",") : value;
128
+ if (!Array.isArray(raw)) {
129
+ throw readableError(path, `${field} must be a boolean, string, or string array`);
130
+ }
131
+ const entries = raw.map((item) => requiredString(item, field, path));
132
+ if (entries.length === 0) {
133
+ return { type: "none" };
134
+ }
135
+ const modifiers = entries.filter(isModifier);
136
+ if (modifiers.length > 0) {
137
+ if (modifiers.length < entries.length) {
138
+ throw readableError(path, `cannot mix exact names with +name or -name entries in ${field}`);
139
+ }
140
+ const add = [];
141
+ const remove = [];
142
+ for (const entry of modifiers) {
143
+ if (entry.includes("*")) {
144
+ throw readableError(path, `+name and -name entries take exact names, not patterns: ${entry}`);
145
+ }
146
+ const name = entry.slice(1).trim();
147
+ if (name === "") {
148
+ throw readableError(path, `${field} entry has empty modifier name`);
149
+ }
150
+ if (entry.startsWith("+")) {
151
+ if (!add.includes(name))
152
+ add.push(name);
153
+ }
154
+ else {
155
+ if (!remove.includes(name))
156
+ remove.push(name);
157
+ }
158
+ }
159
+ return {
160
+ type: "modifier",
161
+ modifiers: Object.freeze(entries),
162
+ add: Object.freeze(add),
163
+ remove: Object.freeze(remove),
164
+ };
165
+ }
166
+ if (new Set(entries).size !== entries.length) {
167
+ throw readableError(path, `${field} selects the same entry twice`);
168
+ }
169
+ return { type: "whitelist", names: Object.freeze(entries) };
170
+ }
171
+ function applyModifiers(base, modifiers) {
172
+ const result = [...base];
173
+ for (const entry of modifiers) {
174
+ const name = entry.slice(1).trim();
175
+ if (entry.startsWith("+")) {
176
+ if (!result.includes(name))
177
+ result.push(name);
178
+ }
179
+ else if (entry.startsWith("-")) {
180
+ const idx = result.indexOf(name);
181
+ if (idx !== -1)
182
+ result.splice(idx, 1);
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+ function mergeDiscoveredFrontmatter(builtinFm, userFm, path, inheritBaseDefaults) {
188
+ const merged = inheritBaseDefaults
189
+ ? { ...builtinFm, ...userFm }
190
+ : { ...userFm };
191
+ if (userFm.tools !== undefined) {
192
+ const parsedUser = parsePiSelection(userFm.tools, "tools", path);
193
+ if (parsedUser.type === "modifier") {
194
+ const baseTools = stringList(builtinFm.tools, "tools", path);
195
+ const evaluatedTools = applyModifiers(baseTools, parsedUser.modifiers ?? []);
196
+ merged.tools = evaluatedTools;
197
+ const existing = Array.isArray(merged.exclude_tools)
198
+ ? merged.exclude_tools
199
+ : typeof merged.exclude_tools === "string"
200
+ ? [merged.exclude_tools]
201
+ : [];
202
+ const currentExcludes = existing.filter((e) => !evaluatedTools.includes(e));
203
+ const finalRemovals = (parsedUser.remove ?? []).filter((r) => !evaluatedTools.includes(r));
204
+ merged.exclude_tools = Array.from(new Set([...currentExcludes, ...finalRemovals]));
205
+ }
206
+ }
207
+ if (userFm.skills !== undefined) {
208
+ const parsedUser = parsePiSelection(userFm.skills, "skills", path);
209
+ if (parsedUser.type === "modifier") {
210
+ if (Array.isArray(builtinFm.skills)) {
211
+ const baseSkills = stringList(builtinFm.skills, "skills", path);
212
+ merged.skills = applyModifiers(baseSkills, parsedUser.modifiers ?? []);
213
+ }
214
+ else {
215
+ merged.skills = userFm.skills;
216
+ }
217
+ }
218
+ }
219
+ if (userFm.mcp !== undefined) {
220
+ const parsedUser = parsePiSelection(userFm.mcp, "mcp", path);
221
+ if (parsedUser.type === "modifier") {
222
+ if (Array.isArray(builtinFm.mcp)) {
223
+ const baseMcp = stringList(builtinFm.mcp, "mcp", path);
224
+ merged.mcp = applyModifiers(baseMcp, parsedUser.modifiers ?? []);
225
+ }
226
+ else if (builtinFm.mcp === false) {
227
+ const evaluated = applyModifiers([], parsedUser.modifiers ?? []);
228
+ merged.mcp = evaluated.length > 0 ? evaluated : false;
229
+ }
230
+ else {
231
+ merged.mcp = userFm.mcp;
232
+ }
233
+ }
234
+ }
235
+ return merged;
236
+ }
237
+ function mcpSelection(value, path) {
238
+ const parsed = parsePiSelection(value, "mcp", path);
239
+ if (parsed.type === "none") {
240
+ return Object.freeze({ enabled: false });
241
+ }
242
+ if (parsed.type === "all") {
243
+ return Object.freeze({ enabled: true });
244
+ }
245
+ if (parsed.type === "whitelist") {
246
+ const servers = parsed.names;
247
+ return Object.freeze({
248
+ enabled: true,
249
+ ...(servers !== undefined ? { servers } : {}),
250
+ });
251
+ }
252
+ const excluded = [];
253
+ for (const entry of parsed.modifiers ?? []) {
254
+ const name = entry.slice(1).trim();
255
+ if (entry.startsWith("-")) {
256
+ if (!excluded.includes(name))
257
+ excluded.push(name);
258
+ }
259
+ else if (entry.startsWith("+")) {
260
+ const idx = excluded.indexOf(name);
261
+ if (idx !== -1)
262
+ excluded.splice(idx, 1);
263
+ }
264
+ }
265
+ const result = {
266
+ enabled: true,
267
+ ...(excluded.length > 0 ? { excluded } : {}),
268
+ };
269
+ return Object.freeze(result);
270
+ }
107
271
  async function resourceSelection(value, field, path, homeDirectory) {
108
272
  if (value === undefined || value === true)
109
273
  return { discovery: true, paths: [] };
@@ -119,9 +283,6 @@ async function resourceSelection(value, field, path, homeDirectory) {
119
283
  }
120
284
  return { discovery: true, paths };
121
285
  }
122
- /** `skills: all` is the default and `skills: none` the opt-out; a list is a whitelist. */
123
- const SKILL_ALL = "all";
124
- const SKILL_NONE = "none";
125
286
  /**
126
287
  * Skills are chosen as a whitelist, unlike extensions: a list means "exactly these", because
127
288
  * inheriting every discovered skill is the thing a definition usually wants to narrow. A bare
@@ -129,32 +290,59 @@ const SKILL_NONE = "none";
129
290
  * absolute path the child needs; anything path-shaped stays what it was, and a name the catalog
130
291
  * does not know is dropped with a warning rather than silently matching nothing.
131
292
  */
132
- async function skillSelection(value, path, homeDirectory, catalog, warn) {
133
- if (value === undefined || value === true || value === SKILL_ALL) {
293
+ async function skillSelection(value, path, homeDirectory, catalog) {
294
+ const parsed = parsePiSelection(value, "skills", path);
295
+ if (parsed.type === "all") {
134
296
  return { discovery: true, paths: [] };
135
297
  }
136
- if (value === false || value === SKILL_NONE)
298
+ if (parsed.type === "none") {
137
299
  return { discovery: false, paths: [] };
138
- const paths = [];
139
- for (const entry of stringList(value, "skills", path)) {
140
- if (LOCAL_PATH_PATTERN.test(entry)) {
141
- const selected = resolve(dirname(path), expandHome(entry, homeDirectory));
142
- const exists = await stat(selected).then(() => true, () => false);
143
- if (!exists)
144
- throw readableError(path, `skills entry does not exist: ${selected}`);
145
- paths.push(selected);
146
- continue;
300
+ }
301
+ if (parsed.type === "whitelist") {
302
+ const paths = [];
303
+ for (const entry of parsed.names ?? []) {
304
+ if (LOCAL_PATH_PATTERN.test(entry)) {
305
+ const selected = resolve(dirname(path), expandHome(entry, homeDirectory));
306
+ const exists = await stat(selected).then(() => true, () => false);
307
+ if (!exists)
308
+ throw readableError(path, `skills entry does not exist: ${selected}`);
309
+ paths.push(selected);
310
+ continue;
311
+ }
312
+ if (entry.includes(":") || entry.includes("/") || entry.includes("\\")) {
313
+ paths.push(entry);
314
+ continue;
315
+ }
316
+ const match = catalog?.find((skill) => skill.name === entry);
317
+ if (match === undefined) {
318
+ // Pass uncatalogued skill name down to Pi CLI so Pi validates it and reports in the subagent panel
319
+ paths.push(entry);
320
+ continue;
321
+ }
322
+ paths.push(match.path);
147
323
  }
148
- if (entry.includes(":") || entry.includes("/") || entry.includes("\\")) {
149
- paths.push(entry);
150
- continue;
324
+ return { discovery: false, paths };
325
+ }
326
+ if (catalog === undefined || catalog.length === 0) {
327
+ const paths = [];
328
+ for (const entry of parsed.modifiers ?? []) {
329
+ if (entry.startsWith("+")) {
330
+ paths.push(entry.slice(1).trim());
331
+ }
151
332
  }
152
- const match = catalog?.find((skill) => skill.name === entry);
153
- if (match === undefined) {
154
- warn(`Subagent skill "${entry}" is not among the skills Pi loaded, so ${path} launches without it.`);
155
- continue;
333
+ return { discovery: false, paths };
334
+ }
335
+ const baseSkillNames = catalog.map((skill) => skill.name);
336
+ const activeSkillNames = applyModifiers(baseSkillNames, parsed.modifiers ?? []);
337
+ const paths = [];
338
+ for (const name of activeSkillNames) {
339
+ const match = catalog.find((skill) => skill.name === name);
340
+ if (match !== undefined) {
341
+ paths.push(match.path);
342
+ }
343
+ else {
344
+ paths.push(name);
156
345
  }
157
- paths.push(match.path);
158
346
  }
159
347
  return { discovery: false, paths };
160
348
  }
@@ -175,9 +363,11 @@ async function markdownFiles(directory) {
175
363
  }
176
364
  /** Most specific scope first; the first definition of a name wins deterministically. */
177
365
  function agentDirectories(cwd, homeDirectory) {
178
- const userAgentDir = process.env.PI_CODING_AGENT_DIR
179
- ? join(process.env.PI_CODING_AGENT_DIR, "agents")
180
- : join(homeDirectory, ".pi", "agent", "agents");
366
+ const userAgentDir = homeDirectory === homedir()
367
+ ? join(getAgentDir(), "agents")
368
+ : process.env.PI_CODING_AGENT_DIR
369
+ ? join(process.env.PI_CODING_AGENT_DIR, "agents")
370
+ : join(homeDirectory, ".pi", "agent", "agents");
181
371
  return [join(cwd, ".pi", "agents"), join(cwd, ".agents", "agents"), userAgentDir];
182
372
  }
183
373
  async function readAgentDefinition(path) {
@@ -200,13 +390,25 @@ export async function discoverAgents(cwd, homeDirectory = homedir()) {
200
390
  for (const directory of agentDirectories(resolve(cwd), resolve(homeDirectory))) {
201
391
  for (const path of await markdownFiles(directory)) {
202
392
  const raw = await readAgentDefinition(path);
393
+ if (selected.has(raw.name))
394
+ continue;
203
395
  const builtin = builtins.get(raw.name);
204
396
  const isBuiltin = builtin !== undefined;
205
397
  const isBodyEmpty = raw.body.trim() === "";
206
- // If it matches a built-in agent and the user markdown body is empty,
207
- // inherit the built-in's body, tools, and description!
208
- const frontmatter = isBuiltin && isBodyEmpty ? { ...builtin.frontmatter, ...raw.frontmatter } : raw.frontmatter;
209
- const body = isBuiltin && isBodyEmpty ? builtin.body : raw.body;
398
+ let frontmatter;
399
+ let body;
400
+ if (isBuiltin && isBodyEmpty) {
401
+ frontmatter = mergeDiscoveredFrontmatter(builtin.frontmatter, raw.frontmatter, path, true);
402
+ body = builtin.body;
403
+ }
404
+ else if (isBuiltin && !isBodyEmpty) {
405
+ frontmatter = mergeDiscoveredFrontmatter(builtin.frontmatter, raw.frontmatter, path, false);
406
+ body = raw.body;
407
+ }
408
+ else {
409
+ frontmatter = raw.frontmatter;
410
+ body = raw.body;
411
+ }
210
412
  const isCompliant = typeof frontmatter.model === "string" &&
211
413
  frontmatter.model.trim() !== "" &&
212
414
  (frontmatter.model.trim() === "inherit" || frontmatter.model.includes("/"));
@@ -303,18 +505,47 @@ export async function resolveAgent(options) {
303
505
  homeDirectory,
304
506
  frontmatterCodemode: codemodeValue,
305
507
  });
306
- const rawTools = stringList(discovered.frontmatter.tools, "tools", path);
307
- const excludeTools = stringList(discovered.frontmatter.exclude_tools, "exclude_tools", path);
308
- if (excludeTools.includes(CONTACT_PARENT_TOOL_NAME)) {
508
+ const excludeToolsRaw = stringList(discovered.frontmatter.exclude_tools, "exclude_tools", path);
509
+ if (excludeToolsRaw.includes(CONTACT_PARENT_TOOL_NAME)) {
309
510
  throw readableError(path, `exclude_tools cannot disable the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
310
511
  }
512
+ const parsedTools = parsePiSelection(discovered.frontmatter.tools, "tools", path);
513
+ let rawTools;
514
+ const excludeTools = [...excludeToolsRaw];
515
+ if (parsedTools.type === "none") {
516
+ rawTools = [CONTACT_PARENT_TOOL_NAME];
517
+ }
518
+ else if (parsedTools.type === "whitelist") {
519
+ rawTools = [...(parsedTools.names ?? [])];
520
+ }
521
+ else if (parsedTools.type === "modifier") {
522
+ const baseTools = [...DEFAULT_PI_TOOLS, CONTACT_PARENT_TOOL_NAME];
523
+ if (parsedTools.remove?.includes(CONTACT_PARENT_TOOL_NAME)) {
524
+ throw readableError(path, `tools modifier cannot remove the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
525
+ }
526
+ rawTools = applyModifiers(baseTools, parsedTools.modifiers ?? []);
527
+ const finalExclusions = (parsedTools.remove ?? []).filter((r) => !rawTools.includes(r));
528
+ for (const r of finalExclusions) {
529
+ if (!excludeTools.includes(r))
530
+ excludeTools.push(r);
531
+ }
532
+ }
533
+ else {
534
+ rawTools = [];
535
+ }
311
536
  const overlap = rawTools.find((tool) => excludeTools.includes(tool));
312
537
  if (overlap !== undefined)
313
538
  throw readableError(path, `tool ${overlap} is both allowed and excluded`);
314
539
  if (rawTools.length > 0 && !rawTools.includes(CONTACT_PARENT_TOOL_NAME)) {
315
540
  throw readableError(path, `tools must include the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
316
541
  }
317
- const tools = codemodeOnly && !rawTools.includes("codemode") && !excludeTools.includes("codemode")
542
+ if (excludeTools.length > 0) {
543
+ rawTools = rawTools.filter((t) => !excludeTools.includes(t));
544
+ }
545
+ const tools = rawTools.length > 0 &&
546
+ codemodeOnly &&
547
+ !rawTools.includes("codemode") &&
548
+ !excludeTools.includes("codemode")
318
549
  ? [...rawTools, "codemode"]
319
550
  : rawTools;
320
551
  const thinkingValue = discovered.frontmatter.thinking;
@@ -327,14 +558,10 @@ export async function resolveAgent(options) {
327
558
  ...extensionSelection.paths.filter((selected) => selected !== bridgeExtensionPath),
328
559
  bridgeExtensionPath,
329
560
  ];
330
- const skills = await skillSelection(discovered.frontmatter.skills, path, homeDirectory, options.skillCatalog, options.onWarning ?? (() => { }));
561
+ const skills = await skillSelection(discovered.frontmatter.skills, path, homeDirectory, options.skillCatalog);
562
+ const mcp = mcpSelection(discovered.frontmatter.mcp, path);
331
563
  const displayName = optionalString(discovered.frontmatter.display_name, "display_name", path);
332
564
  const description = optionalString(discovered.frontmatter.description, "description", path);
333
- const interactiveValue = discovered.frontmatter.interactive;
334
- if (interactiveValue !== undefined && typeof interactiveValue !== "boolean") {
335
- throw readableError(path, "interactive must be boolean");
336
- }
337
- const interactive = interactiveValue === true;
338
565
  return Object.freeze({
339
566
  agent: Object.freeze({
340
567
  name,
@@ -360,7 +587,7 @@ export async function resolveAgent(options) {
360
587
  discovery: skills.discovery,
361
588
  paths: Object.freeze([...skills.paths]),
362
589
  }),
363
- interactive,
590
+ mcp,
364
591
  enabled: discovered.enabled !== false,
365
592
  });
366
593
  }
@@ -10,6 +10,11 @@ export interface ChildBridgeClientOptions {
10
10
  readonly identity: ChildIdentity;
11
11
  /** Executes a parent request; a thrown error becomes a failure response. */
12
12
  readonly handleRequest: (request: ChildBridgeRequest) => Promise<unknown>;
13
+ /**
14
+ * Called after every successful handshake, including the first. A reconnecting child uses it to
15
+ * resend whatever state the parent needs to stay synchronized.
16
+ */
17
+ readonly onConnect?: () => void;
13
18
  readonly reconnectDelayMs?: number;
14
19
  readonly maxBufferedReports?: number;
15
20
  readonly maxFrameBytes?: number;
@@ -274,6 +274,12 @@ export class ChildBridgeClient {
274
274
  this.#connected = true;
275
275
  socket.on("close", () => this.#teardown(socket));
276
276
  socket.on("error", () => this.#teardown(socket));
277
+ try {
278
+ this.#options.onConnect?.();
279
+ }
280
+ catch (error) {
281
+ this.#diagnose(`onConnect handler failed: ${errorMessage(error)}`);
282
+ }
277
283
  void this.#flush();
278
284
  return true;
279
285
  }