pi-minimal-dispatch 0.0.0-stage → 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 +19 -0
- package/LICENSE +21 -0
- package/README.md +168 -2
- package/docs/API.md +119 -0
- package/package.json +42 -5
- package/src/config.ts +96 -0
- package/src/derive.ts +172 -0
- package/src/events.ts +64 -0
- package/src/format.ts +37 -0
- package/src/index.ts +424 -0
- package/src/lane.ts +100 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-23
|
|
4
|
+
|
|
5
|
+
Initial release. Requires pi ≥ 1.3.0 (1.4.0 recommended — see README).
|
|
6
|
+
|
|
7
|
+
- `dispatch` tool: detached lane spawning (own process group, argv array, no
|
|
8
|
+
shell wrapper) with synchronous brief/slug validation, atomic wave-slug
|
|
9
|
+
claim, per-lane model/deadline/tool allowlist, and a parentless deadline
|
|
10
|
+
leash (`timeout -k`).
|
|
11
|
+
- `lanes` tool: stateless status/kill/collect over a wave's manifest + JSON
|
|
12
|
+
event streams — derived state with failure causes, start-time-guarded PID
|
|
13
|
+
liveness, TERM→KILL group kill with tombstones, and suspect-flagged
|
|
14
|
+
artifact collection.
|
|
15
|
+
- Safe defaults: lanes run read-only (`read,grep,find,ls`) unless granted
|
|
16
|
+
more via `tools`, never load skills (`-ns`), and never receive the
|
|
17
|
+
`dispatch` tool themselves (child marker).
|
|
18
|
+
- Statusbar line and `/lanes` command (TUI/RPC modes).
|
|
19
|
+
- 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,169 @@
|
|
|
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`, `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.3.0 — 1.4.0 recommended (it fixes pi deactivating extension tools after
|
|
40
|
+
`/resume` and `/tree` navigation, which wave re-adoption relies on)
|
|
41
|
+
- **macOS or Linux** — Windows is not supported (the extension relies on POSIX
|
|
42
|
+
process groups, `ps`, and `timeout`)
|
|
43
|
+
- GNU `timeout`: preinstalled on Linux; on macOS install it with
|
|
44
|
+
`brew install coreutils` (or configure `timeoutBin: "gtimeout"` — see
|
|
45
|
+
[Configuration](#configuration))
|
|
46
|
+
- `ps` with `-o lstart=` (procps on Linux, built-in on macOS): standard on both
|
|
47
|
+
- Lanes resolve the pi binary from the dispatcher's `PATH` (configurable)
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pi install git:github.com/zhtmike/pi-minimal-dispatch # or npm:pi-minimal-dispatch
|
|
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
|
+
Poll them and collect the artifacts when they settle.
|
|
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", deadlineSec:600}]
|
|
71
|
+
wave 'survey' dispatched — 1/1 lanes spawned
|
|
72
|
+
docs: pid 99015 (deadline 600s)
|
|
73
|
+
manifest: /abs/path/to/.pi/work/survey-manifest.json
|
|
74
|
+
|
|
75
|
+
> lanes wave="survey"
|
|
76
|
+
docs · running · 1 turns · tok 2966in/84out, $0.0005 · stop:toolUse · last 3s ago
|
|
77
|
+
|
|
78
|
+
> lanes wave="survey" action="collect"
|
|
79
|
+
collect wave 'survey':
|
|
80
|
+
docs: /abs/path/to/.pi/work/survey-docs.md stop:stop
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The ceremony is *write briefs → `dispatch` → poll `lanes status` → `collect` →
|
|
84
|
+
clean up*. For a quick single task: dispatch one lane, poll once after the
|
|
85
|
+
expected duration, collect — there is deliberately no blocking mode.
|
|
86
|
+
|
|
87
|
+
**Steering** is kill + redispatch: briefs are files, so refine the brief and run
|
|
88
|
+
a new wave (slugs are single-use). **Hang detection**: `lastEventAgeSec` is the
|
|
89
|
+
signal — no event growth for ~2× the lane's normal turn latency ⇒ `kill` and
|
|
90
|
+
redispatch.
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
Two tools — full parameter, output, and configuration reference in
|
|
95
|
+
[docs/API.md](docs/API.md).
|
|
96
|
+
|
|
97
|
+
- **`dispatch`** `{wave, lanes[], workDir?}` — validate briefs and slugs, claim
|
|
98
|
+
the wave slug atomically, spawn every lane detached in its own process
|
|
99
|
+
group, return in under a second. Per-lane: `model`, `deadlineSec`, `tools`,
|
|
100
|
+
`noExtensions`, `mcp`.
|
|
101
|
+
- **`lanes`** `{wave, action?, workDir?}` — stateless `status` / `kill` /
|
|
102
|
+
`collect` over the wave's manifest; any session can re-adopt a wave,
|
|
103
|
+
including after the dispatcher exited.
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
The lane protocol is fixed (`--mode json --no-session -ns` — lanes never load
|
|
108
|
+
skills); every variable knob is configurable. Defaults come from the
|
|
109
|
+
extension; user config `~/.pi/agent/pi-minimal-dispatch.json` and project
|
|
110
|
+
config `.pi/pi-minimal-dispatch.json` override them, and per-call parameters
|
|
111
|
+
win last: `workDir`, `deadlineSec` (900), `killGraceMs` (5000), `pollMs`
|
|
112
|
+
(2000), `piBin`, `timeoutBin` (set `"gtimeout"` when coreutils isn't linked
|
|
113
|
+
on macOS). Invalid files fail loudly at the next tool call. Full table and
|
|
114
|
+
precedence rules: [docs/API.md](docs/API.md#configuration).
|
|
115
|
+
|
|
116
|
+
## Status bar & `/lanes`
|
|
117
|
+
|
|
118
|
+
While any tracked lane runs, the footer shows one line — e.g. `lanes: 2 running · 1 settled`
|
|
119
|
+
— updated at the `pollMs` cadence and cleared when nothing is in flight.
|
|
120
|
+
`/lanes` (optionally `/lanes <wave>`) prints the full status table for waves
|
|
121
|
+
seen this session — an untracked wave name is resolved from the default
|
|
122
|
+
workDir — and completes wave names. Both are TUI conveniences over the same
|
|
123
|
+
derived state the `lanes` tool reports.
|
|
124
|
+
|
|
125
|
+
## State model
|
|
126
|
+
|
|
127
|
+
Each lane's status is derived on every call from its event stream, PID
|
|
128
|
+
liveness (guarded by process start-time — a reused PID is never signaled),
|
|
129
|
+
and the deadline. Never stored, never stale. `settled` means the
|
|
130
|
+
`agent_settled` event was seen — **success is `collect` with
|
|
131
|
+
`suspect: false`**; a death after the deadline is labeled `deadline` even
|
|
132
|
+
when the process crashed. The statusbar and `/lanes` are TUI/RPC-only —
|
|
133
|
+
`--print`/`--mode json` sessions see status through the `lanes` tool alone.
|
|
134
|
+
|
|
135
|
+
| State | Meaning |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `starting` | spawned, no session header yet |
|
|
138
|
+
| `running` | events flowing (`overdue: true` once past deadline) |
|
|
139
|
+
| `settled` | `agent_settled` emitted (any stop reason — success is `collect` with `suspect: false`) |
|
|
140
|
+
| `failed` | cause: `spawn` (synchronous, nothing ran) · `killed` (tombstoned) · `deadline` (leash fired, even parentless) · `crashed` (`.err` tail attached) |
|
|
141
|
+
|
|
142
|
+
## How it works
|
|
143
|
+
|
|
144
|
+
- **Lane**: detached via an argv array (no `sh -c` wrapper to mangle) in its
|
|
145
|
+
own process group; the `timeout` wrapper is the deadline leash.
|
|
146
|
+
- **Registry**: `<workDir>/<wave>-manifest.json`, created atomically (`wx` —
|
|
147
|
+
slug collisions and concurrent same-wave dispatches fail loudly). Plain
|
|
148
|
+
JSON on disk: pids, process start-times, deadlines, brief paths — everything
|
|
149
|
+
a later session needs to re-adopt the wave. The extension never deletes
|
|
150
|
+
wave artifacts; cleanup stays an explicit step.
|
|
151
|
+
- **State**: derived on every call — never stored, never stale.
|
|
152
|
+
|
|
153
|
+
## Testing
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
node tests/config.test.ts # configuration precedence + validation
|
|
157
|
+
node tests/derive.test.ts # state derivation, liveness guard, event parsing
|
|
158
|
+
node tests/format.test.ts # presentation formatters
|
|
159
|
+
bash tests/prove.sh # integration: replays a live wave (bills models)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Contributing
|
|
163
|
+
|
|
164
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Agent instructions live in
|
|
165
|
+
[AGENTS.md](AGENTS.md).
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
package/docs/API.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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`; add `bash` when a lane must run commands (bash can still invoke git) |
|
|
26
|
+
| `lanes[].noExtensions` | boolean? | false | Pass `-ne` (drop extensions incl. MCP in the lane) |
|
|
27
|
+
| `lanes[].mcp` | boolean? | reachable | Set `false` to pass `--no-mcp` (default: MCP tools stay reachable per pi's tool-exposure rules) |
|
|
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
|
+
wave slug already exists · `timeoutBin`/`piBin` not found on `PATH`.
|
|
37
|
+
|
|
38
|
+
**Recursion guard:** lanes run with `PI_MINIMAL_DISPATCH_CHILD=1` in their
|
|
39
|
+
environment, and the extension's factory stays out of any process carrying that
|
|
40
|
+
marker — a lane never gets the `dispatch` tool. `-ne` (`noExtensions`) remains
|
|
41
|
+
the switch that drops all extensions and MCP in a lane. Every lane in a call is
|
|
42
|
+
spawned; the caller bounds the wave.
|
|
43
|
+
|
|
44
|
+
## `lanes`
|
|
45
|
+
|
|
46
|
+
Stateless: every call reads the manifest fresh, so any session — including a
|
|
47
|
+
fresh one after the dispatcher exited — sees the same truth.
|
|
48
|
+
|
|
49
|
+
| Parameter | Type | Default | Description |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `wave` | string | — | Wave slug |
|
|
52
|
+
| `action` | string? | `"status"` | `"status"` \| `"kill"` \| `"collect"` |
|
|
53
|
+
| `workDir` | string? | `workDir` config (`.pi/work`) | Directory holding the manifest |
|
|
54
|
+
|
|
55
|
+
### `status`
|
|
56
|
+
|
|
57
|
+
Per-lane `structuredContent` entries. Note: `settled` only means the
|
|
58
|
+
`agent_settled` event was seen — success is `collect` returning
|
|
59
|
+
`suspect: false`.
|
|
60
|
+
|
|
61
|
+
| Field | Type | Meaning |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `name` | string | lane slug |
|
|
64
|
+
| `state` | string | `starting` \| `running` \| `settled` \| `failed` |
|
|
65
|
+
| `cause` | string? | for `failed`: `spawn` · `killed` · `deadline` · `crashed` |
|
|
66
|
+
| `overdue` | boolean? | alive past its deadline (leash misfire) — informational |
|
|
67
|
+
| `turns` | number | turn count so far |
|
|
68
|
+
| `usage` | object | run totals summed over assistant messages: `{input, output, cacheRead, cacheWrite, cost}` |
|
|
69
|
+
| `stopReason` | string? | last assistant stop reason (`stop` is clean) |
|
|
70
|
+
| `lastEventAgeSec` | number? | seconds since the events file last grew — the stuck-lane signal |
|
|
71
|
+
| `spawnError` | string? | why a `spawn`-failed lane never started |
|
|
72
|
+
| `errTail` | string? | last lines of `.err` for `crashed` lanes |
|
|
73
|
+
|
|
74
|
+
### `kill`
|
|
75
|
+
|
|
76
|
+
SIGTERM to each eligible lane's process group (never a
|
|
77
|
+
start-time-mismatched/reused PID; settled lanes skipped), SIGKILL after the
|
|
78
|
+
`killGraceMs` config grace, then a manifest tombstone so the kill survives
|
|
79
|
+
re-adoption. Idempotent; honors tool-call cancellation during the grace wait.
|
|
80
|
+
|
|
81
|
+
### `collect`
|
|
82
|
+
|
|
83
|
+
For each finished lane, writes the final assistant message (text blocks
|
|
84
|
+
concatenated) to `<wave>-<name>.md`. Entries carry
|
|
85
|
+
`{ name, artifact, suspect, stopReason }`; `suspect: true` when the lane
|
|
86
|
+
failed, the message is empty, or the stop reason was not clean — treat such an
|
|
87
|
+
artifact as **not done** and read the file only to judge it, don't paste it
|
|
88
|
+
back into the conversation. A refused lane (still running / never spawned) is
|
|
89
|
+
a **successful** tool result carrying a per-lane `reason`, not an error.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
The lane protocol itself is fixed — every lane runs `--mode json --no-session
|
|
94
|
+
-ns` (fresh process, JSON event stream, skills unloaded) — while every
|
|
95
|
+
**variable** knob is configurable. JSON files, later wins:
|
|
96
|
+
|
|
97
|
+
1. defaults (documented in the tables above)
|
|
98
|
+
2. `<agent-dir>/pi-minimal-dispatch.json` — user level (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`)
|
|
99
|
+
3. `.pi/pi-minimal-dispatch.json` — project level (working directory)
|
|
100
|
+
4. per-call tool parameters
|
|
101
|
+
|
|
102
|
+
| Key | Type | Default | Description |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| `workDir` | string | `.pi/work` | Default wave directory |
|
|
105
|
+
| `deadlineSec` | number | `900` | Default lane leash (integer ≥ 1) |
|
|
106
|
+
| `killGraceMs` | number | `5000` | SIGTERM→SIGKILL grace in the `kill` action; also the leash's `timeout -k` escalation (integer ≥ 100) |
|
|
107
|
+
| `pollMs` | number | `2000` | Statusbar poll interval (integer ≥ 100) |
|
|
108
|
+
| `piBin` | string | `"pi"` | Lane pi binary, resolved from `PATH` |
|
|
109
|
+
| `timeoutBin` | string | `"timeout"` | Leash binary — set `"gtimeout"` if coreutils isn't linked on macOS |
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"deadlineSec": 600,
|
|
114
|
+
"timeoutBin": "gtimeout"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Invalid files fail loudly at the next tool call: the error names the file and
|
|
119
|
+
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.1.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
|
+
}
|
package/src/derive.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch derived-state core — manifest registry, process
|
|
3
|
+
* liveness, and lane state derivation. Pure Node builtins: importable and
|
|
4
|
+
* testable without the pi host. Contract: README.md + docs/API.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { type LaneEvent, type StreamMessage, assistantMessageEnd, messageText, parseEvents } from "./events.ts";
|
|
11
|
+
|
|
12
|
+
export type LaneRecord = {
|
|
13
|
+
name: string;
|
|
14
|
+
briefPath: string; // absolute
|
|
15
|
+
eventsPath: string;
|
|
16
|
+
errPath: string;
|
|
17
|
+
pid: number | null;
|
|
18
|
+
processStart: string | null; // `ps -o lstart=` captured at spawn
|
|
19
|
+
startedAt: string; // ISO, stamped at creation
|
|
20
|
+
deadlineSec: number;
|
|
21
|
+
model?: string;
|
|
22
|
+
tools?: string;
|
|
23
|
+
spawned: boolean;
|
|
24
|
+
spawnError?: string;
|
|
25
|
+
killedAt?: string; // tombstone — the only post-spawn write
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Manifest = {
|
|
29
|
+
version: 1;
|
|
30
|
+
wave: string;
|
|
31
|
+
workDir: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
lanes: LaneRecord[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type LaneFailureCause = "spawn" | "killed" | "deadline" | "crashed";
|
|
37
|
+
|
|
38
|
+
export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number };
|
|
39
|
+
|
|
40
|
+
export type LaneStatus = {
|
|
41
|
+
name: string;
|
|
42
|
+
state: "starting" | "running" | "settled" | "failed";
|
|
43
|
+
cause?: LaneFailureCause;
|
|
44
|
+
/** Set while a lane is alive past its deadline (leash misfire) — informational. */
|
|
45
|
+
overdue?: boolean;
|
|
46
|
+
turns: number;
|
|
47
|
+
usage: Usage;
|
|
48
|
+
stopReason?: string;
|
|
49
|
+
lastEventAgeSec: number | null;
|
|
50
|
+
spawnError?: string;
|
|
51
|
+
errTail?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// --- manifest --------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Path of a wave's manifest inside its workDir. */
|
|
57
|
+
export function manifestPathFor(workDir: string, wave: string): string {
|
|
58
|
+
return path.join(workDir, `${wave}-manifest.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read and parse a wave manifest; throws with the path when absent or invalid. */
|
|
62
|
+
export function readManifest(workDir: string, wave: string): Manifest {
|
|
63
|
+
const p = manifestPathFor(workDir, wave);
|
|
64
|
+
if (!fs.existsSync(p)) throw new Error(`unknown wave '${wave}' (no manifest at ${p})`);
|
|
65
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as Manifest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Atomically replace a wave manifest (pid-suffixed tmp, same-fs rename). */
|
|
69
|
+
export function writeManifestAtomic(m: Manifest): void {
|
|
70
|
+
const p = manifestPathFor(m.workDir, m.wave);
|
|
71
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(m, null, "\t"));
|
|
73
|
+
fs.renameSync(tmp, p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- process liveness ------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Process start-time per `ps -o lstart=`; empty string means the pid is dead. */
|
|
79
|
+
export function psLstart(pid: number): string {
|
|
80
|
+
try {
|
|
81
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return ""; // a dead pid makes ps exit nonzero — empty means dead, never an exception
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** PID alive AND its start-time still matches — a reused PID is a different process, never ours. */
|
|
88
|
+
export function pidAlive(pid: number, expectedStart: string | null): boolean {
|
|
89
|
+
let reachable = false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
reachable = true;
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
if (e.code === "ESRCH") return false;
|
|
95
|
+
// EPERM: exists but not signalable — fall through to the start-time check
|
|
96
|
+
}
|
|
97
|
+
if (expectedStart === null) return reachable;
|
|
98
|
+
const actual = psLstart(pid);
|
|
99
|
+
return actual !== "" && actual === expectedStart;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- state derivation ------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
function emptyUsage(): Usage {
|
|
105
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errTailFor(lane: LaneRecord): string | undefined {
|
|
109
|
+
if (!fs.existsSync(lane.errPath)) return undefined;
|
|
110
|
+
const err = fs.readFileSync(lane.errPath, "utf8").trim();
|
|
111
|
+
return err === "" ? undefined : err.split("\n").slice(-5).join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Derive a lane's status from its events file, PID liveness, and manifest facts. Never stored. */
|
|
115
|
+
export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
|
|
116
|
+
const base: LaneStatus = {
|
|
117
|
+
name: lane.name,
|
|
118
|
+
state: "failed",
|
|
119
|
+
turns: 0,
|
|
120
|
+
usage: emptyUsage(),
|
|
121
|
+
lastEventAgeSec: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!lane.spawned) return { ...base, cause: "spawn", spawnError: lane.spawnError };
|
|
125
|
+
|
|
126
|
+
const events = parseEvents(lane.eventsPath);
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (e.type === "turn_start") base.turns++;
|
|
129
|
+
const message = assistantMessageEnd(e);
|
|
130
|
+
if (message !== null) {
|
|
131
|
+
const u = message.usage ?? {};
|
|
132
|
+
base.usage.input += u.input ?? 0;
|
|
133
|
+
base.usage.output += u.output ?? 0;
|
|
134
|
+
base.usage.cacheRead += u.cacheRead ?? 0;
|
|
135
|
+
base.usage.cacheWrite += u.cacheWrite ?? 0;
|
|
136
|
+
base.usage.cost += u.cost?.total ?? 0;
|
|
137
|
+
if (typeof message.stopReason === "string") base.stopReason = message.stopReason;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (fs.existsSync(lane.eventsPath)) {
|
|
141
|
+
base.lastEventAgeSec = Math.round((now - fs.statSync(lane.eventsPath).mtimeMs) / 100) / 10;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pastDeadline = now > Date.parse(lane.startedAt) + lane.deadlineSec * 1000;
|
|
145
|
+
|
|
146
|
+
if (events.some((e) => e.type === "agent_settled")) return { ...base, state: "settled" };
|
|
147
|
+
|
|
148
|
+
const alive = lane.pid !== null && pidAlive(lane.pid, lane.processStart);
|
|
149
|
+
if (!alive) {
|
|
150
|
+
if (lane.killedAt) return { ...base, cause: "killed" };
|
|
151
|
+
if (pastDeadline) return { ...base, cause: "deadline" };
|
|
152
|
+
return { ...base, cause: "crashed", errTail: errTailFor(lane) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
state: events.some((e) => e.type === "session") ? "running" : "starting",
|
|
158
|
+
overdue: pastDeadline,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Final assistant message text (concatenated text blocks of the last assistant message_end). */
|
|
163
|
+
export function finalAssistantText(events: LaneEvent[]): string {
|
|
164
|
+
let text = "";
|
|
165
|
+
for (const e of events) {
|
|
166
|
+
const message = assistantMessageEnd(e);
|
|
167
|
+
if (message !== null) text = messageText(message);
|
|
168
|
+
}
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { parseEvents };
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch lane event stream — typed parsing of a lane's
|
|
3
|
+
* `pi --mode json` JSONL output (see pi's JSON Event Stream docs).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
export type UsageDelta = {
|
|
9
|
+
input?: number;
|
|
10
|
+
output?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
cost?: { total?: number };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ContentBlock = { type: string; text?: string; thinking?: string };
|
|
17
|
+
|
|
18
|
+
export type StreamMessage = {
|
|
19
|
+
role: string;
|
|
20
|
+
stopReason?: string;
|
|
21
|
+
usage?: UsageDelta;
|
|
22
|
+
content?: ContentBlock[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LaneEvent = { type: string } & Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a lane's JSONL event file. A trailing line lacking its newline is a
|
|
29
|
+
* SIGTERM-truncated write and is skipped; any other unparseable line is
|
|
30
|
+
* corruption and throws. Returns [] when the file does not exist yet.
|
|
31
|
+
*/
|
|
32
|
+
export function parseEvents(eventsPath: string): LaneEvent[] {
|
|
33
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
34
|
+
const raw = fs.readFileSync(eventsPath, "utf8");
|
|
35
|
+
if (raw === "") return [];
|
|
36
|
+
const lines = raw.endsWith("\n") ? raw.slice(0, -1).split("\n") : raw.split("\n").slice(0, -1);
|
|
37
|
+
const events: LaneEvent[] = [];
|
|
38
|
+
for (let i = 0; i < lines.length; i++) {
|
|
39
|
+
if (lines[i] === "") continue;
|
|
40
|
+
try {
|
|
41
|
+
events.push(JSON.parse(lines[i]) as LaneEvent);
|
|
42
|
+
} catch (e: any) {
|
|
43
|
+
throw new Error(`corrupt event line ${i + 1} in ${eventsPath}: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Narrow an event to an assistant `message_end`, else null. */
|
|
50
|
+
export function assistantMessageEnd(e: LaneEvent): StreamMessage | null {
|
|
51
|
+
if (e.type !== "message_end") return null;
|
|
52
|
+
const message = (e as { message?: StreamMessage }).message;
|
|
53
|
+
if (message === undefined || message.role !== "assistant") return null;
|
|
54
|
+
return message;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Concatenated text blocks of an assistant message; empty when it has none. */
|
|
58
|
+
export function messageText(message: StreamMessage): string {
|
|
59
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
60
|
+
return blocks
|
|
61
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
62
|
+
.map((b) => b.text)
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch presentation — human-facing text for lane status,
|
|
3
|
+
* shared by the `lanes` tool, the `/lanes` command, and the statusbar.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LaneStatus } from "./derive.ts";
|
|
7
|
+
|
|
8
|
+
/** One status-table line per lane (two-space indent, err tail four-space indented). */
|
|
9
|
+
export function formatLaneLine(s: LaneStatus): string {
|
|
10
|
+
const bits = [s.name, s.state];
|
|
11
|
+
if (s.cause) bits.push(`(${s.cause}${s.spawnError ? `: ${s.spawnError}` : ""})`);
|
|
12
|
+
if (s.overdue) bits.push("OVERDUE");
|
|
13
|
+
bits.push(s.turns === 1 ? "1 turn" : `${s.turns} turns`);
|
|
14
|
+
bits.push(`tok ${s.usage.input}in/${s.usage.output}out, $${s.usage.cost.toFixed(4)}`);
|
|
15
|
+
if (s.stopReason !== undefined) bits.push(`stop:${s.stopReason}`);
|
|
16
|
+
if (s.lastEventAgeSec !== null) bits.push(`last ${s.lastEventAgeSec}s ago`);
|
|
17
|
+
return ` ${bits.join(" · ")}` + (s.errTail ? `\n err: ${s.errTail.replace(/\n/g, "\n ")}` : "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Full status table for one wave. */
|
|
21
|
+
export function formatWaveStatus(wave: string, statuses: LaneStatus[]): string {
|
|
22
|
+
return `wave '${wave}':\n${statuses.map(formatLaneLine).join("\n")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Footer summary across tracked waves; undefined when nothing is in flight — clear the status entry. */
|
|
26
|
+
export function formatStatusBarSummary(statuses: LaneStatus[]): string | undefined {
|
|
27
|
+
let running = 0;
|
|
28
|
+
const done: Record<string, number> = {};
|
|
29
|
+
for (const s of statuses) {
|
|
30
|
+
if (s.state === "running" || s.state === "starting") running++;
|
|
31
|
+
else done[s.state] = (done[s.state] ?? 0) + 1;
|
|
32
|
+
}
|
|
33
|
+
if (running === 0) return undefined;
|
|
34
|
+
const parts = [`${running} running`];
|
|
35
|
+
for (const [k, v] of Object.entries(done).sort()) if (v > 0) parts.push(`${v} ${k}`);
|
|
36
|
+
return `lanes: ${parts.join(" · ")}`;
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch — detached lane dispatch with visible state (README.md).
|
|
3
|
+
* `dispatch`: spawn a wave of detached `timeout … pi --mode json …` lanes, return immediately.
|
|
4
|
+
* `lanes`: stateless status/kill/collect over the wave's manifest + event streams.
|
|
5
|
+
* The lane protocol is fixed (`--mode json --no-session -ns`); every variable
|
|
6
|
+
* knob is configurable (docs/API.md → Configuration).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
13
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { type DispatchConfig, loadConfig } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
type LaneRecord,
|
|
17
|
+
type LaneStatus,
|
|
18
|
+
type Manifest,
|
|
19
|
+
deriveLane,
|
|
20
|
+
finalAssistantText,
|
|
21
|
+
manifestPathFor,
|
|
22
|
+
parseEvents,
|
|
23
|
+
pidAlive,
|
|
24
|
+
psLstart,
|
|
25
|
+
readManifest,
|
|
26
|
+
writeManifestAtomic,
|
|
27
|
+
} from "./derive.ts";
|
|
28
|
+
import { formatStatusBarSummary, formatWaveStatus } from "./format.ts";
|
|
29
|
+
import { type LaneParams, assertSlugs, buildLaneArgs, laneEnv, missingBriefs, resolveExecutable, selectKillTargets } from "./lane.ts";
|
|
30
|
+
|
|
31
|
+
/** Spawn one lane detached; a failure at any step becomes the record's spawnError, never a throw. */
|
|
32
|
+
function spawnLane(workDir: string, wave: string, lane: LaneParams, cfg: DispatchConfig, timeoutBin: string): LaneRecord {
|
|
33
|
+
const briefPath = path.resolve(lane.briefPath);
|
|
34
|
+
const record: LaneRecord = {
|
|
35
|
+
name: lane.name,
|
|
36
|
+
briefPath,
|
|
37
|
+
eventsPath: path.join(workDir, `${wave}-${lane.name}.events.jsonl`),
|
|
38
|
+
errPath: path.join(workDir, `${wave}-${lane.name}.err`),
|
|
39
|
+
pid: null,
|
|
40
|
+
processStart: null,
|
|
41
|
+
startedAt: new Date().toISOString(),
|
|
42
|
+
deadlineSec: lane.deadlineSec ?? cfg.deadlineSec,
|
|
43
|
+
model: lane.model,
|
|
44
|
+
tools: lane.tools,
|
|
45
|
+
spawned: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let outFd = -1;
|
|
49
|
+
let errFd = -1;
|
|
50
|
+
try {
|
|
51
|
+
outFd = fs.openSync(record.eventsPath, "w");
|
|
52
|
+
errFd = fs.openSync(record.errPath, "w");
|
|
53
|
+
const args = [
|
|
54
|
+
timeoutBin,
|
|
55
|
+
...buildLaneArgs(lane, cfg, record.deadlineSec, Math.ceil(cfg.killGraceMs / 1000)),
|
|
56
|
+
];
|
|
57
|
+
const child = spawn(args[0], args.slice(1), {
|
|
58
|
+
detached: true, // own process group; survives this process's exit
|
|
59
|
+
stdio: ["ignore", outFd, errFd],
|
|
60
|
+
env: laneEnv(),
|
|
61
|
+
});
|
|
62
|
+
child.unref();
|
|
63
|
+
child.on("error", (e) => {
|
|
64
|
+
// Async spawn failure lands after we returned; the cause goes to .err
|
|
65
|
+
try {
|
|
66
|
+
fs.appendFileSync(record.errPath, `\n[pi-minimal-dispatch] spawn error: ${e.message}\n`);
|
|
67
|
+
} catch {}
|
|
68
|
+
});
|
|
69
|
+
record.pid = child.pid;
|
|
70
|
+
const start = psLstart(child.pid!);
|
|
71
|
+
if (start === "") {
|
|
72
|
+
// Without a start-time the liveness guard can never match — stop the lane
|
|
73
|
+
// now rather than record an unmatchable identity.
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-child.pid!, "SIGTERM");
|
|
76
|
+
} catch {}
|
|
77
|
+
record.spawnError = "could not capture process start-time (ps failed); lane terminated";
|
|
78
|
+
} else {
|
|
79
|
+
record.processStart = start;
|
|
80
|
+
record.spawned = true;
|
|
81
|
+
}
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
record.spawnError = e.message;
|
|
84
|
+
} finally {
|
|
85
|
+
if (outFd !== -1) fs.closeSync(outFd);
|
|
86
|
+
if (errFd !== -1) fs.closeSync(errFd);
|
|
87
|
+
}
|
|
88
|
+
return record;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dispatchTool = defineTool({
|
|
92
|
+
name: "dispatch",
|
|
93
|
+
label: "Dispatch lanes",
|
|
94
|
+
description:
|
|
95
|
+
"Spawn detached sub-agent lanes (fresh pi processes) for a wave and return immediately. Each lane runs " +
|
|
96
|
+
"`timeout -k <graceSec> <deadlineSec> pi --mode json --no-session -ns [flags] @<briefPath>` in its own " +
|
|
97
|
+
"process group, streaming JSON events to <workDir>/<wave>-<name>.events.jsonl. Lanes survive this " +
|
|
98
|
+
"session's exit. Write briefs first — one brief file per lane is the lane's entire prompt; " +
|
|
99
|
+
"lanes run in fresh processes (JSON event mode, no session) with skills unloaded (-ns), " +
|
|
100
|
+
"a read-only tool default, and never the dispatch tool itself. Every lane in the call is " +
|
|
101
|
+
"spawned — the caller bounds the wave. " +
|
|
102
|
+
"Then poll with the lanes tool; collect artifacts when settled.",
|
|
103
|
+
parameters: Type.Object({
|
|
104
|
+
wave: Type.String({ description: "Wave slug (kebab-case, max 64 chars); must not already exist in workDir" }),
|
|
105
|
+
lanes: Type.Array(
|
|
106
|
+
Type.Object({
|
|
107
|
+
name: Type.String({ description: "Lane slug (kebab-case, max 64 chars, unique within the wave)" }),
|
|
108
|
+
briefPath: Type.String({ description: "Path to the lane's brief file (@file prompt)" }),
|
|
109
|
+
model: Type.Optional(Type.String({ description: "Pin a model id for this lane; omitted: pi's configured default model — not necessarily the dispatcher session's active model" })),
|
|
110
|
+
deadlineSec: Type.Optional(Type.Number({ description: "Deadline leash in seconds (config default: 900)", minimum: 1 })),
|
|
111
|
+
tools: Type.Optional(Type.String({ description: "Tool allowlist for --tools; omitted: 'read,grep,find,ls' — add 'bash' when a lane must run commands (bash can still invoke git)" })),
|
|
112
|
+
noExtensions: Type.Optional(Type.Boolean({ description: "Pass -ne (drop extensions incl. MCP)" })),
|
|
113
|
+
mcp: Type.Optional(Type.Boolean({ description: "Set false to pass --no-mcp (default: MCP tools stay reachable per pi's tool-exposure rules)" })),
|
|
114
|
+
}),
|
|
115
|
+
{ minItems: 1 },
|
|
116
|
+
),
|
|
117
|
+
workDir: Type.Optional(Type.String({ description: "Directory for briefs/events/manifest (config default: .pi/work)" })),
|
|
118
|
+
}),
|
|
119
|
+
|
|
120
|
+
async execute(_id, params) {
|
|
121
|
+
const cfg = loadConfig();
|
|
122
|
+
const workDir = path.resolve(params.workDir ?? cfg.workDir);
|
|
123
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
124
|
+
// Slugs are validated before the manifest claim: a post-claim throw would strand the slug.
|
|
125
|
+
assertSlugs(params.wave, params.lanes.map((l) => l.name));
|
|
126
|
+
|
|
127
|
+
// All briefs must exist before anything spawns: report every missing one.
|
|
128
|
+
const missing = missingBriefs(params.lanes);
|
|
129
|
+
if (missing.length > 0) {
|
|
130
|
+
throw new Error(`brief file(s) not found: ${missing.join(", ")} — nothing spawned`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Claim the wave slug atomically; an existing manifest rejects it.
|
|
134
|
+
const manifestPath = manifestPathFor(workDir, params.wave);
|
|
135
|
+
let claimFd: number;
|
|
136
|
+
try {
|
|
137
|
+
claimFd = fs.openSync(manifestPath, "wx");
|
|
138
|
+
} catch (e: any) {
|
|
139
|
+
if (e.code === "EEXIST") throw new Error(`wave '${params.wave}' already exists (${manifestPath})`);
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let timeoutBin: string;
|
|
144
|
+
try {
|
|
145
|
+
timeoutBin = resolveExecutable(cfg.timeoutBin);
|
|
146
|
+
resolveExecutable(cfg.piBin);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
fs.closeSync(claimFd);
|
|
149
|
+
fs.unlinkSync(manifestPath);
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const manifest: Manifest = {
|
|
154
|
+
version: 1,
|
|
155
|
+
wave: params.wave,
|
|
156
|
+
workDir,
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
lanes: [],
|
|
159
|
+
};
|
|
160
|
+
for (const lane of params.lanes) {
|
|
161
|
+
manifest.lanes.push(spawnLane(workDir, params.wave, lane, cfg, timeoutBin));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
fs.writeSync(claimFd, JSON.stringify(manifest, null, "\t"));
|
|
166
|
+
fs.closeSync(claimFd);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// environmental failure after the claim: free the slug and stop already-spawned lanes
|
|
169
|
+
for (const l of manifest.lanes) {
|
|
170
|
+
if (l.pid !== null) {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(-l.pid, "SIGTERM");
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
fs.closeSync(claimFd);
|
|
178
|
+
} catch {}
|
|
179
|
+
fs.unlinkSync(manifestPath);
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
trackWave(params.wave, workDir);
|
|
183
|
+
|
|
184
|
+
const lines = manifest.lanes.map(
|
|
185
|
+
(l) =>
|
|
186
|
+
` ${l.name}: ${l.spawned ? `pid ${l.pid} (deadline ${l.deadlineSec}s)` : `SPAWN ERROR: ${l.spawnError}`}`,
|
|
187
|
+
);
|
|
188
|
+
return {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: "text",
|
|
192
|
+
text: `wave '${params.wave}' dispatched — ${manifest.lanes.filter((l) => l.spawned).length}/${manifest.lanes.length} lanes spawned\n${lines.join("\n")}\nmanifest: ${manifestPath}`,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
structuredContent: { wave: params.wave, manifestPath, lanes: manifest.lanes },
|
|
196
|
+
details: { manifestPath },
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const lanesTool = defineTool({
|
|
202
|
+
name: "lanes",
|
|
203
|
+
label: "Lane status",
|
|
204
|
+
description:
|
|
205
|
+
"Observe/control a dispatched wave. action=status (default): per-lane derived state " +
|
|
206
|
+
"(starting/running/settled/failed + cause spawn|killed|deadline|crashed), turns, run-total usage, " +
|
|
207
|
+
"stopReason, last-event age, .err tail for crashed lanes. action=kill: SIGTERM the lane process group " +
|
|
208
|
+
"(SIGKILL after the configured grace), start-time guarded. action=collect: write each finished lane's final assistant " +
|
|
209
|
+
"message to <wave>-<name>.md (suspect-flagged on failure/empty/non-clean stopReason); a refusal " +
|
|
210
|
+
"(running / never spawned) is a successful result with a per-lane reason. Stateless: reads the manifest " +
|
|
211
|
+
"fresh — works across sessions, including after the dispatcher exited. A collected lane is a path plus " +
|
|
212
|
+
"`suspect`; `suspect: true` means the artifact is not done — read the file only to judge it, do not paste " +
|
|
213
|
+
"it back. Discover waves: ls <workDir>/*-manifest.json.",
|
|
214
|
+
parameters: Type.Object({
|
|
215
|
+
wave: Type.String({ description: "Wave slug" }),
|
|
216
|
+
action: Type.Optional(StringEnum(["status", "kill", "collect"])),
|
|
217
|
+
workDir: Type.Optional(Type.String({ description: "Directory holding the manifest (config default: .pi/work)" })),
|
|
218
|
+
}),
|
|
219
|
+
|
|
220
|
+
async execute(_id, params, signal) {
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
const workDir = path.resolve(params.workDir ?? cfg.workDir);
|
|
223
|
+
const action = params.action ?? "status";
|
|
224
|
+
const manifest = readManifest(workDir, params.wave);
|
|
225
|
+
trackWave(params.wave, workDir);
|
|
226
|
+
|
|
227
|
+
if (action === "status") {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const statuses = manifest.lanes.map((l) => deriveLane(l, now));
|
|
230
|
+
return {
|
|
231
|
+
content: [{ type: "text", text: formatWaveStatus(params.wave, statuses) }],
|
|
232
|
+
structuredContent: { wave: params.wave, lanes: statuses },
|
|
233
|
+
details: { wave: params.wave },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (action === "kill") {
|
|
238
|
+
const killed: string[] = [];
|
|
239
|
+
try {
|
|
240
|
+
const targets = selectKillTargets(
|
|
241
|
+
manifest.lanes,
|
|
242
|
+
(l) => pidAlive(l.pid!, l.processStart), // never signal a reused PID
|
|
243
|
+
(l) => deriveLane(l, Date.now()).state === "settled",
|
|
244
|
+
);
|
|
245
|
+
for (const lane of targets) {
|
|
246
|
+
try {
|
|
247
|
+
process.kill(-lane.pid!, "SIGTERM"); // negative = process group (timeout + pi)
|
|
248
|
+
} catch (e: any) {
|
|
249
|
+
if (e.code === "ESRCH") continue;
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
killed.push(lane.name);
|
|
253
|
+
lane.killedAt = new Date().toISOString(); // tombstone
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
writeManifestAtomic(manifest); // a throw mid-loop must not lose already-stamped tombstones
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
if (killed.length > 0) {
|
|
260
|
+
const deadline = Date.now() + cfg.killGraceMs;
|
|
261
|
+
while (Date.now() < deadline) {
|
|
262
|
+
if (signal?.aborted) break;
|
|
263
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
264
|
+
const survivors = manifest.lanes.filter(
|
|
265
|
+
(l) => l.killedAt !== undefined && l.pid !== null && pidAlive(l.pid, l.processStart),
|
|
266
|
+
);
|
|
267
|
+
if (survivors.length === 0) break;
|
|
268
|
+
if (Date.now() + 200 >= deadline) {
|
|
269
|
+
for (const l of survivors) {
|
|
270
|
+
try {
|
|
271
|
+
process.kill(-l.pid!, "SIGKILL");
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
writeManifestAtomic(manifest);
|
|
277
|
+
}
|
|
278
|
+
const statuses = manifest.lanes.map((l) => deriveLane(l, Date.now()));
|
|
279
|
+
return {
|
|
280
|
+
content: [
|
|
281
|
+
{
|
|
282
|
+
type: "text",
|
|
283
|
+
text:
|
|
284
|
+
`killed: ${killed.length ? killed.join(", ") : "none (nothing eligible)"}\n` +
|
|
285
|
+
statuses.map((s) => ` ${s.name}: ${s.state}${s.cause ? ` (${s.cause})` : ""}`).join("\n"),
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
structuredContent: { wave: params.wave, killed, lanes: statuses },
|
|
289
|
+
details: { wave: params.wave },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// action === "collect"
|
|
294
|
+
const results: Array<{ name: string; artifact?: string; suspect?: boolean; reason?: string; stopReason?: string }> = [];
|
|
295
|
+
for (const lane of manifest.lanes) {
|
|
296
|
+
const status = deriveLane(lane, Date.now());
|
|
297
|
+
if (status.cause === "spawn") {
|
|
298
|
+
results.push({ name: lane.name, reason: "never spawned — refused" });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (status.state === "running" || status.state === "starting") {
|
|
302
|
+
results.push({ name: lane.name, reason: "still running — refused" });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const text = finalAssistantText(parseEvents(lane.eventsPath));
|
|
306
|
+
const suspect = status.state === "failed" || text === "" || status.stopReason !== "stop";
|
|
307
|
+
const artifact = path.join(workDir, `${params.wave}-${lane.name}.md`);
|
|
308
|
+
fs.writeFileSync(artifact, text.endsWith("\n") || text === "" ? text : text + "\n");
|
|
309
|
+
results.push({ name: lane.name, artifact, suspect, stopReason: status.stopReason });
|
|
310
|
+
}
|
|
311
|
+
const text = results
|
|
312
|
+
.map((r) => ` ${r.name}: ${r.artifact ?? r.reason}${r.suspect ? " [SUSPECT]" : ""}${r.stopReason !== undefined ? ` stop:${r.stopReason}` : ""}`)
|
|
313
|
+
.join("\n");
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: "text", text: `collect wave '${params.wave}':\n${text}` }],
|
|
316
|
+
structuredContent: { wave: params.wave, results },
|
|
317
|
+
details: { wave: params.wave },
|
|
318
|
+
};
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// --- statusbar tracking (in-memory cache; manifest and event files stay authoritative) ---
|
|
323
|
+
|
|
324
|
+
const STATUS_KEY = "pi-minimal-dispatch";
|
|
325
|
+
|
|
326
|
+
type UiCtx = { mode: string; ui: { setStatus(key: string, text: string | undefined): void } };
|
|
327
|
+
|
|
328
|
+
const tracked = new Map<string, string>(); // wave -> workDir
|
|
329
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
330
|
+
let uiCtx: UiCtx | null = null;
|
|
331
|
+
|
|
332
|
+
function statusBarTick(): void {
|
|
333
|
+
const ctx = uiCtx;
|
|
334
|
+
if (ctx === null) return;
|
|
335
|
+
const all: LaneStatus[] = [];
|
|
336
|
+
for (const [wave, workDir] of tracked) {
|
|
337
|
+
if (!fs.existsSync(manifestPathFor(workDir, wave))) {
|
|
338
|
+
tracked.delete(wave); // the wave was cleaned up
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const m = readManifest(workDir, wave);
|
|
343
|
+
all.push(...m.lanes.map((l) => deriveLane(l, Date.now())));
|
|
344
|
+
} catch {
|
|
345
|
+
// corrupt manifest/events: skipped this tick — /lanes and the lanes tool surface the error loudly
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const summary = formatStatusBarSummary(all);
|
|
349
|
+
if (summary === undefined) {
|
|
350
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
351
|
+
stopPolling();
|
|
352
|
+
} else {
|
|
353
|
+
ctx.ui.setStatus(STATUS_KEY, summary);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function armPolling(): void {
|
|
358
|
+
if (pollTimer === null && tracked.size > 0 && uiCtx !== null && (uiCtx.mode === "tui" || uiCtx.mode === "rpc")) {
|
|
359
|
+
pollTimer = setInterval(statusBarTick, loadConfig().pollMs);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function trackWave(wave: string, workDir: string): void {
|
|
364
|
+
tracked.set(wave, workDir);
|
|
365
|
+
armPolling();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function stopPolling(): void {
|
|
369
|
+
if (pollTimer !== null) {
|
|
370
|
+
clearInterval(pollTimer);
|
|
371
|
+
pollTimer = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default function (pi: ExtensionAPI) {
|
|
376
|
+
// A dispatched lane never gets the dispatch tool again — the child marker
|
|
377
|
+
// keeps this extension out of the lane while the rest of the user's setup loads.
|
|
378
|
+
if (process.env.PI_MINIMAL_DISPATCH_CHILD === "1") return;
|
|
379
|
+
|
|
380
|
+
pi.registerTool(dispatchTool);
|
|
381
|
+
pi.registerTool(lanesTool);
|
|
382
|
+
|
|
383
|
+
pi.on("session_start", (_event, ctx) => {
|
|
384
|
+
uiCtx = ctx;
|
|
385
|
+
// session replacement (new/resume/fork) keeps module state: re-arm if waves are still in flight
|
|
386
|
+
armPolling();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
pi.on("session_shutdown", () => {
|
|
390
|
+
stopPolling();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
pi.registerCommand("lanes", {
|
|
394
|
+
description: "Show dispatched lane status — /lanes [wave]",
|
|
395
|
+
getArgumentCompletions: (prefix) => {
|
|
396
|
+
const hits = [...tracked.keys()].filter((w) => w.startsWith(prefix));
|
|
397
|
+
return hits.length > 0 ? hits.map((w) => ({ value: w, label: w })) : null;
|
|
398
|
+
},
|
|
399
|
+
handler: async (args, ctx) => {
|
|
400
|
+
const show = (wave: string, workDir: string) =>
|
|
401
|
+
ctx.ui.notify(formatWaveStatus(wave, readManifest(workDir, wave).lanes.map((l) => deriveLane(l, Date.now()))), "info");
|
|
402
|
+
try {
|
|
403
|
+
const wave = args.trim();
|
|
404
|
+
if (wave === "") {
|
|
405
|
+
if (tracked.size === 0) {
|
|
406
|
+
ctx.ui.notify("no waves tracked this session", "info");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const [w, workDir] of tracked) {
|
|
410
|
+
try {
|
|
411
|
+
show(w, workDir);
|
|
412
|
+
} catch {
|
|
413
|
+
// a vanished manifest must not hide the remaining waves
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
show(wave, tracked.get(wave) ?? path.resolve(loadConfig().workDir));
|
|
419
|
+
} catch (e: any) {
|
|
420
|
+
ctx.ui.notify(e.message, "error");
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
package/src/lane.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch lane contract — slug validation, brief validation,
|
|
3
|
+
* executable resolution, spawn argv construction, and kill-target selection.
|
|
4
|
+
* Pure Node builtins: importable and testable without the pi host.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { DispatchConfig } from "./config.ts";
|
|
10
|
+
import type { LaneRecord } from "./derive.ts";
|
|
11
|
+
|
|
12
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
13
|
+
|
|
14
|
+
/** Lane parameters as they arrive from the dispatch tool. */
|
|
15
|
+
export type LaneParams = {
|
|
16
|
+
name: string;
|
|
17
|
+
briefPath: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
deadlineSec?: number;
|
|
20
|
+
tools?: string;
|
|
21
|
+
noExtensions?: boolean;
|
|
22
|
+
mcp?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Validate wave and lane slugs: kebab-case, max 64 chars, unique within the call. */
|
|
26
|
+
export function assertSlugs(wave: string, names: string[]): void {
|
|
27
|
+
const bad: string[] = [];
|
|
28
|
+
if (!SLUG.test(wave)) bad.push(`wave '${wave}'`);
|
|
29
|
+
for (const n of names) if (!SLUG.test(n)) bad.push(`lane '${n}'`);
|
|
30
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
31
|
+
for (const d of new Set(dupes)) bad.push(`duplicate lane '${d}'`);
|
|
32
|
+
if (bad.length > 0) throw new Error(`slugs must be kebab-case [a-z0-9-], max 64 chars, unique: ${bad.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Return every brief path that does not resolve to an existing regular file. */
|
|
36
|
+
export function missingBriefs(lanes: LaneParams[], cwd: string = process.cwd()): string[] {
|
|
37
|
+
const missing: string[] = [];
|
|
38
|
+
for (const lane of lanes) {
|
|
39
|
+
const abs = path.resolve(cwd, lane.briefPath);
|
|
40
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) missing.push(lane.briefPath);
|
|
41
|
+
}
|
|
42
|
+
return missing;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve an executable on PATH; a directory on PATH is not an executable. */
|
|
46
|
+
export function resolveExecutable(name: string): string {
|
|
47
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
48
|
+
if (!dir) continue;
|
|
49
|
+
const p = path.join(dir, name);
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
52
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
53
|
+
return p;
|
|
54
|
+
} catch {
|
|
55
|
+
// keep scanning
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`executable '${name}' not found on PATH`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the lane argv: `timeout -k <graceSec> <deadlineSec> pi --mode json
|
|
63
|
+
* --no-session -ns [flags] @<brief>`. The json/no-session/-ns triple is the
|
|
64
|
+
* fixed lane protocol (lanes never load skills); everything variable is a
|
|
65
|
+
* parameter or config knob. `-k` escalates a caught SIGTERM to SIGKILL so a
|
|
66
|
+
* stalled dispose cannot outlive the leash. Omitted `tools` defaults to a
|
|
67
|
+
* read-only allowlist — a lane must be granted `bash`/`edit`/`write` explicitly.
|
|
68
|
+
*/
|
|
69
|
+
export function buildLaneArgs(lane: LaneParams, cfg: Pick<DispatchConfig, "piBin">, deadlineSec: number, killGraceSec: number): string[] {
|
|
70
|
+
return [
|
|
71
|
+
"-k",
|
|
72
|
+
String(killGraceSec),
|
|
73
|
+
String(deadlineSec),
|
|
74
|
+
cfg.piBin,
|
|
75
|
+
"--mode",
|
|
76
|
+
"json",
|
|
77
|
+
"--no-session",
|
|
78
|
+
"-ns",
|
|
79
|
+
...(lane.noExtensions ? ["-ne"] : []),
|
|
80
|
+
"--tools",
|
|
81
|
+
lane.tools ?? "read,grep,find,ls",
|
|
82
|
+
...(lane.mcp === false ? ["--no-mcp"] : []),
|
|
83
|
+
...(lane.model ? ["--model", lane.model] : []),
|
|
84
|
+
`@${path.resolve(lane.briefPath)}`,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Child env: marks the process as a dispatched lane so this extension's
|
|
90
|
+
* factory stays out of it — a lane never gets the `dispatch` tool again.
|
|
91
|
+
* `-ne` remains the switch that drops all extensions.
|
|
92
|
+
*/
|
|
93
|
+
export function laneEnv(): NodeJS.ProcessEnv {
|
|
94
|
+
return { ...process.env, PI_MINIMAL_DISPATCH_CHILD: "1" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Lanes eligible for a kill signal: spawned, alive by the caller's check, not settled. */
|
|
98
|
+
export function selectKillTargets(lanes: LaneRecord[], isAlive: (lane: LaneRecord) => boolean, isSettled: (lane: LaneRecord) => boolean): LaneRecord[] {
|
|
99
|
+
return lanes.filter((lane) => lane.pid !== null && lane.spawned && isAlive(lane) && !isSettled(lane));
|
|
100
|
+
}
|