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 +41 -0
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/docs/API.md +163 -0
- package/package.json +42 -5
- package/src/config.ts +96 -0
- package/src/derive.ts +219 -0
- package/src/events.ts +64 -0
- package/src/format.ts +37 -0
- package/src/index.ts +443 -0
- package/src/lane.ts +169 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
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
|
+
waitForTerminal,
|
|
27
|
+
writeManifestAtomic,
|
|
28
|
+
} from "./derive.ts";
|
|
29
|
+
import { formatStatusBarSummary, formatWaveStatus } from "./format.ts";
|
|
30
|
+
import { type LaneParams, assertLaneTools, assertSlugs, buildLaneArgs, laneEnv, missingBriefs, resolveExecutable, selectKillTargets } from "./lane.ts";
|
|
31
|
+
|
|
32
|
+
/** Spawn one lane detached; a failure at any step becomes the record's spawnError, never a throw. */
|
|
33
|
+
function spawnLane(workDir: string, wave: string, lane: LaneParams, cfg: DispatchConfig, timeoutBin: string): LaneRecord {
|
|
34
|
+
const briefPath = path.resolve(lane.briefPath);
|
|
35
|
+
const record: LaneRecord = {
|
|
36
|
+
name: lane.name,
|
|
37
|
+
briefPath,
|
|
38
|
+
eventsPath: path.join(workDir, `${wave}-${lane.name}.events.jsonl`),
|
|
39
|
+
errPath: path.join(workDir, `${wave}-${lane.name}.err`),
|
|
40
|
+
pid: null,
|
|
41
|
+
processStart: null,
|
|
42
|
+
startedAt: new Date().toISOString(),
|
|
43
|
+
deadlineSec: lane.deadlineSec ?? cfg.deadlineSec,
|
|
44
|
+
model: lane.model,
|
|
45
|
+
tools: lane.tools,
|
|
46
|
+
spawned: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let outFd = -1;
|
|
50
|
+
let errFd = -1;
|
|
51
|
+
try {
|
|
52
|
+
outFd = fs.openSync(record.eventsPath, "w");
|
|
53
|
+
errFd = fs.openSync(record.errPath, "w");
|
|
54
|
+
const args = [
|
|
55
|
+
timeoutBin,
|
|
56
|
+
...buildLaneArgs(lane, cfg, record.deadlineSec, Math.ceil(cfg.killGraceMs / 1000)),
|
|
57
|
+
];
|
|
58
|
+
const child = spawn(args[0], args.slice(1), {
|
|
59
|
+
detached: true, // own process group; survives this process's exit
|
|
60
|
+
stdio: ["ignore", outFd, errFd],
|
|
61
|
+
env: laneEnv(),
|
|
62
|
+
});
|
|
63
|
+
child.unref();
|
|
64
|
+
child.on("error", (e) => {
|
|
65
|
+
// Async spawn failure lands after we returned; the cause goes to .err
|
|
66
|
+
try {
|
|
67
|
+
fs.appendFileSync(record.errPath, `\n[pi-minimal-dispatch] spawn error: ${e.message}\n`);
|
|
68
|
+
} catch {}
|
|
69
|
+
});
|
|
70
|
+
record.pid = child.pid;
|
|
71
|
+
const start = psLstart(child.pid!);
|
|
72
|
+
if (start === "") {
|
|
73
|
+
// Without a start-time the liveness guard can never match — stop the lane
|
|
74
|
+
// now rather than record an unmatchable identity.
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-child.pid!, "SIGTERM");
|
|
77
|
+
} catch {}
|
|
78
|
+
record.spawnError = "could not capture process start-time (ps failed); lane terminated";
|
|
79
|
+
} else {
|
|
80
|
+
record.processStart = start;
|
|
81
|
+
record.spawned = true;
|
|
82
|
+
}
|
|
83
|
+
} catch (e: any) {
|
|
84
|
+
record.spawnError = e.message;
|
|
85
|
+
} finally {
|
|
86
|
+
if (outFd !== -1) fs.closeSync(outFd);
|
|
87
|
+
if (errFd !== -1) fs.closeSync(errFd);
|
|
88
|
+
}
|
|
89
|
+
return record;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dispatchTool = defineTool({
|
|
93
|
+
name: "dispatch",
|
|
94
|
+
label: "Dispatch lanes",
|
|
95
|
+
description:
|
|
96
|
+
"Spawn detached sub-agent lanes (fresh pi processes) for a wave and return immediately. Each lane runs " +
|
|
97
|
+
"`timeout -k <graceSec> <deadlineSec> pi --mode json --no-session -ns [flags] @<briefPath>` in its own " +
|
|
98
|
+
"process group, streaming JSON events to <workDir>/<wave>-<name>.events.jsonl. Lanes survive this " +
|
|
99
|
+
"session's exit. Write briefs first — one brief file per lane is the lane's entire prompt; " +
|
|
100
|
+
"lanes run in fresh processes (JSON event mode, no session) with skills unloaded (-ns), " +
|
|
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 " +
|
|
103
|
+
"spawned — the caller bounds the wave. " +
|
|
104
|
+
"Then poll with the lanes tool; collect artifacts when settled.",
|
|
105
|
+
parameters: Type.Object({
|
|
106
|
+
wave: Type.String({ description: "Wave slug (kebab-case, max 64 chars); must not already exist in workDir" }),
|
|
107
|
+
lanes: Type.Array(
|
|
108
|
+
Type.Object({
|
|
109
|
+
name: Type.String({ description: "Lane slug (kebab-case, max 64 chars, unique within the wave)" }),
|
|
110
|
+
briefPath: Type.String({ description: "Path to the lane's brief file (@file prompt)" }),
|
|
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" })),
|
|
112
|
+
deadlineSec: Type.Optional(Type.Number({ description: "Deadline leash in seconds (config default: 900)", minimum: 1 })),
|
|
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)" })),
|
|
114
|
+
noExtensions: Type.Optional(Type.Boolean({ description: "Pass -ne (drop extensions incl. MCP)" })),
|
|
115
|
+
mcp: Type.Optional(Type.Boolean({ description: "Set false to pass --no-mcp (no MCP servers connect). Servers connecting alone grants no reach: with mcp enabled, tools must name 'codemode' or 'tool_search' or dispatch rejects the lane" })),
|
|
116
|
+
}),
|
|
117
|
+
{ minItems: 1 },
|
|
118
|
+
),
|
|
119
|
+
workDir: Type.Optional(Type.String({ description: "Directory for briefs/events/manifest (config default: .pi/work)" })),
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
async execute(_id, params) {
|
|
123
|
+
const cfg = loadConfig();
|
|
124
|
+
const workDir = path.resolve(params.workDir ?? cfg.workDir);
|
|
125
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
126
|
+
// Slugs are validated before the manifest claim: a post-claim throw would strand the slug.
|
|
127
|
+
assertSlugs(params.wave, params.lanes.map((l) => l.name));
|
|
128
|
+
|
|
129
|
+
// All briefs must exist before anything spawns: report every missing one.
|
|
130
|
+
const missing = missingBriefs(params.lanes);
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
throw new Error(`brief file(s) not found: ${missing.join(", ")} — nothing spawned`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Tool contracts are validated before the manifest claim: a post-claim throw would strand the slug.
|
|
136
|
+
assertLaneTools(params.lanes);
|
|
137
|
+
|
|
138
|
+
// Claim the wave slug atomically; an existing manifest rejects it.
|
|
139
|
+
const manifestPath = manifestPathFor(workDir, params.wave);
|
|
140
|
+
let claimFd: number;
|
|
141
|
+
try {
|
|
142
|
+
claimFd = fs.openSync(manifestPath, "wx");
|
|
143
|
+
} catch (e: any) {
|
|
144
|
+
if (e.code === "EEXIST") throw new Error(`wave '${params.wave}' already exists (${manifestPath})`);
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let timeoutBin: string;
|
|
149
|
+
try {
|
|
150
|
+
timeoutBin = resolveExecutable(cfg.timeoutBin);
|
|
151
|
+
resolveExecutable(cfg.piBin);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
fs.closeSync(claimFd);
|
|
154
|
+
fs.unlinkSync(manifestPath);
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const manifest: Manifest = {
|
|
159
|
+
version: 1,
|
|
160
|
+
wave: params.wave,
|
|
161
|
+
workDir,
|
|
162
|
+
createdAt: new Date().toISOString(),
|
|
163
|
+
lanes: [],
|
|
164
|
+
};
|
|
165
|
+
for (const lane of params.lanes) {
|
|
166
|
+
manifest.lanes.push(spawnLane(workDir, params.wave, lane, cfg, timeoutBin));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
fs.writeSync(claimFd, JSON.stringify(manifest, null, "\t"));
|
|
171
|
+
fs.closeSync(claimFd);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// environmental failure after the claim: free the slug and stop already-spawned lanes
|
|
174
|
+
for (const l of manifest.lanes) {
|
|
175
|
+
if (l.pid !== null) {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(-l.pid, "SIGTERM");
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
fs.closeSync(claimFd);
|
|
183
|
+
} catch {}
|
|
184
|
+
fs.unlinkSync(manifestPath);
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
trackWave(params.wave, workDir);
|
|
188
|
+
|
|
189
|
+
const lines = manifest.lanes.map(
|
|
190
|
+
(l) =>
|
|
191
|
+
` ${l.name}: ${l.spawned ? `pid ${l.pid} (deadline ${l.deadlineSec}s)` : `SPAWN ERROR: ${l.spawnError}`}`,
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: `wave '${params.wave}' dispatched — ${manifest.lanes.filter((l) => l.spawned).length}/${manifest.lanes.length} lanes spawned\n${lines.join("\n")}\nmanifest: ${manifestPath}`,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
structuredContent: { wave: params.wave, manifestPath, lanes: manifest.lanes },
|
|
201
|
+
details: { manifestPath },
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const lanesTool = defineTool({
|
|
207
|
+
name: "lanes",
|
|
208
|
+
label: "Lane status",
|
|
209
|
+
description:
|
|
210
|
+
"Observe/control a dispatched wave. action=status (default): per-lane derived state " +
|
|
211
|
+
"(starting/running/settled/failed + cause spawn|killed|deadline|crashed), turns, run-total usage, " +
|
|
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 " +
|
|
216
|
+
"(SIGKILL after the configured grace), start-time guarded. action=collect: write each finished lane's final assistant " +
|
|
217
|
+
"message to <wave>-<name>.md (suspect-flagged on failure/empty/non-clean stopReason); a refusal " +
|
|
218
|
+
"(running / never spawned) is a successful result with a per-lane reason. Stateless: reads the manifest " +
|
|
219
|
+
"fresh — works across sessions, including after the dispatcher exited. A collected lane is a path plus " +
|
|
220
|
+
"`suspect`; `suspect: true` means the artifact is not done — read the file only to judge it, do not paste " +
|
|
221
|
+
"it back. Discover waves: ls <workDir>/*-manifest.json.",
|
|
222
|
+
parameters: Type.Object({
|
|
223
|
+
wave: Type.String({ description: "Wave slug" }),
|
|
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 })),
|
|
226
|
+
workDir: Type.Optional(Type.String({ description: "Directory holding the manifest (config default: .pi/work)" })),
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
async execute(_id, params, signal) {
|
|
230
|
+
const cfg = loadConfig();
|
|
231
|
+
const workDir = path.resolve(params.workDir ?? cfg.workDir);
|
|
232
|
+
const action = params.action ?? "status";
|
|
233
|
+
const manifest = readManifest(workDir, params.wave);
|
|
234
|
+
trackWave(params.wave, workDir);
|
|
235
|
+
|
|
236
|
+
if (action === "status") {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const statuses = manifest.lanes.map((l) => deriveLane(l, now));
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: formatWaveStatus(params.wave, statuses) }],
|
|
241
|
+
structuredContent: { wave: params.wave, lanes: statuses },
|
|
242
|
+
details: { wave: params.wave },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
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
|
+
|
|
256
|
+
if (action === "kill") {
|
|
257
|
+
const killed: string[] = [];
|
|
258
|
+
try {
|
|
259
|
+
const targets = selectKillTargets(
|
|
260
|
+
manifest.lanes,
|
|
261
|
+
(l) => pidAlive(l.pid!, l.processStart), // never signal a reused PID
|
|
262
|
+
(l) => deriveLane(l, Date.now()).state === "settled",
|
|
263
|
+
);
|
|
264
|
+
for (const lane of targets) {
|
|
265
|
+
try {
|
|
266
|
+
process.kill(-lane.pid!, "SIGTERM"); // negative = process group (timeout + pi)
|
|
267
|
+
} catch (e: any) {
|
|
268
|
+
if (e.code === "ESRCH") continue;
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
killed.push(lane.name);
|
|
272
|
+
lane.killedAt = new Date().toISOString(); // tombstone
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
writeManifestAtomic(manifest); // a throw mid-loop must not lose already-stamped tombstones
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
278
|
+
if (killed.length > 0) {
|
|
279
|
+
const deadline = Date.now() + cfg.killGraceMs;
|
|
280
|
+
while (Date.now() < deadline) {
|
|
281
|
+
if (signal?.aborted) break;
|
|
282
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
283
|
+
const survivors = manifest.lanes.filter(
|
|
284
|
+
(l) => l.killedAt !== undefined && l.pid !== null && pidAlive(l.pid, l.processStart),
|
|
285
|
+
);
|
|
286
|
+
if (survivors.length === 0) break;
|
|
287
|
+
if (Date.now() + 200 >= deadline) {
|
|
288
|
+
for (const l of survivors) {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-l.pid!, "SIGKILL");
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
writeManifestAtomic(manifest);
|
|
296
|
+
}
|
|
297
|
+
const statuses = manifest.lanes.map((l) => deriveLane(l, Date.now()));
|
|
298
|
+
return {
|
|
299
|
+
content: [
|
|
300
|
+
{
|
|
301
|
+
type: "text",
|
|
302
|
+
text:
|
|
303
|
+
`killed: ${killed.length ? killed.join(", ") : "none (nothing eligible)"}\n` +
|
|
304
|
+
statuses.map((s) => ` ${s.name}: ${s.state}${s.cause ? ` (${s.cause})` : ""}`).join("\n"),
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
structuredContent: { wave: params.wave, killed, lanes: statuses },
|
|
308
|
+
details: { wave: params.wave },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// action === "collect"
|
|
313
|
+
const results: Array<{ name: string; artifact?: string; suspect?: boolean; reason?: string; stopReason?: string }> = [];
|
|
314
|
+
for (const lane of manifest.lanes) {
|
|
315
|
+
const status = deriveLane(lane, Date.now());
|
|
316
|
+
if (status.cause === "spawn") {
|
|
317
|
+
results.push({ name: lane.name, reason: "never spawned — refused" });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (status.state === "running" || status.state === "starting") {
|
|
321
|
+
results.push({ name: lane.name, reason: "still running — refused" });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const text = finalAssistantText(parseEvents(lane.eventsPath));
|
|
325
|
+
const suspect = status.state === "failed" || text === "" || status.stopReason !== "stop";
|
|
326
|
+
const artifact = path.join(workDir, `${params.wave}-${lane.name}.md`);
|
|
327
|
+
fs.writeFileSync(artifact, text.endsWith("\n") || text === "" ? text : text + "\n");
|
|
328
|
+
results.push({ name: lane.name, artifact, suspect, stopReason: status.stopReason });
|
|
329
|
+
}
|
|
330
|
+
const text = results
|
|
331
|
+
.map((r) => ` ${r.name}: ${r.artifact ?? r.reason}${r.suspect ? " [SUSPECT]" : ""}${r.stopReason !== undefined ? ` stop:${r.stopReason}` : ""}`)
|
|
332
|
+
.join("\n");
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text: `collect wave '${params.wave}':\n${text}` }],
|
|
335
|
+
structuredContent: { wave: params.wave, results },
|
|
336
|
+
details: { wave: params.wave },
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// --- statusbar tracking (in-memory cache; manifest and event files stay authoritative) ---
|
|
342
|
+
|
|
343
|
+
const STATUS_KEY = "pi-minimal-dispatch";
|
|
344
|
+
|
|
345
|
+
type UiCtx = { mode: string; ui: { setStatus(key: string, text: string | undefined): void } };
|
|
346
|
+
|
|
347
|
+
const tracked = new Map<string, string>(); // wave -> workDir
|
|
348
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
349
|
+
let uiCtx: UiCtx | null = null;
|
|
350
|
+
|
|
351
|
+
function statusBarTick(): void {
|
|
352
|
+
const ctx = uiCtx;
|
|
353
|
+
if (ctx === null) return;
|
|
354
|
+
const all: LaneStatus[] = [];
|
|
355
|
+
for (const [wave, workDir] of tracked) {
|
|
356
|
+
if (!fs.existsSync(manifestPathFor(workDir, wave))) {
|
|
357
|
+
tracked.delete(wave); // the wave was cleaned up
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const m = readManifest(workDir, wave);
|
|
362
|
+
all.push(...m.lanes.map((l) => deriveLane(l, Date.now())));
|
|
363
|
+
} catch {
|
|
364
|
+
// corrupt manifest/events: skipped this tick — /lanes and the lanes tool surface the error loudly
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const summary = formatStatusBarSummary(all);
|
|
368
|
+
if (summary === undefined) {
|
|
369
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
370
|
+
stopPolling();
|
|
371
|
+
} else {
|
|
372
|
+
ctx.ui.setStatus(STATUS_KEY, summary);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function armPolling(): void {
|
|
377
|
+
if (pollTimer === null && tracked.size > 0 && uiCtx !== null && (uiCtx.mode === "tui" || uiCtx.mode === "rpc")) {
|
|
378
|
+
pollTimer = setInterval(statusBarTick, loadConfig().pollMs);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function trackWave(wave: string, workDir: string): void {
|
|
383
|
+
tracked.set(wave, workDir);
|
|
384
|
+
armPolling();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function stopPolling(): void {
|
|
388
|
+
if (pollTimer !== null) {
|
|
389
|
+
clearInterval(pollTimer);
|
|
390
|
+
pollTimer = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export default function (pi: ExtensionAPI) {
|
|
395
|
+
// A dispatched lane never gets the dispatch tool again — the child marker
|
|
396
|
+
// keeps this extension out of the lane while the rest of the user's setup loads.
|
|
397
|
+
if (process.env.PI_MINIMAL_DISPATCH_CHILD === "1") return;
|
|
398
|
+
|
|
399
|
+
pi.registerTool(dispatchTool);
|
|
400
|
+
pi.registerTool(lanesTool);
|
|
401
|
+
|
|
402
|
+
pi.on("session_start", (_event, ctx) => {
|
|
403
|
+
uiCtx = ctx;
|
|
404
|
+
// session replacement (new/resume/fork) keeps module state: re-arm if waves are still in flight
|
|
405
|
+
armPolling();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
pi.on("session_shutdown", () => {
|
|
409
|
+
stopPolling();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
pi.registerCommand("lanes", {
|
|
413
|
+
description: "Show dispatched lane status — /lanes [wave]",
|
|
414
|
+
getArgumentCompletions: (prefix) => {
|
|
415
|
+
const hits = [...tracked.keys()].filter((w) => w.startsWith(prefix));
|
|
416
|
+
return hits.length > 0 ? hits.map((w) => ({ value: w, label: w })) : null;
|
|
417
|
+
},
|
|
418
|
+
handler: async (args, ctx) => {
|
|
419
|
+
const show = (wave: string, workDir: string) =>
|
|
420
|
+
ctx.ui.notify(formatWaveStatus(wave, readManifest(workDir, wave).lanes.map((l) => deriveLane(l, Date.now()))), "info");
|
|
421
|
+
try {
|
|
422
|
+
const wave = args.trim();
|
|
423
|
+
if (wave === "") {
|
|
424
|
+
if (tracked.size === 0) {
|
|
425
|
+
ctx.ui.notify("no waves tracked this session", "info");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const [w, workDir] of tracked) {
|
|
429
|
+
try {
|
|
430
|
+
show(w, workDir);
|
|
431
|
+
} catch {
|
|
432
|
+
// a vanished manifest must not hide the remaining waves
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
show(wave, tracked.get(wave) ?? path.resolve(loadConfig().workDir));
|
|
438
|
+
} catch (e: any) {
|
|
439
|
+
ctx.ui.notify(e.message, "error");
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
}
|
package/src/lane.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
/** 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
|
+
|
|
24
|
+
/** Lane parameters as they arrive from the dispatch tool. */
|
|
25
|
+
export type LaneParams = {
|
|
26
|
+
name: string;
|
|
27
|
+
briefPath: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
deadlineSec?: number;
|
|
30
|
+
tools?: string;
|
|
31
|
+
noExtensions?: boolean;
|
|
32
|
+
mcp?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Validate wave and lane slugs: kebab-case, max 64 chars, unique within the call. */
|
|
36
|
+
export function assertSlugs(wave: string, names: string[]): void {
|
|
37
|
+
const bad: string[] = [];
|
|
38
|
+
if (!SLUG.test(wave)) bad.push(`wave '${wave}'`);
|
|
39
|
+
for (const n of names) if (!SLUG.test(n)) bad.push(`lane '${n}'`);
|
|
40
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
41
|
+
for (const d of new Set(dupes)) bad.push(`duplicate lane '${d}'`);
|
|
42
|
+
if (bad.length > 0) throw new Error(`slugs must be kebab-case [a-z0-9-], max 64 chars, unique: ${bad.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Return every brief path that does not resolve to an existing regular file. */
|
|
46
|
+
export function missingBriefs(lanes: LaneParams[], cwd: string = process.cwd()): string[] {
|
|
47
|
+
const missing: string[] = [];
|
|
48
|
+
for (const lane of lanes) {
|
|
49
|
+
const abs = path.resolve(cwd, lane.briefPath);
|
|
50
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) missing.push(lane.briefPath);
|
|
51
|
+
}
|
|
52
|
+
return missing;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve an executable on PATH; a directory on PATH is not an executable. */
|
|
56
|
+
export function resolveExecutable(name: string): string {
|
|
57
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
58
|
+
if (!dir) continue;
|
|
59
|
+
const p = path.join(dir, name);
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
62
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
63
|
+
return p;
|
|
64
|
+
} catch {
|
|
65
|
+
// keep scanning
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`executable '${name}' not found on PATH`);
|
|
69
|
+
}
|
|
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
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build the lane argv: `timeout -k <graceSec> <deadlineSec> pi --mode json
|
|
132
|
+
* --no-session -ns [flags] @<brief>`. The json/no-session/-ns triple is the
|
|
133
|
+
* fixed lane protocol (lanes never load skills); everything variable is a
|
|
134
|
+
* parameter or config knob. `-k` escalates a caught SIGTERM to SIGKILL so a
|
|
135
|
+
* stalled dispose cannot outlive the leash. Omitted `tools` defaults to a
|
|
136
|
+
* read-only allowlist — a lane must be granted `bash`/`edit`/`write` explicitly.
|
|
137
|
+
*/
|
|
138
|
+
export function buildLaneArgs(lane: LaneParams, cfg: Pick<DispatchConfig, "piBin">, deadlineSec: number, killGraceSec: number): string[] {
|
|
139
|
+
return [
|
|
140
|
+
"-k",
|
|
141
|
+
String(killGraceSec),
|
|
142
|
+
String(deadlineSec),
|
|
143
|
+
cfg.piBin,
|
|
144
|
+
"--mode",
|
|
145
|
+
"json",
|
|
146
|
+
"--no-session",
|
|
147
|
+
"-ns",
|
|
148
|
+
...(lane.noExtensions ? ["-ne"] : []),
|
|
149
|
+
"--tools",
|
|
150
|
+
lane.tools ?? DEFAULT_LANE_TOOLS,
|
|
151
|
+
...(lane.mcp === false ? ["--no-mcp"] : []),
|
|
152
|
+
...(lane.model ? ["--model", lane.model] : []),
|
|
153
|
+
`@${path.resolve(lane.briefPath)}`,
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Child env: marks the process as a dispatched lane so this extension's
|
|
159
|
+
* factory stays out of it — a lane never gets the `dispatch` tool again.
|
|
160
|
+
* `-ne` remains the switch that drops all extensions.
|
|
161
|
+
*/
|
|
162
|
+
export function laneEnv(): NodeJS.ProcessEnv {
|
|
163
|
+
return { ...process.env, PI_MINIMAL_DISPATCH_CHILD: "1" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Lanes eligible for a kill signal: spawned, alive by the caller's check, not settled. */
|
|
167
|
+
export function selectKillTargets(lanes: LaneRecord[], isAlive: (lane: LaneRecord) => boolean, isSettled: (lane: LaneRecord) => boolean): LaneRecord[] {
|
|
168
|
+
return lanes.filter((lane) => lane.pid !== null && lane.spawned && isAlive(lane) && !isSettled(lane));
|
|
169
|
+
}
|