pi-minimal-dispatch 0.0.0-stage → 0.2.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 +41 -0
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/docs/API.md +163 -0
- package/package.json +42 -5
- package/src/config.ts +96 -0
- package/src/derive.ts +219 -0
- package/src/events.ts +64 -0
- package/src/format.ts +37 -0
- package/src/index.ts +443 -0
- package/src/lane.ts +169 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-23
|
|
4
|
+
|
|
5
|
+
Requires pi ≥ 1.4.0 (the MCP reach rules the validator encodes are pi
|
|
6
|
+
1.4.0's documented `--tools` semantics).
|
|
7
|
+
|
|
8
|
+
- `dispatch` no longer spawns blind MCP lanes: with MCP enabled, a lane whose
|
|
9
|
+
`tools` allowlist names neither `codemode` nor `tool_search` is rejected at
|
|
10
|
+
the tool boundary — pi's `--tools` allowlist replaces the whole selection,
|
|
11
|
+
and a lane never reaches MCP tools through anything else unless the server
|
|
12
|
+
has `direct` exposure (which dispatch cannot know). Pass `codemode`/
|
|
13
|
+
`tool_search` in `tools` or set `mcp: false`.
|
|
14
|
+
- `tools` now accepts only a plain allowlist: `+name`/`-name` modifier lists
|
|
15
|
+
(pi applies them to its default selection, silently granting
|
|
16
|
+
`bash`/`edit`/`write`) and empty lists are rejected.
|
|
17
|
+
- README and docs/API.md document the reach rule and both reach recipes; the
|
|
18
|
+
`mcp` schema text no longer claims MCP tools "stay reachable" by default.
|
|
19
|
+
|
|
20
|
+
- `lanes` gained `action: "wait"` (+ `waitSec`, default 300, max 3600):
|
|
21
|
+
holds one call open, re-deriving at the `pollMs` cadence, until every lane
|
|
22
|
+
is terminal — replaces blind sleep-then-status polling (which wakes late
|
|
23
|
+
after settles); times out as a normal status result, honors cancellation.
|
|
24
|
+
|
|
25
|
+
## 0.1.0 — 2026-10-23
|
|
26
|
+
|
|
27
|
+
Initial release. Requires pi ≥ 1.3.0 (1.4.0 recommended — see README).
|
|
28
|
+
|
|
29
|
+
- `dispatch` tool: detached lane spawning (own process group, argv array, no
|
|
30
|
+
shell wrapper) with synchronous brief/slug validation, atomic wave-slug
|
|
31
|
+
claim, per-lane model/deadline/tool allowlist, and a parentless deadline
|
|
32
|
+
leash (`timeout -k`).
|
|
33
|
+
- `lanes` tool: stateless status/kill/collect over a wave's manifest + JSON
|
|
34
|
+
event streams — derived state with failure causes, start-time-guarded PID
|
|
35
|
+
liveness, TERM→KILL group kill with tombstones, and suspect-flagged
|
|
36
|
+
artifact collection.
|
|
37
|
+
- Safe defaults: lanes run read-only (`read,grep,find,ls`) unless granted
|
|
38
|
+
more via `tools`, never load skills (`-ns`), and never receive the
|
|
39
|
+
`dispatch` tool themselves (child marker).
|
|
40
|
+
- Statusbar line and `/lanes` command (TUI/RPC modes).
|
|
41
|
+
- Configuration: user/project JSON files with per-call parameter precedence.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Cheung
|
|
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
CHANGED
|
@@ -1,3 +1,190 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-minimal-dispatch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Minimal detached lane dispatch with **visible state** for the
|
|
4
|
+
[pi coding agent](https://pi.dev) — two tools, one manifest, no framework.
|
|
5
|
+
|
|
6
|
+
Dispatch sub-agents from a pi session without blocking it. Lanes run as
|
|
7
|
+
detached `pi --mode json` processes in their own process groups, survive the
|
|
8
|
+
dispatcher's death, and report live state derived from their event streams —
|
|
9
|
+
never guessed from empty files.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
wave 'survey':
|
|
13
|
+
docs · running · 2 turns · tok 5703in/90out, $0.0009 · stop:toolUse · last 3.1s ago
|
|
14
|
+
review · settled · 4 turns · tok 12021in/310out, $0.0041 · stop:stop · last 12s ago
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
Other pi subagent extensions run children inside the dispatcher's process and
|
|
20
|
+
die with it — or need a server to keep them alive. **pi-minimal-dispatch lanes
|
|
21
|
+
are plain `pi --mode json` processes in their own process groups that outlive
|
|
22
|
+
the dispatcher, observable as plain files, collectible by any later session.**
|
|
23
|
+
|
|
24
|
+
- `dispatch` returns the moment lanes are spawned — the dispatcher keeps working
|
|
25
|
+
- **the deadline leash is GNU `timeout -k` around each process** — it fires even
|
|
26
|
+
after the dispatcher has exited
|
|
27
|
+
- a later session re-adopts any wave from its manifest: `status`, `wait`, `kill`, `collect`
|
|
28
|
+
- the final text is a file on disk (`<wave>-<name>.md`), not a block pasted back
|
|
29
|
+
into the parent's context
|
|
30
|
+
|
|
31
|
+
It replaces hand-written bash dispatch (`pi --print @brief > out.md &`), which
|
|
32
|
+
leaves the dispatcher blind while lanes run: the artifact stays empty until the
|
|
33
|
+
lane exits (working and hung look identical), killing means signaling a wrapper
|
|
34
|
+
PID of unclear ownership, a mangled spawn line dies silently, and truncation,
|
|
35
|
+
crash, and never-started are indistinguishable after the fact.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- pi ≥ 1.4.0
|
|
40
|
+
- **macOS or Linux** — Windows is not supported (the extension relies on POSIX
|
|
41
|
+
process groups, `ps`, and `timeout`)
|
|
42
|
+
- GNU `timeout`: preinstalled on Linux; on macOS install it with
|
|
43
|
+
`brew install coreutils` (or configure `timeoutBin: "gtimeout"` — see
|
|
44
|
+
[Configuration](#configuration))
|
|
45
|
+
- `ps` with `-o lstart=` (procps on Linux, built-in on macOS): standard on both
|
|
46
|
+
- Lanes resolve the pi binary from the dispatcher's `PATH` (configurable)
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pi install npm:pi-minimal-dispatch # official (registry)
|
|
52
|
+
pi install git:github.com/zhtmike/pi-minimal-dispatch # tracks main — between releases
|
|
53
|
+
pi -e ./src/index.ts # try it for one invocation
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Try this first
|
|
57
|
+
|
|
58
|
+
Write one brief file per lane (the lane's whole prompt — its goal, scope, and
|
|
59
|
+
expected deliverable), then ask in plain language:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Dispatch two lanes: docs summarizes the request-handling module, review audits src/auth.
|
|
63
|
+
Wait for them to settle and collect the artifacts.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
pi decides when to call the tools. A real session looks like:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
> dispatch wave="survey" lanes=[{name:"docs", briefPath:".pi/work/survey-docs-brief.md",
|
|
70
|
+
model:"zai/glm-5.3-flash", mcp:false},
|
|
71
|
+
{name:"review", briefPath:".pi/work/survey-review-brief.md", mcp:false}]
|
|
72
|
+
wave 'survey' dispatched — 2/2 lanes spawned
|
|
73
|
+
docs: pid 99015 (deadline 900s)
|
|
74
|
+
review: pid 99016 (deadline 900s)
|
|
75
|
+
manifest: /abs/path/to/.pi/work/survey-manifest.json
|
|
76
|
+
|
|
77
|
+
> lanes wave="survey" action="wait"
|
|
78
|
+
docs · settled · 2 turns · tok 5703in/90out, $0.0009 · stop:stop · last 0.8s ago
|
|
79
|
+
review · settled · 4 turns · tok 12021in/310out, $0.0041 · stop:stop · last 2.1s ago
|
|
80
|
+
|
|
81
|
+
> lanes wave="survey" action="collect"
|
|
82
|
+
collect wave 'survey':
|
|
83
|
+
docs: /abs/path/to/.pi/work/survey-docs.md stop:stop
|
|
84
|
+
review: /abs/path/to/.pi/work/survey-review.md stop:stop
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The ceremony is *write briefs → `dispatch` → `lanes wait` (or poll `status`) →
|
|
88
|
+
`collect` → clean up*. For a quick single task: dispatch one lane and let a
|
|
89
|
+
single `wait` call hold open until it settles — no sleep-then-poll guessing,
|
|
90
|
+
no waking late after a settle. `dispatch` itself never blocks; waiting is an
|
|
91
|
+
explicit, bounded `lanes` call (`waitSec`, default 300, max 3600) that returns
|
|
92
|
+
the status table as soon as a poll finds every lane `settled`/`failed`
|
|
93
|
+
(within one `pollMs`).
|
|
94
|
+
|
|
95
|
+
**Steering** is kill + redispatch: briefs are files, so refine the brief and run
|
|
96
|
+
a new wave (slugs are single-use). **Hang detection**: `lastEventAgeSec` is the
|
|
97
|
+
signal — no event growth for ~2× the lane's normal turn latency ⇒ `kill` and
|
|
98
|
+
redispatch.
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
Two tools — full parameter, output, and configuration reference in
|
|
103
|
+
[docs/API.md](docs/API.md).
|
|
104
|
+
|
|
105
|
+
- **`dispatch`** `{wave, lanes[], workDir?}` — validate briefs and slugs, claim
|
|
106
|
+
the wave slug atomically, spawn every lane detached in its own process
|
|
107
|
+
group, return in under a second. Per-lane: `model`, `deadlineSec`, `tools`,
|
|
108
|
+
`noExtensions`, `mcp`.
|
|
109
|
+
- **`lanes`** `{wave, action?, waitSec?, workDir?}` — stateless `status` /
|
|
110
|
+
`wait` / `kill` / `collect` over the wave's manifest; any session can re-adopt
|
|
111
|
+
a wave, including after the dispatcher exited.
|
|
112
|
+
|
|
113
|
+
**Web/MCP reach**: the default lane is offline. MCP servers connecting grants
|
|
114
|
+
nothing by themselves — a lane reaches their tools only through `codemode` or
|
|
115
|
+
`tool_search` named in its `tools` (`codemode` scripts and `tool_search` wait
|
|
116
|
+
for the servers they need; an `mcp__server__*` entry declares tools directly
|
|
117
|
+
only when the server has `direct` exposure in `mcp.json`, so it is a scope,
|
|
118
|
+
not a route). A lane that leaves MCP enabled without `codemode`/`tool_search`
|
|
119
|
+
in `tools` would run blind, so `dispatch` rejects it — pass a route or set
|
|
120
|
+
`mcp: false` (servers never connect). Entries reach pi verbatim, no shell
|
|
121
|
+
quoting: `tools: "read,grep,find,ls,codemode"`. Plain names/patterns only:
|
|
122
|
+
`+name`/`-name` modifier lists (which pi applies to its default selection,
|
|
123
|
+
silently granting `bash`/`edit`/`write`) and empty lists are rejected too.
|
|
124
|
+
|
|
125
|
+
## Configuration
|
|
126
|
+
|
|
127
|
+
The lane protocol is fixed (`--mode json --no-session -ns` — lanes never load
|
|
128
|
+
skills); every variable knob is configurable. Defaults come from the
|
|
129
|
+
extension; user config `~/.pi/agent/pi-minimal-dispatch.json` and project
|
|
130
|
+
config `.pi/pi-minimal-dispatch.json` override them, and per-call parameters
|
|
131
|
+
win last: `workDir`, `deadlineSec` (900), `killGraceMs` (5000), `pollMs`
|
|
132
|
+
(2000), `piBin`, `timeoutBin` (set `"gtimeout"` when coreutils isn't linked
|
|
133
|
+
on macOS). Invalid files fail loudly at the next tool call. Full table and
|
|
134
|
+
precedence rules: [docs/API.md](docs/API.md#configuration).
|
|
135
|
+
|
|
136
|
+
## Status bar & `/lanes`
|
|
137
|
+
|
|
138
|
+
While any tracked lane runs, the footer shows one line — e.g. `lanes: 2 running · 1 settled`
|
|
139
|
+
— updated at the `pollMs` cadence and cleared when nothing is in flight.
|
|
140
|
+
`/lanes` (optionally `/lanes <wave>`) prints the full status table for waves
|
|
141
|
+
seen this session — an untracked wave name is resolved from the default
|
|
142
|
+
workDir — and completes wave names. Both are TUI conveniences over the same
|
|
143
|
+
derived state the `lanes` tool reports.
|
|
144
|
+
|
|
145
|
+
## State model
|
|
146
|
+
|
|
147
|
+
Each lane's status is derived on every call from its event stream, PID
|
|
148
|
+
liveness (guarded by process start-time — a reused PID is never signaled),
|
|
149
|
+
and the deadline. Never stored, never stale. `settled` means the
|
|
150
|
+
`agent_settled` event was seen — **success is `collect` with
|
|
151
|
+
`suspect: false`**; a death after the deadline is labeled `deadline` even
|
|
152
|
+
when the process crashed. The statusbar and `/lanes` are TUI/RPC-only —
|
|
153
|
+
`--print`/`--mode json` sessions see status through the `lanes` tool alone.
|
|
154
|
+
|
|
155
|
+
| State | Meaning |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `starting` | spawned, no session header yet |
|
|
158
|
+
| `running` | events flowing (`overdue: true` once past deadline) |
|
|
159
|
+
| `settled` | `agent_settled` emitted (any stop reason — success is `collect` with `suspect: false`) |
|
|
160
|
+
| `failed` | cause: `spawn` (synchronous, nothing ran) · `killed` (tombstoned) · `deadline` (leash fired, even parentless) · `crashed` (`.err` tail attached) |
|
|
161
|
+
|
|
162
|
+
## How it works
|
|
163
|
+
|
|
164
|
+
- **Lane**: detached via an argv array (no `sh -c` wrapper to mangle) in its
|
|
165
|
+
own process group; the `timeout` wrapper is the deadline leash.
|
|
166
|
+
- **Registry**: `<workDir>/<wave>-manifest.json`, created atomically (`wx` —
|
|
167
|
+
slug collisions and concurrent same-wave dispatches fail loudly). Plain
|
|
168
|
+
JSON on disk: pids, process start-times, deadlines, brief paths — everything
|
|
169
|
+
a later session needs to re-adopt the wave. The extension never deletes
|
|
170
|
+
wave artifacts; cleanup stays an explicit step.
|
|
171
|
+
- **State**: derived on every call — never stored, never stale.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
node tests/config.test.ts # configuration precedence + validation
|
|
177
|
+
node tests/derive.test.ts # state derivation, wait loop, liveness guard, event parsing
|
|
178
|
+
node tests/format.test.ts # presentation formatters
|
|
179
|
+
node tests/lane.test.ts # slugs, briefs, argv, lane tool-contract validation
|
|
180
|
+
bash tests/prove.sh # integration: replays a live wave (bills models)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Contributing
|
|
184
|
+
|
|
185
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Agent instructions live in
|
|
186
|
+
[AGENTS.md](AGENTS.md).
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
[MIT](LICENSE)
|
package/docs/API.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Full reference for the two tools, their structured outputs, and configuration.
|
|
4
|
+
The lean overview lives in the [README](../README.md); the model-facing
|
|
5
|
+
descriptions live in the tool schemas in [`src/index.ts`](../src/index.ts) —
|
|
6
|
+
this document is the human-facing copy and must stay in sync with both.
|
|
7
|
+
|
|
8
|
+
## `dispatch`
|
|
9
|
+
|
|
10
|
+
Spawns a wave and returns in under a second. Lanes run
|
|
11
|
+
`<timeoutBin> -k <graceSec> <deadlineSec> <piBin> --mode json --no-session -ns [flags] @<brief>`
|
|
12
|
+
detached — the `--mode json --no-session -ns` triple is the fixed lane
|
|
13
|
+
protocol (fresh process, JSON event stream, skills never loaded); `-k`
|
|
14
|
+
escalates a caught SIGTERM to SIGKILL so a stalled dispose cannot outlive
|
|
15
|
+
the leash. Events stream to `<workDir>/<wave>-<name>.events.jsonl`.
|
|
16
|
+
|
|
17
|
+
| Parameter | Type | Default | Description |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| `wave` | string | — | Wave slug: `kebab-case`, max 64 chars, must not exist in `workDir` |
|
|
20
|
+
| `lanes` | array | — | One entry per lane (min 1); lane slugs unique within the wave |
|
|
21
|
+
| `lanes[].name` | string | — | Lane slug: `kebab-case`, max 64 chars |
|
|
22
|
+
| `lanes[].briefPath` | string | — | Path to the lane's brief file (its `@file` prompt) |
|
|
23
|
+
| `lanes[].model` | string? | pi default | Pin a model id; omitted: pi's **configured default model** — not the dispatcher session's active model |
|
|
24
|
+
| `lanes[].deadlineSec` | number? | `deadlineSec` config (900) | Leash in seconds; holds even if the dispatcher is gone. Minimum 1 |
|
|
25
|
+
| `lanes[].tools` | string? | `read,grep,find,ls` | Tool allowlist for `--tools` — plain names or `*` patterns; no `+name`/`-name` modifiers, no shell quoting, not empty. Add `bash` when a lane must run commands (bash can still invoke git). With MCP enabled, reach requires `codemode` or `tool_search` here — see [MCP and web reach](#mcp-and-web-reach) |
|
|
26
|
+
| `lanes[].noExtensions` | boolean? | false | Pass `-ne` (drop extensions incl. MCP in the lane) |
|
|
27
|
+
| `lanes[].mcp` | boolean? | enabled | Set `false` to pass `--no-mcp` (no MCP servers connect). With MCP enabled, `tools` must name `codemode` or `tool_search` or the call is rejected — servers connecting alone never declares MCP tools to the lane |
|
|
28
|
+
| `workDir` | string? | `workDir` config (`.pi/work`) | Directory for manifest, events, and artifacts |
|
|
29
|
+
|
|
30
|
+
**Result:** text summary plus `structuredContent` — `{ wave, manifestPath, lanes[] }`
|
|
31
|
+
with each lane's pid, paths, and deadline. Spawned lanes stream events
|
|
32
|
+
immediately; a lane that failed to spawn appears as `SPAWN ERROR: …`.
|
|
33
|
+
|
|
34
|
+
**Errors** (the whole call fails, zero lanes spawned, no slug claimed):
|
|
35
|
+
a brief file missing (all missing paths listed) · malformed or duplicate slugs ·
|
|
36
|
+
`tools` that is not a plain allowlist (`+name`/`-name` modifiers, shell quoting,
|
|
37
|
+
or an empty list) · a lane leaving MCP enabled while its `tools` names neither
|
|
38
|
+
`codemode` nor `tool_search` · wave slug already exists · `timeoutBin`/`piBin`
|
|
39
|
+
not found on `PATH`.
|
|
40
|
+
|
|
41
|
+
### MCP and web reach
|
|
42
|
+
|
|
43
|
+
`mcp` only controls whether servers connect; it never grants reach. A plain
|
|
44
|
+
`--tools` allowlist replaces the whole selection, and pi declares a server's
|
|
45
|
+
tools to the lane only through the routes below — anything else leaves
|
|
46
|
+
connected servers as unreachable dead weight, so `dispatch` rejects a lane
|
|
47
|
+
that leaves MCP enabled without one:
|
|
48
|
+
|
|
49
|
+
- `codemode` — scripts reach every non-hidden server's tools and wait for the
|
|
50
|
+
servers they name: `tools: "read,grep,find,ls,codemode"`
|
|
51
|
+
- `tool_search` — loads non-direct MCP tools on demand, waiting for all
|
|
52
|
+
servers when it runs
|
|
53
|
+
|
|
54
|
+
An `mcp__server__*` entry is a *scope*, not a route: it keeps only that
|
|
55
|
+
server's tools registered (for `codemode`/`tool_search` to use) and declares
|
|
56
|
+
them directly **only when the server or tool has `direct` exposure** in
|
|
57
|
+
`mcp.json` (`"exposure": "direct"` / `toolExposure`) — dispatch cannot know
|
|
58
|
+
the lane's server exposures, so `mcp__` entries alone never satisfy the rule.
|
|
59
|
+
For direct-exposure servers: `tools: "read,grep,find,ls,codemode,mcp__server__*"`
|
|
60
|
+
declares the tools and keeps them callable from scripts. The MCP resource
|
|
61
|
+
tools (`list_mcp_resources`, `list_mcp_resource_templates`,
|
|
62
|
+
`read_mcp_resource`) are exposure-gated the same way and do not count either.
|
|
63
|
+
|
|
64
|
+
Lanes that need no MCP set `mcp: false`. Entries reach pi verbatim (the
|
|
65
|
+
allowlist is one argv value — no shell quoting), and `+name`/`-name` modifier
|
|
66
|
+
lists are rejected because pi applies them to its default selection, silently
|
|
67
|
+
granting `bash`/`edit`/`write`.
|
|
68
|
+
|
|
69
|
+
**Recursion guard:** lanes run with `PI_MINIMAL_DISPATCH_CHILD=1` in their
|
|
70
|
+
environment, and the extension's factory stays out of any process carrying that
|
|
71
|
+
marker — a lane never gets the `dispatch` tool. `-ne` (`noExtensions`) remains
|
|
72
|
+
the switch that drops all extensions and MCP in a lane. Every lane in a call is
|
|
73
|
+
spawned; the caller bounds the wave.
|
|
74
|
+
|
|
75
|
+
## `lanes`
|
|
76
|
+
|
|
77
|
+
Stateless: every call reads the manifest fresh, so any session — including a
|
|
78
|
+
fresh one after the dispatcher exited — sees the same truth.
|
|
79
|
+
|
|
80
|
+
| Parameter | Type | Default | Description |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `wave` | string | — | Wave slug |
|
|
83
|
+
| `action` | string? | `"status"` | `"status"` \| `"wait"` \| `"kill"` \| `"collect"` |
|
|
84
|
+
| `waitSec` | number? | `300` | `wait` only: max seconds to hold the call open (1–3600) |
|
|
85
|
+
| `workDir` | string? | `workDir` config (`.pi/work`) | Directory holding the manifest |
|
|
86
|
+
|
|
87
|
+
### `status`
|
|
88
|
+
|
|
89
|
+
Per-lane `structuredContent` entries. Note: `settled` only means the
|
|
90
|
+
`agent_settled` event was seen — success is `collect` returning
|
|
91
|
+
`suspect: false`.
|
|
92
|
+
|
|
93
|
+
| Field | Type | Meaning |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `name` | string | lane slug |
|
|
96
|
+
| `state` | string | `starting` \| `running` \| `settled` \| `failed` |
|
|
97
|
+
| `cause` | string? | for `failed`: `spawn` · `killed` · `deadline` · `crashed` |
|
|
98
|
+
| `overdue` | boolean? | alive past its deadline (leash misfire) — informational |
|
|
99
|
+
| `turns` | number | turn count so far |
|
|
100
|
+
| `usage` | object | run totals summed over assistant messages: `{input, output, cacheRead, cacheWrite, cost}` |
|
|
101
|
+
| `stopReason` | string? | last assistant stop reason (`stop` is clean) |
|
|
102
|
+
| `lastEventAgeSec` | number? | seconds since the events file last grew — the stuck-lane signal |
|
|
103
|
+
| `spawnError` | string? | why a `spawn`-failed lane never started |
|
|
104
|
+
| `errTail` | string? | last lines of `.err` for `crashed` lanes |
|
|
105
|
+
|
|
106
|
+
### `wait`
|
|
107
|
+
|
|
108
|
+
Holds the tool call open, re-reading the manifest and re-deriving state every
|
|
109
|
+
`pollMs`, until every lane is terminal (`settled` or `failed`), `waitSec`
|
|
110
|
+
(default 300, max 3600) elapses, or the call is cancelled — then returns
|
|
111
|
+
exactly the `status` payload. A timeout is **not** an error: lanes still
|
|
112
|
+
running show as `running`, and the caller waits again (or proceeds to
|
|
113
|
+
`kill`/`collect`, which refuse running lanes as usual). This replaces
|
|
114
|
+
sleep-then-status polling, which guesses the duration and wakes late after a
|
|
115
|
+
settle. `dispatch` still returns in under a second — blocking is always this
|
|
116
|
+
explicit, bounded observation call, and the deadline leash needs no observer.
|
|
117
|
+
|
|
118
|
+
### `kill`
|
|
119
|
+
|
|
120
|
+
SIGTERM to each eligible lane's process group (never a
|
|
121
|
+
start-time-mismatched/reused PID; settled lanes skipped), SIGKILL after the
|
|
122
|
+
`killGraceMs` config grace, then a manifest tombstone so the kill survives
|
|
123
|
+
re-adoption. Idempotent; honors tool-call cancellation during the grace wait.
|
|
124
|
+
|
|
125
|
+
### `collect`
|
|
126
|
+
|
|
127
|
+
For each finished lane, writes the final assistant message (text blocks
|
|
128
|
+
concatenated) to `<wave>-<name>.md`. Entries carry
|
|
129
|
+
`{ name, artifact, suspect, stopReason }`; `suspect: true` when the lane
|
|
130
|
+
failed, the message is empty, or the stop reason was not clean — treat such an
|
|
131
|
+
artifact as **not done** and read the file only to judge it, don't paste it
|
|
132
|
+
back into the conversation. A refused lane (still running / never spawned) is
|
|
133
|
+
a **successful** tool result carrying a per-lane `reason`, not an error.
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
The lane protocol itself is fixed — every lane runs `--mode json --no-session
|
|
138
|
+
-ns` (fresh process, JSON event stream, skills unloaded) — while every
|
|
139
|
+
**variable** knob is configurable. JSON files, later wins:
|
|
140
|
+
|
|
141
|
+
1. defaults (documented in the tables above)
|
|
142
|
+
2. `<agent-dir>/pi-minimal-dispatch.json` — user level (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`)
|
|
143
|
+
3. `.pi/pi-minimal-dispatch.json` — project level (working directory)
|
|
144
|
+
4. per-call tool parameters
|
|
145
|
+
|
|
146
|
+
| Key | Type | Default | Description |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| `workDir` | string | `.pi/work` | Default wave directory |
|
|
149
|
+
| `deadlineSec` | number | `900` | Default lane leash (integer ≥ 1) |
|
|
150
|
+
| `killGraceMs` | number | `5000` | SIGTERM→SIGKILL grace in the `kill` action; also the leash's `timeout -k` escalation (integer ≥ 100) |
|
|
151
|
+
| `pollMs` | number | `2000` | Statusbar poll interval (integer ≥ 100) |
|
|
152
|
+
| `piBin` | string | `"pi"` | Lane pi binary, resolved from `PATH` |
|
|
153
|
+
| `timeoutBin` | string | `"timeout"` | Leash binary — set `"gtimeout"` if coreutils isn't linked on macOS |
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"deadlineSec": 600,
|
|
158
|
+
"timeoutBin": "gtimeout"
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Invalid files fail loudly at the next tool call: the error names the file and
|
|
163
|
+
the valid keys/values. Unknown keys are rejected.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "pi-minimal-dispatch",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Minimal detached lane dispatch with visible state for the pi coding agent — dispatch/lanes tools over a manifest + JSON-event protocol",
|
|
5
|
+
"author": "Mike Cheung",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"agent",
|
|
10
|
+
"subagent",
|
|
11
|
+
"dispatch",
|
|
12
|
+
"orchestration"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/zhtmike/pi-minimal-dispatch.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/zhtmike/pi-minimal-dispatch/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/zhtmike/pi-minimal-dispatch#readme",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.19.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node tests/config.test.ts && node tests/derive.test.ts && node tests/format.test.ts && node tests/lane.test.ts",
|
|
29
|
+
"prepublishOnly": "npm test"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"docs",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": ["./src/index.ts"]
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch configuration.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (later wins): built-in defaults < `<agent-dir>/pi-minimal-dispatch.json`
|
|
5
|
+
* < `.pi/pi-minimal-dispatch.json` < tool-call parameters.
|
|
6
|
+
* The agent directory follows `PI_CODING_AGENT_DIR`, defaulting to `~/.pi/agent`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
export type DispatchConfig = {
|
|
14
|
+
/** Default wave directory; the `workDir` tool parameter overrides. */
|
|
15
|
+
workDir: string;
|
|
16
|
+
/** Default lane deadline (seconds); the per-lane `deadlineSec` overrides. Minimum 1. */
|
|
17
|
+
deadlineSec: number;
|
|
18
|
+
/** Milliseconds between SIGTERM and SIGKILL in the `kill` action. Minimum 100. */
|
|
19
|
+
killGraceMs: number;
|
|
20
|
+
/** Statusbar poll interval in milliseconds. Minimum 100. */
|
|
21
|
+
pollMs: number;
|
|
22
|
+
/** Lane binary, resolved from PATH. */
|
|
23
|
+
piBin: string;
|
|
24
|
+
/** Deadline-leash binary (GNU coreutils `timeout`, or e.g. `gtimeout`). */
|
|
25
|
+
timeoutBin: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_CONFIG: DispatchConfig = {
|
|
29
|
+
workDir: ".pi/work",
|
|
30
|
+
deadlineSec: 900,
|
|
31
|
+
killGraceMs: 5000,
|
|
32
|
+
pollMs: 2000,
|
|
33
|
+
piBin: "pi",
|
|
34
|
+
timeoutBin: "timeout",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const CONFIG_NAME = "pi-minimal-dispatch.json";
|
|
38
|
+
|
|
39
|
+
/** Numeric floors guarding against degenerate values (instant kills, busy loops). */
|
|
40
|
+
const MINIMA: Partial<Record<keyof DispatchConfig, number>> = {
|
|
41
|
+
deadlineSec: 1,
|
|
42
|
+
killGraceMs: 100,
|
|
43
|
+
pollMs: 100,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function readConfigFile(file: string): Record<string, unknown> {
|
|
47
|
+
let raw: string;
|
|
48
|
+
try {
|
|
49
|
+
raw = fs.readFileSync(file, "utf8");
|
|
50
|
+
} catch (e: any) {
|
|
51
|
+
if (e.code === "ENOENT") return {};
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
if (raw.trim() === "") return {};
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
throw new Error(`invalid JSON in ${file}: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
62
|
+
throw new Error(`invalid config in ${file}: expected a JSON object`);
|
|
63
|
+
}
|
|
64
|
+
return parsed as Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function applyOverrides(cfg: DispatchConfig, overrides: Record<string, unknown>, source: string): DispatchConfig {
|
|
68
|
+
const known = new Set(Object.keys(DEFAULT_CONFIG));
|
|
69
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
70
|
+
if (!known.has(key)) {
|
|
71
|
+
throw new Error(`unknown config key '${key}' in ${source} — valid keys: ${[...known].join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
const minimum = MINIMA[key as keyof DispatchConfig];
|
|
74
|
+
if (minimum !== undefined) {
|
|
75
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < minimum) {
|
|
76
|
+
throw new Error(`config key '${key}' in ${source} must be an integer >= ${minimum}, got ${JSON.stringify(value)}`);
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof value !== "string" || value === "") {
|
|
79
|
+
throw new Error(`config key '${key}' in ${source} must be a non-empty string, got ${JSON.stringify(value)}`);
|
|
80
|
+
}
|
|
81
|
+
(cfg as Record<string, unknown>)[key] = value;
|
|
82
|
+
}
|
|
83
|
+
return cfg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load the effective configuration: defaults, then user file, then project file.
|
|
88
|
+
* Throws with the offending file path and valid choices on any invalid input.
|
|
89
|
+
*/
|
|
90
|
+
export function loadConfig(projectRoot: string = process.cwd()): DispatchConfig {
|
|
91
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
92
|
+
let cfg = { ...DEFAULT_CONFIG };
|
|
93
|
+
cfg = applyOverrides(cfg, readConfigFile(path.join(agentDir, CONFIG_NAME)), path.join(agentDir, CONFIG_NAME));
|
|
94
|
+
cfg = applyOverrides(cfg, readConfigFile(path.join(projectRoot, ".pi", CONFIG_NAME)), path.join(projectRoot, ".pi", CONFIG_NAME));
|
|
95
|
+
return cfg;
|
|
96
|
+
}
|