@workweave/router 0.2.5 → 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,11 +87,23 @@ 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
 
87
- `curl -fsSL https://weave.ai/cc/install.sh | sh` still works and is fine.
88
- `npx @workweave/router` adds: Windows support via Git Bash, painless version
89
- pinning, no `curl | sh` aversion, and discoverability via the npm registry.
95
+ `npx @workweave/router` gives Windows support via Git Bash, painless version
96
+ pinning, and discoverability via the npm registry.
97
+
98
+ ## Older npm
99
+
100
+ On npm ≤ 6 the bundled `npx` treats an undeclared `-y` as consuming the next
101
+ token, so `npx -y @workweave/router --claude` silently drops the package name
102
+ and resolves the following argument as the command instead. Either upgrade
103
+ (`npm i -g npm@latest`) or name the binary explicitly:
104
+
105
+ ```bash
106
+ npx --package @workweave/router -y -- weave-router --claude
107
+ ```
108
+
109
+ That form is correct on every npm version.
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
@@ -137,6 +159,7 @@ prices='{
137
159
  "claude-opus-4-6": 0.005,
138
160
  "claude-opus-4-7": 0.005,
139
161
  "claude-opus-4-8": 0.005,
162
+ "claude-opus-5": 0.005,
140
163
  "claude-sonnet-4-5": 0.003,
141
164
  "claude-sonnet-4-6": 0.003,
142
165
  "claude-sonnet-5": 0.003,
@@ -152,6 +175,8 @@ prices='{
152
175
  "gemini-3.1-flash-lite-preview": 0.0001,
153
176
  "gemini-3.1-pro-preview": 0.002,
154
177
  "gemini-3.5-flash": 0.0015,
178
+ "gemini-3.5-flash-lite": 0.0003,
179
+ "gemini-3.6-flash": 0.0015,
155
180
  "google/gemma-4-26b-a4b-it": 0.00015,
156
181
  "gpt-4.1": 0.002,
157
182
  "gpt-4.1-mini": 0.0004,
@@ -170,12 +195,17 @@ prices='{
170
195
  "gpt-5.5-mini": 0.0005,
171
196
  "gpt-5.5-nano": 0.00015,
172
197
  "gpt-5.5-pro": 0.03,
198
+ "gpt-5.6-luna": 0.001,
199
+ "gpt-5.6-sol": 0.005,
200
+ "gpt-5.6-terra": 0.0025,
201
+ "grok-4.5": 0.002,
173
202
  "minimax/minimax-m2.7": 0.0003,
174
203
  "minimax/minimax-m3": 0.0003,
175
204
  "mistralai/mistral-small-2603": 0.0002,
176
205
  "moonshotai/kimi-k2.5": 0.0006,
177
206
  "moonshotai/kimi-k2.6": 0.00095,
178
207
  "moonshotai/kimi-k2.7": 0.00095,
208
+ "moonshotai/kimi-k3": 0.003,
179
209
  "qwen/qwen3-235b-a22b-2507": 0.0002266,
180
210
  "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
181
211
  "qwen/qwen3-coder": 0.0009,
@@ -198,6 +228,7 @@ prices='{
198
228
  "claude-opus-4-6": 0.025,
199
229
  "claude-opus-4-7": 0.025,
200
230
  "claude-opus-4-8": 0.025,
231
+ "claude-opus-5": 0.025,
201
232
  "claude-sonnet-4-5": 0.015,
202
233
  "claude-sonnet-4-6": 0.015,
203
234
  "claude-sonnet-5": 0.015,
@@ -213,6 +244,8 @@ prices='{
213
244
  "gemini-3.1-flash-lite-preview": 0.0004,
214
245
  "gemini-3.1-pro-preview": 0.008,
215
246
  "gemini-3.5-flash": 0.009,
247
+ "gemini-3.5-flash-lite": 0.0025,
248
+ "gemini-3.6-flash": 0.0075,
216
249
  "google/gemma-4-26b-a4b-it": 0.0006,
217
250
  "gpt-4.1": 0.008,
218
251
  "gpt-4.1-mini": 0.0016,
