@hheei/pi-subagents 0.1.2 → 0.2.2
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/README.md +42 -137
- package/dist/agent-resolver.d.ts +20 -0
- package/dist/agent-resolver.js +155 -21
- package/dist/bridge-client.d.ts +55 -0
- package/dist/bridge-client.js +453 -0
- package/dist/bridge-server.d.ts +72 -0
- package/dist/bridge-server.js +490 -0
- package/dist/builtin-agents.d.ts +24 -0
- package/dist/builtin-agents.js +152 -0
- package/dist/child-bridge.d.ts +31 -15
- package/dist/child-bridge.js +95 -117
- package/dist/child-control.d.ts +58 -0
- package/dist/child-control.js +176 -0
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +93 -0
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +52 -52
- package/dist/diagnostics.d.ts +0 -2
- package/dist/diagnostics.js +0 -8
- package/dist/domain.d.ts +57 -27
- package/dist/domain.js +60 -11
- package/dist/extension.d.ts +26 -1
- package/dist/extension.js +243 -46
- package/dist/host-adapter.d.ts +37 -12
- package/dist/host-adapter.js +214 -63
- package/dist/json-lines.js +2 -1
- package/dist/launch-spec.d.ts +8 -6
- package/dist/launch-spec.js +61 -18
- package/dist/manager.d.ts +93 -49
- package/dist/manager.js +1175 -528
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +21 -0
- package/dist/protocol.d.ts +33 -52
- package/dist/protocol.js +41 -52
- package/dist/registry.d.ts +3 -8
- package/dist/registry.js +41 -163
- package/dist/rpc-events.d.ts +3 -5
- package/dist/rpc-events.js +5 -9
- package/dist/runtime.d.ts +66 -11
- package/dist/runtime.js +182 -208
- package/dist/session-bootstrap.d.ts +23 -7
- package/dist/session-bootstrap.js +79 -15
- package/dist/session-fork.d.ts +16 -0
- package/dist/session-fork.js +67 -0
- package/dist/state.d.ts +7 -0
- package/dist/state.js +60 -25
- package/dist/tool-activation.d.ts +18 -0
- package/dist/tool-activation.js +227 -0
- package/dist/tools.d.ts +6 -3
- package/dist/tools.js +213 -72
- package/dist/widget.d.ts +4 -8
- package/dist/widget.js +55 -64
- package/package.json +51 -52
- package/dist/child-nudge.d.ts +0 -24
- package/dist/child-nudge.js +0 -102
- package/dist/connector.d.ts +0 -53
- package/dist/connector.js +0 -283
- package/dist/pause-gate.d.ts +0 -14
- package/dist/pause-gate.js +0 -64
- package/dist/rpc-adapter.d.ts +0 -40
- package/dist/rpc-adapter.js +0 -194
- package/dist/runner-entry.d.ts +0 -24
- package/dist/runner-entry.js +0 -166
- package/dist/runner.d.ts +0 -52
- package/dist/runner.js +0 -930
package/README.md
CHANGED
|
@@ -1,42 +1,51 @@
|
|
|
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
|
|
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
|
|
|
19
13
|
| Tool | Purpose |
|
|
20
14
|
| --- | --- |
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
15
|
+
| `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. |
|
|
16
|
+
| `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. |
|
|
17
|
+
| `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`. |
|
|
18
|
+
| `get_agent({ id })` | Inspect one child: state (`running` / `done` / `blocked`), presentation (`panel`/`background`), session, summary, usage, runtime freshness, and model/thinking. Requires `subagent_enable`. |
|
|
19
|
+
| `list_agents({})` | List available interactive agent definitions and running subagents owned by this parent session. |
|
|
20
|
+
| `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`. |
|
|
21
|
+
|
|
22
|
+
### Unified Task Management
|
|
23
|
+
|
|
24
|
+
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
|
+
|
|
26
|
+
## Child execution and reporting
|
|
27
|
+
|
|
28
|
+
Subagents communicate with the parent over a dedicated Unix domain socket bridge:
|
|
29
|
+
|
|
30
|
+
- **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.
|
|
31
|
+
- **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`.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
## Three visual states
|
|
38
|
+
|
|
39
|
+
Subagent lifecycle is streamlined into three visual states in the TUI widget and status line:
|
|
40
|
+
|
|
41
|
+
- **`running`** (blue/amber): Starting, executing turns, auto-retrying, or waiting out the 5-second settlement debounce.
|
|
42
|
+
- **`done`** (green checkmark `✓`): Succeeded and automatically reported final assistant text back to the parent.
|
|
43
|
+
- **`blocked`** (red/dim `!`): Failed, interrupted, stopped, or explicitly blocked via `contact_parent`.
|
|
44
|
+
|
|
45
|
+
## Host Integration and Isolation
|
|
46
|
+
|
|
47
|
+
- **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.
|
|
48
|
+
- **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
49
|
|
|
41
50
|
## Agent definitions
|
|
42
51
|
|
|
@@ -48,114 +57,10 @@ Agent Markdown files are discovered in this order, most specific first, by `name
|
|
|
48
57
|
~/.pi/agent/agents/*.md
|
|
49
58
|
```
|
|
50
59
|
|
|
51
|
-
Frontmatter is parsed with Pi's `parseFrontmatter` and
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
60
|
+
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`.
|
|
61
|
+
|
|
62
|
+
### Built-in Agents
|
|
63
|
+
|
|
64
|
+
- **`scout`**: Read-only reconnaissance agent. Disabled by default until the user explicitly configures a model in `~/.pi/agent/agents/scout.md`.
|
|
65
|
+
- **`worker`**: General implementation agent. Configured with `model: inherit` to inherit the parent session's model and thinking level.
|
|
66
|
+
- **`reviewer`**: Code review agent. Configured with `model: inherit` and leverages skill discovery to run review skills.
|
package/dist/agent-resolver.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/agent-resolver.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
100
|
-
join(
|
|
101
|
-
join(
|
|
102
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
+
}
|