pi-minimal-dispatch 0.1.0 → 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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
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
+
3
25
  ## 0.1.0 — 2026-10-23
4
26
 
5
27
  Initial release. Requires pi ≥ 1.3.0 (1.4.0 recommended — see README).
package/README.md CHANGED
@@ -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.3.0 — 1.4.0 recommended (it fixes pi deactivating extension tools after
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 git:github.com/zhtmike/pi-minimal-dispatch # or npm:pi-minimal-dispatch
53
- pi -e ./src/index.ts # try it for one invocation
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
- Poll them and collect the artifacts when they settle.
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", deadlineSec:600}]
71
- wave 'survey' dispatched — 1/1 lanes spawned
72
- docs: pid 99015 (deadline 600s)
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 · running · 1 turns · tok 2966in/84out, $0.0005 · stop:toolUse · last 3s ago
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
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` → 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.
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` / `kill` /
102
- `collect` over the wave's manifest; any session can re-adopt a wave,
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
 
@@ -154,8 +174,9 @@ when the process crashed. The statusbar and `/lanes` are TUI/RPC-only —
154
174
 
155
175
  ```bash
156
176
  node tests/config.test.ts # configuration precedence + validation
157
- node tests/derive.test.ts # state derivation, liveness guard, event parsing
177
+ node tests/derive.test.ts # state derivation, wait loop, liveness guard, event parsing
158
178
  node tests/format.test.ts # presentation formatters
179
+ node tests/lane.test.ts # slugs, briefs, argv, lane tool-contract validation
159
180
  bash tests/prove.sh # integration: replays a live wave (bills models)
160
181
  ```
161
182
 
package/docs/API.md CHANGED
@@ -22,9 +22,9 @@ the leash. Events stream to `<workDir>/<wave>-<name>.events.jsonl`.
22
22
  | `lanes[].briefPath` | string | — | Path to the lane's brief file (its `@file` prompt) |
23
23
  | `lanes[].model` | string? | pi default | Pin a model id; omitted: pi's **configured default model** — not the dispatcher session's active model |
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`; add `bash` when a lane must run commands (bash can still invoke git) |
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? | reachable | Set `false` to pass `--no-mcp` (default: MCP tools stay reachable per pi's tool-exposure rules) |
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
- wave slug already exists · `timeoutBin`/`piBin` not found on `PATH`.
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`
@@ -71,6 +103,18 @@ Per-lane `structuredContent` entries. Note: `settled` only means the
71
103
  | `spawnError` | string? | why a `spawn`-failed lane never started |
72
104
  | `errTail` | string? | last lines of `.err` for `crashed` lanes |
73
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
+
74
118
  ### `kill`
75
119
 
76
120
  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.0",
3
+ "version": "0.2.0",
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
@@ -159,6 +159,53 @@ export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
159
159
  };
160
160
  }
161
161
 
162
+ /** Whether every lane of a wave has reached a terminal state (`settled` or `failed`). */
163
+ export function waveTerminal(lanes: LaneRecord[], now: number): boolean {
164
+ return lanes.every((lane) => {
165
+ const state = deriveLane(lane, now).state;
166
+ return state === "settled" || state === "failed";
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Poll a wave until every lane is terminal, `waitMs` elapses, or the signal
172
+ * aborts; returns the final statuses. Re-reads the manifest on every poll —
173
+ * the same derived truth as the status action, held open.
174
+ */
175
+ export async function waitForTerminal(
176
+ workDir: string,
177
+ wave: string,
178
+ waitMs: number,
179
+ pollMs: number,
180
+ signal?: AbortSignal,
181
+ ): Promise<LaneStatus[]> {
182
+ const deadline = Date.now() + waitMs;
183
+ for (;;) {
184
+ const lanes = readManifest(workDir, wave).lanes;
185
+ const now = Date.now();
186
+ if (waveTerminal(lanes, now) || now >= deadline || signal?.aborted) {
187
+ return lanes.map((lane) => deriveLane(lane, now));
188
+ }
189
+ // The sleep races the abort so a cancelled call returns at once, not at the next poll.
190
+ await new Promise<void>((resolve) => {
191
+ const delay = Math.min(pollMs, deadline - now);
192
+ if (signal === undefined) {
193
+ setTimeout(resolve, delay);
194
+ return;
195
+ }
196
+ const timer = setTimeout(() => {
197
+ signal.removeEventListener("abort", onAbort);
198
+ resolve();
199
+ }, delay);
200
+ const onAbort = () => {
201
+ clearTimeout(timer);
202
+ resolve();
203
+ };
204
+ signal.addEventListener("abort", onAbort, { once: true });
205
+ });
206
+ }
207
+ }
208
+
162
209
  /** Final assistant message text (concatenated text blocks of the last assistant message_end). */
163
210
  export function finalAssistantText(events: LaneEvent[]): string {
164
211
  let text = "";
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, and never the dispatch tool itself. Every lane in the call is " +
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({
@@ -108,9 +110,9 @@ const dispatchTool = defineTool({
108
110
  briefPath: Type.String({ description: "Path to the lane's brief file (@file prompt)" }),
109
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" })),
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 (default: MCP tools stay reachable per pi's tool-exposure rules)" })),
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,10 @@ 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
- "stopReason, last-event age, .err tail for crashed lanes. action=kill: SIGTERM the lane process group " +
212
+ "stopReason, last-event age, .err tail for crashed lanes. action=wait: hold this call open, " +
213
+ "re-deriving at the pollMs cadence, until every lane is settled/failed or waitSec (default 300, " +
214
+ "max 3600) elapses — then return the status table; replaces blind sleep-then-status polling. " +
215
+ "action=kill: SIGTERM the lane process group " +
208
216
  "(SIGKILL after the configured grace), start-time guarded. action=collect: write each finished lane's final assistant " +
209
217
  "message to <wave>-<name>.md (suspect-flagged on failure/empty/non-clean stopReason); a refusal " +
210
218
  "(running / never spawned) is a successful result with a per-lane reason. Stateless: reads the manifest " +
@@ -213,7 +221,8 @@ const lanesTool = defineTool({
213
221
  "it back. Discover waves: ls <workDir>/*-manifest.json.",
214
222
  parameters: Type.Object({
215
223
  wave: Type.String({ description: "Wave slug" }),
216
- action: Type.Optional(StringEnum(["status", "kill", "collect"])),
224
+ action: Type.Optional(StringEnum(["status", "wait", "kill", "collect"])),
225
+ 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
226
  workDir: Type.Optional(Type.String({ description: "Directory holding the manifest (config default: .pi/work)" })),
218
227
  }),
219
228
 
@@ -234,6 +243,16 @@ const lanesTool = defineTool({
234
243
  };
235
244
  }
236
245
 
246
+ if (action === "wait") {
247
+ // A timeout is a normal status result with running lanes, never an error.
248
+ const statuses = await waitForTerminal(workDir, params.wave, (params.waitSec ?? 300) * 1000, cfg.pollMs, signal);
249
+ return {
250
+ content: [{ type: "text", text: formatWaveStatus(params.wave, statuses) }],
251
+ structuredContent: { wave: params.wave, lanes: statuses },
252
+ details: { wave: params.wave },
253
+ };
254
+ }
255
+
237
256
  if (action === "kill") {
238
257
  const killed: string[] = [];
239
258
  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 ?? "read,grep,find,ls",
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)}`,