@@ -231,12 +264,17 @@ prices='{
231
264
  "gpt-5.5-mini": 0.0025,
232
265
  "gpt-5.5-nano": 0.0006,
233
266
  "gpt-5.5-pro": 0.18,
267
+ "gpt-5.6-luna": 0.006,
268
+ "gpt-5.6-sol": 0.03,
269
+ "gpt-5.6-terra": 0.015,
270
+ "grok-4.5": 0.006,
234
271
  "minimax/minimax-m2.7": 0.0012,
235
272
  "minimax/minimax-m3": 0.0012,
236
273
  "mistralai/mistral-small-2603": 0.0006,
237
274
  "moonshotai/kimi-k2.5": 0.003,
238
275
  "moonshotai/kimi-k2.6": 0.004,
239
276
  "moonshotai/kimi-k2.7": 0.004,
277
+ "moonshotai/kimi-k3": 0.015,
240
278
  "qwen/qwen3-235b-a22b-2507": 0.0009064,
241
279
  "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
242
280
  "qwen/qwen3-coder": 0.0027,
@@ -383,6 +421,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
383
421
  ) || true
384
422
  fi
385
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
+
386
460
  # Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
387
461
  # every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
388
462
  # wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Print the session id that correlates this session in router telemetry and logs.
3
+ allowed-tools: Bash(echo:*)
4
+ ---
5
+
6
+ Print the session id used by the Weave Router to correlate this session's
7
+ telemetry, logs, and feedback submissions. Also works as a transcript
8
+ filename key for manual lookups in the router dashboard.
9
+
10
+ The router carries this id as `X-Claude-Code-Session-Id` on every request and
11
+ stores it in `model_router_users.SessionID` for analytics joins.
12
+
13
+ Run `echo "$CLAUDE_CODE_SESSION_ID"`.
14
+
15
+ Then tell me the session id in one line, formatted as inline code so I can copy it.
package/install.sh CHANGED
@@ -127,7 +127,10 @@ skip() { printf "%s⊙%s %s%s%s\n" "$C_DIM" "$C_RESET" "$C_DIM" "$*" "$C_RESET";
127
127
  # print after a successful install is copy-paste-correct. Kept in sync with
128
128
  # uninstall.sh's flag surface.
129
129
  uninstall_cmd() {
130
- local cmd="npx -y @workweave/router --uninstall"
130
+ # --package + `--` is load-bearing: npm <= 6's bundled npx treats an
131
+ # undeclared `-y` as consuming the NEXT token, so `npx -y @workweave/router`
132
+ # loses the package name and resolves whatever follows as the command.
133
+ local cmd="npx --package @workweave/router -y -- weave-router --uninstall"
131
134
  case "$target" in
132
135
  codex) cmd="$cmd --codex" ;;
133
136
  opencode) cmd="$cmd --opencode" ;;
