@hheei/pi-subagents 0.1.2 → 0.2.3

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 (65) hide show
  1. package/README.md +45 -137
  2. package/dist/agent-resolver.d.ts +20 -0
  3. package/dist/agent-resolver.js +155 -21
  4. package/dist/bridge-client.d.ts +55 -0
  5. package/dist/bridge-client.js +453 -0
  6. package/dist/bridge-server.d.ts +72 -0
  7. package/dist/bridge-server.js +490 -0
  8. package/dist/builtin-agents.d.ts +24 -0
  9. package/dist/builtin-agents.js +152 -0
  10. package/dist/child-bridge.d.ts +31 -15
  11. package/dist/child-bridge.js +95 -117
  12. package/dist/child-control.d.ts +58 -0
  13. package/dist/child-control.js +176 -0
  14. package/dist/child-process.d.ts +21 -0
  15. package/dist/child-process.js +93 -0
  16. package/dist/commands.d.ts +2 -2
  17. package/dist/commands.js +52 -60
  18. package/dist/diagnostics.d.ts +0 -2
  19. package/dist/diagnostics.js +0 -8
  20. package/dist/domain.d.ts +57 -27
  21. package/dist/domain.js +60 -11
  22. package/dist/extension.d.ts +26 -1
  23. package/dist/extension.js +243 -46
  24. package/dist/host-adapter.d.ts +37 -12
  25. package/dist/host-adapter.js +214 -63
  26. package/dist/json-lines.js +2 -1
  27. package/dist/launch-spec.d.ts +8 -6
  28. package/dist/launch-spec.js +61 -18
  29. package/dist/manager.d.ts +93 -49
  30. package/dist/manager.js +1175 -528
  31. package/dist/markdown.d.ts +8 -0
  32. package/dist/markdown.js +21 -0
  33. package/dist/protocol.d.ts +33 -52
  34. package/dist/protocol.js +41 -52
  35. package/dist/registry.d.ts +3 -8
  36. package/dist/registry.js +41 -163
  37. package/dist/rpc-events.d.ts +3 -5
  38. package/dist/rpc-events.js +5 -9
  39. package/dist/runtime.d.ts +66 -11
  40. package/dist/runtime.js +182 -208
  41. package/dist/session-bootstrap.d.ts +23 -7
  42. package/dist/session-bootstrap.js +79 -15
  43. package/dist/session-fork.d.ts +16 -0
  44. package/dist/session-fork.js +67 -0
  45. package/dist/state.d.ts +7 -0
  46. package/dist/state.js +60 -25
  47. package/dist/tool-activation.d.ts +18 -0
  48. package/dist/tool-activation.js +227 -0
  49. package/dist/tools.d.ts +6 -3
  50. package/dist/tools.js +213 -72
  51. package/dist/widget.d.ts +4 -8
  52. package/dist/widget.js +55 -64
  53. package/package.json +51 -52
  54. package/dist/child-nudge.d.ts +0 -24
  55. package/dist/child-nudge.js +0 -102
  56. package/dist/connector.d.ts +0 -53
  57. package/dist/connector.js +0 -283
  58. package/dist/pause-gate.d.ts +0 -14
  59. package/dist/pause-gate.js +0 -64
  60. package/dist/rpc-adapter.d.ts +0 -40
  61. package/dist/rpc-adapter.js +0 -194
  62. package/dist/runner-entry.d.ts +0 -24
  63. package/dist/runner-entry.js +0 -166
  64. package/dist/runner.d.ts +0 -52
  65. package/dist/runner.js +0 -930
package/README.md CHANGED
@@ -1,42 +1,54 @@
1
1
  # @hheei/pi-subagents
2
2
 
3
3
  Run several independent Pi child sessions from one Pi session, each with its own Pi session
4
- file, its own RPC runtime, and its own durable identity.
4
+ 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
8
  model-facing tools, commands, and widget; the child branch registers only the reporting
9
9
  bridge and session-leave reporting.
10
10
 
