pi-minimal-dispatch 0.0.0-stage → 0.2.0

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