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 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
- # 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`, `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
- "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.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
+ }