@workweave/router 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Environment, routing-knob presets, identity, and the static per-process
3
+ * header policy shared by every part of the extension.
4
+ *
5
+ * The whole design hinges on one pi constraint: `before_provider_request` can
6
+ * mutate the request body but cannot set HTTP headers (headers bind when the
7
+ * Anthropic client is created, before the hook runs). So routing-knob headers
8
+ * are *static per provider config*, i.e. static per process. Subagents are
9
+ * separate processes, which is exactly why "quality for the main loop, speed
10
+ * for subagents" maps cleanly onto process identity (`WEAVE_PI_SUBAGENT`).
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
18
+
19
+ /** Provider name. NOT "anthropic" — overriding the built-in provider hijacks the Claude OAuth token. */
20
+ export const PROVIDER_NAME = "weave";
21
+
22
+ export type Role = "main" | "subagent";
23
+
24
+ /** Children spawned by the dispatch tool run with WEAVE_PI_SUBAGENT=1. */
25
+ export function isSubagent(): boolean {
26
+ return process.env.WEAVE_PI_SUBAGENT === "1";
27
+ }
28
+
29
+ export function getRole(): Role {
30
+ return isSubagent() ? "subagent" : "main";
31
+ }
32
+
33
+ function numEnv(name: string, fallback: number): number {
34
+ const raw = process.env[name];
35
+ if (raw === undefined || raw.trim() === "") return fallback;
36
+ const n = Number(raw);
37
+ return Number.isFinite(n) ? n : fallback;
38
+ }
39
+
40
+ // ---------- router endpoint ----------
41
+
42
+ /**
43
+ * Router base URL — the ROOT, with no /v1 and no trailing slash. pi's
44
+ * anthropic-messages provider uses @anthropic-ai/sdk, which appends /v1/messages
45
+ * to this; a trailing /v1 would produce /v1/v1/messages and 404. Order:
46
+ * WEAVE_ROUTER_URL env (children inherit it), then the installer-written
47
+ * models.json baseUrl (so the extension always agrees with however the user
48
+ * installed — hosted, --local, or custom), then the local default. Without the
49
+ * models.json fallback, a hosted install with no env var would be silently
50
+ * re-pointed at localhost. The /v1 strip on the models.json value keeps installs
51
+ * written by an older installer (which appended /v1) working after an update.
52
+ */
53
+ export function getRouterBaseUrl(): string {
54
+ const env = process.env.WEAVE_ROUTER_URL?.trim();
55
+ if (env) return env.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
56
+ const fromModels = readWeaveProvider().baseUrl?.trim();
57
+ if (fromModels) return fromModels.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
58
+ return "http://localhost:8080";
59
+ }
60
+
61
+ // ---------- router key resolution ----------
62
+
63
+ /** Agent config dir, honoring PI_CODING_AGENT_DIR (set for project-scope installs). */
64
+ function getAgentDir(): string {
65
+ const env = process.env.PI_CODING_AGENT_DIR?.trim();
66
+ if (env) return env.startsWith("~/") ? path.join(os.homedir(), env.slice(2)) : env;
67
+ return path.join(os.homedir(), ".pi", "agent");
68
+ }
69
+
70
+ export function getKeyFilePath(): string {
71
+ return process.env.WEAVE_ROUTER_KEY_FILE?.trim() || path.join(getAgentDir(), ".weave_router_key");
72
+ }
73
+
74
+ interface WeaveProviderConfig {
75
+ baseUrl?: string;
76
+ apiKey?: string;
77
+ }
78
+
79
+ let cachedWeaveProvider: WeaveProviderConfig | undefined;
80
+
81
+ /** Read the installer-written `weave` provider block from models.json (cached). */
82
+ function readWeaveProvider(): WeaveProviderConfig {
83
+ if (cachedWeaveProvider) return cachedWeaveProvider;
84
+ let result: WeaveProviderConfig = {};
85
+ try {
86
+ const raw = fs.readFileSync(path.join(getAgentDir(), "models.json"), "utf-8");
87
+ const parsed = JSON.parse(raw) as { providers?: { weave?: WeaveProviderConfig } };
88
+ result = parsed.providers?.weave ?? {};
89
+ } catch {
90
+ /* no models.json — fall through to env/defaults */
91
+ }
92
+ // Cache only a successful, non-empty read. An empty result (file missing or no
93
+ // weave block yet) must not be cached forever — a later install/write should be
94
+ // picked up on the next call.
95
+ if (result.baseUrl || result.apiKey) cachedWeaveProvider = result;
96
+ return result;
97
+ }
98
+
99
+ /** Router key from WEAVE_ROUTER_KEY, else the installer-written key file, else models.json. */
100
+ export function resolveRouterKey(): string | undefined {
101
+ const envKey = process.env.WEAVE_ROUTER_KEY?.trim();
102
+ if (envKey) return envKey;
103
+ try {
104
+ const contents = fs.readFileSync(getKeyFilePath(), "utf-8").trim();
105
+ if (contents) return contents;
106
+ } catch {
107
+ /* no key file — fall through */
108
+ }
109
+ return readWeaveProvider().apiKey?.trim() || undefined;
110
+ }
111
+
112
+ // ---------- identity ----------
113
+
114
+ export interface Identity {
115
+ email?: string;
116
+ name?: string;
117
+ }
118
+
119
+ function gitConfig(key: string): string | undefined {
120
+ try {
121
+ const out = execFileSync("git", ["config", "--get", key], {
122
+ encoding: "utf-8",
123
+ stdio: ["ignore", "pipe", "ignore"],
124
+ }).trim();
125
+ return out || undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ let cachedIdentity: Identity | undefined;
132
+
133
+ /** Resolve user identity the same way the installer does: env overrides, then git config. */
134
+ export function resolveIdentity(): Identity {
135
+ if (cachedIdentity) return cachedIdentity;
136
+ const email = process.env.WEAVE_USER_EMAIL?.trim() || gitConfig("user.email");
137
+ const name = process.env.WEAVE_USER_NAME?.trim() || gitConfig("user.name");
138
+ cachedIdentity = { email: email || undefined, name: name || undefined };
139
+ return cachedIdentity;
140
+ }
141
+
142
+ // ---------- routing-knob presets ----------
143
+
144
+ export interface Knobs {
145
+ alpha: number;
146
+ speedWeight: number;
147
+ outputCostRatio: number;
148
+ expectedOutputTokens: number;
149
+ }
150
+
151
+ // Validated against the scorer: alpha + speedWeight <= 1.
152
+ // main: 0.80 + 0.05 = 0.85 (quality-biased; first turn pins a high tier)
153
+ // subagent: 0.25 + 0.45 = 0.70 (speed + cheap fan-out, isolated session)
154
+ // compaction: 0.05 + 0.55 = 0.60 (cheapest — summarization is throwaway)
155
+ const MAIN_LOOP_KNOBS: Knobs = { alpha: 0.8, speedWeight: 0.05, outputCostRatio: 0.5, expectedOutputTokens: 3000 };
156
+ const FAST_CHEAP_KNOBS: Knobs = { alpha: 0.25, speedWeight: 0.45, outputCostRatio: 2.0, expectedOutputTokens: 1500 };
157
+ const COMPACTION_KNOBS: Knobs = { alpha: 0.05, speedWeight: 0.55, outputCostRatio: 3.0, expectedOutputTokens: 1000 };
158
+
159
+ /** Knobs for this process, with per-knob env overrides applied on top of the role preset. */
160
+ export function knobsForRole(role: Role): Knobs {
161
+ const base = role === "subagent" ? FAST_CHEAP_KNOBS : MAIN_LOOP_KNOBS;
162
+ return {
163
+ alpha: numEnv("WEAVE_ROUTING_ALPHA", base.alpha),
164
+ speedWeight: numEnv("WEAVE_ROUTING_SPEED_WEIGHT", base.speedWeight),
165
+ outputCostRatio: numEnv("WEAVE_ROUTING_OUTPUT_COST_RATIO", base.outputCostRatio),
166
+ expectedOutputTokens: numEnv("WEAVE_ROUTING_EXPECTED_OUTPUT_TOKENS", base.expectedOutputTokens),
167
+ };
168
+ }
169
+
170
+ export function compactionKnobs(): Knobs {
171
+ return { ...COMPACTION_KNOBS };
172
+ }
173
+
174
+ // ---------- header builders ----------
175
+
176
+ const KNOB_HEADER = {
177
+ alpha: "x-weave-routing-alpha",
178
+ speedWeight: "x-weave-routing-speed-weight",
179
+ outputCostRatio: "x-weave-routing-output-cost-ratio",
180
+ expectedOutputTokens: "x-weave-routing-expected-output-tokens",
181
+ } as const;
182
+
183
+ export function knobHeaders(k: Knobs): Record<string, string> {
184
+ return {
185
+ [KNOB_HEADER.alpha]: String(k.alpha),
186
+ [KNOB_HEADER.speedWeight]: String(k.speedWeight),
187
+ [KNOB_HEADER.outputCostRatio]: String(k.outputCostRatio),
188
+ [KNOB_HEADER.expectedOutputTokens]: String(k.expectedOutputTokens),
189
+ };
190
+ }
191
+
192
+ /** Identity + auth headers. The router authenticates off X-Weave-Router-Key (authHeader stays false). */
193
+ export function identityHeaders(role: Role, key: string): Record<string, string> {
194
+ const id = resolveIdentity();
195
+ const headers: Record<string, string> = {
196
+ "X-Weave-Router-Key": key,
197
+ "X-App": role === "subagent" ? "pi-subagent" : "pi",
198
+ };
199
+ if (id.email) headers["X-Weave-User-Email"] = id.email;
200
+ if (id.name) headers["X-Weave-User-Name"] = id.name;
201
+ return headers;
202
+ }
203
+
204
+ /** The full static header set for this process's `weave` provider. */
205
+ export function providerHeaders(role: Role, key: string): Record<string, string> {
206
+ return {
207
+ ...identityHeaders(role, key),
208
+ ...knobHeaders(knobsForRole(role)),
209
+ // pi surfaces the routed model in its status bar (from x-router-model), so
210
+ // opt out of the router's in-band "✦ Weave Router → …" badge — it arrives as
211
+ // a separate text block that hides the answer in pi's renderer. (router #263)
212
+ "X-Weave-Routing-Marker": "off",
213
+ };
214
+ }
215
+
216
+ // ---------- model list ----------
217
+
218
+ // Carried as a shared constant rather than relying on registerProvider preserving
219
+ // an omitted `models` list, so the extension is self-sufficient: a freshly
220
+ // dispatched child (and a `pi -e` smoke test with no models.json) can still
221
+ // resolve `weave/<model>`. The list mirrors the installer's headline models;
222
+ // the router re-routes every request regardless, so this is a UX/label surface.
223
+ export const WEAVE_MODELS: ProviderModelConfig[] = [
224
+ model("claude-opus-4-8", "Claude Opus 4.8 (via Weave Router)", 64000),
225
+ model("claude-opus-4-7", "Claude Opus 4.7 (via Weave Router)", 64000),
226
+ model("claude-sonnet-4-6", "Claude Sonnet 4.6 (via Weave Router)", 64000),
227
+ model("claude-haiku-4-5", "Claude Haiku 4.5 (via Weave Router)", 32000),
228
+ ];
229
+
230
+ function model(id: string, name: string, maxTokens: number): ProviderModelConfig {
231
+ return {
232
+ id,
233
+ name,
234
+ reasoning: true,
235
+ input: ["text", "image"],
236
+ // Real cost is decided by the router per request and is unknown client-side.
237
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
238
+ contextWindow: 200000,
239
+ maxTokens,
240
+ };
241
+ }
242
+
243
+ // ---------- dispatch / misc tunables ----------
244
+
245
+ export const ROUTED_MODEL_HEADER = (process.env.WEAVE_ROUTED_MODEL_HEADER || "x-router-model").toLowerCase();
246
+ /** Marker a headless child prints to stderr so the parent dispatch can read its routed model. */
247
+ export const ROUTED_MODEL_STDERR_PREFIX = "weave-routed-model:";
248
+
249
+ export const SUBAGENT_MODEL = process.env.WEAVE_PI_SUBAGENT_MODEL?.trim() || "claude-sonnet-4-6";
250
+ export const DISPATCH_CONCURRENCY = Math.max(1, numEnv("WEAVE_PI_DISPATCH_CONCURRENCY", 4));
251
+ export const MAX_SUBAGENTS = 8;
252
+ export const SUBAGENT_TIMEOUT_MS = Math.max(1000, numEnv("WEAVE_PI_SUBAGENT_TIMEOUT_MS", 600000));
253
+ export const DEFAULT_READONLY_TOOLS = ["read", "grep", "find", "ls"];
254
+
255
+ // Tools that let a subagent mutate the filesystem or run arbitrary commands.
256
+ // dispatch strips these from model-requested per-task `tools` (and downgrades
257
+ // readOnly:false's "all tools") unless WEAVE_PI_ALLOW_SUBAGENT_TOOLS=1, so a
258
+ // prompt-injected main loop can't silently escalate a read-only fan-out into
259
+ // writes/exec. The catastrophic-command gate in safety.ts is a separate, narrower
260
+ // backstop; this is the capability gate.
261
+ export const DANGEROUS_SUBAGENT_TOOLS = new Set(["bash", "edit", "write", "patch", "multiedit", "apply_patch"]);
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `dispatch` — parallel, context-isolated subagents.
3
+ *
4
+ * pi has no native subagents. The canonical pattern (examples/extensions/
5
+ * subagent) is to spawn a fresh `pi --print --mode json --no-session` process
6
+ * per task: true context isolation, reuses pi's own agent loop, and an
7
+ * independent router session. We layer Weave routing on top by loading this
8
+ * same extension in each child (`-e <self>`) with WEAVE_PI_SUBAGENT=1, so the
9
+ * child registers the `weave` provider with the speed/cheap knobs and a
10
+ * "subagent:" session id. Only the final assistant text comes back to the
11
+ * parent — intermediate tool output stays in the child, keeping the main
12
+ * context tiny.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { randomUUID } from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
20
+ import type { Message } from "@mariozechner/pi-ai";
21
+ import { Text } from "@mariozechner/pi-tui";
22
+ import { Type } from "typebox";
23
+ import {
24
+ DANGEROUS_SUBAGENT_TOOLS,
25
+ DEFAULT_READONLY_TOOLS,
26
+ DISPATCH_CONCURRENCY,
27
+ getRouterBaseUrl,
28
+ MAX_SUBAGENTS,
29
+ PROVIDER_NAME,
30
+ resolveIdentity,
31
+ resolveRouterKey,
32
+ ROUTED_MODEL_STDERR_PREFIX,
33
+ SUBAGENT_MODEL,
34
+ SUBAGENT_TIMEOUT_MS,
35
+ } from "./config.js";
36
+
37
+ const SIGKILL_GRACE_MS = 5000;
38
+
39
+ const TaskItem = Type.Object({
40
+ prompt: Type.String({ description: "The full instruction for this subagent." }),
41
+ tools: Type.Optional(
42
+ Type.Array(Type.String(), {
43
+ description: "Tool names this subagent may use (e.g. read, grep). Overrides readOnly for this task. Dangerous tools (bash, write, edit) are ignored unless WEAVE_PI_ALLOW_SUBAGENT_TOOLS=1.",
44
+ }),
45
+ ),
46
+ cwd: Type.Optional(Type.String({ description: "Working directory for this subagent. Defaults to the parent cwd." })),
47
+ });
48
+
49
+ const DispatchParams = Type.Object({
50
+ tasks: Type.Array(TaskItem, {
51
+ minItems: 1,
52
+ maxItems: MAX_SUBAGENTS,
53
+ description: "Tasks to run as parallel, context-isolated subagents.",
54
+ }),
55
+ readOnly: Type.Optional(
56
+ Type.Boolean({
57
+ default: true,
58
+ description: "When true (default), subagents get read-only tools (read, grep, find, ls). Per-task `tools` overrides this. Without WEAVE_PI_ALLOW_SUBAGENT_TOOLS=1, readOnly:false still yields read-only tools (no silent bash/write).",
59
+ }),
60
+ ),
61
+ });
62
+
63
+ interface ChildResult {
64
+ index: number;
65
+ finalText: string;
66
+ routedModel?: string;
67
+ exitCode: number;
68
+ error?: string;
69
+ }
70
+
71
+ /** Resolve how to launch a nested pi (node/bun script, compiled binary, or `pi` on PATH). */
72
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
73
+ const currentScript = process.argv[1];
74
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
75
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
76
+ return { command: process.execPath, args: [currentScript, ...args] };
77
+ }
78
+ const execName = path.basename(process.execPath).toLowerCase();
79
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
80
+ if (!isGenericRuntime) return { command: process.execPath, args };
81
+ return { command: "pi", args };
82
+ }
83
+
84
+ async function mapWithConcurrencyLimit<TIn, TOut>(
85
+ items: TIn[],
86
+ concurrency: number,
87
+ fn: (item: TIn, index: number) => Promise<TOut>,
88
+ ): Promise<TOut[]> {
89
+ if (items.length === 0) return [];
90
+ const limit = Math.max(1, Math.min(concurrency, items.length));
91
+ const results: TOut[] = new Array(items.length);
92
+ let nextIndex = 0;
93
+ const workers = new Array(limit).fill(null).map(async () => {
94
+ while (true) {
95
+ const current = nextIndex++;
96
+ if (current >= items.length) return;
97
+ results[current] = await fn(items[current], current);
98
+ }
99
+ });
100
+ await Promise.all(workers);
101
+ return results;
102
+ }
103
+
104
+ /** Full text of the child's last assistant message (all text blocks joined). */
105
+ function finalAssistantText(messages: Message[]): string {
106
+ for (let i = messages.length - 1; i >= 0; i--) {
107
+ const msg = messages[i];
108
+ if (msg.role !== "assistant") continue;
109
+ const texts: string[] = [];
110
+ for (const part of msg.content ?? []) {
111
+ if (part.type === "text") texts.push(part.text);
112
+ }
113
+ if (texts.length > 0) return texts.join("");
114
+ }
115
+ return "";
116
+ }
117
+
118
+ function runChild(
119
+ selfPath: string,
120
+ task: { prompt: string; tools?: string[]; cwd?: string },
121
+ readOnly: boolean,
122
+ defaultCwd: string,
123
+ key: string,
124
+ signal: AbortSignal | undefined,
125
+ index: number,
126
+ ): Promise<ChildResult> {
127
+ const args = ["--print", "--mode", "json", "--no-session", "-e", selfPath, "--model", `${PROVIDER_NAME}/${SUBAGENT_MODEL}`];
128
+
129
+ // Secure-by-default tool gating. Task input is model-influenced, so without an
130
+ // explicit opt-in we never hand a child write/exec tools: model-requested
131
+ // `tools` are stripped of dangerous entries, and readOnly:false's "all tools"
132
+ // is downgraded to read-only. WEAVE_PI_ALLOW_SUBAGENT_TOOLS=1 restores full
133
+ // flexibility. Stops a prompt-injected main loop from escalating a read-only
134
+ // fan-out into arbitrary command/file execution under the user's identity.
135
+ const allowDangerousTools = process.env.WEAVE_PI_ALLOW_SUBAGENT_TOOLS === "1";
136
+ let tools: string[] | undefined;
137
+ if (task.tools && task.tools.length > 0) {
138
+ tools = task.tools;
139
+ } else if (readOnly || !allowDangerousTools) {
140
+ tools = DEFAULT_READONLY_TOOLS;
141
+ } else {
142
+ tools = undefined; // readOnly:false + opt-in => full toolset
143
+ }
144
+ if (tools && !allowDangerousTools) {
145
+ // Trim first: pi's `--tools` parser trims each entry, so " bash" would slip
146
+ // past a non-trimmed check here and then get re-enabled downstream.
147
+ tools = tools.map((t) => t.trim()).filter((t) => t !== "" && !DANGEROUS_SUBAGENT_TOOLS.has(t.toLowerCase()));
148
+ if (tools.length === 0) tools = DEFAULT_READONLY_TOOLS;
149
+ }
150
+ if (tools) args.push("--tools", tools.join(","));
151
+ args.push(task.prompt);
152
+
153
+ const identity = resolveIdentity();
154
+ const env: NodeJS.ProcessEnv = {
155
+ ...process.env,
156
+ WEAVE_PI_SUBAGENT: "1",
157
+ WEAVE_PI_SUBAGENT_ID: randomUUID(),
158
+ WEAVE_ROUTER_KEY: key,
159
+ WEAVE_ROUTER_URL: getRouterBaseUrl(),
160
+ };
161
+ // Don't let the main loop's per-knob routing overrides leak into children:
162
+ // knobsForRole prefers env values over the role preset, which would route
163
+ // subagents on the main loop's quality knobs instead of the speed/cheap ones.
164
+ delete env.WEAVE_ROUTING_ALPHA;
165
+ delete env.WEAVE_ROUTING_SPEED_WEIGHT;
166
+ delete env.WEAVE_ROUTING_OUTPUT_COST_RATIO;
167
+ delete env.WEAVE_ROUTING_EXPECTED_OUTPUT_TOKENS;
168
+ if (identity.email) env.WEAVE_USER_EMAIL = identity.email;
169
+ if (identity.name) env.WEAVE_USER_NAME = identity.name;
170
+
171
+ const result: ChildResult = { index, finalText: "", exitCode: 0 };
172
+ const messages: Message[] = [];
173
+
174
+ return new Promise<ChildResult>((resolve) => {
175
+ const invocation = getPiInvocation(args);
176
+ const proc = spawn(invocation.command, invocation.args, {
177
+ cwd: task.cwd ?? defaultCwd,
178
+ shell: false,
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ env,
181
+ });
182
+
183
+ let stdoutBuf = "";
184
+ let stderrBuf = "";
185
+ let settled = false;
186
+ let timedOut = false;
187
+
188
+ const processLine = (line: string) => {
189
+ if (!line.trim()) return;
190
+ let event: { type?: string; message?: Message };
191
+ try {
192
+ event = JSON.parse(line);
193
+ } catch {
194
+ return;
195
+ }
196
+ if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
197
+ messages.push(event.message);
198
+ }
199
+ };
200
+
201
+ proc.stdout.on("data", (data) => {
202
+ stdoutBuf += data.toString();
203
+ const lines = stdoutBuf.split("\n");
204
+ stdoutBuf = lines.pop() ?? "";
205
+ for (const line of lines) processLine(line);
206
+ });
207
+ proc.stderr.on("data", (data) => {
208
+ stderrBuf += data.toString();
209
+ });
210
+
211
+ const timer = setTimeout(() => {
212
+ timedOut = true;
213
+ proc.kill("SIGTERM");
214
+ // proc.killed flips true the instant SIGTERM is *sent*, so escalate based
215
+ // on whether the process actually exited (settled), not proc.killed.
216
+ setTimeout(() => {
217
+ if (!settled) proc.kill("SIGKILL");
218
+ }, SIGKILL_GRACE_MS);
219
+ }, SUBAGENT_TIMEOUT_MS);
220
+
221
+ const onAbort = () => {
222
+ proc.kill("SIGTERM");
223
+ setTimeout(() => {
224
+ if (!settled) proc.kill("SIGKILL");
225
+ }, SIGKILL_GRACE_MS);
226
+ };
227
+ if (signal) {
228
+ if (signal.aborted) onAbort();
229
+ else signal.addEventListener("abort", onAbort, { once: true });
230
+ }
231
+
232
+ const settle = (exitCode: number) => {
233
+ if (settled) return;
234
+ settled = true;
235
+ clearTimeout(timer);
236
+ signal?.removeEventListener("abort", onAbort);
237
+ result.exitCode = exitCode;
238
+ // resolve() must always run — a parse error here would otherwise strand
239
+ // this promise and block the whole dispatch via mapWithConcurrencyLimit.
240
+ try {
241
+ if (stdoutBuf.trim()) processLine(stdoutBuf);
242
+ result.finalText = finalAssistantText(messages);
243
+ result.routedModel = lastRoutedModel(stderrBuf);
244
+ if (exitCode !== 0 && !result.finalText) {
245
+ result.error = timedOut
246
+ ? `timed out after ${Math.round(SUBAGENT_TIMEOUT_MS / 1000)}s`
247
+ : signal?.aborted
248
+ ? "aborted"
249
+ : lastStderrLine(stderrBuf) || `exited with code ${exitCode}`;
250
+ }
251
+ } catch (err) {
252
+ if (!result.error) result.error = `failed to read subagent output: ${(err as Error).message}`;
253
+ }
254
+ resolve(result);
255
+ };
256
+
257
+ // A null exit code means the child was killed by a signal (timeout / abort /
258
+ // crash) — settle as failure (1), not success (0), so a killed subagent with
259
+ // no output can't be counted as succeeded.
260
+ proc.on("close", (code) => settle(code ?? 1));
261
+ proc.on("error", (err) => {
262
+ stderrBuf += `${err.message}\n`;
263
+ settle(1);
264
+ });
265
+ });
266
+ }
267
+
268
+ function lastRoutedModel(stderr: string): string | undefined {
269
+ let found: string | undefined;
270
+ for (const line of stderr.split("\n")) {
271
+ const idx = line.indexOf(ROUTED_MODEL_STDERR_PREFIX);
272
+ if (idx !== -1) found = line.slice(idx + ROUTED_MODEL_STDERR_PREFIX.length).trim();
273
+ }
274
+ return found || undefined;
275
+ }
276
+
277
+ function lastStderrLine(stderr: string): string {
278
+ const lines = stderr
279
+ .split("\n")
280
+ .map((l) => l.trim())
281
+ .filter(Boolean);
282
+ return lines.length > 0 ? lines[lines.length - 1] : "";
283
+ }
284
+
285
+ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
286
+ pi.registerTool({
287
+ name: "dispatch",
288
+ label: "Dispatch",
289
+ description: [
290
+ "Run tasks as parallel, context-isolated subagents (separate pi processes).",
291
+ "Each subagent has its own context window and routes through the Weave Router on speed/cheap knobs;",
292
+ "only its final answer returns here, so intermediate work never bloats this context.",
293
+ "Subagents are read-only by default (read, grep, find, ls); pass per-task `tools` to widen.",
294
+ "Use for fan-out investigation/search across independent questions.",
295
+ ].join(" "),
296
+ parameters: DispatchParams,
297
+ executionMode: "parallel",
298
+
299
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
300
+ const key = resolveRouterKey();
301
+ if (!key) {
302
+ return {
303
+ content: [
304
+ { type: "text", text: "Weave dispatch unavailable: no router key (set WEAVE_ROUTER_KEY or run the --pi installer)." },
305
+ ],
306
+ details: { results: [] as ChildResult[] },
307
+ isError: true,
308
+ };
309
+ }
310
+
311
+ const readOnly = params.readOnly ?? true;
312
+ const results = await mapWithConcurrencyLimit(params.tasks, DISPATCH_CONCURRENCY, (task, index) =>
313
+ runChild(selfPath, task, readOnly, ctx.cwd, key, signal, index),
314
+ );
315
+
316
+ const succeeded = results.filter((r) => r.exitCode === 0 && !r.error).length;
317
+ const blocks = results.map((r) => {
318
+ const tags = [r.routedModel ? `routed: ${r.routedModel}` : null, r.exitCode === 0 && !r.error ? null : "FAILED"]
319
+ .filter(Boolean)
320
+ .join(" · ");
321
+ const header = `## Subagent ${r.index + 1}${tags ? ` (${tags})` : ""}`;
322
+ const body = r.error ? `Error: ${r.error}` : r.finalText || "(no output)";
323
+ return `${header}\n${body}`;
324
+ });
325
+
326
+ return {
327
+ content: [{ type: "text", text: `${succeeded}/${results.length} subagents succeeded\n\n${blocks.join("\n\n")}` }],
328
+ details: { results },
329
+ isError: succeeded === 0,
330
+ };
331
+ },
332
+
333
+ renderCall(args, theme) {
334
+ const tasks = args.tasks ?? [];
335
+ let text = `${theme.fg("toolTitle", theme.bold("dispatch "))}${theme.fg("accent", `${tasks.length} subagent${tasks.length === 1 ? "" : "s"}`)}`;
336
+ for (const t of tasks.slice(0, 3)) {
337
+ const preview = t.prompt.length > 60 ? `${t.prompt.slice(0, 60)}...` : t.prompt;
338
+ text += `\n ${theme.fg("dim", preview)}`;
339
+ }
340
+ if (tasks.length > 3) text += `\n ${theme.fg("muted", `... +${tasks.length - 3} more`)}`;
341
+ return new Text(text, 0, 0);
342
+ },
343
+ });
344
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @workweave/router — route the pi coding agent through the WorkWeave Router.
3
+ *
4
+ * Wiring (all on the existing router surface — no router source change beyond
5
+ * the installer):
6
+ * - provider: register `weave` with per-process knob headers (quality on
7
+ * the main loop, speed/cheap in subagents).
8
+ * - metadata: stamp body.metadata.user_id for sticky sessions + subagent
9
+ * detection.
10
+ * - routed-model: show which model the router actually picked.
11
+ * - safety: block catastrophic bash (unless WEAVE_NO_SAFETY=1).
12
+ * - compaction: experimental cheap path (only when WEAVE_CHEAP_COMPACTION=1).
13
+ * - dispatch: parallel, context-isolated subagents — top-level process
14
+ * only (no grandchildren).
15
+ *
16
+ * The same module loads in dispatched children via `-e <self>`; WEAVE_PI_SUBAGENT
17
+ * flips the provider knobs and suppresses the dispatch tool so fan-out doesn't recurse.
18
+ */
19
+
20
+ import { fileURLToPath } from "node:url";
21
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
+ import { isSubagent } from "./config.js";
23
+ import { registerCheapCompaction } from "./compaction.js";
24
+ import { registerDispatch } from "./dispatch.js";
25
+ import { registerMetadata } from "./metadata.js";
26
+ import { registerRoutedModel } from "./routed-model.js";
27
+ import { registerSafety } from "./safety.js";
28
+ import { registerWeave } from "./provider.js";
29
+
30
+ const SELF_PATH = fileURLToPath(import.meta.url);
31
+
32
+ export default function (pi: ExtensionAPI): void {
33
+ // Register at load so the provider is available for `--list-models` and
34
+ // print mode (dispatched children), and again on session_start so the right
35
+ // knob headers survive `/reload` and new/resumed sessions.
36
+ registerWeave(pi);
37
+ pi.on("session_start", () => registerWeave(pi));
38
+
39
+ registerMetadata(pi);
40
+ registerRoutedModel(pi);
41
+
42
+ if (process.env.WEAVE_NO_SAFETY !== "1") registerSafety(pi);
43
+ if (process.env.WEAVE_CHEAP_COMPACTION === "1") registerCheapCompaction(pi);
44
+
45
+ // Only the top-level process fans out. Children (WEAVE_PI_SUBAGENT=1) load
46
+ // this same extension but get no dispatch tool, so subagents can't spawn
47
+ // grandchildren.
48
+ if (!isSubagent()) registerDispatch(pi, SELF_PATH);
49
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Injects `metadata.user_id` into the request body so the router can:
3
+ * - keep the main loop on a sticky session pin ("pi:<sessionId>"), and
4
+ * - detect subagents ("subagent:<uuid>") for an independent pin + server-side
5
+ * SubAgentDispatch handling.
6
+ *
7
+ * This is the one body-level signal we control (the session pin key derives
8
+ * from metadata.user_id when present; subagent detection on the Anthropic
9
+ * ingress path keys off a "subagent:" prefix). Headers can't carry it because
10
+ * before_provider_request can't set headers.
11
+ */
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { isSubagent } from "./config.js";
16
+
17
+ // One id per child process so all of a subagent's requests share a single pin.
18
+ // The parent passes WEAVE_PI_SUBAGENT_ID; a standalone child falls back to a uuid.
19
+ const SUBAGENT_USER_ID = `subagent:${process.env.WEAVE_PI_SUBAGENT_ID?.trim() || randomUUID()}`;
20
+
21
+ export function registerMetadata(pi: ExtensionAPI): void {
22
+ pi.on("before_provider_request", (event, ctx: ExtensionContext) => {
23
+ const body = event.payload as { metadata?: { user_id?: string } } | undefined;
24
+ if (!body || typeof body !== "object") return undefined;
25
+
26
+ const userId = isSubagent() ? SUBAGENT_USER_ID : `pi:${ctx.sessionManager.getSessionId()}`;
27
+ if (body.metadata?.user_id === userId) return undefined;
28
+
29
+ body.metadata = { ...(body.metadata ?? {}), user_id: userId };
30
+ return body;
31
+ });
32
+ }