@@ -577,11 +580,10 @@ write_opencode_config() {
577
580
  | (if $name != "" then . + {"X-Weave-User-Name": $name } else . end)
578
581
  ')"
579
582
 
580
- # Headline models we surface in opencode's picker. The router re-routes
581
- # each request anyway, so this list is mostly UX — what shows up when the
582
- # user runs /models inside opencode. We list a mix of GPT + Claude so the
583
- # picker reflects that the router spans families; whichever model serves the
584
- # turn, its matching subscription (if connected) pays.
583
+ # Surface one Auto choice in opencode's picker. The router chooses the
584
+ # upstream model for every request, so presenting pinned model names would
585
+ # imply a choice that the router intentionally does not honor. Whichever
586
+ # model serves a turn uses its matching subscription when one is connected.
585
587
  #
586
588
  # npm is @ai-sdk/openai and baseURL KEEPS its /v1 here: opencode's
587
589
  # @ai-sdk/openai provider appends /responses, yielding the router's
@@ -601,12 +603,7 @@ write_opencode_config() {
601
603
  name: "Weave Router",
602
604
  options: { apiKey: $key, baseURL: $url, headers: $headers },
603
605
  models: {
604
- "claude-sonnet-5": { name: "Claude Sonnet 5 (via Weave Router)" },
605
- "claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
606
- "claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
607
- "claude-haiku-4-5": { name: "Claude Haiku 4.5 (via Weave Router)" },
608
- "gpt-5.5": { name: "GPT-5.5 (via Weave Router)" },
609
- "gpt-5.4": { name: "GPT-5.4 (via Weave Router)" }
606
+ auto: { name: "Auto" }
610
607
  }
611
608
  }
612
609
  ')"
@@ -656,8 +653,9 @@ write_opencode_config() {
656
653
 
657
654
  # Merge into any existing opencode.json. We always overwrite provider.weave
658
655
  # so re-install reflects the latest key/identity, but we leave the rest of the
659
- # file (other providers, mcp, agent settings) untouched. Top-level `model` is
660
- # only set when the user hasn't already picked one.
656
+ # file (other providers, mcp, agent settings) untouched. A previously
657
+ # installed `weave/*` model is migrated to `weave/auto`; unrelated provider
658
+ # choices stay untouched.
661
659
  #
662
660
  # The weave-claude login provider AND the plugin entry are written together
663
661
  # only when the bundled plugin was present and copied ($plugin non-empty): the
@@ -685,12 +683,12 @@ write_opencode_config() {
685
683
  then (.plugin -= [$pluginspec]) | (if (.plugin | length) == 0 then del(.plugin) else . end)
686
684
  else . end)
687
685
  end)
688
- | (if (.model // "") == "" then .model = "weave/claude-sonnet-4-6" else . end)
689
- # Reset the default model if it points at a provider we just removed: the
690
- # retired weave-codex, or weave-claude on a plugin-less re-install (it has
691
- # no models anyway). Otherwise opencode boots with a dangling model.
692
- | (if (.model // "" | tostring | startswith("weave-codex/")) then .model = "weave/claude-sonnet-4-6" else . end)
693
- | (if $plugin == "" and (.model // "" | tostring | startswith("weave-claude/")) then .model = "weave/claude-sonnet-4-6" else . end)
686
+ | (if (.model // "") == "" then .model = "weave/auto" else . end)
687
+ # Replace any legacy Weave model choice with the single auto-routing
688
+ # choice. Models from unrelated providers remain unchanged.
689
+ | (if (.model // "" | tostring) as $model
690
+ | ($model | startswith("weave/") or startswith("weave-codex/") or startswith("weave-claude/"))
691
+ then .model = "weave/auto" else . end)
694
692
  | (.["$schema"] //= "https://opencode.ai/config.json")
695
693
  ' "$config_file")"
696
694
  else
@@ -700,7 +698,7 @@ write_opencode_config() {
700
698
  --arg plugin "$plugin_arg" '
701
699
  {
702
700
  "$schema": "https://opencode.ai/config.json",
703
- model: "weave/claude-sonnet-4-6",
701
+ model: "weave/auto",
704
702
  provider: { weave: $block }
705
703
  }
706
704
  | (if $plugin != "" then .provider["weave-claude"] = $claude | .plugin = [$plugin] else . end)
@@ -771,10 +769,12 @@ write_pi_models_config() {
771
769
  headers: $headers,
772
770
  models: [
773
771
  { id: "claude-fable-5", name: "Claude Fable 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
774
- { id: "claude-opus-4-8", name: "Claude Opus 4.8 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
772
+ { id: "claude-opus-5", name: "Claude Opus 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
775
773
  { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
776
774
  { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
777
- { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 }
775
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 },
776
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1050000, maxTokens: 128000 },
777
+ { id: "grok-4.5", name: "Grok 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 500000, maxTokens: 131072 }
778
778
  ]
779
779
  }
780
780
  ')"
@@ -1573,30 +1573,34 @@ toggle_opencode() {
1573
1573
  ;;
1574
1574
  on)
1575
1575
  if [ "$on" = "true" ]; then ok "opencode is already on — nothing to do."; return 0; fi
1576
- restore_model="weave/claude-sonnet-4-6"
1576
+ restore_model="weave/auto"
1577
1577
  if [ "$parked_present" = "true" ]; then
1578
- restore_model="$(jq -r '.model // "weave/claude-sonnet-4-6"' "$parked")"
1578
+ restore_model="$(jq -r '.model // "weave/auto"' "$parked")"
1579
1579
  elif [ "$has_weave" != "true" ]; then
1580
1580
  warn "opencode isn't configured for the router. Run the installer first."; return 0
1581
1581
  else
1582
1582
  # No parked model (sidecar deleted by hand). Derive the default from the
1583
1583
  # installed provider.weave.models block rather than a hardcoded literal
1584
- # that silently diverges when the installer's default changes — prefer a
1585
- # sonnet entry, else the first model the installer registered.
1584
+ # that silently diverges when the installer's default changes — prefer
1585
+ # the auto-routing entry, else the first model the installer registered.
1586
1586
  restore_model="$(jq -r '
1587
1587
  (.provider.weave.models // {} | keys) as $k
1588
- | (([$k[] | select(test("sonnet"))] | first) // $k[0] // "claude-sonnet-4-6")
1588
+ | (([$k[] | select(. == "auto")] | first) // $k[0] // "auto")
1589
1589
  | "weave/" + .
1590
- ' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
1590
+ ' "$f" 2>/dev/null || echo "weave/auto")"
1591
1591
  fi
1592
- # Never restore a legacy weave-codex model whose provider is no longer
1593
- # registered (every current install strips weave-codex). Fall back to the
1594
- # `weave` default so `on` can't leave opencode pointing at a missing
1595
- # provider.
1592
+ # Restore only a registered `weave` model. Legacy installations may have
1593
+ # parked a pinned model, but a current install exposes only `weave/auto`.
1596
1594
  case "$restore_model" in
1595
+ weave/*)
1596
+ local model_id="${restore_model#weave/}"
1597
+ if ! jq -e --arg id "$model_id" '(.provider.weave.models // {}) | has($id)' "$f" >/dev/null 2>&1; then
1598
+ restore_model="weave/auto"
1599
+ fi
1600
+ ;;
1597
1601
  weave-codex/*)
1598
1602
  if [ "$(jq -r '((.provider // {}) | has("weave-codex"))' "$f" 2>/dev/null || true)" != "true" ]; then
1599
- restore_model="weave/claude-sonnet-4-6"
1603
+ restore_model="weave/auto"
1600
1604
  fi
1601
1605
  ;;
1602
1606
  esac
@@ -1689,6 +1693,7 @@ fi
1689
1693
  # Layout:
1690
1694
  # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
1691
1695
  # Codex: <codex_dir>/prompts/{force-model,unforce-model}.md → /prompts:force-model
1696
+ # opencode: <commands_dir>/{force-model,unforce-model}.md → /force-model
1692
1697
  #
1693
1698
  # Files come from install/commands/ in the repo (or the colocated commands/
1694
1699
  # directory the npm package ships alongside install.sh).
@@ -1719,8 +1724,8 @@ install_slash_commands() {
1719
1724
  installed="force-model, unforce-model, router-feedback, fm, ufm, rf"
1720
1725
  cmds="force-model unforce-model router-feedback fm ufm rf"
1721
1726
  if [ "$target" = "claude" ]; then
1722
- cmds="$cmds router-off router-on router-status"
1723
- installed="$installed, router-off, router-on, router-status"
1727
+ cmds="$cmds router-off router-on router-status router-session"
1728
+ installed="$installed, router-off, router-on, router-status, router-session"
1724
1729
  fi
1725
1730
 
1726
1731
  # Bake the same scope selector the toggle needs to find this install: --dir
@@ -1830,6 +1835,26 @@ if [ "$target" = "opencode" ]; then
1830
1835
  ok "Updated $gitignore (ignored opencode.json, .weave/)"
1831
1836
  fi
1832
1837
 
1838
+ # Slash command wrappers: opencode discovers commands in *.md files under
1839
+ # ~/.config/opencode/commands/ (user scope) and .opencode/commands/ (project
1840
+ # scope). Install command wrappers for /rf (±), /force-model, /unforce-model
1841
+ # so typing /rf + in the TUI expands to /router-feedback + and reaches the
1842
+ # router's server-side feedback interceptor, same as Claude Code + Codex.
1843
+ #
1844
+ # Router on/off/status are not installed — they run npx shell commands
1845
+ # specific to the Claude Code settings model and don't apply to opencode.
1846
+ if [ "$scope" = "project" ]; then
1847
+ opencode_commands_dir="$opencode_dir/.opencode/commands"
1848
+ else
1849
+ # User scope and --dir installs: use the global commands path that opencode
1850
+ # discovers regardless of working directory.
1851
+ opencode_commands_dir="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands"
1852
+ fi
1853
+ install_slash_commands "$opencode_commands_dir"
1854
+ if [ "$scope" = "user" ] || [ -n "$install_dir" ]; then
1855
+ info "opencode restart required for commands to take effect."
1856
+ fi
1857
+
1833
1858
  # Post-install verification: same probes the Claude/Codex paths run.
1834
1859
  if [ "$quiet" != "true" ]; then
1835
1860
  if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
@@ -1974,10 +1999,21 @@ set -euo pipefail
1974
1999
  # lives in their per-user cache dir, and on no-content-change days we skip
1975
2000
  # the mv entirely so the repo working tree stays clean. When upstream does
1976
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.
1977
2004
  #
1978
2005
  # Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
1979
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.
1980
2013
  weave_self_refresh() {
2014
+ local stamp_suffix="${1:-}"
2015
+ local retry_on_fail="${2:-0}"
2016
+
1981
2017
  [ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
1982
2018
  command -v curl >/dev/null 2>&1 || return 0
1983
2019
 
@@ -1994,7 +2030,7 @@ weave_self_refresh() {
1994
2030
  mkdir -p "$cache_dir" 2>/dev/null || return 0
1995
2031
  local script_slug
1996
2032
  script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
1997
- local stamp="$cache_dir/checked-at${script_slug}"
2033
+ local stamp="$cache_dir/checked-at${script_slug}${stamp_suffix}"
1998
2034
 
1999
2035
  local now stamp_mtime
2000
2036
  now="$(date +%s 2>/dev/null)" || return 0
@@ -2018,7 +2054,13 @@ weave_self_refresh() {
2018
2054
  : > "$stamp" 2>/dev/null || return 0
2019
2055
 
2020
2056
  local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
2021
- 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}"
2022
2064
  (
2023
2065
  # Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
2024
2066
  # it, and silence all output so nothing leaks into the statusline.
@@ -2037,6 +2079,11 @@ weave_self_refresh() {
2037
2079
  fi
2038
2080
  else
2039
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
2040
2087
  fi
2041
2088
  ) >/dev/null 2>&1 &
2042
2089
  disown 2>/dev/null || true
@@ -2078,6 +2125,7 @@ prices='{
2078
2125
  "claude-opus-4-6": 0.005,
2079
2126
  "claude-opus-4-7": 0.005,
2080
2127
  "claude-opus-4-8": 0.005,
2128
+ "claude-opus-5": 0.005,
2081
2129
  "claude-sonnet-4-5": 0.003,
2082
2130
  "claude-sonnet-4-6": 0.003,
2083
2131
  "claude-sonnet-5": 0.003,
@@ -2093,6 +2141,8 @@ prices='{
2093
2141
  "gemini-3.1-flash-lite-preview": 0.0001,
2094
2142
  "gemini-3.1-pro-preview": 0.002,
2095
2143
  "gemini-3.5-flash": 0.0015,
2144
+ "gemini-3.5-flash-lite": 0.0003,
2145
+ "gemini-3.6-flash": 0.0015,
2096
2146
  "google/gemma-4-26b-a4b-it": 0.00015,
2097
2147
  "gpt-4.1": 0.002,
2098
2148
  "gpt-4.1-mini": 0.0004,
@@ -2111,12 +2161,17 @@ prices='{
2111
2161
  "gpt-5.5-mini": 0.0005,
2112
2162
  "gpt-5.5-nano": 0.00015,
2113
2163
  "gpt-5.5-pro": 0.03,
2164
+ "gpt-5.6-luna": 0.001,
2165
+ "gpt-5.6-sol": 0.005,
2166
+ "gpt-5.6-terra": 0.0025,
2167
+ "grok-4.5": 0.002,
2114
2168
  "minimax/minimax-m2.7": 0.0003,
2115
2169
  "minimax/minimax-m3": 0.0003,
2116
2170
  "mistralai/mistral-small-2603": 0.0002,
2117
2171
  "moonshotai/kimi-k2.5": 0.0006,
2118
2172
  "moonshotai/kimi-k2.6": 0.00095,
2119
2173
  "moonshotai/kimi-k2.7": 0.00095,
2174
+ "moonshotai/kimi-k3": 0.003,
2120
2175
  "qwen/qwen3-235b-a22b-2507": 0.0002266,
2121
2176
  "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
2122
2177
  "qwen/qwen3-coder": 0.0009,
@@ -2139,6 +2194,7 @@ prices='{
2139
2194
  "claude-opus-4-6": 0.025,
2140
2195
  "claude-opus-4-7": 0.025,
2141
2196
  "claude-opus-4-8": 0.025,
2197
+ "claude-opus-5": 0.025,
2142
2198
  "claude-sonnet-4-5": 0.015,
2143
2199
  "claude-sonnet-4-6": 0.015,
2144
2200
  "claude-sonnet-5": 0.015,
@@ -2154,6 +2210,8 @@ prices='{
2154
2210
  "gemini-3.1-flash-lite-preview": 0.0004,
2155
2211
  "gemini-3.1-pro-preview": 0.008,
2156
2212
  "gemini-3.5-flash": 0.009,
2213
+ "gemini-3.5-flash-lite": 0.0025,
2214
+ "gemini-3.6-flash": 0.0075,
2157
2215
  "google/gemma-4-26b-a4b-it": 0.0006,
2158
2216
  "gpt-4.1": 0.008,
2159
2217
  "gpt-4.1-mini": 0.0016,
@@ -2172,12 +2230,17 @@ prices='{
2172
2230
  "gpt-5.5-mini": 0.0025,
2173
2231
  "gpt-5.5-nano": 0.0006,
2174
2232
  "gpt-5.5-pro": 0.18,
2233
+ "gpt-5.6-luna": 0.006,
2234
+ "gpt-5.6-sol": 0.03,
2235
+ "gpt-5.6-terra": 0.015,
2236
+ "grok-4.5": 0.006,
2175
2237
  "minimax/minimax-m2.7": 0.0012,
2176
2238
  "minimax/minimax-m3": 0.0012,
2177
2239
  "mistralai/mistral-small-2603": 0.0006,
2178
2240
  "moonshotai/kimi-k2.5": 0.003,
2179
2241
  "moonshotai/kimi-k2.6": 0.004,
2180
2242
  "moonshotai/kimi-k2.7": 0.004,
2243
+ "moonshotai/kimi-k3": 0.015,
2181
2244
  "qwen/qwen3-235b-a22b-2507": 0.0009064,
2182
2245
  "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
2183
2246
  "qwen/qwen3-coder": 0.0027,
@@ -2324,6 +2387,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2324
2387
  ) || true
2325
2388
  fi
2326
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
+
2327
2426
  # Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
2328
2427
  # every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
2329
2428
  # wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
@@ -2439,12 +2538,20 @@ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
2439
2538
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
2440
2539
  jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
2441
2540
  env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
2442
- 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
+ }
2443
2546
  }' >"$tmp_patch"
2444
2547
  else
2445
2548
  jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
2446
2549
  env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
2447
- 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
+ }
2448
2555
  }' >"$tmp_patch"
2449
2556
  fi
2450
2557
 
@@ -2460,6 +2567,7 @@ if [ -f "$settings_file" ]; then
2460
2567
  | (if (.env | length) == 0 then del(.env) else . end)
2461
2568
  | del(.apiKeyHelper)
2462
2569
  | (if $b.statusLine then .statusLine = $b.statusLine else . end)
2570
+ | (if $b.attribution then .attribution = $b.attribution else . end)
2463
2571
  ' "$settings_file" "$tmp_patch")"
2464
2572
  printf '%s\n' "$merged" >"$settings_file"
2465
2573
  else