@workweave/router 0.2.10 → 0.2.12

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.
Files changed (47) hide show
  1. package/README.md +40 -14
  2. package/cc-statusline.sh +274 -23
  3. package/codex-skills/fm/SKILL.md +15 -0
  4. package/codex-skills/fm/scripts/emit.sh +8 -0
  5. package/codex-skills/force-model/SKILL.md +15 -0
  6. package/codex-skills/force-model/scripts/emit.sh +9 -0
  7. package/codex-skills/rf/SKILL.md +15 -0
  8. package/codex-skills/rf/scripts/emit.sh +7 -0
  9. package/codex-skills/router-feedback/SKILL.md +15 -0
  10. package/codex-skills/router-feedback/scripts/emit.sh +9 -0
  11. package/codex-skills/router-models/SKILL.md +51 -0
  12. package/codex-skills/router-off/SKILL.md +22 -0
  13. package/codex-skills/router-on/SKILL.md +22 -0
  14. package/codex-skills/router-status/SKILL.md +19 -0
  15. package/codex-skills/ufm/SKILL.md +14 -0
  16. package/codex-skills/ufm/scripts/emit.sh +3 -0
  17. package/codex-skills/unforce-model/SKILL.md +14 -0
  18. package/codex-skills/unforce-model/scripts/emit.sh +4 -0
  19. package/codex-status.sh +312 -0
  20. package/commands/beta.md +5 -0
  21. package/commands/models.md +46 -0
  22. package/commands/router-models.md +46 -0
  23. package/directives.tsv +13 -0
  24. package/install.sh +1849 -263
  25. package/package.json +7 -1
  26. package/pi-router/README.md +39 -7
  27. package/pi-router/skills/install-lsps/SKILL.md +75 -0
  28. package/pi-router/skills/lsp-guide/SKILL.md +63 -0
  29. package/pi-router/src/beta.ts +21 -0
  30. package/pi-router/src/compaction.ts +46 -8
  31. package/pi-router/src/config.ts +34 -5
  32. package/pi-router/src/context-window.ts +12 -0
  33. package/pi-router/src/dispatch.ts +35 -2
  34. package/pi-router/src/index.ts +12 -1
  35. package/pi-router/src/lsp-broker.ts +255 -0
  36. package/pi-router/src/lsp-client.ts +435 -0
  37. package/pi-router/src/lsp-format.ts +230 -0
  38. package/pi-router/src/lsp-install.ts +215 -0
  39. package/pi-router/src/lsp-protocol.ts +128 -0
  40. package/pi-router/src/lsp-servers.ts +361 -0
  41. package/pi-router/src/lsp.ts +529 -0
  42. package/pi-router/src/pricing.generated.ts +77 -70
  43. package/pi-router/src/provider.ts +15 -2
  44. package/pi-router/src/routed-model.ts +17 -0
  45. package/pi-router/src/savings.ts +1 -1
  46. package/registry.sh +102 -0
  47. package/uninstall.sh +210 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js",
@@ -9,6 +9,9 @@
9
9
  "pi": {
10
10
  "extensions": [
11
11
  "./pi-router/src/index.ts"
12
+ ],
13
+ "skills": [
14
+ "./pi-router/skills"
12
15
  ]
13
16
  },
