@hheei/pi-subagents 0.1.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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/agent-resolver.d.ts +37 -0
- package/dist/agent-resolver.js +232 -0
- package/dist/child-bridge.d.ts +41 -0
- package/dist/child-bridge.js +214 -0
- package/dist/child-nudge.d.ts +24 -0
- package/dist/child-nudge.js +102 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +155 -0
- package/dist/connector.d.ts +53 -0
- package/dist/connector.js +283 -0
- package/dist/diagnostics.d.ts +10 -0
- package/dist/diagnostics.js +20 -0
- package/dist/domain.d.ts +192 -0
- package/dist/domain.js +63 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +145 -0
- package/dist/host-adapter.d.ts +76 -0
- package/dist/host-adapter.js +323 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/json-lines.d.ts +14 -0
- package/dist/json-lines.js +94 -0
- package/dist/launch-spec.d.ts +47 -0
- package/dist/launch-spec.js +149 -0
- package/dist/manager.d.ts +101 -0
- package/dist/manager.js +802 -0
- package/dist/pause-gate.d.ts +14 -0
- package/dist/pause-gate.js +64 -0
- package/dist/protocol.d.ts +141 -0
- package/dist/protocol.js +168 -0
- package/dist/registry.d.ts +41 -0
- package/dist/registry.js +575 -0
- package/dist/rpc-adapter.d.ts +40 -0
- package/dist/rpc-adapter.js +194 -0
- package/dist/rpc-events.d.ts +8 -0
- package/dist/rpc-events.js +29 -0
- package/dist/runner-entry.d.ts +24 -0
- package/dist/runner-entry.js +166 -0
- package/dist/runner.d.ts +52 -0
- package/dist/runner.js +930 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +253 -0
- package/dist/session-bootstrap.d.ts +69 -0
- package/dist/session-bootstrap.js +195 -0
- package/dist/state.d.ts +15 -0
- package/dist/state.js +168 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +158 -0
- package/dist/widget.d.ts +16 -0
- package/dist/widget.js +158 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hheei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @hheei/pi-subagents
|
|
2
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
The package is a single concrete extension with one `pi.extensions` entry
|
|
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
|
|
9
|
+
bridge and session-leave reporting.
|
|
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
|
+
## Parent tools
|
|
18
|
+
|
|
19
|
+
| Tool | Purpose |
|
|
20
|
+
| --- | --- |
|
|
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.
|
|
40
|
+
|
|
41
|
+
## Agent definitions
|
|
42
|
+
|
|
43
|
+
Agent Markdown files are discovered in this order, most specific first, by `name`:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
<cwd>/.pi/agents/*.md
|
|
47
|
+
<cwd>/.agents/agents/*.md
|
|
48
|
+
~/.pi/agent/agents/*.md
|
|
49
|
+
```
|
|
50
|
+
|
|
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.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ResolvedAgentPolicy, ThinkingLevel } from "./domain.js";
|
|
2
|
+
export interface ModelRegistryLike {
|
|
3
|
+
/** `ModelRegistry.find` from the Pi extension context: exact provider/id lookup. */
|
|
4
|
+
find(provider: string, modelId: string): unknown | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface ParentAgentDefaults {
|
|
7
|
+
/** Parent's current model, inherited only when the definition does not name one. */
|
|
8
|
+
readonly model: {
|
|
9
|
+
readonly provider: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
};
|
|
12
|
+
/** Parent's current thinking level, inherited only when the definition does not name one. */
|
|
13
|
+
readonly thinking: ThinkingLevel;
|
|
14
|
+
}
|
|
15
|
+
export interface ResolveAgentOptions {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly cwd: string;
|
|
18
|
+
readonly modelRegistry: ModelRegistryLike;
|
|
19
|
+
readonly parent: ParentAgentDefaults;
|
|
20
|
+
/** Absolute path to this package's extension entry, always kept in the child extension selection. */
|
|
21
|
+
readonly bridgeExtensionPath: string;
|
|
22
|
+
readonly homeDirectory?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface DiscoveredAgent {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly frontmatter: Record<string, unknown>;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function discoverAgents(cwd: string, homeDirectory?: string): Promise<readonly DiscoveredAgent[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Resolves one agent definition into an immutable, non-secret policy snapshot.
|
|
33
|
+
* Unknown fields, deferred fields, unknown models, invalid thinking levels,
|
|
34
|
+
* conflicting tool policy, and a disabled `contact_parent` bridge all fail here,
|
|
35
|
+
* before any child process exists.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveAgent(options: ResolveAgentOptions): Promise<ResolvedAgentPolicy>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
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";
|
|
6
|
+
/**
|
|
7
|
+
* Agent definition fields this version actually implements. Anything outside this
|
|
8
|
+
* table fails before launch; `DEFERRED_FIELDS` exists only to give those known
|
|
9
|
+
* candidates a clearer message than an outright typo.
|
|
10
|
+
*/
|
|
11
|
+
const SUPPORTED_FIELDS = {
|
|
12
|
+
name: true,
|
|
13
|
+
display_name: true,
|
|
14
|
+
description: true,
|
|
15
|
+
hidden: true,
|
|
16
|
+
model: true,
|
|
17
|
+
thinking: true,
|
|
18
|
+
tools: true,
|
|
19
|
+
exclude_tools: true,
|
|
20
|
+
extensions: true,
|
|
21
|
+
skills: true,
|
|
22
|
+
interactive: true,
|
|
23
|
+
};
|
|
24
|
+
/** Recognized candidate fields with no execution semantics yet; silently ignoring them is prohibited. */
|
|
25
|
+
const DEFERRED_FIELDS = {
|
|
26
|
+
exclude_extensions: true,
|
|
27
|
+
preload_skills: true,
|
|
28
|
+
max_turns: true,
|
|
29
|
+
max_tokens: true,
|
|
30
|
+
};
|
|
31
|
+
function readableError(path, message) {
|
|
32
|
+
return new Error(`Invalid agent definition ${path}: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
function requiredString(value, field, path) {
|
|
35
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
36
|
+
throw readableError(path, `${field} must be a non-empty string`);
|
|
37
|
+
}
|
|
38
|
+
return value.trim();
|
|
39
|
+
}
|
|
40
|
+
function optionalString(value, field, path) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
return requiredString(value, field, path);
|
|
44
|
+
}
|
|
45
|
+
function stringList(value, field, path) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return [];
|
|
48
|
+
const raw = typeof value === "string" ? value.split(",") : value;
|
|
49
|
+
if (!Array.isArray(raw))
|
|
50
|
+
throw readableError(path, `${field} must be a string or string array`);
|
|
51
|
+
const values = raw.map((item) => requiredString(item, field, path));
|
|
52
|
+
if (new Set(values).size !== values.length) {
|
|
53
|
+
throw readableError(path, `${field} selects the same entry twice`);
|
|
54
|
+
}
|
|
55
|
+
return values;
|
|
56
|
+
}
|
|
57
|
+
const LOCAL_PATH_PATTERN = /^(?:[A-Za-z]:[\\/]|[~/]|\.{1,2}[\\/])/;
|
|
58
|
+
/** Local selections become absolute paths next to the definition; package specs are left for Pi to resolve. */
|
|
59
|
+
function resolveResourcePaths(value, field, path) {
|
|
60
|
+
return stringList(value, field, path).map((item) => {
|
|
61
|
+
if (!LOCAL_PATH_PATTERN.test(item))
|
|
62
|
+
return item;
|
|
63
|
+
const trimmed = item.startsWith("~/") ? join(homedir(), item.slice(2)) : item;
|
|
64
|
+
return resolve(dirname(path), trimmed);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function resourceSelection(value, field, path) {
|
|
68
|
+
if (value === undefined || value === true)
|
|
69
|
+
return { discovery: true, paths: [] };
|
|
70
|
+
if (value === false)
|
|
71
|
+
return { discovery: false, paths: [] };
|
|
72
|
+
const paths = resolveResourcePaths(value, field, path);
|
|
73
|
+
for (const selected of paths) {
|
|
74
|
+
if (!isAbsolute(selected))
|
|
75
|
+
continue;
|
|
76
|
+
const exists = await stat(selected).then(() => true, () => false);
|
|
77
|
+
if (!exists)
|
|
78
|
+
throw readableError(path, `${field} entry does not exist: ${selected}`);
|
|
79
|
+
}
|
|
80
|
+
return { discovery: true, paths };
|
|
81
|
+
}
|
|
82
|
+
async function markdownFiles(directory) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
89
|
+
return [];
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return entries
|
|
93
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
94
|
+
.map((entry) => join(directory, entry.name))
|
|
95
|
+
.sort((left, right) => left.localeCompare(right));
|
|
96
|
+
}
|
|
97
|
+
/** Most specific scope first; the first definition of a name wins deterministically. */
|
|
98
|
+
function agentDirectories(cwd, homeDirectory) {
|
|
99
|
+
return [
|
|
100
|
+
join(cwd, ".pi", "agents"),
|
|
101
|
+
join(cwd, ".agents", "agents"),
|
|
102
|
+
join(homeDirectory, ".pi", "agent", "agents"),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
async function readAgentDefinition(path) {
|
|
106
|
+
const content = await readFile(path, "utf8").catch((error) => {
|
|
107
|
+
throw new Error(`Unable to read agent definition ${path}`, { cause: error });
|
|
108
|
+
});
|
|
109
|
+
const parsed = parseFrontmatter(content);
|
|
110
|
+
if (!isRecord(parsed.frontmatter))
|
|
111
|
+
throw readableError(path, "frontmatter must be an object");
|
|
112
|
+
return {
|
|
113
|
+
name: requiredString(parsed.frontmatter.name, "name", path),
|
|
114
|
+
path,
|
|
115
|
+
frontmatter: parsed.frontmatter,
|
|
116
|
+
body: parsed.body,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export async function discoverAgents(cwd, homeDirectory = homedir()) {
|
|
120
|
+
const selected = new Map();
|
|
121
|
+
for (const directory of agentDirectories(resolve(cwd), resolve(homeDirectory))) {
|
|
122
|
+
for (const path of await markdownFiles(directory)) {
|
|
123
|
+
const agent = await readAgentDefinition(path);
|
|
124
|
+
if (!selected.has(agent.name))
|
|
125
|
+
selected.set(agent.name, agent);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...selected.values()];
|
|
129
|
+
}
|
|
130
|
+
function resolveModel(value, defaults, registry, path) {
|
|
131
|
+
if (value === undefined) {
|
|
132
|
+
if (registry.find(defaults.model.provider, defaults.model.id) === undefined) {
|
|
133
|
+
throw readableError(path, `parent model ${defaults.model.provider}/${defaults.model.id} is unavailable`);
|
|
134
|
+
}
|
|
135
|
+
return { ...defaults.model, source: "parent" };
|
|
136
|
+
}
|
|
137
|
+
const reference = requiredString(value, "model", path);
|
|
138
|
+
const slash = reference.indexOf("/");
|
|
139
|
+
if (slash <= 0 || slash === reference.length - 1) {
|
|
140
|
+
throw readableError(path, "model must use the exact provider/model-id form");
|
|
141
|
+
}
|
|
142
|
+
const provider = reference.slice(0, slash);
|
|
143
|
+
const id = reference.slice(slash + 1);
|
|
144
|
+
if (registry.find(provider, id) === undefined) {
|
|
145
|
+
throw readableError(path, `unknown model ${provider}/${id}`);
|
|
146
|
+
}
|
|
147
|
+
return { provider, id, source: "agent" };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolves one agent definition into an immutable, non-secret policy snapshot.
|
|
151
|
+
* Unknown fields, deferred fields, unknown models, invalid thinking levels,
|
|
152
|
+
* conflicting tool policy, and a disabled `contact_parent` bridge all fail here,
|
|
153
|
+
* before any child process exists.
|
|
154
|
+
*/
|
|
155
|
+
export async function resolveAgent(options) {
|
|
156
|
+
const agents = await discoverAgents(options.cwd, options.homeDirectory);
|
|
157
|
+
const discovered = agents.find((agent) => agent.name === options.name);
|
|
158
|
+
if (discovered === undefined)
|
|
159
|
+
throw new Error(`Unknown agent ${options.name}`);
|
|
160
|
+
const path = discovered.path;
|
|
161
|
+
for (const field of Object.keys(discovered.frontmatter)) {
|
|
162
|
+
if (SUPPORTED_FIELDS[field] === true)
|
|
163
|
+
continue;
|
|
164
|
+
if (DEFERRED_FIELDS[field] === true) {
|
|
165
|
+
throw readableError(path, `${field} is not part of the V1 agent contract`);
|
|
166
|
+
}
|
|
167
|
+
throw readableError(path, `unsupported field ${field}`);
|
|
168
|
+
}
|
|
169
|
+
// `discovered.name` is already the definition's own `name`; the lookup above proved they match.
|
|
170
|
+
const name = discovered.name;
|
|
171
|
+
const hidden = discovered.frontmatter.hidden ?? false;
|
|
172
|
+
if (typeof hidden !== "boolean")
|
|
173
|
+
throw readableError(path, "hidden must be boolean");
|
|
174
|
+
const body = discovered.body.trim();
|
|
175
|
+
if (body === "")
|
|
176
|
+
throw readableError(path, "Markdown body must not be empty");
|
|
177
|
+
const tools = stringList(discovered.frontmatter.tools, "tools", path);
|
|
178
|
+
const excludeTools = stringList(discovered.frontmatter.exclude_tools, "exclude_tools", path);
|
|
179
|
+
if (excludeTools.includes(CONTACT_PARENT_TOOL_NAME)) {
|
|
180
|
+
throw readableError(path, `exclude_tools cannot disable the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
|
|
181
|
+
}
|
|
182
|
+
const overlap = tools.find((tool) => excludeTools.includes(tool));
|
|
183
|
+
if (overlap !== undefined)
|
|
184
|
+
throw readableError(path, `tool ${overlap} is both allowed and excluded`);
|
|
185
|
+
if (tools.length > 0 && !tools.includes(CONTACT_PARENT_TOOL_NAME)) {
|
|
186
|
+
throw readableError(path, `tools must include the required ${CONTACT_PARENT_TOOL_NAME} bridge`);
|
|
187
|
+
}
|
|
188
|
+
const thinkingValue = discovered.frontmatter.thinking;
|
|
189
|
+
if (thinkingValue !== undefined && !isThinkingLevel(thinkingValue)) {
|
|
190
|
+
throw readableError(path, "thinking is invalid");
|
|
191
|
+
}
|
|
192
|
+
const extensionSelection = await resourceSelection(discovered.frontmatter.extensions, "extensions", path);
|
|
193
|
+
const bridgeExtensionPath = resolve(options.bridgeExtensionPath);
|
|
194
|
+
const extensionPaths = [
|
|
195
|
+
...extensionSelection.paths.filter((selected) => selected !== bridgeExtensionPath),
|
|
196
|
+
bridgeExtensionPath,
|
|
197
|
+
];
|
|
198
|
+
const skills = await resourceSelection(discovered.frontmatter.skills, "skills", path);
|
|
199
|
+
const displayName = optionalString(discovered.frontmatter.display_name, "display_name", path);
|
|
200
|
+
const description = optionalString(discovered.frontmatter.description, "description", path);
|
|
201
|
+
const interactiveValue = discovered.frontmatter.interactive;
|
|
202
|
+
if (interactiveValue !== undefined && typeof interactiveValue !== "boolean") {
|
|
203
|
+
throw readableError(path, "interactive must be boolean");
|
|
204
|
+
}
|
|
205
|
+
const interactive = interactiveValue === true;
|
|
206
|
+
return Object.freeze({
|
|
207
|
+
agent: Object.freeze({
|
|
208
|
+
name,
|
|
209
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
210
|
+
...(description === undefined ? {} : { description }),
|
|
211
|
+
hidden,
|
|
212
|
+
sourcePath: path,
|
|
213
|
+
instructions: body,
|
|
214
|
+
}),
|
|
215
|
+
model: Object.freeze(resolveModel(discovered.frontmatter.model, options.parent, options.modelRegistry, path)),
|
|
216
|
+
thinking: Object.freeze({
|
|
217
|
+
level: thinkingValue ?? options.parent.thinking,
|
|
218
|
+
source: thinkingValue === undefined ? "parent" : "agent",
|
|
219
|
+
}),
|
|
220
|
+
tools: Object.freeze(tools),
|
|
221
|
+
excludeTools: Object.freeze(excludeTools),
|
|
222
|
+
extensions: Object.freeze({
|
|
223
|
+
discovery: extensionSelection.discovery,
|
|
224
|
+
paths: Object.freeze(extensionPaths),
|
|
225
|
+
}),
|
|
226
|
+
skills: Object.freeze({
|
|
227
|
+
discovery: skills.discovery,
|
|
228
|
+
paths: Object.freeze([...skills.paths]),
|
|
229
|
+
}),
|
|
230
|
+
interactive,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-branch session binding. The TUI process may /reload the same session,
|
|
3
|
+
* /quit, or switch to an unrelated session B. Only a confirmed leave of A
|
|
4
|
+
* unbinds the bridge; shutdown or switch requests alone do not.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ChildIdentity } from "./domain.js";
|
|
8
|
+
import { PauseGate } from "./pause-gate.js";
|
|
9
|
+
export type ChildLifecycleKind = "left_session" | "tui_quit" | "user_interrupt";
|
|
10
|
+
export interface ChildBridgeState {
|
|
11
|
+
bound: boolean;
|
|
12
|
+
boundSessionId: string;
|
|
13
|
+
readonly gate: PauseGate;
|
|
14
|
+
}
|
|
15
|
+
export interface ChildPauseSocket {
|
|
16
|
+
connect?(signal?: AbortSignal): Promise<void>;
|
|
17
|
+
reportPaused(generation: number, signal?: AbortSignal): Promise<void>;
|
|
18
|
+
onEvent(listener: (event: unknown) => void): () => void;
|
|
19
|
+
close(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface ChildBridgeOptions {
|
|
22
|
+
readonly pauseSocket?: ChildPauseSocket;
|
|
23
|
+
readonly gate?: PauseGate;
|
|
24
|
+
readonly connect?: (socket: ChildPauseSocket, signal?: AbortSignal) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function boundSessionIdFromEnv(env?: NodeJS.ProcessEnv): string | undefined;
|
|
27
|
+
export declare function shouldLeaveBoundSession(params: {
|
|
28
|
+
readonly boundSessionId: string;
|
|
29
|
+
readonly currentSessionId: string;
|
|
30
|
+
readonly reason: SessionStartEvent["reason"];
|
|
31
|
+
}): boolean;
|
|
32
|
+
export declare function isConfirmedUserInterrupt(messages: readonly {
|
|
33
|
+
role?: string;
|
|
34
|
+
stopReason?: string;
|
|
35
|
+
}[] | undefined): boolean;
|
|
36
|
+
export declare function lastActivityText(messages: readonly {
|
|
37
|
+
role?: string;
|
|
38
|
+
content?: unknown;
|
|
39
|
+
}[] | undefined): string;
|
|
40
|
+
/** Registers the child-only tools, identity widget, and session-leave reporting. */
|
|
41
|
+
export declare function registerChildBridge(pi: ExtensionAPI, identity: ChildIdentity, options?: ChildBridgeOptions): ChildBridgeState;
|