11
- Current behavior is defined by [`docs/pi-subagents/spec.md`](../../docs/pi-subagents/spec.md).
12
- [`docs/pi-subagents/PLAN.md`](../../docs/pi-subagents/PLAN.md) and
13
- [`docs/pi-subagents/PLAN-delivery-presentation.md`](../../docs/pi-subagents/PLAN-delivery-presentation.md)
14
- are design sources, not the live contract. Remaining pause-handshake work is tracked in
15
- [`docs/pi-subagents/tickets.md`](../../docs/pi-subagents/tickets.md) SUB-08.
16
-
17
11
  ## Parent tools
18
12
 
13
+ The extension registers no global keyboard shortcuts. Use `/subagents` to manage
14
+ children and `/subagents stop [id]` to stop one without conflicting with other extensions.
15
+
19
16
  | Tool | Purpose |
20
17
  | --- | --- |
21
- | `spawn_subagent({ task, agent, cwd? })` | Start one background RPC child and return when the runtime is ready. Do not poll `get`/`list` for the child's work; reports arrive as `pi-subagent-report` messages. |
22
- | `send_subagent({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. Do not poll afterwards. |
23
- | `get_subagent({ id })` | Inspect one child: state, mode, session, summary, usage, runtime freshness, and inherited model/thinking. Use this for identity or state, not to wait. |
24
- | `list_subagents({})` | List children owned by this parent session. Use this for ids or current state, not to wait. |
25
- | `stop_subagent({ id })` | Persist a stopped intent, then end the runtime. |
26
-
27
- There is no batch-spawn tool: parallel children come from Pi's own parallel tool calls.
28
- `agent` is required; this package ships no built-in default agent, so an unresolved or
29
- missing agent name fails before any process starts.
30
-
31
- The child branch registers exactly one tool, `contact_parent({ reason, message })`, with
32
- reasons `progress_update`, `important_finding`, `need_decision`, and `blocked`. Calling it
33
- wakes the parent. If a child ends a turn without reporting, it may receive a follow-up
34
- nudge to call `contact_parent`; the child session is never auto-exited. Delay defaults to
35
- 5s (`PI_SUBAGENTS_NUDGE_DELAY_MS`); set `PI_SUBAGENTS_NUDGE_DISABLE=1` to turn it off.
36
-
37
- `get_subagent` / `list_subagents` include `interactive`, `freshness`, `updatedAt`, and the
38
- resolved `model` / `thinking` with whether each came from the agent or the parent.
39
- Reports delivered to the parent are titled with the agent display name and child id.
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`. |
24
+
25
+ ### Unified Task Management
26
+
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.
28
+
29
+ ## Child execution and reporting
30
+
31
+ Subagents communicate with the parent over a dedicated Unix domain socket bridge:
32
+
33
+ - **Completion by Direct Output**: Subagents do **not** need to call any reporting tool upon successful completion. The subagent simply outputs its final answer as normal assistant text.
34
+ - **Settlement Debounce**: When a subagent finishes a turn and remains idle for 5 seconds (to prevent false idles), the harness automatically captures the trailing assistant text and delivers it to the parent conversation dialog as a completed `subagent-report`.
35
+ - **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
+ - **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
+ - **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.
39
+
40
+ ## Three visual states
41
+
42
+ Subagent lifecycle is streamlined into three visual states in the TUI widget and status line:
43
+
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`.
47
+
48
+ ## Host Integration and Isolation
49
+
50
+ - **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.
40
52
 
41
53
  ## Agent definitions
42
54
 
@@ -48,114 +60,10 @@ Agent Markdown files are discovered in this order, most specific first, by `name
48
60
  ~/.pi/agent/agents/*.md
49
61
  ```
50
62
 
51
- Frontmatter is parsed with Pi's `parseFrontmatter` and then validated against this
52
- package's schema. Supported fields: `name`, `display_name`, `description`, `hidden`,
53
- `model`, `thinking`, `tools`, `exclude_tools`, `extensions`, `skills`, `interactive`.
54
- `interactive` defaults to `false` and is frozen into the launch snapshot. The Markdown body
55
- becomes the child's agent instructions.
56
-
57
- Validation happens before launch and fails closed:
58
-
59
- - unknown fields, and the recognized-but-unimplemented `exclude_extensions`,
60
- `preload_skills`, `max_turns`, `max_tokens` fields;
61
- - `model` that is not an exact `provider/model-id` present in Pi's `ModelRegistry`;
62
- - invalid `thinking` levels, conflicting `tools`/`exclude_tools`, missing resource paths;
63
- - any policy that would remove the `contact_parent` bridge.
64
-
65
- `model` and `thinking` fall back to the parent session's current values, and the resolved
66
- result records whether each value came from the agent (`agent`) or the parent (`parent`).
67
- Models are never chosen by fuzzy match.
68
-
69
- ## Launch configuration
70
-
71
- One builder produces every child process description. It resolves the Pi invocation, the
72
- full argv, cwd, session placement, model/thinking, tool/extension/skill selection, the
73
- assembled prompt, and the non-secret bridge environment, then reports `mode` and `stdio`
74
- separately. Values that contain spaces stay single argv atoms, and no shell is involved.
75
-
76
- Session identity is always explicit: a session that has never been flushed is created with
77
- `--session-id <id> --session-dir <dir>`, and a flushed session is opened by path. No path
78
- opens an absent file, so a child can never silently acquire a random session id.
79
- `extensions: false` keeps this package's absolute `-e <entry>` bridge argument while
80
- disabling discovery, so a child never loses `contact_parent` and never gains a manager.
81
-
82
- ## Presentation hosts
83
-
84
- A HostAdapter only carries a native TUI. It receives the same LaunchSpec the RPC runner
85
- uses, probes real host capability, and never re-resolves agent, model, or Pi flags.
86
-
87
- `/attach-subagent <id>` opens an RPC child as native Pi in Herdr or cmux. With no
88
- id, `/subagents` or the attach shortcut uses `ctx.ui.select`. An idle child switches
89
- immediately. A busy child waits for the current turn's `turn_end`, then closes the
90
- RPC writer. Timeout or cancel keeps the RPC writer and fails visibly. Session files
91
- that have never flushed refuse attach. There is no model-facing attach tool.
92
-
93
- - Default selection is Herdr, then cmux. An explicit unavailable host fails visibly and
94
- does not fall back.
95
- - Probe talks to the live session (`herdr pane current` inside `HERDR_ENV=1`, `cmux ping`
96
- plus `capabilities`). A binary on PATH is not enough.
97
- - Herdr attach splits with `--cwd` / `--env` and runs the quoted command+argv. cmux attach
98
- uses `new-split --command`. Neither path invents a second argv.
99
- - Observation uses host process APIs where they exist. A pane still being open is not
100
- evidence that the Pi process is alive. Command timeout is not rollback: the attachment
101
- is returned so the caller can inspect it, and nothing is closed automatically.
102
- - Cleanup closes only the pane or surface this transition created and still owns.
103
- - After a confirmed TUI process exit (`/quit`, pane close, crash), the same runner restores
104
- RPC on session A and waits for input. Crash adds an extra diagnostic. A pane event with a
105
- still-live process does not detach.
106
- - `/new`, `/resume` other, and `/fork` unbind A only after the TUI is confirmed on session
107
- B. A returns to RPC; B is a native Pi session, not a subagent, and cannot `contact_parent`
108
- as A. `/reload` and `/resume` of A do not detach. This package does not intercept a
109
- user opening the same session with native `/resume` outside the managed path.
110
- - A confirmed user interrupt (Pi `stopReason: aborted`) notifies the parent and stays on
111
- the TUI. Nothing is auto-resumed.
112
-
113
- ## Persistence
114
-
115
- Each parent session gets its own registry file under
116
- `<agent-dir>/pi-subagents/registry/<parentSessionId>.json`, written through ext-core's
117
- atomic, cross-process-locked JSON root update. Nothing is written to `settings.json`.
118
-
119
- A record holds the child id, parent session id, session id/path, cwd, initial task, active
120
- or stopped intent, the non-secret launch configuration, state and mode, last-known summary
121
- and usage, and the reconnect metadata (`runtimeIdentity`, `endpoint`, optional `pid`).
122
- Revisions are monotonic; stale writers and updates aimed at a replaced runtime are
123
- rejected. Corruption, unsupported versions, parent mismatch, and identity mismatch fail
124
- closed instead of returning partial state. API keys and controller tokens are never
125
- persisted.
126
-
127
- Spawn order is fixed: resolve, persist intent, then start the runner. If the registry write
128
- fails, no child process is started, and a session that has already flushed must still prove
129
- its id on disk before anything opens it.
130
-
131
- Parent reload reconnects a surviving runner by `runtimeIdentity` and endpoint. Replacement
132
- starts only after the old runner PID is confirmed dead (a reused PID is not treated as
133
- ours). Pending input is marked interrupted and never replayed. A flushed session whose
134
- file is missing fails closed; a never-flushed child keeps its original session id.
135
-
136
- ## Observation and commands
137
-
138
- An interactive TUI parent shows an above-editor widget of live children (`starting`,
139
- `running`, `idle`). It is a projection of `list()`:
140
- no second running set, no border, no file polling. Rows use the agent display name when
141
- present, mark `last known` when the runner is not connected, and show elapsed time since
142
- spawn. Headless/RPC parents do not mount it.
143
-
144
- `setStatus` shows a compact `running` / `idle` / `tui` / `failed` line, including
145
- `interrupted` when that diagnostic is set. `/subagents` lists, inspects, attaches, sends,
146
- or stops through `ctx.ui.select`. `/attach-subagent` and `/stop-subagent` accept an id or
147
- open the same picker. Shortcuts `ctrl+shift+a` and `ctrl+shift+s` call the same manager
148
- operations. Spawn/send/get/list/stop results render through ext-core ToolTui so collapsed
149
- output still keeps the full details.
150
-
151
- A TUI child shows one borderless identity line (agent, `contact_parent`, tool count). It
152
- is not a control surface.
153
-
154
- ## Notes
155
-
156
- - `@hheei/pi-ext-core` is a production dependency, not a separately loaded extension.
157
- - Child processes are deliberately independent of the parent process lifetime; parent
158
- cleanup releases local connections only.
159
- - This package does not use ext-core's in-process `startSubagent`: that contract cancels the
160
- child when the parent shuts down, which is incompatible with surviving, reconnectable
161
- children.
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`.
64
+
65
+ ### Built-in Agents
66
+
67
+ - **`scout`**: Read-only reconnaissance agent. Disabled by default until the user explicitly configures a model in `~/.pi/agent/agents/scout.md`.
68
+ - **`worker`**: General implementation agent. Configured with `model: inherit` to inherit the parent session's model and thinking level.
69
+ - **`reviewer`**: Code review agent. Configured with `model: inherit` and leverages skill discovery to run review skills.
@@ -1,4 +1,11 @@
1
1
  import type { ResolvedAgentPolicy, ThinkingLevel } from "./domain.js";
2
+ /** Detects whether codemode is configured with mode: "only" in settings or environment. */
3
+ export declare function detectCodemodeOnly(cwd?: string, homeDirectory?: string): boolean;
4
+ export declare function isCodemodeOnlyMode(options: {
5
+ cwd?: string;
6
+ homeDirectory?: string;
7
+ frontmatterCodemode?: unknown;
8
+ }): boolean;
2
9
  export interface ModelRegistryLike {
3
10
  /** `ModelRegistry.find` from the Pi extension context: exact provider/id lookup. */
4
11
  find(provider: string, modelId: string): unknown | undefined;
@@ -12,6 +19,14 @@ export interface ParentAgentDefaults {
12
19
  /** Parent's current thinking level, inherited only when the definition does not name one. */
13
20
  readonly thinking: ThinkingLevel;
14
21
  }
22
+ /**
23
+ * One skill Pi has loaded, as the parent sees it. A definition names skills the way Pi lists them,
24
+ * so the parent's own catalog is the only authority for name → path; nothing here re-scans disk.
25
+ */
26
+ export interface SkillCatalogEntry {
27
+ readonly name: string;
28
+ readonly path: string;
29
+ }
15
30
  export interface ResolveAgentOptions {
16
31
  readonly name: string;
17
32
  readonly cwd: string;
@@ -20,12 +35,17 @@ export interface ResolveAgentOptions {
20
35
  /** Absolute path to this package's extension entry, always kept in the child extension selection. */
21
36
  readonly bridgeExtensionPath: string;
22
37
  readonly homeDirectory?: string;
38
+ /** Parent's loaded skills, the catalog a definition's skill names resolve against. */
39
+ readonly skillCatalog?: readonly SkillCatalogEntry[];
40
+ /** Diagnostics for the caller to surface; a caller that omits this drops them. */
41
+ readonly onWarning?: (message: string) => void;
23
42
  }
24
43
  export interface DiscoveredAgent {
25
44
  readonly name: string;
26
45
  readonly path: string;
27
46
  readonly frontmatter: Record<string, unknown>;
28
47
  readonly body: string;
48
+ readonly enabled?: boolean;
29
49
  }
30
50
  export declare function discoverAgents(cwd: string, homeDirectory?: string): Promise<readonly DiscoveredAgent[]>;
31
51
  /**
@@ -1,8 +1,11 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { readdir, readFile, stat } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
- import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
- import { CONTACT_PARENT_TOOL_NAME, isRecord, isThinkingLevel } from "./domain.js";
5
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
6
+ import { expandHome, isRecord } from "@hheei/pi-ext-core";
7
+ import { builtinAgents } from "./builtin-agents.js";
8
+ import { CONTACT_PARENT_TOOL_NAME, isThinkingLevel } from "./domain.js";
6
9
  /**
7
10
  * Agent definition fields this version actually implements. Anything outside this
8
11
  * table fails before launch; `DEFERRED_FIELDS` exists only to give those known
@@ -20,7 +23,44 @@ const SUPPORTED_FIELDS = {
20
23
  extensions: true,
21
24
  skills: true,
22
25
  interactive: true,
26
+ codemode: true,
23
27
  };
28
+ /** Detects whether codemode is configured with mode: "only" in settings or environment. */
29
+ export function detectCodemodeOnly(cwd, homeDirectory) {
30
+ if (process.env.PI_CODEMODE_MODE === "only")
31
+ return true;
32
+ try {
33
+ const agentDir = process.env.PI_CODING_AGENT_DIR !== undefined && process.env.PI_CODING_AGENT_DIR !== ""
34
+ ? process.env.PI_CODING_AGENT_DIR
35
+ : homeDirectory !== undefined
36
+ ? join(homeDirectory, ".pi", "agent")
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;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // Ignore filesystem or JSON parse failures
53
+ }
54
+ return false;
55
+ }
56
+ export function isCodemodeOnlyMode(options) {
57
+ const val = options.frontmatterCodemode;
58
+ if (val === "only" || val === true)
59
+ return true;
60
+ if (val === "on" || val === false || val === "off")
61
+ return false;
62
+ return detectCodemodeOnly(options.cwd, options.homeDirectory);
63
+ }
24
64
  /** Recognized candidate fields with no execution semantics yet; silently ignoring them is prohibited. */
25
65
  const DEFERRED_FIELDS = {
26
66
  exclude_extensions: true,
@@ -56,20 +96,20 @@ function stringList(value, field, path) {
56
96
  }
57
97
  const LOCAL_PATH_PATTERN = /^(?:[A-Za-z]:[\\/]|[~/]|\.{1,2}[\\/])/;
58
98
  /** Local selections become absolute paths next to the definition; package specs are left for Pi to resolve. */
59
- function resolveResourcePaths(value, field, path) {
99
+ function resolveResourcePaths(value, field, path, homeDirectory) {
60
100
  return stringList(value, field, path).map((item) => {
61
101
  if (!LOCAL_PATH_PATTERN.test(item))
62
102
  return item;
63
- const trimmed = item.startsWith("~/") ? join(homedir(), item.slice(2)) : item;
103
+ const trimmed = expandHome(item, homeDirectory);
64
104
  return resolve(dirname(path), trimmed);
65
105
  });
66
106
  }
67
- async function resourceSelection(value, field, path) {
107
+ async function resourceSelection(value, field, path, homeDirectory) {
68
108
  if (value === undefined || value === true)
69
109
  return { discovery: true, paths: [] };
70
110
  if (value === false)
71
111
  return { discovery: false, paths: [] };
72
- const paths = resolveResourcePaths(value, field, path);
112
+ const paths = resolveResourcePaths(value, field, path, homeDirectory);
73
113
  for (const selected of paths) {
74
114
  if (!isAbsolute(selected))
75
115
  continue;
@@ -79,6 +119,45 @@ async function resourceSelection(value, field, path) {
79
119
  }
80
120
  return { discovery: true, paths };
81
121
  }
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
+ /**
126
+ * Skills are chosen as a whitelist, unlike extensions: a list means "exactly these", because
127
+ * inheriting every discovered skill is the thing a definition usually wants to narrow. A bare
128
+ * word is a skill *name* and resolves against the parent's loaded catalog, which also gives the
129
+ * absolute path the child needs; anything path-shaped stays what it was, and a name the catalog
130
+ * does not know is dropped with a warning rather than silently matching nothing.
131
+ */
132
+ async function skillSelection(value, path, homeDirectory, catalog, warn) {
133
+ if (value === undefined || value === true || value === SKILL_ALL) {
134
+ return { discovery: true, paths: [] };
135
+ }
136
+ if (value === false || value === SKILL_NONE)
137
+ 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;
147
+ }
148
+ if (entry.includes(":") || entry.includes("/") || entry.includes("\\")) {
149
+ paths.push(entry);
150
+ continue;
151
+ }
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;
156
+ }
157
+ paths.push(match.path);
158
+ }
159
+ return { discovery: false, paths };
160
+ }
82
161
  async function markdownFiles(directory) {
83
162
  let entries;
84
163
  try {
@@ -96,11 +175,10 @@ async function markdownFiles(directory) {
96
175
  }
97
176
  /** Most specific scope first; the first definition of a name wins deterministically. */
98
177
  function agentDirectories(cwd, homeDirectory) {
99
- return [
100
- join(cwd, ".pi", "agents"),
101
- join(cwd, ".agents", "agents"),
102
- join(homeDirectory, ".pi", "agent", "agents"),
103
- ];
178
+ const userAgentDir = process.env.PI_CODING_AGENT_DIR
179
+ ? join(process.env.PI_CODING_AGENT_DIR, "agents")
180
+ : join(homeDirectory, ".pi", "agent", "agents");
181
+ return [join(cwd, ".pi", "agents"), join(cwd, ".agents", "agents"), userAgentDir];
104
182
  }
105
183
  async function readAgentDefinition(path) {
106
184
  const content = await readFile(path, "utf8").catch((error) => {
@@ -118,17 +196,51 @@ async function readAgentDefinition(path) {
118
196
  }
119
197
  export async function discoverAgents(cwd, homeDirectory = homedir()) {
120
198
  const selected = new Map();
199
+ const builtins = new Map(builtinAgents().map((a) => [a.name, a]));
121
200
  for (const directory of agentDirectories(resolve(cwd), resolve(homeDirectory))) {
122
201
  for (const path of await markdownFiles(directory)) {
123
- const agent = await readAgentDefinition(path);
202
+ const raw = await readAgentDefinition(path);
203
+ const builtin = builtins.get(raw.name);
204
+ const isBuiltin = builtin !== undefined;
205
+ 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;
210
+ const isCompliant = typeof frontmatter.model === "string" &&
211
+ frontmatter.model.trim() !== "" &&
212
+ (frontmatter.model.trim() === "inherit" || frontmatter.model.includes("/"));
213
+ const agent = {
214
+ name: raw.name,
215
+ path: raw.path,
216
+ frontmatter,
217
+ body,
218
+ enabled: isCompliant,
219
+ };
124
220
  if (!selected.has(agent.name))
125
221
  selected.set(agent.name, agent);
126
222
  }
127
223
  }
224
+ // Built-ins not overridden on disk:
225
+ // If a built-in agent explicitly specifies model (e.g. model: inherit like reviewer/worker),
226
+ // it is compliant and directly available.
227
+ // If a built-in agent omits model (like scout), it is non-compliant and not loaded
228
+ // until the user supplements model metadata in user configuration.
229
+ for (const builtin of builtinAgents()) {
230
+ if (!selected.has(builtin.name)) {
231
+ const isCompliant = typeof builtin.frontmatter.model === "string" &&
232
+ builtin.frontmatter.model.trim() !== "" &&
233
+ (builtin.frontmatter.model.trim() === "inherit" || builtin.frontmatter.model.includes("/"));
234
+ selected.set(builtin.name, {
235
+ ...builtin,
236
+ enabled: isCompliant,
237
+ });
238
+ }
239
+ }
128
240
  return [...selected.values()];
129
241
  }
130
242
  function resolveModel(value, defaults, registry, path) {
131
- if (value === undefined) {
243
+ if (value === undefined || value === "inherit") {
132
244
  if (registry.find(defaults.model.provider, defaults.model.id) === undefined) {
133
245
  throw readableError(path, `parent model ${defaults.model.provider}/${defaults.model.id} is unavailable`);
134
246
  }
@@ -153,10 +265,14 @@ function resolveModel(value, defaults, registry, path) {
153
265
  * before any child process exists.
154
266
  */
155
267
  export async function resolveAgent(options) {
156
- const agents = await discoverAgents(options.cwd, options.homeDirectory);
268
+ const homeDirectory = resolve(options.homeDirectory ?? homedir());
269
+ const agents = await discoverAgents(options.cwd, homeDirectory);
157
270
  const discovered = agents.find((agent) => agent.name === options.name);
158
- if (discovered === undefined)
159
- throw new Error(`Unknown agent ${options.name}`);
271
+ if (discovered === undefined) {
272
+ const available = agents.map((agent) => agent.name).join(", ");
273
+ const searched = agentDirectories(resolve(options.cwd), homeDirectory).join(", ");
274
+ throw new Error(`Unknown agent ${options.name}. Available: [${available || "none"}]. Searched: ${searched}`);
275
+ }
160
276
  const path = discovered.path;
161
277
  for (const field of Object.keys(discovered.frontmatter)) {
162
278
  if (SUPPORTED_FIELDS[field] === true)
@@ -174,28 +290,44 @@ export async function resolveAgent(options) {
174
290
  const body = discovered.body.trim();
175
291
  if (body === "")
176
292
  throw readableError(path, "Markdown body must not be empty");
177
- const tools = stringList(discovered.frontmatter.tools, "tools", path);
293
+ const codemodeValue = discovered.frontmatter.codemode;
294
+ if (codemodeValue !== undefined &&
295
+ codemodeValue !== "only" &&
296
+ codemodeValue !== "on" &&
297
+ codemodeValue !== "off" &&
298
+ typeof codemodeValue !== "boolean") {
299
+ throw readableError(path, "codemode must be boolean, 'only', 'on', or 'off'");
300
+ }
301
+ const codemodeOnly = isCodemodeOnlyMode({
302
+ cwd: options.cwd,
303
+ homeDirectory,
304
+ frontmatterCodemode: codemodeValue,
305
+ });
306
+ const rawTools = stringList(discovered.frontmatter.tools, "tools", path);
178
307
  const excludeTools = stringList(discovered.frontmatter.exclude_tools, "exclude_tools", path);
179
308
  if (excludeTools.includes(CONTACT_PARENT_TOOL_NAME)) {
180
309
  throw readableError(path, `exclude_tools cannot disable the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
181
310
  }
182
- const overlap = tools.find((tool) => excludeTools.includes(tool));
311
+ const overlap = rawTools.find((tool) => excludeTools.includes(tool));
183
312
  if (overlap !== undefined)
184
313
  throw readableError(path, `tool ${overlap} is both allowed and excluded`);
185
- if (tools.length > 0 && !tools.includes(CONTACT_PARENT_TOOL_NAME)) {
314
+ if (rawTools.length > 0 && !rawTools.includes(CONTACT_PARENT_TOOL_NAME)) {
186
315
  throw readableError(path, `tools must include the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
187
316
  }
317
+ const tools = codemodeOnly && !rawTools.includes("codemode") && !excludeTools.includes("codemode")
318
+ ? [...rawTools, "codemode"]
319
+ : rawTools;
188
320
  const thinkingValue = discovered.frontmatter.thinking;
189
321
  if (thinkingValue !== undefined && !isThinkingLevel(thinkingValue)) {
190
322
  throw readableError(path, "thinking is invalid");
191
323
  }
192
- const extensionSelection = await resourceSelection(discovered.frontmatter.extensions, "extensions", path);
324
+ const extensionSelection = await resourceSelection(discovered.frontmatter.extensions, "extensions", path, homeDirectory);
193
325
  const bridgeExtensionPath = resolve(options.bridgeExtensionPath);
194
326
  const extensionPaths = [
195
327
  ...extensionSelection.paths.filter((selected) => selected !== bridgeExtensionPath),
196
328
  bridgeExtensionPath,
197
329
  ];
198
- const skills = await resourceSelection(discovered.frontmatter.skills, "skills", path);
330
+ const skills = await skillSelection(discovered.frontmatter.skills, path, homeDirectory, options.skillCatalog, options.onWarning ?? (() => { }));
199
331
  const displayName = optionalString(discovered.frontmatter.display_name, "display_name", path);
200
332
  const description = optionalString(discovered.frontmatter.description, "description", path);
201
333
  const interactiveValue = discovered.frontmatter.interactive;
@@ -218,6 +350,7 @@ export async function resolveAgent(options) {
218
350
  source: thinkingValue === undefined ? "parent" : "agent",
219
351
  }),
220
352
  tools: Object.freeze(tools),
353
+ codemodeOnly,
221
354
  excludeTools: Object.freeze(excludeTools),
222
355
  extensions: Object.freeze({
223
356
  discovery: extensionSelection.discovery,
@@ -228,5 +361,6 @@ export async function resolveAgent(options) {
228
361
  paths: Object.freeze([...skills.paths]),
229
362
  }),
230
363
  interactive,
364
+ enabled: discovered.enabled !== false,
231
365
  });
232
366
  }
@@ -0,0 +1,55 @@
1
+ import { type ChildIdentity } from "./domain.js";
2
+ import { type ChildInputSource } from "./protocol.js";
3
+ export interface ChildBridgeRequest {
4
+ readonly operation: string;
5
+ readonly payload: unknown;
6
+ }
7
+ export interface ChildBridgeClientOptions {
8
+ /** The parent's bridge socket. */
9
+ readonly endpoint: string;
10
+ readonly identity: ChildIdentity;
11
+ /** Executes a parent request; a thrown error becomes a failure response. */
12
+ readonly handleRequest: (request: ChildBridgeRequest) => Promise<unknown>;
13
+ readonly reconnectDelayMs?: number;
14
+ readonly maxBufferedReports?: number;
15
+ readonly maxFrameBytes?: number;
16
+ readonly connectTimeoutMs?: number;
17
+ readonly reportTimeoutMs?: number;
18
+ readonly diagnose?: (message: string) => void;
19
+ }
20
+ /**
21
+ * The child side of the bridge: one long-lived connection to the parent, carrying both directions
22
+ * of control.
23
+ *
24
+ * The child therefore never owns its own lifecycle endpoint: it dials the parent, keeps dialing
25
+ * while the parent is away, and reconnects with the same identity when the parent comes back (a
26
+ * parent restart re-listens the same socket path). Reports the parent has not accepted yet are
27
+ * buffered here, bounded, and flushed in order after a reconnect, so a report is never silently
28
+ * lost just because the parent was momentarily gone. Pi events are advisory snapshots and are
29
+ * dropped instead of buffered.
30
+ */
31
+ export declare class ChildBridgeClient {
32
+ #private;
33
+ constructor(options: ChildBridgeClientOptions);
34
+ get connected(): boolean;
35
+ /**
36
+ * Starts the connect loop. It keeps retrying on its own: a panel child outlives the parent
37
+ * process that started it, so being unable to reach the parent is a state to wait out, not a
38
+ * failure to give up on. Call `close()` to stop it.
39
+ */
40
+ start(): void;
41
+ /** Sends an advisory frame: Pi events and child lifecycle signals. */
42
+ sendEvent(event: unknown): void;
43
+ /**
44
+ * Tells the parent that input arrived, with its source and nothing else. The parent uses this
45
+ * to cancel an idle countdown, so the text of a human message stays inside the child.
46
+ */
47
+ sendChildInput(source: ChildInputSource): void;
48
+ /**
49
+ * Reports to the parent, waiting for its answer. While the parent is unreachable the report is
50
+ * buffered instead of failed, and delivered after the reconnect.
51
+ */
52
+ sendReport(operation: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
53
+ /** Stops reconnecting and fails everything still waiting for the parent. */
54
+ close(): void;
55
+ }