14
17
  "files": [
@@ -16,6 +19,9 @@
16
19
  "install.sh",
17
20
  "uninstall.sh",
18
21
  "cc-statusline.sh",
22
+ "codex-status.sh",
23
+ "registry.sh",
24
+ "directives.tsv",
19
25
  "commands/",
20
26
  "codex-skills/",
21
27
  "pi-router/",
@@ -11,6 +11,7 @@ Installed automatically by the Weave Router installer:
11
11
  ```bash
12
12
  WEAVE_ROUTER_KEY=rk_… npx --package @workweave/router -y -- weave-router --pi
13
13
  WEAVE_ROUTER_KEY=rk_… npx --package @workweave/router -y -- weave-router --pi --local # local router
14
+ WEAVE_ROUTER_KEY=rk_… npx --package @workweave/router -y -- weave-router --pi --lsp go,typescript # + language servers for the lsp tool
14
15
  ```
15
16
 
16
17
  That writes `~/.pi/agent/models.json` (the `weave` provider), adds
@@ -30,19 +31,42 @@ from npm on next start and loads this extension via its `pi.extensions` field.
30
31
  current router session; `/ufm` and `/unforce-model` resume automatic routing.
31
32
  The persistent status changes to `WEAVE ROUTER — <model> [forced]` after the
32
33
  router validates and canonicalizes the requested model.
34
+ - **Beta routing toggle.** `/beta` toggles the router's beta HMM strategy for
35
+ the current session. The router confirms whether beta routing is enabled or
36
+ disabled; run `/beta` again to switch back.
33
37
  - **Per-process routing bias.** Static `x-weave-routing-*` knob headers bias the
34
38
  router: quality on the main loop and speed + cheap on subagents.
35
- - **Long tool-loop compaction.** Pi 0.74 can cross its context threshold inside
36
- an uninterrupted tool loop before its normal post-run compaction check. The
37
- extension preserves a usable output budget for the real continuation and
38
- compacts once the loop settles, while leaving ordinary threshold compaction
39
- to Pi.
39
+ - **Long tool-loop compaction.** Pi can cross its context threshold inside an
40
+ uninterrupted tool loop before its normal post-run compaction check. The
41
+ extension preserves a usable output budget for the real continuation,
42
+ compacts once the loop settles, and resumes that extension-owned tool loop.
43
+ Ordinary Pi threshold compaction remains under Pi's control.
40
44
  - **Sticky sessions.** `metadata.user_id = "pi:<sessionId>"` pins the main loop
41
45
  to one model for the session; subagents get their own pins.
42
46
  - **`dispatch` tool — parallel, context-isolated subagents.** pi has none
43
47
  natively. `dispatch` spawns child `pi` processes (read-only by default), runs
44
48
  them concurrently, and returns only each subagent's final answer — intermediate
45
49
  tool output stays in the child, so the main context stays small.
50
+ - **`lsp` tool — code intelligence through a language server.** definition,
51
+ references, hover, documentSymbol, and diagnostics for Go, TypeScript/
52
+ JavaScript, Python, and Rust (gopls, typescript-language-server, pyright,
53
+ rust-analyzer). Servers are spawned lazily per workspace root, shut down
54
+ after idling, and shared with dispatch subagents through a parent-side local
55
+ socket — a fan-out reuses the parent's warm servers instead of cold-starting
56
+ its own. A bundled `lsp-guide` skill (`/skill:lsp-guide`) carries the usage
57
+ recipes (per-operation cookbook, the locate-then-references flow, caveats).
58
+ Disable with `WEAVE_PI_NO_LSP=1`.
59
+ - **Opt-in language-server install — nothing installs silently.** Two ways in:
60
+ pass `--lsp go,typescript` to the installer, or let the assistant offer.
61
+ When the workspace contains a language whose server is missing, the
62
+ assistant offers once in conversation ("I can enable go LSP support if
63
+ you'd like — just say the word!"); a yes runs the install through the
64
+ `lsp_enable` tool (main process only — subagents can't trigger installs), a
65
+ "don't ask again" is persisted per language in `<agentDir>/.weave_lsp.json`
66
+ and respected across sessions. Installs need that language's toolchain
67
+ (`go`, `npm`, or `rustup`) on PATH; when the toolchain itself is missing,
68
+ the bundled `install-lsps` skill (`/skill:install-lsps`) walks the agent
69
+ through installing it — again only with explicit consent.
46
70
  - **Persistent route + savings display.** Shows
47
71
  `WEAVE ROUTER — <routed> ← <selected> · saved $X.XX` below pi's native footer
48
72
  data. Savings compare the selected and routed catalog prices against the same
@@ -70,9 +94,17 @@ from npm on next start and loads this extension via its `pi.extensions` field.
70
94
  | `WEAVE_ROUTING_ALPHA` / `…_SPEED_WEIGHT` / `…_OUTPUT_COST_RATIO` / `…_EXPECTED_OUTPUT_TOKENS` | role preset | Override individual routing knobs (main process only — children always use their role preset) |
71
95
  | `WEAVE_NO_SAFETY` | unset | `1` disables the catastrophic-bash gate |
72
96
  | `WEAVE_PI_AUTO_COMPACTION` | unset | `0` disables the routed tool-loop compaction safeguard |
97
+ | `WEAVE_PI_NO_LSP` | unset | `1` disables the `lsp` tool, the server pool, and the subagent broker |
98
+ | `WEAVE_PI_LSP_IDLE_MS` | `300000` | Idle window before an unused language server is shut down |
99
+ | `WEAVE_PI_LSP_REQUEST_TIMEOUT_MS` | `15000` | Per-request budget once a server is warm |
100
+ | `WEAVE_PI_LSP_WARMUP_TIMEOUT_MS` | `60000` | Budget for initialize + the first query (indexing) |
101
+ | `WEAVE_PI_LSP_DIAGNOSTICS_WAIT_MS` | `3000` | How long `diagnostics` waits for a fresh publish |
102
+ | `WEAVE_PI_LSP_MAX_SERVERS` | `4` | LRU cap on concurrently live language servers |
103
+ | `WEAVE_PI_LSP_MAX_REFERENCES` | `100` | Cap on reported reference sites |
73
104
 
74
- Internal: `WEAVE_PI_SUBAGENT=1` and `WEAVE_PI_SUBAGENT_ID` are set by `dispatch`
75
- on child processes; don't set them yourself.
105
+ Internal: `WEAVE_PI_SUBAGENT=1`, `WEAVE_PI_SUBAGENT_ID`, `WEAVE_PI_LSP_BROKER`,
106
+ and `WEAVE_PI_LSP_BROKER_TOKEN` are set by `dispatch` on child processes; don't
107
+ set them yourself.
76
108
 
77
109
  ## Billing
78
110
 
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: install-lsps
3
+ description: Install language servers (gopls, typescript-language-server, pyright, rust-analyzer) and their prerequisite toolchains (Go, Node/npm, rustup) so the lsp tool works. Use when the user asks to enable or install LSP / language-server support, when lsp_enable reports a missing toolchain, or when the lsp tool reports no language server is installed.
4
+ ---
5
+
6
+ # Install LSPs
7
+
8
+ Enable the `lsp` tool for a language by installing its language server — and,
9
+ when the server install needs it, the underlying toolchain.
10
+
11
+ ## Rules
12
+
13
+ - Everything here is consent-gated: only act on the user's explicit go-ahead,
14
+ and confirm each toolchain install separately — those are system-level
15
+ changes, not part of whatever task prompted this.
16
+ - If the user declines, stop. If they ask not to be offered again, call
17
+ `lsp_enable` with `{"language": "<language>", "action": "dismiss"}`.
18
+ - Prefer the `lsp_enable` tool over bash whenever the toolchain already
19
+ exists; reach for bash only for the prerequisites `lsp_enable` refuses to
20
+ install itself.
21
+
22
+ ## Per language
23
+
24
+ Work the ladder for each language the user wants:
25
+
26
+ 1. **Check what is missing.** Server binaries: `gopls`,
27
+ `typescript-language-server`, `pyright-langserver` (or
28
+ `basedpyright-langserver`), `rust-analyzer` — also look in `~/go/bin` and
29
+ `~/.cargo/bin`, which the lsp tool searches even when they are off PATH.
30
+ Toolchains: `go`, `npm`, `rustup`.
31
+ 2. **Toolchain present** → call `lsp_enable` with
32
+ `{"language": "<go|typescript|python|rust>"}` and you are done; it runs the
33
+ right server install and verifies the binary resolves.
34
+ 3. **Toolchain missing** → show the user the command you intend to run, get
35
+ their confirmation, run it in bash, then return to step 2.
36
+ 4. **Verify** with a real `lsp` query (`documentSymbol` on an actual source
37
+ file). The first query per workspace can take up to a minute while the
38
+ server indexes; that is normal.
39
+
40
+ ## Toolchain installs (step 3 reference)
41
+
42
+ Go (for gopls):
43
+
44
+ ```bash
45
+ brew install go # macOS
46
+ sudo apt-get install -y golang-go # Debian/Ubuntu; or dnf install golang
47
+ ```
48
+
49
+ Or the official downloads at https://go.dev/dl for anything else.
50
+
51
+ Node/npm (for typescript-language-server and pyright):
52
+
53
+ ```bash
54
+ brew install node # macOS
55
+ sudo apt-get install -y nodejs npm # Debian/Ubuntu
56
+ ```
57
+
58
+ Or nvm (https://github.com/nvm-sh/nvm) when the user prefers per-user installs.
59
+
60
+ rustup (for rust-analyzer):
61
+
62
+ ```bash
63
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
64
+ ```
65
+
66
+ This is the official rust installer; show the user the command before running
67
+ it and follow its printed PATH instructions afterwards.
68
+
69
+ ## Notes
70
+
71
+ - `go install` drops gopls in `~/go/bin` and rustup drops rust-analyzer in
72
+ `~/.cargo/bin`. The lsp tool finds both without PATH changes; other tools
73
+ may still want a PATH entry.
74
+ - All of this can also happen at install time:
75
+ `npx @workweave/router --pi --lsp go,typescript,python,rust`.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: lsp-guide
3
+ description: Recipes for the lsp tool — resolving where a symbol is defined, every place it is used, type signatures and docs, file outlines, and compiler/type errors through a real language server (Go, TypeScript/JavaScript, Python, Rust). Use when navigating or explaining code by symbol, finding callers or usages, checking what broke after an edit, or deciding between lsp and text search.
4
+ ---
5
+
6
+ # Using the lsp tool
7
+
8
+ The `lsp` tool answers *semantic* questions through the language's own server:
9
+ it resolves through imports, types, and aliases, where grep only matches text.
10
+ Reach for it whenever the question is about a **symbol**; keep grep for plain
11
+ strings, comments, and file types no server supports.
12
+
13
+ ## Operations
14
+
15
+ | Operation | Answers | Needs |
16
+ |---|---|---|
17
+ | `definition` | where the symbol at a position is defined | `path`, `line`, `column` |
18
+ | `references` | every place the symbol at a position is used | `path`, `line`, `column` |
19
+ | `hover` | type signature + docs for the symbol at a position | `path`, `line`, `column` |
20
+ | `documentSymbol` | the outline (types, functions, methods) of one file | `path` |
21
+ | `diagnostics` | compiler/type errors and warnings for one file | `path` |
22
+
23
+ Positions are 1-based, and `column` must point **at the identifier** (its first
24
+ character works). All paths resolve against the working directory.
25
+
26
+ ## Recipes
27
+
28
+ **"Where is X defined?"** — from any usage site of X:
29
+ `{"operation": "definition", "path": "<file>", "line": <L>, "column": <C>}`.
30
+ Don't know a usage site? Find one first (see next recipe's step 1).
31
+
32
+ **"Everywhere X is used"** — two steps, always:
33
+
34
+ 1. Locate the declaration: `rg -n --column '\bX\b'` (the `--column` output is
35
+ exactly the coordinate the next call needs), or `documentSymbol` when you
36
+ already know the file.
37
+ 2. `{"operation": "references", "path": "<decl file>", "line": <L>, "column": <C>}`.
38
+
39
+ A grep hit list is **not** a references answer: it includes comments and
40
+ strings, misses aliased imports, and can't distinguish same-named symbols.
41
+ Step 2 is what makes the answer authoritative.
42
+
43
+ **"What is this / what's its signature?"** — `hover` at the symbol. Prefer it
44
+ over reading the definition file when you only need the type or doc comment.
45
+
46
+ **"What's in this file?"** — `documentSymbol` before reading a large file; jump
47
+ straight to the line the outline gives you.
48
+
49
+ **"Did my edit break anything here?"** — `diagnostics` on the file after
50
+ editing. It reports the server's current analysis of the saved file.
51
+
52
+ ## Behavior worth knowing
53
+
54
+ - **First query per workspace is slow** (the server indexes — up to a minute
55
+ in a big repo). Later queries on that workspace are fast; don't give up
56
+ after one timeout, retry once.
57
+ - **References are capped** (default 100, header says how many exist).
58
+ - **Unsupported file type or missing server** returns advice text, not an
59
+ error — fall back to grep for that file. A missing server can be installed
60
+ with the user's consent via `lsp_enable` (see the install-lsps skill for
61
+ toolchain prerequisites).
62
+ - Works inside dispatch subagents too (they share the parent's warm servers),
63
+ so symbol lookups are safe to fan out.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Forward Pi's local /beta command to the router as one canonical user turn.
3
+ *
4
+ * The router owns the session strategy state and the enabled/disabled reply;
5
+ * the client only validates the command shape and preserves busy-turn ordering.
6
+ */
7
+
8
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
9
+
10
+ export function registerBetaCommand(pi: ExtensionAPI): void {
11
+ pi.registerCommand("beta", {
12
+ description: "Toggle beta HMM routing for this Weave Router session",
13
+ handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
14
+ if (args.trim()) {
15
+ ctx.ui.notify("Usage: /beta", "warning");
16
+ return;
17
+ }
18
+ pi.sendUserMessage("/beta", ctx.isIdle() ? undefined : { deliverAs: "followUp" });
19
+ },
20
+ });
21
+ }
@@ -21,11 +21,15 @@ import type {
21
21
  TurnEndEvent,
22
22
  } from "@mariozechner/pi-coding-agent";
23
23
  import type { AssistantMessage } from "@mariozechner/pi-ai";
24
+ import { ROUTED_CONTEXT_WINDOW_HEADER } from "./config.js";
25
+ import { parseRoutedContextWindow } from "./context-window.js";
24
26
 
25
27
  const PROBE_MAX_TOKENS = 4;
26
28
  const CONTINUATION_MAX_TOKENS = 16_384;
27
29
  const COMPACTION_RESERVE_TOKENS = 16_384;
28
30
  const STATUS_KEY = "weave-compaction";
31
+ /** Pi does not retry threshold compaction automatically, so resume our tool loop explicitly. */
32
+ export const ROUTED_COMPACTION_CONTINUATION = "Continue from the compacted context. Pick up exactly where you left off.";
29
33
 
30
34
  interface ProviderPayload {
31
35
  max_tokens?: unknown;
@@ -86,11 +90,20 @@ export function registerCompaction(pi: ExtensionAPI, schedule: Schedule = (callb
86
90
  let lastTurnTokens = 0;
87
91
  let repairedContinuation = false;
88
92
  let compactionScheduled = false;
93
+ // Smallest served context window observed on a response this run. The router
94
+ // can reroute any request regardless of the requested model, so the served
95
+ // window is authoritative for the compaction budget. Tracking the minimum
96
+ // (not the latest or the max) is the conservative choice: if the router ever
97
+ // routed to a smaller window during the run, that window could clamp again,
98
+ // so the budget must respect it. Fall back to the registered model window
99
+ // until the first routed response.
100
+ let servedContextWindow: number | undefined;
89
101
 
90
102
  const resetRun = () => {
91
103
  highWaterTokens = 0;
92
104
  lastTurnTokens = 0;
93
105
  repairedContinuation = false;
106
+ servedContextWindow = undefined;
94
107
  };
95
108
 
96
109
  const finishCompaction = (ctx: ExtensionContext) => {
@@ -107,6 +120,15 @@ export function registerCompaction(pi: ExtensionAPI, schedule: Schedule = (callb
107
120
  if (repairClampedToolContinuation(event.payload)) repairedContinuation = true;
108
121
  });
109
122
 
123
+ pi.on("after_provider_response", (event, _ctx: ExtensionContext) => {
124
+ if (event.status < 200 || event.status >= 300) return;
125
+ const contextWindow = parseRoutedContextWindow(event.headers?.[ROUTED_CONTEXT_WINDOW_HEADER]);
126
+ if (contextWindow === undefined) return;
127
+ if (servedContextWindow === undefined || contextWindow < servedContextWindow) {
128
+ servedContextWindow = contextWindow;
129
+ }
130
+ });
131
+
110
132
  pi.on("turn_end", (event: TurnEndEvent) => {
111
133
  if (event.message.role !== "assistant") return;
112
134
  lastTurnTokens = contextTokens(event.message as AssistantMessage);
@@ -115,14 +137,19 @@ export function registerCompaction(pi: ExtensionAPI, schedule: Schedule = (callb
115
137
 
116
138
  pi.on("agent_end", (_event: AgentEndEvent, ctx: ExtensionContext) => {
117
139
  if (process.env.WEAVE_PI_AUTO_COMPACTION === "0" || compactionScheduled) return;
118
- const contextWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? 0;
140
+ const registeredWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? 0;
141
+ const contextWindow = servedContextWindow ?? registeredWindow;
119
142
  if (contextWindow <= COMPACTION_RESERVE_TOKENS) return;
120
- const threshold = contextWindow - COMPACTION_RESERVE_TOKENS;
121
- // Pi's built-in check runs immediately after this event and owns the
122
- // ordinary final-turn threshold case. Starting another compaction while
123
- // that async summary is in flight would race it.
124
- if (lastTurnTokens > threshold) return;
125
- if (!repairedContinuation && highWaterTokens <= threshold) return;
143
+ const servedThreshold = contextWindow - COMPACTION_RESERVE_TOKENS;
144
+ const registeredThreshold = registeredWindow - COMPACTION_RESERVE_TOKENS;
145
+ // Pi's built-in check runs immediately after this event and budgets against
146
+ // the REGISTERED model window. If the final turn clears Pi's registered
147
+ // threshold, Pi will compact asynchronously — deferring is correct, and
148
+ // scheduling our own would race that async compaction. Otherwise Pi will not
149
+ // act, so we compact ourselves when the run crossed the SERVED threshold
150
+ // (which can be smaller than the registered window after a reroute).
151
+ if (lastTurnTokens > registeredThreshold) return;
152
+ if (!repairedContinuation && highWaterTokens <= servedThreshold) return;
126
153
 
127
154
  compactionScheduled = true;
128
155
  const previousCompactionId = latestCompactionId(ctx);
@@ -139,7 +166,18 @@ export function registerCompaction(pi: ExtensionAPI, schedule: Schedule = (callb
139
166
  }
140
167
  if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, "compacting routed context...");
141
168
  ctx.compact({
142
- onComplete: () => finishCompaction(ctx),
169
+ onComplete: () => {
170
+ finishCompaction(ctx);
171
+ // Pi only auto-retries an overflow compaction. This compaction is
172
+ // extension-owned and follows a routed tool loop, so queue the next
173
+ // turn after its summary is committed rather than leaving the agent idle.
174
+ // A user may have started another turn while the compaction ran; in
175
+ // that case Pi requires an explicit follow-up delivery mode.
176
+ pi.sendUserMessage(
177
+ ROUTED_COMPACTION_CONTINUATION,
178
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
179
+ );
180
+ },
143
181
  onError: (error) => {
144
182
  compactionScheduled = false;
145
183
  if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, `compaction failed: ${error.message}`);
@@ -71,6 +71,11 @@ export function getKeyFilePath(): string {
71
71
  return process.env.WEAVE_ROUTER_KEY_FILE?.trim() || path.join(getAgentDir(), ".weave_router_key");
72
72
  }
73
73
 
74
+ /** Persisted LSP preferences (per-language "don't offer again" dismissals). */
75
+ export function getLspPrefsPath(): string {
76
+ return path.join(getAgentDir(), ".weave_lsp.json");
77
+ }
78
+
74
79
  interface WeaveProviderConfig {
75
80
  baseUrl?: string;
76
81
  apiKey?: string;
@@ -220,12 +225,17 @@ export function providerHeaders(role: Role, key: string): Record<string, string>
220
225
  // dispatched child (and a `pi -e` smoke test with no models.json) can still
221
226
  // resolve `weave/<model>`. The list mirrors the installer's headline models;
222
227
  // the router re-routes every request regardless, so this is a UX/label surface.
228
+ // The context windows mirror the router's catalog for these models (see
229
+ // internal/router/catalog) so the footer and auto-compaction budget reflect
230
+ // what the router actually serves; per-request reroutes can still land on a
231
+ // different window, which the served-window header reports on the response.
223
232
  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
- model("grok-4.6", "Grok 4.6 (via Weave Router)", 131072, 500000),
233
+ model("claude-opus-4-8", "Claude Opus 4.8 (via Weave Router)", 64000, 1_000_000),
234
+ model("claude-opus-4-7", "Claude Opus 4.7 (via Weave Router)", 64000, 1_000_000),
235
+ model("claude-sonnet-4-6", "Claude Sonnet 4.6 (via Weave Router)", 64000, 1_000_000),
236
+ model("claude-haiku-4-5", "Claude Haiku 4.5 (via Weave Router)", 32000, 200_000),
237
+ model("grok-4.6", "Grok 4.6 (via Weave Router)", 131072, 500_000),
238
+ model("qwen/qwen3.8-max", "Qwen 3.8 Max (via Weave Router)", 131072, 1_000_000),
229
239
  ];
230
240
 
231
241
  function model(id: string, name: string, maxTokens: number, contextWindow: number = 200000): ProviderModelConfig {
@@ -246,6 +256,8 @@ function model(id: string, name: string, maxTokens: number, contextWindow: numbe
246
256
  export const ROUTED_MODEL_HEADER = (process.env.WEAVE_ROUTED_MODEL_HEADER || "x-router-model").toLowerCase();
247
257
  export const ROUTED_PROVIDER_HEADER = "x-router-provider";
248
258
  export const ROUTER_DECISION_HEADER = "x-router-decision";
259
+ /** Context window (tokens) of the model that actually served the routed response. */
260
+ export const ROUTED_CONTEXT_WINDOW_HEADER = "x-router-context-window";
249
261
  /** Marker a headless child prints to stderr so the parent dispatch can read its routed model. */
250
262
  export const ROUTED_MODEL_STDERR_PREFIX = "weave-routed-model:";
251
263
 
@@ -255,6 +267,23 @@ export const MAX_SUBAGENTS = 8;
255
267
  export const SUBAGENT_TIMEOUT_MS = Math.max(1000, numEnv("WEAVE_PI_SUBAGENT_TIMEOUT_MS", 600000));
256
268
  export const DEFAULT_READONLY_TOOLS = ["read", "grep", "find", "ls"];
257
269
 
270
+ // ---------- LSP tunables ----------
271
+
272
+ /** Idle window before an unused language server is shut down. */
273
+ export const LSP_IDLE_MS = Math.max(10_000, numEnv("WEAVE_PI_LSP_IDLE_MS", 300_000));
274
+ export const LSP_REQUEST_TIMEOUT_MS = Math.max(1000, numEnv("WEAVE_PI_LSP_REQUEST_TIMEOUT_MS", 15_000));
275
+ /** Budget for `initialize` and the first query, which race the server's initial indexing pass. */
276
+ export const LSP_WARMUP_TIMEOUT_MS = Math.max(5000, numEnv("WEAVE_PI_LSP_WARMUP_TIMEOUT_MS", 60_000));
277
+ export const LSP_DIAGNOSTICS_WAIT_MS = Math.max(500, numEnv("WEAVE_PI_LSP_DIAGNOSTICS_WAIT_MS", 3000));
278
+ export const LSP_MAX_SERVERS = Math.max(1, numEnv("WEAVE_PI_LSP_MAX_SERVERS", 4));
279
+ export const LSP_MAX_REFERENCES = Math.max(1, numEnv("WEAVE_PI_LSP_MAX_REFERENCES", 100));
280
+
281
+ // Set by dispatch on children so a subagent reaches the parent's warm server
282
+ // pool over a local socket instead of cold-starting its own gopls. Internal:
283
+ // never set these yourself.
284
+ export const LSP_BROKER_ENV = "WEAVE_PI_LSP_BROKER";
285
+ export const LSP_BROKER_TOKEN_ENV = "WEAVE_PI_LSP_BROKER_TOKEN";
286
+
258
287
  // Tools that let a subagent mutate the filesystem or run arbitrary commands.
259
288
  // dispatch strips these from model-requested per-task `tools` (and downgrades
260
289
  // readOnly:false's "all tools") unless WEAVE_PI_ALLOW_SUBAGENT_TOOLS=1, so a
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Router-served context-window parsing shared by the model registry and
3
+ * compaction guard. The response header is the source of truth for the model
4
+ * that actually served a routed request.
5
+ */
6
+
7
+ /** Parse a router context-window header without accepting fractional or unsafe values. */
8
+ export function parseRoutedContextWindow(value: string | undefined): number | undefined {
9
+ if (!value || !/^[1-9]\d*$/.test(value)) return undefined;
10
+ const contextWindow = Number(value);
11
+ return Number.isSafeInteger(contextWindow) ? contextWindow : undefined;
12
+ }
@@ -25,6 +25,8 @@ import {
25
25
  DEFAULT_READONLY_TOOLS,
26
26
  DISPATCH_CONCURRENCY,
27
27
  getRouterBaseUrl,
28
+ LSP_BROKER_ENV,
29
+ LSP_BROKER_TOKEN_ENV,
28
30
  MAX_SUBAGENTS,
29
31
  PROVIDER_NAME,
30
32
  resolveIdentity,
@@ -34,6 +36,15 @@ import {
34
36
  SUBAGENT_TIMEOUT_MS,
35
37
  } from "./config.js";
36
38
 
39
+ /**
40
+ * The slice of the LSP subsystem dispatch depends on: hand me the env a child
41
+ * needs to reach a warm server pool. Declared here, by the consumer, so
42
+ * dispatch and lsp stay unaware of each other and index.ts does the wiring.
43
+ */
44
+ export interface SubagentEnvProvider {
45
+ ensure(): Promise<Record<string, string>>;
46
+ }
47
+
37
48
  const SIGKILL_GRACE_MS = 5000;
38
49
 
39
50
  const TaskItem = Type.Object({
@@ -123,6 +134,7 @@ function runChild(
123
134
  key: string,
124
135
  signal: AbortSignal | undefined,
125
136
  index: number,
137
+ lspEnv: Record<string, string> | undefined,
126
138
  ): Promise<ChildResult> {
127
139
  const args = ["--print", "--mode", "json", "--no-session", "-e", selfPath, "--model", `${PROVIDER_NAME}/${SUBAGENT_MODEL}`];
128
140
 
@@ -147,12 +159,17 @@ function runChild(
147
159
  tools = tools.map((t) => t.trim()).filter((t) => t !== "" && !DANGEROUS_SUBAGENT_TOOLS.has(t.toLowerCase()));
148
160
  if (tools.length === 0) tools = DEFAULT_READONLY_TOOLS;
149
161
  }
162
+ // All five lsp operations are read-only, so granting it does not widen the
163
+ // capability gate above. Only offered when the parent actually has a broker
164
+ // listening; otherwise the child's tool list is exactly what it is today.
165
+ if (tools && lspEnv && !tools.includes("lsp")) tools = [...tools, "lsp"];
150
166
  if (tools) args.push("--tools", tools.join(","));
151
167
  args.push(task.prompt);
152
168
 
153
169
  const identity = resolveIdentity();
154
170
  const env: NodeJS.ProcessEnv = {
155
171
  ...process.env,
172
+ ...lspEnv,
156
173
  WEAVE_PI_SUBAGENT: "1",
157
174
  WEAVE_PI_SUBAGENT_ID: randomUUID(),
158
175
  WEAVE_ROUTER_KEY: key,
@@ -165,6 +182,12 @@ function runChild(
165
182
  delete env.WEAVE_ROUTING_SPEED_WEIGHT;
166
183
  delete env.WEAVE_ROUTING_OUTPUT_COST_RATIO;
167
184
  delete env.WEAVE_ROUTING_EXPECTED_OUTPUT_TOKENS;
185
+ // With no broker there must be no broker env at all, so a child never tries
186
+ // to dial a socket that isn't there.
187
+ if (!lspEnv) {
188
+ delete env[LSP_BROKER_ENV];
189
+ delete env[LSP_BROKER_TOKEN_ENV];
190
+ }
168
191
  if (identity.email) env.WEAVE_USER_EMAIL = identity.email;
169
192
  if (identity.name) env.WEAVE_USER_NAME = identity.name;
170
193
 
@@ -282,7 +305,7 @@ function lastStderrLine(stderr: string): string {
282
305
  return lines.length > 0 ? lines[lines.length - 1] : "";
283
306
  }
284
307
 
285
- export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
308
+ export function registerDispatch(pi: ExtensionAPI, selfPath: string, lspBroker?: SubagentEnvProvider): void {
286
309
  pi.registerTool({
287
310
  name: "dispatch",
288
311
  label: "Dispatch",
@@ -308,9 +331,19 @@ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
308
331
  };
309
332
  }
310
333
 
334
+ // Start the broker before fan-out so N children share the parent's warm
335
+ // language servers instead of each cold-starting its own. A broker that
336
+ // will not start is not fatal: children just run without the lsp tool.
337
+ let lspEnv: Record<string, string> | undefined;
338
+ try {
339
+ lspEnv = await lspBroker?.ensure();
340
+ } catch {
341
+ lspEnv = undefined;
342
+ }
343
+
311
344
  const readOnly = params.readOnly ?? true;
312
345
  const results = await mapWithConcurrencyLimit(params.tasks, DISPATCH_CONCURRENCY, (task, index) =>
313
- runChild(selfPath, task, readOnly, ctx.cwd, key, signal, index),
346
+ runChild(selfPath, task, readOnly, ctx.cwd, key, signal, index, lspEnv),
314
347
  );
315
348
 
316
349
  const succeeded = results.filter((r) => r.exitCode === 0 && !r.error).length;
@@ -12,6 +12,9 @@
12
12
  * - compaction: protect long tool loops, then compact routed context.
13
13
  * - dispatch: parallel, context-isolated subagents — top-level process
14
14
  * only (no grandchildren).
15
+ * - lsp: code intelligence (definition/references/hover/symbols/
16
+ * diagnostics) over lazily spawned language servers, shared
17
+ * with subagents through a parent-side broker socket.
15
18
  *
16
19
  * The same module loads in dispatched children via `-e <self>`; WEAVE_PI_SUBAGENT
17
20
  * flips the provider knobs and suppresses the dispatch tool so fan-out doesn't recurse.
@@ -19,10 +22,12 @@
19
22
 
20
23
  import { fileURLToPath } from "node:url";
21
24
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
25
+ import { registerBetaCommand } from "./beta.js";
22
26
  import { isSubagent } from "./config.js";
23
27
  import { registerCompaction } from "./compaction.js";
24
28
  import { registerDispatch } from "./dispatch.js";
25
29
  import { registerForceModelCommands } from "./force-model.js";
30
+ import { registerLsp } from "./lsp.js";
26
31
  import { registerMetadata } from "./metadata.js";
27
32
  import { registerRoutedModel } from "./routed-model.js";
28
33
  import { registerSafety } from "./safety.js";
@@ -38,14 +43,20 @@ export default function (pi: ExtensionAPI): void {
38
43
  pi.on("session_start", () => registerWeave(pi));
39
44
 
40
45
  registerMetadata(pi);
46
+ registerBetaCommand(pi);
41
47
  registerForceModelCommands(pi);
42
48
  registerRoutedModel(pi);
43
49
  registerCompaction(pi);
44
50
 
45
51
  if (process.env.WEAVE_NO_SAFETY !== "1") registerSafety(pi);
46
52
 
53
+ // registerLsp picks its own role: the main process gets the pool-backed tool
54
+ // and returns the broker provider below; a child gets the broker-backed tool
55
+ // only when dispatch actually handed it a socket.
56
+ const lspBroker = process.env.WEAVE_PI_NO_LSP === "1" ? undefined : registerLsp(pi);
57
+
47
58
  // Only the top-level process fans out. Children (WEAVE_PI_SUBAGENT=1) load
48
59
  // this same extension but get no dispatch tool, so subagents can't spawn
49
60
  // grandchildren.
50
- if (!isSubagent()) registerDispatch(pi, SELF_PATH);
61
+ if (!isSubagent()) registerDispatch(pi, SELF_PATH, lspBroker);
51
62
  }