@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/agent-resolver.d.ts +37 -0
  4. package/dist/agent-resolver.js +232 -0
  5. package/dist/child-bridge.d.ts +41 -0
  6. package/dist/child-bridge.js +214 -0
  7. package/dist/child-nudge.d.ts +24 -0
  8. package/dist/child-nudge.js +102 -0
  9. package/dist/commands.d.ts +6 -0
  10. package/dist/commands.js +155 -0
  11. package/dist/connector.d.ts +53 -0
  12. package/dist/connector.js +283 -0
  13. package/dist/diagnostics.d.ts +10 -0
  14. package/dist/diagnostics.js +20 -0
  15. package/dist/domain.d.ts +192 -0
  16. package/dist/domain.js +63 -0
  17. package/dist/extension.d.ts +2 -0
  18. package/dist/extension.js +145 -0
  19. package/dist/host-adapter.d.ts +76 -0
  20. package/dist/host-adapter.js +323 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/json-lines.d.ts +14 -0
  24. package/dist/json-lines.js +94 -0
  25. package/dist/launch-spec.d.ts +47 -0
  26. package/dist/launch-spec.js +149 -0
  27. package/dist/manager.d.ts +101 -0
  28. package/dist/manager.js +802 -0
  29. package/dist/pause-gate.d.ts +14 -0
  30. package/dist/pause-gate.js +64 -0
  31. package/dist/protocol.d.ts +141 -0
  32. package/dist/protocol.js +168 -0
  33. package/dist/registry.d.ts +41 -0
  34. package/dist/registry.js +575 -0
  35. package/dist/rpc-adapter.d.ts +40 -0
  36. package/dist/rpc-adapter.js +194 -0
  37. package/dist/rpc-events.d.ts +8 -0
  38. package/dist/rpc-events.js +29 -0
  39. package/dist/runner-entry.d.ts +24 -0
  40. package/dist/runner-entry.js +166 -0
  41. package/dist/runner.d.ts +52 -0
  42. package/dist/runner.js +930 -0
  43. package/dist/runtime.d.ts +22 -0
  44. package/dist/runtime.js +253 -0
  45. package/dist/session-bootstrap.d.ts +69 -0
  46. package/dist/session-bootstrap.js +195 -0
  47. package/dist/state.d.ts +15 -0
  48. package/dist/state.js +168 -0
  49. package/dist/tools.d.ts +9 -0
  50. package/dist/tools.js +158 -0
  51. package/dist/widget.d.ts +16 -0
  52. package/dist/widget.js +158 -0
  53. 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;