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 +26 -0
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/agents.ts +161 -0
- package/child.ts +67 -0
- package/index.ts +717 -0
- package/package.json +61 -0
- package/results.ts +293 -0
- package/tmux.ts +75 -0
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
|
+
}
|