@workweave/router 0.2.6 → 0.2.7

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/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # @workweave/router
2
2
 
3
- One command, anywhere, to point Claude Code, Codex, or opencode at the Weave Router.
3
+ One command, anywhere, to point Claude Code, Codex, opencode, or pi at the Weave Router.
4
4
 
5
5
  ```bash
6
- npx @workweave/router # interactive: pick Claude Code / Codex / opencode, then scope
6
+ npx @workweave/router # interactive: pick Claude Code / Codex / opencode / pi, then scope
7
7
  npx @workweave/router --claude # skip the picker, target Claude Code
8
8
  npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
9
9
  npx @workweave/router --opencode # skip the picker, target opencode
10
+ npx @workweave/router --pi # skip the picker, target pi + Loom UI
10
11
  npx @workweave/router --scope project # per-repo install, commit settings.json (or .codex/ / opencode.json)
11
12
  npx @workweave/router --local # self-hosted via docker-compose (localhost:8080)
12
13
  npx @workweave/router --base-url https://router.acme.internal
@@ -40,6 +41,7 @@ Uninstall:
40
41
  npx @workweave/router --uninstall # Claude Code, user scope
41
42
  npx @workweave/router --uninstall --codex # Codex, user scope
42
43
  npx @workweave/router --uninstall --opencode # opencode, user scope
44
+ npx @workweave/router --uninstall --pi # pi, user scope
43
45
  npx @workweave/router --uninstall --scope project # Claude Code, inside the repo
44
46
  npx @workweave/router --uninstall --codex --scope project
45
47
  ```
@@ -52,7 +54,7 @@ Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
52
54
  shell installer documents (targets, scopes, flags, environment variables)
53
55
  works identically here.
54
56
 
55
- Three install targets:
57
+ Four install targets:
56
58
 
57
59
  - **Claude Code** (default) — patches `~/.claude/settings.json` (or
58
60
  `<repo>/.claude/settings.json` with `--scope project`) so `claude` routes
@@ -71,6 +73,12 @@ Three install targets:
71
73
  natively, so opencode talks to it unmodified. Re-install rewrites only
72
74
  the managed `provider.weave` block; `--uninstall --opencode` strips it
73
75
  and leaves your other providers and settings alone.
76
+ - **pi** (`--pi`) — registers the `weave` provider and installs this package as
77
+ a pi extension. Stock pi then gets the Loom startup header, Wooly's animated
78
+ mascot, the persistent actual-route display, cumulative session savings,
79
+ `/fm` + `/ufm` model-pin commands with a `[forced]` status, and the
80
+ context-isolated `dispatch` tool. There is no forked pi binary and no separate
81
+ Loom runtime.
74
82
 
