pi-squire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are recorded here, in the
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. The package
5
+ follows [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-02
10
+
11
+ The first public release. Before this repository the package lived in its
12
+ author's dotfiles; the changes below are against that copy.
13
+
14
+ ### Added
15
+
16
+ - Published to npm as `pi-squire`.
17
+ - MIT license and package metadata, including `engines` (Node 22.19 or later).
18
+ - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
19
+ published file list.
20
+
21
+ ### Fixed
22
+
23
+ - The published file list includes `child.ts`, which `index.ts` imports.
24
+
25
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.1.0...HEAD
26
+ [0.1.0]: https://github.com/gvanderclay/pi-squire/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gage Vander Clay
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,351 @@
1
+ # pi-squire
2
+
3
+ Hands a task to a delegate Pi session in a background tmux window, through the
4
+ `/delegate` command and the `delegate` tool.
5
+
6
+ `/delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--auto-exit | --no-auto-exit] <task…>` reads
7
+ the agent from `<agent dir>/agents/<name>/AGENT.md`, writes the task to the
8
+ delegate's inbox through `message:send`, emits `session:launch` so other
9
+ extensions can shape the launch, then opens a detached window running the
10
+ parent's own Pi in the parent's working directory and agent root. The window
11
+ and the child's Pi session share one name: `<agent>-<label>`, or
12
+ `<agent>-<first 8 characters of the id>` without a label. A label is at most
13
+ 32 letters, digits, `_` or `-`, starting with a letter or digit; `-` rather
14
+ than `:` joins it, since tmux reads `:` in a target as session and window.
15
+ Labels need not be unique: the delegation id stays the handle for
16
+ `delegation_status`, `delegation_close` and mail. The delegate id is fresh, so a
17
+ delegate never reopens a live session, and it is also the delegate's mailbox
18
+ address: the task waits in its inbox and is delivered at session start, with
19
+ no handshake.
20
+
21
+ Starting a delegation adds nothing to the parent's context — the model sees
22
+ neither the command nor the task. The model can also start one itself with the
23
+ `delegate` tool, which starts the delegation at once without opening a
24
+ dialog, so several calls in one message each start their own;
25
+ `delegation_status` and `delegation_close` let it check on a delegation
26
+ and end one. When the delegate settles, its answer is taken over on
27
+ `message:inbound` and shown to the parent as one result message that quotes
28
+ the task. By default the delegate then closes its own window
29
+ ([auto-exit](#auto-exit)); with auto-exit off the window stays open so you can
30
+ read it or keep working in it. Delegations are recorded in the parent session
31
+ without the task text.
32
+
33
+ Inside a delegate (`PI_DELEGATE_PARENT` set) the extension registers only
34
+ auto-exit's side, the `/auto-exit` command and its handlers, so a delegate
35
+ cannot start delegates of its own. Without a UI the command
36
+ and the `delegate` tool still work; it needs tmux and a provider of `message:*`, and refuses with a
37
+ message when either is missing.
38
+
39
+ ## Install
40
+
41
+ Install the package from npm:
42
+
43
+ ```bash
44
+ pi install npm:pi-squire
45
+ ```
46
+
47
+ To follow the latest commit instead, install it from GitHub:
48
+
49
+ ```bash
50
+ pi install git:github.com/gvanderclay/pi-squire
51
+ ```
52
+
53
+ The package has no runtime dependencies and no build step. The `pi` manifest loads only
54
+ `./index.ts`, and the tests under `test/` are neither loaded by Pi nor included
55
+ in the npm tarball.
56
+ Run them with `pnpm install && pnpm test`.
57
+
58
+ ## Requirements
59
+
60
+ - Pi, with the `pi.events` bus, `pi.registerCommand` and `pi.registerTool`.
61
+ - `@earendil-works/pi-coding-agent` for `getAgentDir()` and the frontmatter
62
+ parser, declared as a peer dependency and supplied by Pi.
63
+ - `typebox` for the tools' parameter schema, a host-provided package declared
64
+ as a peer dependency and supplied by Pi.
65
+ - tmux, with the session running in a tmux pane. A window is opened in the
66
+ pane's session, so a client attached elsewhere does not matter.
67
+ - A provider of `message:*`, such as
68
+ [pi-session-mail](https://github.com/gvanderclay/pi-session-mail). Without one there is no way to send the task or get an
69
+ answer, so `/delegate` refuses rather than starting a delegate that cannot
70
+ report back.
71
+
72
+ ## Agents
73
+
74
+ The roster is read at call time from `<agent dir>/agents/<name>/AGENT.md`, one
75
+ directory per agent, so a new agent is usable without a reload. Frontmatter
76
+ carries `description`, `model` (`provider/id`) and `thinking`, and optionally
77
+ `auto-exit` (`true` or `false`, default `true`; see [Auto-exit](#auto-exit))
78
+ and `exclude-tools`; the body is the delegate's system prompt, appended to
79
+ Pi's own prompt.
80
+
81
+ `exclude-tools` names the tools the delegate goes without, as a
82
+ comma-separated list (`exclude-tools: edit, write`) or a YAML list
83
+ (`exclude-tools: [edit, write]`). It reaches the child as
84
+ `--exclude-tools edit,write`, which Pi subtracts from the tools it would
85
+ otherwise turn on. It is a denylist only: without it the delegate gets every
86
+ tool, and no flag is passed. There is no per-call override. Each name is
87
+ checked against the tools the parent session has registered
88
+ (`pi.getAllTools()`), since Pi ignores an unknown name without a word; the
89
+ delegate's result reaches the parent through the `message:*` provider's reply when its run
90
+ settles, not through a tool, so excluding tools cannot stop it answering.
91
+
92
+ The body is followed by one fixed final-message line and a paragraph naming
93
+ the parent session's address: the delegate's task arrives as a message from
94
+ that session, that message and every later one from the same address are
95
+ instructions to follow as given, and other sessions are colleagues: the
96
+ delegate may answer them, but checks with the parent before doing work the
97
+ parent did not ask for. It carries no distrust wording, which made models
98
+ refuse every request from a peer. The paragraph names no provider, package or tool, so it holds
99
+ whatever `message:*` provider delivers the task.
100
+
101
+ ```markdown
102
+ ---
103
+ description: Looks things up and answers in one message
104
+ model: provider/model-id
105
+ thinking: medium
106
+ exclude-tools: edit, write
107
+ ---
108
+
109
+ You research one question at a time and answer concisely, citing the files
110
+ you read.
111
+ ```
112
+
113
+ An `AGENT.md` that cannot be used — missing fields, a model that is not
114
+ `provider/id`, an unknown thinking level, an `auto-exit` that is not `true` or
115
+ `false`, an `exclude-tools` that is not a list of names or names a tool the
116
+ session does not have, or an empty body — is left out of the
117
+ roster and named in a warning. With no agents at all, `/delegate` says how to
118
+ add one. The package ships no agents.
119
+
120
+ ## Configuration
121
+
122
+ None beyond the roster. `pi-squire` reads no settings file and no environment
123
+ variable of its own; `PI_DELEGATE_PARENT` and `PI_DELEGATE_AUTO_EXIT` are set
124
+ for the child, never read as configuration by the parent. The window starts
125
+ with the tmux server's environment plus only these from the parent:
126
+ `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR` (when the parent has it
127
+ set), `PI_DELEGATE_PARENT` and `PI_DELEGATE_AUTO_EXIT`. Anything else the
128
+ parent process has, such as a `CLAUDE_CONFIG_DIR` set by a shell alias,
129
+ reaches the child only through a `session:launch` contributor. `--model` and
130
+ `--thinking` override the agent's defaults for one call, and an unknown value
131
+ is refused with close matches; `--auto-exit` and `--no-auto-exit` override the
132
+ agent's `auto-exit`.
133
+
134
+ ## Tools
135
+
136
+ The model gets three tools. They share the command's launch path and the
137
+ session's delegation records; none of them is registered inside a delegate.
138
+
139
+ | Tool | Parameters | What it does |
140
+ | --- | --- | --- |
141
+ | `delegate` | `agent`, `task`, optional `model`, `thinking`, `label` and `auto_exit` (boolean) | Validates the call the way the command does, starts the delegation without opening a dialog, and returns the delegation id at once without waiting for a result. |
142
+ | `delegation_status` | optional `id` | Reports each delegation as running, done, closed or closed without a result, with the window, the delegate's session and the result envelope's path. |
143
+ | `delegation_close` | `id` | Kills the window through tmux and records the close, so `delegation_status` reports it closed. |
144
+
145
+ The `delegate` tool's description lists the current roster — each agent's name,
146
+ description, default model and thinking, auto-exit when it is off, and its
147
+ excluded tools — one line per agent:
148
+
149
+ ```text
150
+ - scout — Read-only recon (default provider/model-id, thinking low, auto-exit off, no edit/write)
151
+ ```
152
+
153
+ The model picks an agent from these lines without reading files. The
154
+ description also tells the model to turn `auto_exit` off when it means to keep
155
+ talking to the delegate by mail after its result. A tool's description is
156
+ fixed when it is registered, so the description is rebuilt at every session
157
+ start; a roster change shows up in the next session. Pi cannot list the
158
+ session's tools while extensions load, so the description built then skips
159
+ the `exclude-tools` check, and the one built at session start applies it. The roster itself is read at call time, and an unknown agent,
160
+ model or thinking level is refused with close matches and nothing starts.
161
+
162
+ `delegation_status` reports one state per delegation:
163
+
164
+ ```text
165
+ 2 delegations:
166
+
167
+ <id> scout (provider/model, thinking low)
168
+ name: scout-<first 8 of the id>
169
+ state: running — the window is open and no result has arrived
170
+ window: scout-<id> (@1)
171
+ auto-exit: on — the delegate closes its window after a normal completion unless the user took over there
172
+ session: <the delegate's session file, or its id when there is none yet>
173
+
174
+ <id> researcher (provider/other, thinking high)
175
+ name: researcher-<first 8 of the id>
176
+ state: done — result status: done
177
+ window: researcher-<id> (@2)
178
+ auto-exit: off — the window stays open after the result
179
+ session: <session file>
180
+ envelope: <the reply's path in the parent's cur/>
181
+ ```
182
+
183
+ - **Name** is the delegate's Pi session name, its agent and the first 8
184
+ characters of its delegation id; the window keeps the full id.
185
+ - **Running** means the window is open and no result has arrived. An open
186
+ window is connection, not task state: the delegate may still be working, or
187
+ waiting for the user, and only a result means done.
188
+ - **Done** comes from the recorded result, and shows the envelope's status
189
+ (`done`, `failed` or `stopped`).
190
+ - A delegate that closed itself by auto-exit reads **done**: its result
191
+ arrived before its window went. For a moment before the result is claimed
192
+ it can read closed, since the window is already gone.
193
+ - **Auto-exit** reads off for delegations recorded before auto-exit existed.
194
+ - **Closed** covers a close `delegation_close` recorded and a window that is no
195
+ longer there. A recorded close wins over a result, and a result wins over a
196
+ window that is gone.
197
+ - **Closed without a result** is what the poll records when a window is gone
198
+ and no reply arrived (see Results). A result outranks it, so a late reply
199
+ turns it into **done**; a close from `delegation_close` outranks both.
200
+ - When tmux cannot be asked about a window, the state is `unknown`, never
201
+ `closed`, because a failed check is not evidence the window is gone.
202
+
203
+ `delegation_close` kills the window and records the close even when the
204
+ result has already arrived; when the window is already gone it only records
205
+ the close. An unknown or already closed id returns a message and kills
206
+ nothing. The delegate's session file and result envelope stay on disk.
207
+
208
+ ## Auto-exit
209
+
210
+ The parent resolves each delegation's auto-exit from the call
211
+ (`auto_exit`, or `--auto-exit` / `--no-auto-exit`), then the agent's
212
+ `auto-exit`, then on, and passes it to the child as `PI_DELEGATE_AUTO_EXIT`
213
+ (`1` or `0`). Inside the delegate:
214
+
215
+ - While it is on, a run that settles as a normal completion shuts the delegate
216
+ down (`ctx.shutdown()`) on the next event-loop turn, after the settle
217
+ reply has been sent, so its tmux window closes. The session file stays, and
218
+ `pi --session <path>` reopens it.
219
+ - A run that ended stopped or failed never exits the delegate.
220
+ - The user taking over turns it off for the rest of the session, with a
221
+ notice: typed or RPC input, or a run the user stopped (Esc).
222
+ - `/auto-exit` inside the delegate turns it on again for the next normal
223
+ completion, whatever the launch said.
224
+
225
+ The name and the take-over rule follow edxeth/pi-subagents' `auto-exit`
226
+ (README "Child lifecycle"); unlike it, the default is on, and there is no
227
+ `subagent_done` tool: auto-exit off only means the window stays.
228
+
229
+ ## Results
230
+
231
+ A delegate answers its task with one reply through the `message:*` provider. `pi-squire` takes that
232
+ reply over before the provider can inject it, and sends the parent one
233
+ message labelled delegate output:
234
+
235
+ ```
236
+ [delegate] Result from scout (provider/model), delegation <id>, request <request id>.
237
+ Status: done
238
+ Task, quoted from <sent/ copy>:
239
+ > the task
240
+ Envelope: <cur/ path>
241
+ Delegate session: <session file or id>
242
+
243
+ <the result>
244
+ ```
245
+
246
+ - The header carries the agent, the model, the delegation id and the request
247
+ id; the status is the envelope's (`done`, `failed` or
248
+ `stopped`).
249
+ - Stopping a delegate's run (Esc) sends no result: the provider holds the
250
+ task, so the delegation stays running while the user steers it. The next
251
+ run that completes sends the result, `done`, and its body opens with a note
252
+ that the user took over partway.
253
+ - The task is quoted from the request copy the provider puts on the payload,
254
+ capped at 2 KiB with the copy's `sent/` path. A request with no copy is
255
+ named by id alone.
256
+ - The envelope path is the claimed reply in the parent's `cur/`; the
257
+ delegate's session path is found in the session directory by the delegation
258
+ id, and the id alone is shown when no file exists yet.
259
+ - The body is cut at 32 KiB with the envelope path.
260
+ - The message is sent as a `followUp` with `triggerTurn`: a result starts a
261
+ turn when the parent is idle and arrives as a follow-up when it is
262
+ mid-turn.
263
+ - The footer counts the running delegations (`⇄ N running`), clears one entry
264
+ per result or close and hides itself at zero.
265
+ - Each result and each close is recorded in the parent session, and the
266
+ records are rebuilt synchronously at session start; a resume still
267
+ recognises replies — including one that arrived while the parent was
268
+ closed — and still reports a recorded close as closed. A result is shown
269
+ once and never again.
270
+ - Plain replies, and every request, are left to the provider.
271
+
272
+ While any delegation is running (no result, no close), the parent polls every
273
+ 5 seconds; the timer does not keep the process alive and stops when nothing is
274
+ running and on `session_shutdown`. Each tick first emits `message:scan`, so the
275
+ provider claims waiting replies, and then asks tmux about each still-running
276
+ window. A delegation whose window is gone and still has no result after that
277
+ scan is recorded as closed without a result, a record separate from
278
+ `delegation_close`'s, and the parent gets one `[delegate]` message naming the
279
+ delegation, with the delegate session path when one is found, sent as a
280
+ `followUp` with `triggerTurn`. A tick declares nothing when nobody answers
281
+ `message:scan` or when tmux reports an error. A reply that arrives later is
282
+ still delivered and makes the delegation done. A resumed session polls again
283
+ for every delegation it restores as running.
284
+
285
+ ## Hooks
286
+
287
+ The `js` block below is the contract's worked example, and
288
+ `test/hooks.test.ts` extracts and runs it verbatim on a real `createEventBus`.
289
+ Its scope is the test harness's: `pi` is the extension API. Write it as plain
290
+ JavaScript so it stays runnable; the name after `js` is the test that runs
291
+ that block.
292
+
293
+ ### `session:launch`
294
+
295
+ `pi-squire` provides this hook. It emits `{ args, env }` after the task has been
296
+ sent and just before it opens the delegate's window.
297
+
298
+ | Field | Meaning |
299
+ | --- | --- |
300
+ | `args` | the child `pi`'s complete argument list, launcher first |
301
+ | `env` | the child's environment, as `KEY`/`value` pairs |
302
+
303
+ Listeners may only append to `args` and add keys to `env`; there is no veto,
304
+ and `pi-squire` passes both to tmux unchanged. Do all work synchronously: the
305
+ emitter reads the payload the moment `emit` returns.
306
+
307
+ ```js session:launch
308
+ // Any extension can shape a launch: append a flag and add an environment key.
309
+ pi.events.on("session:launch", (payload) => {
310
+ payload.args.push("--auto");
311
+ payload.env.GATE_MODE = "auto";
312
+ });
313
+ ```
314
+
315
+ ### `message:send`
316
+
317
+ `pi-squire` consumes this hook to write the task; the provider writes the
318
+ envelope from its own session's address and sets `envelope` (or `error`) on the
319
+ payload before `emit` returns. **If neither is set, no provider is installed**
320
+ and `pi-squire` refuses with a message naming `message:*`. The envelope's `id`
321
+ is kept in the delegation's record, so an answer can be matched to it. The
322
+ provider's own README carries the contract.
323
+
324
+ ### `message:inbound`
325
+
326
+ `pi-squire` consumes this hook for answers: a reply whose `in_reply_to` names a
327
+ recorded delegation's request is taken over — `pi-squire` sets `handled`, so
328
+ the provider injects nothing, and shows the parent the result message above.
329
+ The reply's request copies fill the quoted task and its `cur/` path is the
330
+ envelope it names. A reply to a request this session did not delegate, and
331
+ every request, is left to the provider. The provider's README carries the
332
+ contract.
333
+
334
+ `pi-squire` rebuilds its records synchronously in its own `session_start`, so it
335
+ relies on the provider not emitting `message:inbound` from inside a
336
+ `session_start` handler: mail waiting at session start must be claimed on a
337
+ later event-loop turn, as pi-session-mail's `message:inbound` contract guarantees.
338
+ That is what makes a result that arrived while the parent was closed reach the
339
+ rebuilt records. Keep extensions whose `session_start` waits on I/O from
340
+ loading between the provider and `pi-squire`.
341
+
342
+ ### `message:scan`
343
+
344
+ `pi-squire` consumes this hook on every poll tick (see Results): it emits `{}`,
345
+ and the provider claims the mail waiting in its inbox, emitting each reply as
346
+ `message:inbound`, before setting `scanned` to `true` on the payload and
347
+ returning from `emit`. Only then does `pi-squire` check windows, so a reply
348
+ written just before a window closed is never mistaken for no reply. **If
349
+ `scanned` is not set, nobody answered**, and the tick declares nothing closed,
350
+ because a waiting reply cannot be ruled out. The provider's README carries the
351
+ contract.
package/agents.ts ADDED
@@ -0,0 +1,161 @@
1
+ // The parent's agent roster: `<agent dir>/agents/<name>/AGENT.md`, read at
2
+ // call time so a new agent is usable without a reload. Frontmatter carries
3
+ // `description`, `model` and `thinking`, and optionally `auto-exit` (default
4
+ // true) and `exclude-tools` (tools the delegate goes without, passed to its Pi
5
+ // as `--exclude-tools`; each must be a tool the parent session has); the body
6
+ // is the delegate's system prompt. A file that is missing or malformed is left out, with a warning.
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
11
+
12
+ /** The levels `pi --thinking` accepts. */
13
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
14
+
15
+ export type Thinking = (typeof THINKING_LEVELS)[number];
16
+
17
+ export function isThinking(value: string): value is Thinking {
18
+ return (THINKING_LEVELS as readonly string[]).includes(value);
19
+ }
20
+
21
+ /** An agent name is one safe path segment and one safe tmux window name. */
22
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
23
+
24
+ /** `provider/id`; the id may itself contain slashes. */
25
+ const MODEL = /^[^\s/]+\/\S+$/;
26
+
27
+ export type Agent = {
28
+ name: string;
29
+ description: string;
30
+ model: string;
31
+ thinking: Thinking;
32
+ /** Whether the delegate closes itself after a normal completion; `auto-exit`, default true. */
33
+ autoExit: boolean;
34
+ /** Tools the delegate goes without, from `exclude-tools`; empty means all of them. */
35
+ excludeTools: string[];
36
+ /** The body of `AGENT.md`: the delegate's system prompt. */
37
+ prompt: string;
38
+ };
39
+
40
+ export type Roster = {
41
+ agents: Agent[];
42
+ /** One line per `AGENT.md` left out, naming its path and the reason. */
43
+ warnings: string[];
44
+ };
45
+
46
+ const field = (frontmatter: Record<string, unknown>, key: string): string | undefined => {
47
+ const value = frontmatter[key];
48
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
49
+ };
50
+
51
+ /** Parse one `AGENT.md`; undefined with a reason when it cannot be an agent. */
52
+ function parseAgent(
53
+ name: string,
54
+ path: string,
55
+ text: string,
56
+ tools: readonly string[] | undefined,
57
+ ): { agent?: Agent; warning?: string } {
58
+ let frontmatter: Record<string, unknown>;
59
+ let body: string;
60
+ try {
61
+ ({ frontmatter, body } = parseFrontmatter(text) as { frontmatter: Record<string, unknown>; body: string });
62
+ } catch (err) {
63
+ return { warning: `${path} is not readable frontmatter (${(err as Error).message}); skipped` };
64
+ }
65
+ if (frontmatter === null || typeof frontmatter !== "object")
66
+ return { warning: `${path} has no frontmatter fields; skipped` };
67
+ const description = field(frontmatter, "description");
68
+ const model = field(frontmatter, "model");
69
+ const thinking = field(frontmatter, "thinking");
70
+ const missing = [
71
+ description === undefined ? "description" : undefined,
72
+ model === undefined ? "model" : undefined,
73
+ thinking === undefined ? "thinking" : undefined,
74
+ ].filter((key): key is string => key !== undefined);
75
+ if (missing.length > 0) return { warning: `${path} has no ${missing.join(", ")}; skipped` };
76
+ if (!MODEL.test(model as string))
77
+ return { warning: `${path} has model ${JSON.stringify(model)}, not provider/id; skipped` };
78
+ if (!isThinking(thinking as string))
79
+ return {
80
+ warning: `${path} has thinking ${JSON.stringify(thinking)}, not one of ${THINKING_LEVELS.join(", ")}; skipped`,
81
+ };
82
+ if (body === "") return { warning: `${path} has no prompt body; skipped` };
83
+ const autoExit = flag(frontmatter["auto-exit"]);
84
+ if (autoExit === null)
85
+ return { warning: `${path} has auto-exit ${JSON.stringify(frontmatter["auto-exit"])}, not true or false; skipped` };
86
+ const listed = toolList(frontmatter["exclude-tools"]);
87
+ if (listed === null)
88
+ return {
89
+ warning: `${path} has exclude-tools ${JSON.stringify(frontmatter["exclude-tools"])}, not a comma-separated list of tool names; skipped`,
90
+ };
91
+ const excludeTools = listed ?? [];
92
+ const unknownTool = tools === undefined ? undefined : excludeTools.find((tool) => !tools.includes(tool));
93
+ if (unknownTool !== undefined)
94
+ return {
95
+ warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
96
+ };
97
+ return {
98
+ agent: {
99
+ name,
100
+ description: description as string,
101
+ model: model as string,
102
+ thinking: thinking as Thinking,
103
+ autoExit: autoExit ?? true,
104
+ excludeTools,
105
+ prompt: body,
106
+ },
107
+ };
108
+ }
109
+
110
+ /** A tool list, comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
111
+ function toolList(value: unknown): string[] | undefined | null {
112
+ if (value === undefined || value === null) return undefined;
113
+ const names = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : null;
114
+ if (names === null || !names.every((name) => typeof name === "string")) return null;
115
+ return names.map((name) => name.trim()).filter((name) => name !== "");
116
+ }
117
+
118
+ /** A true/false frontmatter value; undefined when absent, null when it is neither. */
119
+ function flag(value: unknown): boolean | undefined | null {
120
+ if (value === undefined || value === null) return undefined;
121
+ if (typeof value === "boolean") return value;
122
+ if (value === "true") return true;
123
+ if (value === "false") return false;
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Read every agent under `<agentDir>/agents/`, in name order. A directory
129
+ * with no `AGENT.md` is not an agent and is passed over quietly; an
130
+ * `AGENT.md` that cannot be used is warned about and left out. `tools` are
131
+ * the session's tool names, which `exclude-tools` is checked against;
132
+ * undefined skips that check, as before the session's tools are known.
133
+ */
134
+ export function readRoster(agentDir: string, tools?: readonly string[]): Roster {
135
+ const dir = join(agentDir, "agents");
136
+ let names: string[];
137
+ try {
138
+ names = readdirSync(dir).sort();
139
+ } catch {
140
+ return { agents: [], warnings: [] }; // no agents directory yet
141
+ }
142
+ const agents: Agent[] = [];
143
+ const warnings: string[] = [];
144
+ for (const name of names) {
145
+ const path = join(dir, name, "AGENT.md");
146
+ let text: string;
147
+ try {
148
+ text = readFileSync(path, "utf8");
149
+ } catch {
150
+ continue;
151
+ }
152
+ if (!NAME.test(name)) {
153
+ warnings.push(`${path} is not under a usable agent name; skipped`);
154
+ continue;
155
+ }
156
+ const { agent, warning } = parseAgent(name, path, text, tools);
157
+ if (agent !== undefined) agents.push(agent);
158
+ else if (warning !== undefined) warnings.push(warning);
159
+ }
160
+ return { agents, warnings };
161
+ }
package/child.ts ADDED
@@ -0,0 +1,67 @@
1
+ // The delegate's side of `auto-exit`, registered only inside a delegate.
2
+ //
3
+ // The parent resolves the setting and passes it as `PI_DELEGATE_AUTO_EXIT`
4
+ // (`1` on, `0` off). While it is on, the delegate shuts Pi down once a run
5
+ // settles as a normal completion, so its tmux window closes; the settle
6
+ // reply has been sent by then, since the shutdown waits for the next
7
+ // event-loop turn. A run that ended stopped or failed never exits. The user
8
+ // taking over — typing here, or stopping a run — turns it off for the rest of
9
+ // the session, with a notice; `/auto-exit` turns it back on. Modelled on
10
+ // edxeth/pi-subagents' `auto-exit` (README "Child lifecycle", `cf6dbf4`).
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+
13
+ /** The launch variable that carries the parent's resolved setting. */
14
+ export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
15
+
16
+ const OFF_NOTICE =
17
+ "delegate: auto-exit is off for this session because you took over here; the window stays open. /auto-exit turns it back on.";
18
+ const ON_NOTICE = "delegate: auto-exit is on; this delegate closes after its next normal completion.";
19
+
20
+ type Message = { role?: string; stopReason?: unknown };
21
+
22
+ export function registerChild(pi: ExtensionAPI): void {
23
+ let armed = process.env[AUTO_EXIT_ENV] === "1";
24
+ /** Whether the run that just ended finished normally: not stopped, not failed. */
25
+ let completed = false;
26
+
27
+ function disarm(ctx: ExtensionContext): void {
28
+ if (!armed) return;
29
+ armed = false;
30
+ ctx.ui.notify(OFF_NOTICE, "info");
31
+ }
32
+
33
+ // Typed text and RPC input are the user's; a prompt an extension sends is not.
34
+ pi.on("input", async (event, ctx) => {
35
+ if (event.source !== "extension") disarm(ctx);
36
+ });
37
+
38
+ // Stopped mid-text the last message says `aborted`; stopped during a tool
39
+ // call Pi 0.99.1 ends with an `error` message, and only the signal tells a
40
+ // stop from a real error. Either way it is the user taking over.
41
+ pi.on("agent_end", async (event, ctx) => {
42
+ let last: Message | undefined;
43
+ for (let i = event.messages.length - 1; i >= 0 && last === undefined; i--) {
44
+ const message = event.messages[i] as Message;
45
+ if (message?.role === "assistant") last = message;
46
+ }
47
+ const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
48
+ if (stopped) disarm(ctx);
49
+ completed = !stopped && last?.stopReason !== "error";
50
+ });
51
+
52
+ pi.on("agent_settled", async (_event, ctx) => {
53
+ const exit = armed && completed;
54
+ completed = false;
55
+ if (!exit) return;
56
+ // Later settle handlers (the `message:*` provider's reply among them) run first.
57
+ setImmediate(() => ctx.shutdown());
58
+ });
59
+
60
+ pi.registerCommand("auto-exit", {
61
+ description: "Close this delegate after its next normal completion",
62
+ handler: async (_args, ctx) => {
63
+ armed = true;
64
+ ctx.ui.notify(ON_NOTICE, "info");
65
+ },
66
+ });
67
+ }