pi-minimal-dispatch 0.1.0 → 0.2.1
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 +33 -0
- package/README.md +46 -22
- package/docs/API.md +51 -5
- package/package.json +1 -1
- package/src/derive.ts +62 -0
- package/src/events.ts +2 -0
- package/src/format.ts +5 -1
- package/src/index.ts +27 -7
- package/src/lane.ts +70 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Status lines (`lanes` status/wait, `/lanes`) tag each lane with its model:
|
|
6
|
+
`[short-id think:level]` after the name — provider prefix dropped, thinking
|
|
7
|
+
level only when set. The tag shows the actual model from the lane's last
|
|
8
|
+
assistant message, falling back to the pinned value until the first answer;
|
|
9
|
+
`LaneStatus` carries `model`/`thinkingLevel` (full ids) for structured
|
|
10
|
+
consumers.
|
|
11
|
+
- `dispatch`'s `model` param documents pi's `id:thinking` suffix
|
|
12
|
+
(e.g. `zai/glm-4.6:high`), which the tag renders split off the id.
|
|
13
|
+
|
|
14
|
+
## 0.2.0 — 2026-10-23
|
|
15
|
+
|
|
16
|
+
Requires pi ≥ 1.4.0 (the MCP reach rules the validator encodes are pi
|
|
17
|
+
1.4.0's documented `--tools` semantics).
|
|
18
|
+
|
|
19
|
+
- `dispatch` no longer spawns blind MCP lanes: with MCP enabled, a lane whose
|
|
20
|
+
`tools` allowlist names neither `codemode` nor `tool_search` is rejected at
|
|
21
|
+
the tool boundary — pi's `--tools` allowlist replaces the whole selection,
|
|
22
|
+
and a lane never reaches MCP tools through anything else unless the server
|
|
23
|
+
has `direct` exposure (which dispatch cannot know). Pass `codemode`/
|
|
24
|
+
`tool_search` in `tools` or set `mcp: false`.
|
|
25
|
+
- `tools` now accepts only a plain allowlist: `+name`/`-name` modifier lists
|
|
26
|
+
(pi applies them to its default selection, silently granting
|
|
27
|
+
`bash`/`edit`/`write`) and empty lists are rejected.
|
|
28
|
+
- README and docs/API.md document the reach rule and both reach recipes; the
|
|
29
|
+
`mcp` schema text no longer claims MCP tools "stay reachable" by default.
|
|
30
|
+
|
|
31
|
+
- `lanes` gained `action: "wait"` (+ `waitSec`, default 300, max 3600):
|
|
32
|
+
holds one call open, re-deriving at the `pollMs` cadence, until every lane
|
|
33
|
+
is terminal — replaces blind sleep-then-status polling (which wakes late
|
|
34
|
+
after settles); times out as a normal status result, honors cancellation.
|
|
35
|
+
|
|
3
36
|
## 0.1.0 — 2026-10-23
|
|
4
37
|
|
|
5
38
|
Initial release. Requires pi ≥ 1.3.0 (1.4.0 recommended — see README).
|
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ never guessed from empty files.
|
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
wave 'survey':
|
|
13
|
-
docs
|
|
14
|
-
review · settled · 4 turns · tok 12021in/310out, $0.0041 · stop:stop · last 12s ago
|
|
13
|
+
docs [glm-4.6 think:high] · running · 2 turns · tok 5703in/90out, $0.0009 · stop:toolUse · last 3.1s ago
|
|
14
|
+
review [glm-5.3-flash] · settled · 4 turns · tok 12021in/310out, $0.0041 · stop:stop · last 12s ago
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Why
|
|
@@ -24,7 +24,7 @@ the dispatcher, observable as plain files, collectible by any later session.**
|
|
|
24
24
|
- `dispatch` returns the moment lanes are spawned — the dispatcher keeps working
|
|
25
25
|
- **the deadline leash is GNU `timeout -k` around each process** — it fires even
|
|
26
26
|
after the dispatcher has exited
|
|
27
|
-
- a later session re-adopts any wave from its manifest: `status`, `kill`, `collect`
|
|
27
|
+
- a later session re-adopts any wave from its manifest: `status`, `wait`, `kill`, `collect`
|
|
28
28
|
- the final text is a file on disk (`<wave>-<name>.md`), not a block pasted back
|
|
29
29
|
into the parent's context
|
|
30
30
|
|
|
@@ -36,8 +36,7 @@ crash, and never-started are indistinguishable after the fact.
|
|
|
36
36
|
|
|
37
37
|
## Requirements
|
|
38
38
|
|
|
39
|
-
- pi ≥ 1.
|
|
40
|
-
`/resume` and `/tree` navigation, which wave re-adoption relies on)
|
|
39
|
+
- pi ≥ 1.4.0
|
|
41
40
|
- **macOS or Linux** — Windows is not supported (the extension relies on POSIX
|
|
42
41
|
process groups, `ps`, and `timeout`)
|
|
43
42
|
- GNU `timeout`: preinstalled on Linux; on macOS install it with
|
|
@@ -49,8 +48,9 @@ crash, and never-started are indistinguishable after the fact.
|
|
|
49
48
|
## Install
|
|
50
49
|
|
|
51
50
|
```bash
|
|
52
|
-
pi install
|
|
53
|
-
pi
|
|
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
54
|
```
|
|
55
55
|
|
|
56
56
|
## Try this first
|
|
@@ -60,29 +60,37 @@ expected deliverable), then ask in plain language:
|
|
|
60
60
|
|
|
61
61
|
```text
|
|
62
62
|
Dispatch two lanes: docs summarizes the request-handling module, review audits src/auth.
|
|
63
|
-
|
|
63
|
+
Wait for them to settle and collect the artifacts.
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
pi decides when to call the tools. A real session looks like:
|
|
67
67
|
|
|
68
68
|
```text
|
|
69
69
|
> dispatch wave="survey" lanes=[{name:"docs", briefPath:".pi/work/survey-docs-brief.md",
|
|
70
|
-
model:"zai/glm-5.3-flash",
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
75
|
manifest: /abs/path/to/.pi/work/survey-manifest.json
|
|
74
76
|
|
|
75
|
-
> lanes wave="survey"
|
|
76
|
-
docs ·
|
|
77
|
+
> lanes wave="survey" action="wait"
|
|
78
|
+
docs [glm-5.3-flash] · settled · 2 turns · tok 5703in/90out, $0.0009 · stop:stop · last 0.8s ago
|
|
79
|
+
review [glm-4.6] · settled · 4 turns · tok 12021in/310out, $0.0041 · stop:stop · last 2.1s ago
|
|
77
80
|
|
|
78
81
|
> lanes wave="survey" action="collect"
|
|
79
82
|
collect wave 'survey':
|
|
80
83
|
docs: /abs/path/to/.pi/work/survey-docs.md stop:stop
|
|
84
|
+
review: /abs/path/to/.pi/work/survey-review.md stop:stop
|
|
81
85
|
```
|
|
82
86
|
|
|
83
|
-
The ceremony is *write briefs → `dispatch` →
|
|
84
|
-
clean up*. For a quick single task: dispatch one lane
|
|
85
|
-
|
|
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`).
|
|
86
94
|
|
|
87
95
|
**Steering** is kill + redispatch: briefs are files, so refine the brief and run
|
|
88
96
|
a new wave (slugs are single-use). **Hang detection**: `lastEventAgeSec` is the
|
|
@@ -98,9 +106,21 @@ Two tools — full parameter, output, and configuration reference in
|
|
|
98
106
|
the wave slug atomically, spawn every lane detached in its own process
|
|
99
107
|
group, return in under a second. Per-lane: `model`, `deadlineSec`, `tools`,
|
|
100
108
|
`noExtensions`, `mcp`.
|
|
101
|
-
- **`lanes`** `{wave, action?, workDir?}` — stateless `status` /
|
|
102
|
-
`collect` over the wave's manifest; any session can re-adopt
|
|
103
|
-
including after the dispatcher exited.
|
|
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.
|
|
104
124
|
|
|
105
125
|
## Configuration
|
|
106
126
|
|
|
@@ -119,8 +139,11 @@ While any tracked lane runs, the footer shows one line — e.g. `lanes: 2 runnin
|
|
|
119
139
|
— updated at the `pollMs` cadence and cleared when nothing is in flight.
|
|
120
140
|
`/lanes` (optionally `/lanes <wave>`) prints the full status table for waves
|
|
121
141
|
seen this session — an untracked wave name is resolved from the default
|
|
122
|
-
workDir — and completes wave names.
|
|
123
|
-
|
|
142
|
+
workDir — and completes wave names. Each line tags the lane with its model —
|
|
143
|
+
short id (provider prefix dropped), thinking level appended as `think:<level>`
|
|
144
|
+
when set — the actual model from the event stream once answers exist, the pinned
|
|
145
|
+
value before that. Both are TUI conveniences over the same derived state the
|
|
146
|
+
`lanes` tool reports.
|
|
124
147
|
|
|
125
148
|
## State model
|
|
126
149
|
|
|
@@ -154,8 +177,9 @@ when the process crashed. The statusbar and `/lanes` are TUI/RPC-only —
|
|
|
154
177
|
|
|
155
178
|
```bash
|
|
156
179
|
node tests/config.test.ts # configuration precedence + validation
|
|
157
|
-
node tests/derive.test.ts # state derivation, liveness guard, event parsing
|
|
180
|
+
node tests/derive.test.ts # state derivation, wait loop, liveness guard, event parsing
|
|
158
181
|
node tests/format.test.ts # presentation formatters
|
|
182
|
+
node tests/lane.test.ts # slugs, briefs, argv, lane tool-contract validation
|
|
159
183
|
bash tests/prove.sh # integration: replays a live wave (bills models)
|
|
160
184
|
```
|
|
161
185
|
|
package/docs/API.md
CHANGED
|
@@ -20,11 +20,11 @@ the leash. Events stream to `<workDir>/<wave>-<name>.events.jsonl`.
|
|
|
20
20
|
| `lanes` | array | — | One entry per lane (min 1); lane slugs unique within the wave |
|
|
21
21
|
| `lanes[].name` | string | — | Lane slug: `kebab-case`, max 64 chars |
|
|
22
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 |
|
|
23
|
+
| `lanes[].model` | string? | pi default | Pin a model id; omitted: pi's **configured default model** — not the dispatcher session's active model. Accepts pi's `id:thinking` suffix (e.g. `zai/glm-4.6:high`) |
|
|
24
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
|
|
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
26
|
| `lanes[].noExtensions` | boolean? | false | Pass `-ne` (drop extensions incl. MCP in the lane) |
|
|
27
|
-
| `lanes[].mcp` | boolean? |
|
|
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
28
|
| `workDir` | string? | `workDir` config (`.pi/work`) | Directory for manifest, events, and artifacts |
|
|
29
29
|
|
|
30
30
|
**Result:** text summary plus `structuredContent` — `{ wave, manifestPath, lanes[] }`
|
|
@@ -33,7 +33,38 @@ immediately; a lane that failed to spawn appears as `SPAWN ERROR: …`.
|
|
|
33
33
|
|
|
34
34
|
**Errors** (the whole call fails, zero lanes spawned, no slug claimed):
|
|
35
35
|
a brief file missing (all missing paths listed) · malformed or duplicate slugs ·
|
|
36
|
-
|
|
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`.
|
|
37
68
|
|
|
38
69
|
**Recursion guard:** lanes run with `PI_MINIMAL_DISPATCH_CHILD=1` in their
|
|
39
70
|
environment, and the extension's factory stays out of any process carrying that
|
|
@@ -49,7 +80,8 @@ fresh one after the dispatcher exited — sees the same truth.
|
|
|
49
80
|
| Parameter | Type | Default | Description |
|
|
50
81
|
|---|---|---|---|
|
|
51
82
|
| `wave` | string | — | Wave slug |
|
|
52
|
-
| `action` | string? | `"status"` | `"status"` \| `"kill"` \| `"collect"` |
|
|
83
|
+
| `action` | string? | `"status"` | `"status"` \| `"wait"` \| `"kill"` \| `"collect"` |
|
|
84
|
+
| `waitSec` | number? | `300` | `wait` only: max seconds to hold the call open (1–3600) |
|
|
53
85
|
| `workDir` | string? | `workDir` config (`.pi/work`) | Directory holding the manifest |
|
|
54
86
|
|
|
55
87
|
### `status`
|
|
@@ -66,11 +98,25 @@ Per-lane `structuredContent` entries. Note: `settled` only means the
|
|
|
66
98
|
| `overdue` | boolean? | alive past its deadline (leash misfire) — informational |
|
|
67
99
|
| `turns` | number | turn count so far |
|
|
68
100
|
| `usage` | object | run totals summed over assistant messages: `{input, output, cacheRead, cacheWrite, cost}` |
|
|
101
|
+
| `model` | string? | model answering the last assistant message; until the first assistant message, the pinned model with pi's `:thinking` suffix split off (last colon, valid level word only) |
|
|
102
|
+
| `thinkingLevel` | string? | thinking level of the last assistant message (`off`–`max`) — absent for models without thinking |
|
|
69
103
|
| `stopReason` | string? | last assistant stop reason (`stop` is clean) |
|
|
70
104
|
| `lastEventAgeSec` | number? | seconds since the events file last grew — the stuck-lane signal |
|
|
71
105
|
| `spawnError` | string? | why a `spawn`-failed lane never started |
|
|
72
106
|
| `errTail` | string? | last lines of `.err` for `crashed` lanes |
|
|
73
107
|
|
|
108
|
+
### `wait`
|
|
109
|
+
|
|
110
|
+
Holds the tool call open, re-reading the manifest and re-deriving state every
|
|
111
|
+
`pollMs`, until every lane is terminal (`settled` or `failed`), `waitSec`
|
|
112
|
+
(default 300, max 3600) elapses, or the call is cancelled — then returns
|
|
113
|
+
exactly the `status` payload. A timeout is **not** an error: lanes still
|
|
114
|
+
running show as `running`, and the caller waits again (or proceeds to
|
|
115
|
+
`kill`/`collect`, which refuse running lanes as usual). This replaces
|
|
116
|
+
sleep-then-status polling, which guesses the duration and wakes late after a
|
|
117
|
+
settle. `dispatch` still returns in under a second — blocking is always this
|
|
118
|
+
explicit, bounded observation call, and the deadline leash needs no observer.
|
|
119
|
+
|
|
74
120
|
### `kill`
|
|
75
121
|
|
|
76
122
|
SIGTERM to each eligible lane's process group (never a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal-dispatch",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Minimal detached lane dispatch with visible state for the pi coding agent — dispatch/lanes tools over a manifest + JSON-event protocol",
|
|
5
5
|
"author": "Mike Cheung",
|
|
6
6
|
"keywords": [
|
package/src/derive.ts
CHANGED
|
@@ -45,6 +45,10 @@ export type LaneStatus = {
|
|
|
45
45
|
overdue?: boolean;
|
|
46
46
|
turns: number;
|
|
47
47
|
usage: Usage;
|
|
48
|
+
/** Model answering the last assistant message; before the first, the pinned model (pi's `id:thinking` suffix split off). */
|
|
49
|
+
model?: string;
|
|
50
|
+
/** Thinking level of the last assistant message; absent for models without thinking. */
|
|
51
|
+
thinkingLevel?: string;
|
|
48
52
|
stopReason?: string;
|
|
49
53
|
lastEventAgeSec: number | null;
|
|
50
54
|
spawnError?: string;
|
|
@@ -113,11 +117,16 @@ function errTailFor(lane: LaneRecord): string | undefined {
|
|
|
113
117
|
|
|
114
118
|
/** Derive a lane's status from its events file, PID liveness, and manifest facts. Never stored. */
|
|
115
119
|
export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
|
|
120
|
+
// pi's --model splits a `:thinking` suffix only on the last colon and only for a valid
|
|
121
|
+
// level word (model-resolver's nSuffix) — ids like `qwen/qwen3-coder:exacto` keep their colon.
|
|
122
|
+
const pinned = lane.model?.match(/^(.+):(off|minimal|low|medium|high|xhigh|max)$/);
|
|
116
123
|
const base: LaneStatus = {
|
|
117
124
|
name: lane.name,
|
|
118
125
|
state: "failed",
|
|
119
126
|
turns: 0,
|
|
120
127
|
usage: emptyUsage(),
|
|
128
|
+
model: pinned?.[1] ?? lane.model,
|
|
129
|
+
thinkingLevel: pinned?.[2],
|
|
121
130
|
lastEventAgeSec: null,
|
|
122
131
|
};
|
|
123
132
|
|
|
@@ -135,6 +144,12 @@ export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
|
|
|
135
144
|
base.usage.cacheWrite += u.cacheWrite ?? 0;
|
|
136
145
|
base.usage.cost += u.cost?.total ?? 0;
|
|
137
146
|
if (typeof message.stopReason === "string") base.stopReason = message.stopReason;
|
|
147
|
+
// The last assistant message is authoritative for identity — a message without
|
|
148
|
+
// thinkingLevel (model switch to a non-thinking model) clears a stale level.
|
|
149
|
+
if (typeof message.model === "string") {
|
|
150
|
+
base.model = message.model;
|
|
151
|
+
base.thinkingLevel = typeof message.thinkingLevel === "string" ? message.thinkingLevel : undefined;
|
|
152
|
+
}
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
if (fs.existsSync(lane.eventsPath)) {
|
|
@@ -159,6 +174,53 @@ export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
|
|
|
159
174
|
};
|
|
160
175
|
}
|
|
161
176
|
|
|
177
|
+
/** Whether every lane of a wave has reached a terminal state (`settled` or `failed`). */
|
|
178
|
+
export function waveTerminal(lanes: LaneRecord[], now: number): boolean {
|
|
179
|
+
return lanes.every((lane) => {
|
|
180
|
+
const state = deriveLane(lane, now).state;
|
|
181
|
+
return state === "settled" || state === "failed";
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Poll a wave until every lane is terminal, `waitMs` elapses, or the signal
|
|
187
|
+
* aborts; returns the final statuses. Re-reads the manifest on every poll —
|
|
188
|
+
* the same derived truth as the status action, held open.
|
|
189
|
+
*/
|
|
190
|
+
export async function waitForTerminal(
|
|
191
|
+
workDir: string,
|
|
192
|
+
wave: string,
|
|
193
|
+
waitMs: number,
|
|
194
|
+
pollMs: number,
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<LaneStatus[]> {
|
|
197
|
+
const deadline = Date.now() + waitMs;
|
|
198
|
+
for (;;) {
|
|
199
|
+
const lanes = readManifest(workDir, wave).lanes;
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (waveTerminal(lanes, now) || now >= deadline || signal?.aborted) {
|
|
202
|
+
return lanes.map((lane) => deriveLane(lane, now));
|
|
203
|
+
}
|
|
204
|
+
// The sleep races the abort so a cancelled call returns at once, not at the next poll.
|
|
205
|
+
await new Promise<void>((resolve) => {
|
|
206
|
+
const delay = Math.min(pollMs, deadline - now);
|
|
207
|
+
if (signal === undefined) {
|
|
208
|
+
setTimeout(resolve, delay);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const timer = setTimeout(() => {
|
|
212
|
+
signal.removeEventListener("abort", onAbort);
|
|
213
|
+
resolve();
|
|
214
|
+
}, delay);
|
|
215
|
+
const onAbort = () => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
162
224
|
/** Final assistant message text (concatenated text blocks of the last assistant message_end). */
|
|
163
225
|
export function finalAssistantText(events: LaneEvent[]): string {
|
|
164
226
|
let text = "";
|
package/src/events.ts
CHANGED
package/src/format.ts
CHANGED
|
@@ -7,7 +7,11 @@ import type { LaneStatus } from "./derive.ts";
|
|
|
7
7
|
|
|
8
8
|
/** One status-table line per lane (two-space indent, err tail four-space indented). */
|
|
9
9
|
export function formatLaneLine(s: LaneStatus): string {
|
|
10
|
-
|
|
10
|
+
// Short form: provider prefix dropped, thinking level labeled `think:` (a bare level word
|
|
11
|
+
// reads like a model variant) — the full ids live in the structured status payload.
|
|
12
|
+
const id = s.model ? s.model.split("/").pop() : undefined;
|
|
13
|
+
const tag = id === undefined ? "" : ` [${id}${s.thinkingLevel !== undefined ? ` think:${s.thinkingLevel}` : ""}]`;
|
|
14
|
+
const bits = [`${s.name}${tag}`, s.state];
|
|
11
15
|
if (s.cause) bits.push(`(${s.cause}${s.spawnError ? `: ${s.spawnError}` : ""})`);
|
|
12
16
|
if (s.overdue) bits.push("OVERDUE");
|
|
13
17
|
bits.push(s.turns === 1 ? "1 turn" : `${s.turns} turns`);
|
package/src/index.ts
CHANGED
|
@@ -23,10 +23,11 @@ import {
|
|
|
23
23
|
pidAlive,
|
|
24
24
|
psLstart,
|
|
25
25
|
readManifest,
|
|
26
|
+
waitForTerminal,
|
|
26
27
|
writeManifestAtomic,
|
|
27
28
|
} from "./derive.ts";
|
|
28
29
|
import { formatStatusBarSummary, formatWaveStatus } from "./format.ts";
|
|
29
|
-
import { type LaneParams, assertSlugs, buildLaneArgs, laneEnv, missingBriefs, resolveExecutable, selectKillTargets } from "./lane.ts";
|
|
30
|
+
import { type LaneParams, assertLaneTools, assertSlugs, buildLaneArgs, laneEnv, missingBriefs, resolveExecutable, selectKillTargets } from "./lane.ts";
|
|
30
31
|
|
|
31
32
|
/** Spawn one lane detached; a failure at any step becomes the record's spawnError, never a throw. */
|
|
32
33
|
function spawnLane(workDir: string, wave: string, lane: LaneParams, cfg: DispatchConfig, timeoutBin: string): LaneRecord {
|
|
@@ -97,7 +98,8 @@ const dispatchTool = defineTool({
|
|
|
97
98
|
"process group, streaming JSON events to <workDir>/<wave>-<name>.events.jsonl. Lanes survive this " +
|
|
98
99
|
"session's exit. Write briefs first — one brief file per lane is the lane's entire prompt; " +
|
|
99
100
|
"lanes run in fresh processes (JSON event mode, no session) with skills unloaded (-ns), " +
|
|
100
|
-
"a read-only tool default,
|
|
101
|
+
"a read-only tool default, MCP reach only via a route named in tools (else set mcp: false), " +
|
|
102
|
+
"and never the dispatch tool itself. Every lane in the call is " +
|
|
101
103
|
"spawned — the caller bounds the wave. " +
|
|
102
104
|
"Then poll with the lanes tool; collect artifacts when settled.",
|
|
103
105
|
parameters: Type.Object({
|
|
@@ -106,11 +108,11 @@ const dispatchTool = defineTool({
|
|
|
106
108
|
Type.Object({
|
|
107
109
|
name: Type.String({ description: "Lane slug (kebab-case, max 64 chars, unique within the wave)" }),
|
|
108
110
|
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" })),
|
|
111
|
+
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. Accepts pi's `id:thinking` suffix (e.g. 'zai/glm-4.6:high')" })),
|
|
110
112
|
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)" })),
|
|
113
|
+
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). Plain names or mcp__/tool patterns only: +name/-name modifiers, shell quoting, and empty lists are rejected. With mcp enabled, MCP/web tools are reachable only through 'codemode' or 'tool_search' named here ('mcp__server__*' entries declare tools directly only for direct-exposure servers)" })),
|
|
112
114
|
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 (
|
|
115
|
+
mcp: Type.Optional(Type.Boolean({ description: "Set false to pass --no-mcp (no MCP servers connect). Servers connecting alone grants no reach: with mcp enabled, tools must name 'codemode' or 'tool_search' or dispatch rejects the lane" })),
|
|
114
116
|
}),
|
|
115
117
|
{ minItems: 1 },
|
|
116
118
|
),
|
|
@@ -130,6 +132,9 @@ const dispatchTool = defineTool({
|
|
|
130
132
|
throw new Error(`brief file(s) not found: ${missing.join(", ")} — nothing spawned`);
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
// Tool contracts are validated before the manifest claim: a post-claim throw would strand the slug.
|
|
136
|
+
assertLaneTools(params.lanes);
|
|
137
|
+
|
|
133
138
|
// Claim the wave slug atomically; an existing manifest rejects it.
|
|
134
139
|
const manifestPath = manifestPathFor(workDir, params.wave);
|
|
135
140
|
let claimFd: number;
|
|
@@ -204,7 +209,11 @@ const lanesTool = defineTool({
|
|
|
204
209
|
description:
|
|
205
210
|
"Observe/control a dispatched wave. action=status (default): per-lane derived state " +
|
|
206
211
|
"(starting/running/settled/failed + cause spawn|killed|deadline|crashed), turns, run-total usage, " +
|
|
207
|
-
"
|
|
212
|
+
"model + thinking level (actual, from the last assistant message; pinned value before the first), " +
|
|
213
|
+
"stopReason, last-event age, .err tail for crashed lanes. action=wait: hold this call open, " +
|
|
214
|
+
"re-deriving at the pollMs cadence, until every lane is settled/failed or waitSec (default 300, " +
|
|
215
|
+
"max 3600) elapses — then return the status table; replaces blind sleep-then-status polling. " +
|
|
216
|
+
"action=kill: SIGTERM the lane process group " +
|
|
208
217
|
"(SIGKILL after the configured grace), start-time guarded. action=collect: write each finished lane's final assistant " +
|
|
209
218
|
"message to <wave>-<name>.md (suspect-flagged on failure/empty/non-clean stopReason); a refusal " +
|
|
210
219
|
"(running / never spawned) is a successful result with a per-lane reason. Stateless: reads the manifest " +
|
|
@@ -213,7 +222,8 @@ const lanesTool = defineTool({
|
|
|
213
222
|
"it back. Discover waves: ls <workDir>/*-manifest.json.",
|
|
214
223
|
parameters: Type.Object({
|
|
215
224
|
wave: Type.String({ description: "Wave slug" }),
|
|
216
|
-
action: Type.Optional(StringEnum(["status", "kill", "collect"])),
|
|
225
|
+
action: Type.Optional(StringEnum(["status", "wait", "kill", "collect"])),
|
|
226
|
+
waitSec: Type.Optional(Type.Number({ description: "wait action only: max seconds to hold the call open (default 300; 1–3600)", minimum: 1, maximum: 3600 })),
|
|
217
227
|
workDir: Type.Optional(Type.String({ description: "Directory holding the manifest (config default: .pi/work)" })),
|
|
218
228
|
}),
|
|
219
229
|
|
|
@@ -234,6 +244,16 @@ const lanesTool = defineTool({
|
|
|
234
244
|
};
|
|
235
245
|
}
|
|
236
246
|
|
|
247
|
+
if (action === "wait") {
|
|
248
|
+
// A timeout is a normal status result with running lanes, never an error.
|
|
249
|
+
const statuses = await waitForTerminal(workDir, params.wave, (params.waitSec ?? 300) * 1000, cfg.pollMs, signal);
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: "text", text: formatWaveStatus(params.wave, statuses) }],
|
|
252
|
+
structuredContent: { wave: params.wave, lanes: statuses },
|
|
253
|
+
details: { wave: params.wave },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
237
257
|
if (action === "kill") {
|
|
238
258
|
const killed: string[] = [];
|
|
239
259
|
try {
|
package/src/lane.ts
CHANGED
|
@@ -11,6 +11,16 @@ import type { LaneRecord } from "./derive.ts";
|
|
|
11
11
|
|
|
12
12
|
export const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
13
13
|
|
|
14
|
+
/** Tool allowlist used when a lane omits `tools` — read-only by design. */
|
|
15
|
+
export const DEFAULT_LANE_TOOLS = "read,grep,find,ls";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tools that route a lane to MCP tools whatever the servers' exposure —
|
|
19
|
+
* `mcp__` entries and the resource tools declare tools only under `direct`
|
|
20
|
+
* exposure (pi waits for those), which dispatch cannot know in advance.
|
|
21
|
+
*/
|
|
22
|
+
const MCP_REACH_TOOLS = ["codemode", "tool_search"];
|
|
23
|
+
|
|
14
24
|
/** Lane parameters as they arrive from the dispatch tool. */
|
|
15
25
|
export type LaneParams = {
|
|
16
26
|
name: string;
|
|
@@ -58,6 +68,65 @@ export function resolveExecutable(name: string): string {
|
|
|
58
68
|
throw new Error(`executable '${name}' not found on PATH`);
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
/** Parse a `tools` string exactly as pi parses `--tools`: split on commas, trim, drop empties. */
|
|
72
|
+
export function toolEntries(lane: LaneParams): string[] {
|
|
73
|
+
return (lane.tools ?? DEFAULT_LANE_TOOLS)
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((s) => s.trim())
|
|
76
|
+
.filter((e) => e !== "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a `--tools` entry matches a tool name — exact, or a `*` pattern anchored both ends (pi's matcher). */
|
|
80
|
+
function entryMatches(entry: string, name: string): boolean {
|
|
81
|
+
if (!entry.includes("*")) return entry === name;
|
|
82
|
+
const source = entry.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
83
|
+
return new RegExp(`^${source}$`).test(name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate every lane's `tools` at the tool boundary. An allowlist names plain
|
|
88
|
+
* tools or `*` patterns — `+name`/`-name` lists are not allowlists: pi applies
|
|
89
|
+
* them to its default selection and would silently grant `bash`/`edit`/`write`.
|
|
90
|
+
* A lane that leaves MCP enabled must name `codemode` or `tool_search`, else
|
|
91
|
+
* its connected servers stay unreachable dead weight.
|
|
92
|
+
*/
|
|
93
|
+
export function assertLaneTools(lanes: LaneParams[]): void {
|
|
94
|
+
const problems: string[] = [];
|
|
95
|
+
for (const lane of lanes) {
|
|
96
|
+
const entries = toolEntries(lane);
|
|
97
|
+
const modifiers = entries.filter((e) => e.startsWith("+") || e.startsWith("-"));
|
|
98
|
+
if (modifiers.length > 0) {
|
|
99
|
+
problems.push(
|
|
100
|
+
`lane '${lane.name}': tools must be a plain allowlist, not +name/-name modifiers (${modifiers.join(", ")}) — ` +
|
|
101
|
+
"modifiers apply to pi's default selection and silently grant bash/edit/write; list every tool instead",
|
|
102
|
+
);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const quoted = entries.filter((e) => e.includes("'") || e.includes('"'));
|
|
106
|
+
if (quoted.length > 0) {
|
|
107
|
+
problems.push(
|
|
108
|
+
`lane '${lane.name}': tools entries must not carry shell quoting (${quoted.join(", ")}) — ` +
|
|
109
|
+
"the allowlist is one argv value and pi takes entries verbatim, so a quoted name matches no tool; write read, not 'read'",
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
problems.push(`lane '${lane.name}': tools allowlist is empty — name at least one tool`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (lane.mcp === false || lane.noExtensions === true) continue; // MCP never connects
|
|
118
|
+
const hasRoute = entries.some((e) => MCP_REACH_TOOLS.some((t) => entryMatches(e, t)));
|
|
119
|
+
if (!hasRoute) {
|
|
120
|
+
problems.push(
|
|
121
|
+
`lane '${lane.name}': MCP left enabled but tools '${lane.tools ?? DEFAULT_LANE_TOOLS}' grants no route to MCP tools — ` +
|
|
122
|
+
"name 'codemode' or 'tool_search' (an 'mcp__server__*' entry alone reaches only direct-exposure servers; pair it with codemode), " +
|
|
123
|
+
"or set mcp:false — entries reach pi verbatim, no shell quoting",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (problems.length > 0) throw new Error(problems.join("\n"));
|
|
128
|
+
}
|
|
129
|
+
|
|
61
130
|
/**
|
|
62
131
|
* Build the lane argv: `timeout -k <graceSec> <deadlineSec> pi --mode json
|
|
63
132
|
* --no-session -ns [flags] @<brief>`. The json/no-session/-ns triple is the
|
|
@@ -78,7 +147,7 @@ export function buildLaneArgs(lane: LaneParams, cfg: Pick<DispatchConfig, "piBin
|
|
|
78
147
|
"-ns",
|
|
79
148
|
...(lane.noExtensions ? ["-ne"] : []),
|
|
80
149
|
"--tools",
|
|
81
|
-
lane.tools ??
|
|
150
|
+
lane.tools ?? DEFAULT_LANE_TOOLS,
|
|
82
151
|
...(lane.mcp === false ? ["--no-mcp"] : []),
|
|
83
152
|
...(lane.model ? ["--model", lane.model] : []),
|
|
84
153
|
`@${path.resolve(lane.briefPath)}`,
|