75
83
  See the [main installer docs](https://github.com/workweave/router/tree/main/install)
76
84
  for the full reference.
@@ -79,8 +87,8 @@ for the full reference.
79
87
 
80
88
  - Node ≥ 18 (ships with `npx`)
81
89
  - `bash` on PATH (macOS / Linux native; Windows needs Git Bash or WSL)
82
- - `jq` on PATH — used by the Claude Code status line script and the
83
- opencode JSON merge. Not required for the Codex path.
90
+ - `jq` on PATH — used by the Claude Code status line and the opencode/pi JSON
91
+ merges. Not required for the Codex path.
84
92
 
85
93
  ## Why npx
86
94
 
package/cc-statusline.sh CHANGED
@@ -33,10 +33,21 @@ set -euo pipefail
33
33
  # lives in their per-user cache dir, and on no-content-change days we skip
34
34
  # the mv entirely so the repo working tree stays clean. When upstream does
35
35
  # change, the first teammate's commit propagates the new version to the rest.
36
+ # A pricing-table miss also triggers a refresh off-schedule — see
37
+ # weave_refresh_on_price_miss below.
36
38
  #
37
39
  # Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
38
40
  # source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
41
+ #
42
+ # $1 is an optional stamp suffix, so a caller refreshing for a reason other than
43
+ # "the interval elapsed" rate-limits on its own clock rather than sharing the
44
+ # periodic check's budget. $2=1 drops the stamp again if the download fails, for
45
+ # callers recovering from a known-bad state where waiting out the full interval
46
+ # on a transient network error is worse than retrying next turn.
39
47
  weave_self_refresh() {
48
+ local stamp_suffix="${1:-}"
49
+ local retry_on_fail="${2:-0}"
50
+
40
51
  [ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
41
52
  command -v curl >/dev/null 2>&1 || return 0
42
53
 
@@ -53,7 +64,7 @@ weave_self_refresh() {
53
64
  mkdir -p "$cache_dir" 2>/dev/null || return 0
54
65
  local script_slug
55
66
  script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
56
- local stamp="$cache_dir/checked-at${script_slug}"
67
+ local stamp="$cache_dir/checked-at${script_slug}${stamp_suffix}"
57
68
 
58
69
  local now stamp_mtime
59
70
  now="$(date +%s 2>/dev/null)" || return 0
@@ -77,7 +88,13 @@ weave_self_refresh() {
77
88
  : > "$stamp" 2>/dev/null || return 0
78
89
 
79
90
  local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
80
- local tmp="${self}.tmp.$$"
91
+ # $$ alone is not unique: two calls can run in one invocation (the periodic
92
+ # check and a pricing-miss retry both fire on a cold cache) and would then
93
+ # curl -o into the same path and mv over each other, installing a truncated
94
+ # script that can never self-heal. The stamp suffix is the right key — it is
95
+ # what makes two callers mutually exclusive in the first place, so callers
96
+ # that could overlap necessarily have different suffixes.
97
+ local tmp="${self}.tmp.$$${stamp_suffix}"
81
98
  (
82
99
  # Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
83
100
  # it, and silence all output so nothing leaks into the statusline.
@@ -96,6 +113,11 @@ weave_self_refresh() {
96
113
  fi
97
114
  else
98
115
  rm -f "$tmp"
116
+ # A download that never landed shouldn't spend the caller's whole
117
+ # interval; drop the stamp so the next turn can try again.
118
+ if [ "$retry_on_fail" = "1" ]; then
119
+ rm -f "$stamp"
120
+ fi
99
121
  fi
100
122
  ) >/dev/null 2>&1 &
101
123
  disown 2>/dev/null || true
@@ -399,6 +421,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
399
421
  ) || true
400
422
  fi
401
423
 
424
+ # ---------- refresh when a model isn't in the pricing table ----------
425
+ #
426
+ # A model that shipped after this copy of the script has no price entry, so the
427
+ # jq guard above zeroes savings on every turn and the line reads "saved $0.00"
428
+ # — indistinguishable from "the router ran and didn't beat your selection". The
429
+ # periodic check heals that eventually, but a week late, and a model launch is
430
+ # exactly when the number gets looked at. Refresh off-schedule instead.
431
+ #
432
+ # Keyed on its own stamp per unpriced id: a model we never price (self-hosted,
433
+ # unrecognized) then costs one download per interval, not one per turn, and
434
+ # can't starve the periodic check.
435
+ weave_refresh_on_price_miss() {
436
+ local candidates="" m
437
+ for m in "$@"; do
438
+ case "$m" in
439
+ "" | "?" | failure | weave-router | "<synthetic>") continue ;;
440
+ esac
441
+ candidates="${candidates}${m}"$'\n'
442
+ done
443
+ [ -n "$candidates" ] || return 0
444
+
445
+ # A malformed $prices emits nothing here, which fails closed (no refresh)
446
+ # rather than re-downloading every turn.
447
+ local missing
448
+ missing="$(printf '%s' "$candidates" \
449
+ | jq -rR --argjson p "$prices" \
450
+ 'select($p.input[.] == null or $p.output[.] == null)' 2>/dev/null \
451
+ | head -n 1)" || return 0
452
+ [ -n "$missing" ] || return 0
453
+
454
+ local model_slug
455
+ model_slug="$(printf '%s' "$missing" | tr -c 'A-Za-z0-9._-' '_')"
456
+ weave_self_refresh ".miss.${model_slug}" 1
457
+ }
458
+ weave_refresh_on_price_miss "$requested_norm" "$routed" 2>/dev/null || true
459
+
402
460
  # Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
403
461
  # every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
404
462
  # wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
package/install.sh CHANGED
@@ -1999,10 +1999,21 @@ set -euo pipefail
1999
1999
  # lives in their per-user cache dir, and on no-content-change days we skip
2000
2000
  # the mv entirely so the repo working tree stays clean. When upstream does
2001
2001
  # change, the first teammate's commit propagates the new version to the rest.
2002
+ # A pricing-table miss also triggers a refresh off-schedule — see
2003
+ # weave_refresh_on_price_miss below.
2002
2004
  #
2003
2005
  # Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
2004
2006
  # source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
2007
+ #
2008
+ # $1 is an optional stamp suffix, so a caller refreshing for a reason other than
2009
+ # "the interval elapsed" rate-limits on its own clock rather than sharing the
2010
+ # periodic check's budget. $2=1 drops the stamp again if the download fails, for
2011
+ # callers recovering from a known-bad state where waiting out the full interval
2012
+ # on a transient network error is worse than retrying next turn.
2005
2013
  weave_self_refresh() {
2014
+ local stamp_suffix="${1:-}"
2015
+ local retry_on_fail="${2:-0}"
2016
+
2006
2017
  [ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
2007
2018
  command -v curl >/dev/null 2>&1 || return 0
2008
2019
 
@@ -2019,7 +2030,7 @@ weave_self_refresh() {
2019
2030
  mkdir -p "$cache_dir" 2>/dev/null || return 0
2020
2031
  local script_slug
2021
2032
  script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
2022
- local stamp="$cache_dir/checked-at${script_slug}"
2033
+ local stamp="$cache_dir/checked-at${script_slug}${stamp_suffix}"
2023
2034
 
2024
2035
  local now stamp_mtime
2025
2036
  now="$(date +%s 2>/dev/null)" || return 0
@@ -2043,7 +2054,13 @@ weave_self_refresh() {
2043
2054
  : > "$stamp" 2>/dev/null || return 0
2044
2055
 
2045
2056
  local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
2046
- local tmp="${self}.tmp.$$"
2057
+ # $$ alone is not unique: two calls can run in one invocation (the periodic
2058
+ # check and a pricing-miss retry both fire on a cold cache) and would then
2059
+ # curl -o into the same path and mv over each other, installing a truncated
2060
+ # script that can never self-heal. The stamp suffix is the right key — it is
2061
+ # what makes two callers mutually exclusive in the first place, so callers
2062
+ # that could overlap necessarily have different suffixes.
2063
+ local tmp="${self}.tmp.$$${stamp_suffix}"
2047
2064
  (
2048
2065
  # Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
2049
2066
  # it, and silence all output so nothing leaks into the statusline.
@@ -2062,6 +2079,11 @@ weave_self_refresh() {
2062
2079
  fi
2063
2080
  else
2064
2081
  rm -f "$tmp"
2082
+ # A download that never landed shouldn't spend the caller's whole
2083
+ # interval; drop the stamp so the next turn can try again.
2084
+ if [ "$retry_on_fail" = "1" ]; then
2085
+ rm -f "$stamp"
2086
+ fi
2065
2087
  fi
2066
2088
  ) >/dev/null 2>&1 &
2067
2089
  disown 2>/dev/null || true
@@ -2365,6 +2387,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2365
2387
  ) || true
2366
2388
  fi
2367
2389
 
2390
+ # ---------- refresh when a model isn't in the pricing table ----------
2391
+ #
2392
+ # A model that shipped after this copy of the script has no price entry, so the
2393
+ # jq guard above zeroes savings on every turn and the line reads "saved $0.00"
2394
+ # — indistinguishable from "the router ran and didn't beat your selection". The
2395
+ # periodic check heals that eventually, but a week late, and a model launch is
2396
+ # exactly when the number gets looked at. Refresh off-schedule instead.
2397
+ #
2398
+ # Keyed on its own stamp per unpriced id: a model we never price (self-hosted,
2399
+ # unrecognized) then costs one download per interval, not one per turn, and
2400
+ # can't starve the periodic check.
2401
+ weave_refresh_on_price_miss() {
2402
+ local candidates="" m
2403
+ for m in "$@"; do
2404
+ case "$m" in
2405
+ "" | "?" | failure | weave-router | "<synthetic>") continue ;;
2406
+ esac
2407
+ candidates="${candidates}${m}"$'\n'
2408
+ done
2409
+ [ -n "$candidates" ] || return 0
2410
+
2411
+ # A malformed $prices emits nothing here, which fails closed (no refresh)
2412
+ # rather than re-downloading every turn.
2413
+ local missing
2414
+ missing="$(printf '%s' "$candidates" \
2415
+ | jq -rR --argjson p "$prices" \
2416
+ 'select($p.input[.] == null or $p.output[.] == null)' 2>/dev/null \
2417
+ | head -n 1)" || return 0
2418
+ [ -n "$missing" ] || return 0
2419
+
2420
+ local model_slug
2421
+ model_slug="$(printf '%s' "$missing" | tr -c 'A-Za-z0-9._-' '_')"
2422
+ weave_self_refresh ".miss.${model_slug}" 1
2423
+ }
2424
+ weave_refresh_on_price_miss "$requested_norm" "$routed" 2>/dev/null || true
2425
+
2368
2426
  # Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
2369
2427
  # every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
2370
2428
  # wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
@@ -2480,12 +2538,20 @@ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
2480
2538
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
2481
2539
  jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
2482
2540
  env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
2483
- statusLine: { type: "command", command: $sl }
2541
+ statusLine: { type: "command", command: $sl },
2542
+ attribution: {
2543
+ commit: "Co-Authored-By: Weave Router <noreply@workweave.ai>",
2544
+ pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
2545
+ }
2484
2546
  }' >"$tmp_patch"
2485
2547
  else
2486
2548
  jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
2487
2549
  env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
2488
- statusLine: { type: "command", command: $sl }
2550
+ statusLine: { type: "command", command: $sl },
2551
+ attribution: {
2552
+ commit: "Co-Authored-By: Weave Router <noreply@workweave.ai>",
2553
+ pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
2554
+ }
2489
2555
  }' >"$tmp_patch"
2490
2556
  fi
2491
2557
 
@@ -2501,6 +2567,7 @@ if [ -f "$settings_file" ]; then
2501
2567
  | (if (.env | length) == 0 then del(.env) else . end)
2502
2568
  | del(.apiKeyHelper)
2503
2569
  | (if $b.statusLine then .statusLine = $b.statusLine else . end)
2570
+ | (if $b.attribution then .attribution = $b.attribution else . end)
2504
2571
  ' "$settings_file" "$tmp_patch")"
2505
2572
  printf '%s\n' "$merged" >"$settings_file"
2506
2573
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
@@ -20,8 +20,16 @@ from npm on next start and loads this extension via its `pi.extensions` field.
20
20
 
21
21
  ## What it does
22
22
 
23
+ - **Loom experience on stock pi.** Replaces pi's startup header through the
24
+ public extension API, adds Wooly's responsive orange terminal animation, and
25
+ keeps pi's own runtime/footer intact. Wooly is visual only: there is no
26
+ dialogue box, narration, coaching request, or separate Loom runtime.
23
27
  - **Automatic model selection.** All pi traffic flows through the router, which
24
28
  selects the model per request. You don't pick a model — the router does.
29
+ - **Force-model commands.** `/fm <model>` and `/force-model <model>` pin the
30
+ current router session; `/ufm` and `/unforce-model` resume automatic routing.
31
+ The persistent status changes to `WEAVE ROUTER — <model> [forced]` after the
32
+ router validates and canonicalizes the requested model.
25
33
  - **Per-process routing bias.** Static `x-weave-routing-*` knob headers bias the
26
34
  router: quality on the main loop, speed + cheap on subagents, cheapest on
27
35
  compaction.
@@ -31,10 +39,14 @@ from npm on next start and loads this extension via its `pi.extensions` field.
31
39
  natively. `dispatch` spawns child `pi` processes (read-only by default), runs
32
40
  them concurrently, and returns only each subagent's final answer — intermediate
33
41
  tool output stays in the child, so the main context stays small.
34
- - **Routed-model display.** Shows the model the router actually chose
35
- (`x-router-model`) in the status bar, and opts the request out of the router's
36
- in-band routing badge (`X-Weave-Routing-Marker: off`) pi can't render that
37
- separate marker text block inline, and the status bar already conveys the model.
42
+ - **Persistent route + savings display.** Shows
43
+ `WEAVE ROUTER <routed> <selected> · saved $X.XX` below pi's native footer
44
+ data. Savings compare the selected and routed catalog prices against the same
45
+ input/output/cache usage, accumulate across the reachable session branch,
46
+ and survive resume. Unknown catalog prices are labeled `unpriced` instead of
47
+ silently contributing zero; costlier routing is labeled `extra`, not savings.
48
+ - **No duplicate in-band badge.** Sets `X-Weave-Routing-Marker: off` because the
49
+ persistent status already conveys the actual model.
38
50
  - **Safety backstop.** Blocks a few catastrophic shell commands (`rm -rf /`,
39
51
  `mkfs`, `dd of=/dev/…`, fork bombs, force-push to main). Disable with
40
52
  `WEAVE_NO_SAFETY=1`.
@@ -64,6 +76,11 @@ Routing through the router switches pi from Claude **subscription OAuth** to
64
76
  **per-token** billing on the router deployment's key (or your BYOK key). BYOK
65
77
  skips cross-provider failover; deployment-key billing is the default.
66
78
 
79
+ The displayed savings are a client-side estimate from the router's generated
80
+ model-price catalog. Cache writes use 1.25× input price and cache reads use 0.1×
81
+ input price, matching the Claude Code statusline. The ledger stores its catalog
82
+ version with each response so resumed totals remain auditable.
83
+
67
84
  ## Notes
68
85
 
69
86
  - Cheap compaction is currently a reserved flag — the handler defers to pi's
@@ -243,6 +243,8 @@ function model(id: string, name: string, maxTokens: number): ProviderModelConfig
243
243
  // ---------- dispatch / misc tunables ----------
244
244
 
245
245
  export const ROUTED_MODEL_HEADER = (process.env.WEAVE_ROUTED_MODEL_HEADER || "x-router-model").toLowerCase();
246
+ export const ROUTED_PROVIDER_HEADER = "x-router-provider";
247
+ export const ROUTER_DECISION_HEADER = "x-router-decision";
246
248
  /** Marker a headless child prints to stderr so the parent dispatch can read its routed model. */
247
249
  export const ROUTED_MODEL_STDERR_PREFIX = "weave-routed-model:";
248
250
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pi consumes slash commands locally, so expose the router's force-model
3
+ * directives as extension commands and forward one canonical user turn.
4
+ *
5
+ * The router remains authoritative for aliases, validation, canonical model
6
+ * ids, and pin persistence. UI state is reconstructed from command/response
7
+ * pairs on the reachable Pi branch; this is necessary because Pi records the
8
+ * selected model handle on assistant messages rather than the response model.
9
+ */
10
+
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionCommandContext,
14
+ SessionEntry,
15
+ } from "@mariozechner/pi-coding-agent";
16
+
17
+ export type ForceModelTransition =
18
+ | { kind: "applied"; model: string }
19
+ | { kind: "cleared" }
20
+ | { kind: "noop" };
21
+
22
+ type ForceModelDirective = "force" | "clear";
23
+
24
+ function messageText(message: unknown): string | undefined {
25
+ if (!message || typeof message !== "object" || !("content" in message)) return undefined;
26
+ const content = message.content;
27
+ if (typeof content === "string") return content;
28
+ if (!Array.isArray(content)) return undefined;
29
+ return content
30
+ .filter(
31
+ (block): block is { type: "text"; text: string } =>
32
+ Boolean(
33
+ block &&
34
+ typeof block === "object" &&
35
+ "type" in block &&
36
+ block.type === "text" &&
37
+ "text" in block &&
38
+ typeof block.text === "string",
39
+ ),
40
+ )
41
+ .map((block) => block.text)
42
+ .join("\n");
43
+ }
44
+
45
+ export function parseForceModelDirective(text: string): ForceModelDirective | undefined {
46
+ const command = text.trim();
47
+ if (/^\/(?:force-model|fm)\s+\S+/i.test(command)) return "force";
48
+ if (/^\/(?:unforce-model|ufm)$/i.test(command)) return "clear";
49
+ return undefined;
50
+ }
51
+
52
+ export function parseForceModelAcknowledgement(text: string): ForceModelTransition | undefined {
53
+ const applied = /force-model applied:\s+([^\s()]+)/i.exec(text);
54
+ if (applied) return { kind: "applied", model: applied[1] };
55
+ if (/force-model cleared/i.test(text)) return { kind: "cleared" };
56
+ if (/is(?:n't| not) a recognized model/i.test(text)) return { kind: "noop" };
57
+ return undefined;
58
+ }
59
+
60
+ function isSyntheticPinClear(text: string): boolean {
61
+ return /^(?:✦ \*\*Weave Router\*\* →|Weave Router:)\s+(?:Tool-call|Repetition|No-progress) loop detected\b[\s\S]*\bclearing the session pin\b/i.test(
62
+ text.trim(),
63
+ );
64
+ }
65
+
66
+ /** Reconstruct the effective router pin on the currently reachable branch. */
67
+ export function forcedModelFromBranch(entries: readonly SessionEntry[]): string | undefined {
68
+ let forcedModel: string | undefined;
69
+ let pendingDirective: ForceModelDirective | undefined;
70
+
71
+ for (const entry of entries) {
72
+ if (entry.type !== "message") continue;
73
+ if (entry.message.role === "user") {
74
+ pendingDirective = parseForceModelDirective(messageText(entry.message) ?? "");
75
+ continue;
76
+ }
77
+ if (entry.message.role !== "assistant") continue;
78
+
79
+ const text = messageText(entry.message) ?? "";
80
+ if (isSyntheticPinClear(text)) {
81
+ forcedModel = undefined;
82
+ pendingDirective = undefined;
83
+ continue;
84
+ }
85
+ if (!pendingDirective) continue;
86
+
87
+ const transition = parseForceModelAcknowledgement(text);
88
+ if (transition?.kind === "applied" && pendingDirective === "force") forcedModel = transition.model;
89
+ else if (transition?.kind === "cleared" && pendingDirective === "clear") forcedModel = undefined;
90
+ // Rejected force-model commands intentionally retain the previous pin.
91
+ pendingDirective = undefined;
92
+ }
93
+
94
+ return forcedModel;
95
+ }
96
+
97
+ function sendRouterCommand(pi: ExtensionAPI, command: string, ctx: ExtensionCommandContext): void {
98
+ pi.sendUserMessage(command, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
99
+ }
100
+
101
+ export function registerForceModelCommands(pi: ExtensionAPI): void {
102
+ const forceModel = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
103
+ const modelAndPrompt = args.trim();
104
+ if (!modelAndPrompt) {
105
+ ctx.ui.notify("Usage: /fm <model-id>", "warning");
106
+ return;
107
+ }
108
+ sendRouterCommand(pi, `/force-model ${modelAndPrompt}`, ctx);
109
+ };
110
+ const clearForceModel = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
111
+ sendRouterCommand(pi, "/unforce-model", ctx);
112
+ };
113
+
114
+ for (const name of ["fm", "force-model"]) {
115
+ pi.registerCommand(name, {
116
+ description: "Pin this session to a specific model via the Weave Router",
117
+ handler: forceModel,
118
+ });
119
+ }
120
+ for (const name of ["ufm", "unforce-model"]) {
121
+ pi.registerCommand(name, {
122
+ description: "Clear this session's forced Weave Router model",
123
+ handler: clearForceModel,
124
+ });
125
+ }
126
+ }
@@ -7,7 +7,7 @@
7
7
  * the main loop, speed/cheap in subagents).
8
8
  * - metadata: stamp body.metadata.user_id for sticky sessions + subagent
9
9
  * detection.
10
- * - routed-model: show which model the router actually picked.
10
+ * - Loom UI: branded header, Wooly animation, actual route, and saved $.
11
11
  * - safety: block catastrophic bash (unless WEAVE_NO_SAFETY=1).
12
12
  * - compaction: experimental cheap path (only when WEAVE_CHEAP_COMPACTION=1).
13
13
  * - dispatch: parallel, context-isolated subagents — top-level process
@@ -22,6 +22,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
22
  import { isSubagent } from "./config.js";
23
23
  import { registerCheapCompaction } from "./compaction.js";
24
24
  import { registerDispatch } from "./dispatch.js";
25
+ import { registerForceModelCommands } from "./force-model.js";
25
26
  import { registerMetadata } from "./metadata.js";
26
27
  import { registerRoutedModel } from "./routed-model.js";
27
28
  import { registerSafety } from "./safety.js";
@@ -37,6 +38,7 @@ export default function (pi: ExtensionAPI): void {
37
38
  pi.on("session_start", () => registerWeave(pi));
38
39
 
39
40
  registerMetadata(pi);
41
+ registerForceModelCommands(pi);
40
42
  registerRoutedModel(pi);
41
43
 
42
44
  if (process.env.WEAVE_NO_SAFETY !== "1") registerSafety(pi);
@@ -0,0 +1,79 @@
1
+ // Code generated by cmd/genprices; DO NOT EDIT.
2
+ // Source: internal/router/catalog (USD per 1M tokens).
3
+
4
+ export interface ModelPricing {
5
+ inputUsdPerMillion: number;
6
+ outputUsdPerMillion: number;
7
+ }
8
+
9
+ export const PRICING_VERSION = "catalog-sha256:a13e743aa9694647";
10
+
11
+ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = Object.freeze({
12
+ "claude-fable-5": { inputUsdPerMillion: 10, outputUsdPerMillion: 50 },
13
+ "claude-haiku-4-5": { inputUsdPerMillion: 1, outputUsdPerMillion: 5 },
14
+ "claude-opus-4-0": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
15
+ "claude-opus-4-1": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
16
+ "claude-opus-4-5": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
17
+ "claude-opus-4-6": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
18
+ "claude-opus-4-7": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
19
+ "claude-opus-4-8": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
20
+ "claude-opus-5": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
21
+ "claude-sonnet-4-5": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
22
+ "claude-sonnet-4-6": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
23
+ "claude-sonnet-5": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
24
+ "deepseek/deepseek-v4-flash": { inputUsdPerMillion: 0.1134, outputUsdPerMillion: 0.2791 },
25
+ "deepseek/deepseek-v4-pro": { inputUsdPerMillion: 1.318, outputUsdPerMillion: 2.6361 },
26
+ "gemini-2.0-flash": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
27
+ "gemini-2.0-flash-lite": { inputUsdPerMillion: 0.075, outputUsdPerMillion: 0.3 },
28
+ "gemini-2.5-flash": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
29
+ "gemini-2.5-flash-lite": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
30
+ "gemini-2.5-pro": { inputUsdPerMillion: 1.25, outputUsdPerMillion: 5 },
31
+ "gemini-3-flash-preview": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2 },
32
+ "gemini-3-pro-preview": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
33
+ "gemini-3.1-flash-lite-preview": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
34
+ "gemini-3.1-pro-preview": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
35
+ "gemini-3.5-flash": { inputUsdPerMillion: 1.5, outputUsdPerMillion: 9 },
36
+ "gemini-3.5-flash-lite": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5 },
37
+ "gemini-3.6-flash": { inputUsdPerMillion: 1.5, outputUsdPerMillion: 7.5 },
38
+ "google/gemma-4-26b-a4b-it": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
39
+ "gpt-4.1": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
40
+ "gpt-4.1-mini": { inputUsdPerMillion: 0.4, outputUsdPerMillion: 1.6 },
41
+ "gpt-4.1-nano": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
42
+ "gpt-4o": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
43
+ "gpt-4o-mini": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
44
+ "gpt-5": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
45
+ "gpt-5-chat": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
46
+ "gpt-5-mini": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2 },
47
+ "gpt-5-nano": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
48
+ "gpt-5.4": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 15 },
49
+ "gpt-5.4-mini": { inputUsdPerMillion: 0.75, outputUsdPerMillion: 4.5 },
50
+ "gpt-5.4-nano": { inputUsdPerMillion: 0.2, outputUsdPerMillion: 1.25 },
51
+ "gpt-5.4-pro": { inputUsdPerMillion: 30, outputUsdPerMillion: 180 },
52
+ "gpt-5.5": { inputUsdPerMillion: 5, outputUsdPerMillion: 30 },
53
+ "gpt-5.5-mini": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2.5 },
54
+ "gpt-5.5-nano": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
55
+ "gpt-5.5-pro": { inputUsdPerMillion: 30, outputUsdPerMillion: 180 },
56
+ "gpt-5.6-luna": { inputUsdPerMillion: 1, outputUsdPerMillion: 6 },
57
+ "gpt-5.6-sol": { inputUsdPerMillion: 5, outputUsdPerMillion: 30 },
58
+ "gpt-5.6-terra": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 15 },
59
+ "grok-4.5": { inputUsdPerMillion: 2, outputUsdPerMillion: 6 },
60
+ "minimax/minimax-m2.7": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
61
+ "minimax/minimax-m3": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
62
+ "mistralai/mistral-small-2603": { inputUsdPerMillion: 0.2, outputUsdPerMillion: 0.6 },
63
+ "moonshotai/kimi-k2.5": { inputUsdPerMillion: 0.6, outputUsdPerMillion: 3 },
64
+ "moonshotai/kimi-k2.6": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
65
+ "moonshotai/kimi-k2.7": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
66
+ "moonshotai/kimi-k3": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
67
+ "qwen/qwen3-235b-a22b-2507": { inputUsdPerMillion: 0.2266, outputUsdPerMillion: 0.9064 },
68
+ "qwen/qwen3-30b-a3b-instruct-2507": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
69
+ "qwen/qwen3-coder": { inputUsdPerMillion: 0.9, outputUsdPerMillion: 2.7 },
70
+ "qwen/qwen3-coder-next": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 1.2 },
71
+ "qwen/qwen3-next-80b-a3b-instruct": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 1.2 },
72
+ "qwen/qwen3.5-flash-02-23": { inputUsdPerMillion: 0.05, outputUsdPerMillion: 0.15 },
73
+ "qwen/qwen3.6-35b-a3b": { inputUsdPerMillion: 0.172, outputUsdPerMillion: 1.2002 },
74
+ "qwen/qwen3.7-plus": { inputUsdPerMillion: 0.4, outputUsdPerMillion: 1.6 },
75
+ "xiaomi/mimo-v2.5-pro": { inputUsdPerMillion: 1, outputUsdPerMillion: 3 },
76
+ "z-ai/glm-5": { inputUsdPerMillion: 1, outputUsdPerMillion: 3.2 },
77
+ "z-ai/glm-5.1": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
78
+ "z-ai/glm-5.2": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
79
+ });