Compare commits
10
Commits
da50a3c7e5
...
7dfc428d63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfc428d63 | ||
|
|
4c36f4d00b | ||
|
|
43cef6bf9e | ||
|
|
a14418b5d9 | ||
|
|
a4afa55580 | ||
|
|
9ff9b82c25 | ||
|
|
9c301e10a1 | ||
|
|
22cadaebc5 | ||
|
|
35690bcd8d | ||
|
|
f68ca08009 |
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
*.bak
|
||||
*.bak.*
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a
|
||||
local **LM Studio** server — with named per-tier model presets.
|
||||
Windows / PowerShell 5.1, no external dependencies.
|
||||
|
||||
Windows / PowerShell 5.1 with no external dependencies, and a POSIX port for
|
||||
Linux and macOS that needs only python3 — including a theme-aware TUI and an
|
||||
Omarchy bar widget. See [Linux / Omarchy](#linux--omarchy).
|
||||
|
||||
```
|
||||
claude-mode # interactive menu
|
||||
@@ -110,6 +113,13 @@ claude-mode preset rm <name>
|
||||
claude-mode set-key [ref] store a key (hidden prompt, DPAPI)
|
||||
claude-mode models [filter] models available from the active provider
|
||||
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
||||
|
||||
claude-mode preflight <mode> [preset] can this mode actually serve? (no switch)
|
||||
claude-mode sessions running sessions, and which are mid-request
|
||||
claude-mode sessions --stop close them (asks first)
|
||||
claude-mode sessions --restart close and reopen each in its own directory
|
||||
claude-mode sessions --dry-run show what either would do, and do nothing
|
||||
claude-mode <mode> --force switch even when preflight says no
|
||||
```
|
||||
|
||||
Omitting the preset uses a **fixed** per-provider default, not "most recently
|
||||
@@ -117,19 +127,24 @@ used" — so `claude-mode openrouter` always means `default`.
|
||||
|
||||
## Presets shipped
|
||||
|
||||
One per mode, so `claude-mode <mode>` is never ambiguous and there is no
|
||||
menu to read before the thing you asked for happens. Build more with
|
||||
`preset new` whenever one stops being enough.
|
||||
|
||||
| preset | provider | opus | sonnet | haiku | fable |
|
||||
|---|---|---|---|---|---|
|
||||
| `default` | openrouter | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openrouter/free` | `z-ai/glm-5.2` |
|
||||
| `cheap` | openrouter | `deepseek/deepseek-v4-pro` | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openai/gpt-5.6-luna-pro` |
|
||||
| `claude-via-or` | openrouter | `anthropic/claude-opus-5` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4.5` | `anthropic/claude-fable-5` |
|
||||
| `zai` | zai | `glm-5.2` | `glm-5.2` | `glm-4.7` | `glm-5.2` |
|
||||
| `lmstudio` | lmstudio | `kwaipilot_kat-coder-v2.5-dev` (all tiers) | | | |
|
||||
| `lmstudio-qwen` | lmstudio | `qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved` (all tiers) | | | |
|
||||
| `default` | openrouter | `z-ai/glm-5.3-flash` | `deepseek/deepseek-v4-flash-0731` | `openrouter/free` | `z-ai/glm-5.3` |
|
||||
| `zai` | zai | `glm-5.3` | `glm-5.3` | `glm-4.7` | `glm-5.3` |
|
||||
| `lmstudio` | lmstudio | whatever setup finds on your server (all tiers) | | | |
|
||||
|
||||
Presets are plain JSON in `~/.claude-mode/presets/`. A preset declares its
|
||||
`provider`; `claude-mode lmstudio default` is rejected rather than silently
|
||||
pointing a local URL at remote model IDs.
|
||||
|
||||
To route the real Anthropic models through OpenRouter, copy one and repoint the
|
||||
tiers - `claude-mode preset new claude-via-or default`, then `preset set` each
|
||||
tier to `anthropic/claude-opus-5` and friends.
|
||||
|
||||
## Context windows and early auto-compaction
|
||||
|
||||
**Symptom:** switch to a gateway and the session starts auto-compacting almost
|
||||
@@ -151,8 +166,8 @@ CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
|
||||
|
||||
| preset | contextTokens |
|
||||
|---|---|
|
||||
| `default`, `cheap`, `claude-via-or`, `zai` | 1,000,000 |
|
||||
| `lmstudio`, `lmstudio-qwen` | 262,144 |
|
||||
| `default`, `zai` | 1,000,000 |
|
||||
| `lmstudio` | 262,144 |
|
||||
|
||||
`doctor` cross-checks the declared window against each tier's *actual* model
|
||||
window and names any tier that falls short — `default` maps haiku to
|
||||
@@ -233,17 +248,18 @@ Scanning your installed models' templates:
|
||||
| **`qwen/qwen3.5-9b`**, **`prism-ml/bonsai-27b`** | **assert** |
|
||||
|
||||
`doctor` now reports this per model, and `models` flags affected entries with
|
||||
`TEMPLATE RISK`. **The fix is to use a model without the flag** — which is
|
||||
exactly the `lmstudio-qwen` preset, verified end-to-end with a cold JIT load,
|
||||
streaming, and tool calls.
|
||||
`TEMPLATE RISK`. **The fix is to use a model without the flag.** The Qwen3.6
|
||||
line above is what the single `lmstudio` preset ships pointed at, verified
|
||||
end-to-end with a cold JIT load, streaming, and tool calls.
|
||||
|
||||
Honest caveat: KAT-Coder's template *does* contain the assertion, but I could not
|
||||
reproduce the failure against it here — cold JIT, streaming, tools, system
|
||||
blocks, and multi-turn `tool_result` all succeeded. Whether it trips seems to
|
||||
depend on which parser strategy the runtime picks. If it spams, switch:
|
||||
depend on which parser strategy the runtime picks. If the model setup lands on
|
||||
does spam, run setup again and pick another:
|
||||
|
||||
```powershell
|
||||
claude-mode lmstudio lmstudio-qwen
|
||||
```bash
|
||||
claude-mode setup lmstudio
|
||||
```
|
||||
|
||||
Other notes: use a model with **>25k context** (`doctor` warns below that), and
|
||||
@@ -272,24 +288,148 @@ this tool writes is referenced by it:
|
||||
|
||||
## Setup
|
||||
|
||||
One line, on any machine that can reach the repo (PowerShell 5.1 is fine):
|
||||
|
||||
```powershell
|
||||
cd c:\Users\smoido\Projects\cli\claude-code-switcher
|
||||
irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 | iex
|
||||
```
|
||||
|
||||
Piped in like that there is no checkout, so the installer fetches the
|
||||
repository archive to `%TEMP%` and installs from it. To pass switches
|
||||
(`-Force`, `-SkipKeyPrompt`), download first and run the file:
|
||||
|
||||
```powershell
|
||||
irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 -OutFile install.ps1
|
||||
.\install.ps1 -Force
|
||||
```
|
||||
|
||||
Or clone it — the normal path if you want to read the code and pull updates:
|
||||
|
||||
```powershell
|
||||
git clone https://git.nebulm.com/smoido/claude-mode.git
|
||||
cd claude-mode
|
||||
.\install.ps1
|
||||
```
|
||||
|
||||
Installs to `~/.claude-mode/` (ACL: you only), drops `claude-mode.cmd` into
|
||||
`~/.local/bin` (already on your User PATH, next to `claude.exe`), and adds a
|
||||
marked block to `~/Documents/WindowsPowerShell/profile.ps1`.
|
||||
All three install the same way: to `~/.claude-mode/` (ACL: you only), dropping
|
||||
`claude-mode.cmd` into `~/.local/bin` (already on your User PATH, next to
|
||||
`claude.exe`), and adding a marked block to
|
||||
`~/Documents/WindowsPowerShell/profile.ps1`.
|
||||
`~/.claude/settings.json` is **not** touched by the installer — only by an actual
|
||||
mode switch, which backs it up to `~/.claude-mode/backups/` first (last 20 kept).
|
||||
|
||||
Re-running any of them is a safe upgrade: scripts are overwritten, existing
|
||||
presets are kept unless `-Force` is passed.
|
||||
|
||||
If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||
|
||||
## Restarting sessions
|
||||
|
||||
Claude Code reads all of this **once at startup**. A switch does not affect a
|
||||
running session — that's how the process loads config, not something scripting
|
||||
can change.
|
||||
A switch **breaks** running sessions. This originally said it did not affect
|
||||
them; that was wrong, and the difference matters, because the two halves of the
|
||||
config behave differently.
|
||||
|
||||
The static half — base URL, model ids, the env block — genuinely is read once at
|
||||
startup, and a running session keeps what it started with.
|
||||
|
||||
The credential is not. It comes from running `apiKeyHelper`, which Claude Code
|
||||
re-invokes on a timer (`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`, present in 2.1.251),
|
||||
and the helper answers for whatever `state.json` says *at that moment*. So a
|
||||
switch reaches into a live session through the one thing that was never cached:
|
||||
|
||||
| switching to | what the running session gets |
|
||||
|---|---|
|
||||
| `anthropic` | the helper returns nothing, by design — no credential at all |
|
||||
| another provider | the new provider's key, sent to the old base URL, which rejects it |
|
||||
| another preset of the *same* provider, same `keyRef` | same key, same endpoint — this one survives, on the model ids it started with |
|
||||
|
||||
Either of the first two starts failing calls whenever the TTL happens to expire:
|
||||
mid-turn as easily as between turns.
|
||||
|
||||
### The part that is not just an inconvenience
|
||||
|
||||
A failed call is recoverable. A *successful* one may not be.
|
||||
|
||||
If a running session takes even one completion from the new provider before
|
||||
anything notices — which happens when the mode it is switched to matches the
|
||||
base URL it already had cached — that provider's message-id format lands in its
|
||||
transcript. OpenRouter issues `gen-<epoch>-<rand>` where Anthropic issues
|
||||
`msg_…`. Native Anthropic then refuses to resume the session at all:
|
||||
|
||||
```
|
||||
API Error: 400 diagnostics.previous_message_id: must be the `id` from a
|
||||
prior /v1/messages response (starts with `msg_`)
|
||||
```
|
||||
|
||||
There is no supported way back from that. The transcript has to be rolled back
|
||||
to the last message Anthropic issued:
|
||||
|
||||
```bash
|
||||
claude-mode repair-session # transcripts for this project
|
||||
claude-mode repair-session --all # every project, problems only
|
||||
claude-mode repair-session <session-id> # show what it would cut
|
||||
claude-mode repair-session <session-id> --apply
|
||||
```
|
||||
|
||||
Scope, since it is not obvious: a bare listing covers only the project you are
|
||||
standing in (walking up from the current directory to find it), while a **named
|
||||
session id is looked up across every project** — you rarely remember which
|
||||
project a session you cannot resume belonged to. `--all` drops the scoping
|
||||
entirely.
|
||||
|
||||
`--all` reports only what is actually actionable, which matters more than it
|
||||
sounds. Of 59 transcripts here it initially flagged 16; on inspection 5 had
|
||||
simply never received a reply, and 10 had run start-to-finish on a gateway, so
|
||||
every id in them is that provider's by design. Those resume perfectly well under
|
||||
the provider they were born on, have nothing to truncate back to, and are only a
|
||||
problem if you try to resume them as Anthropic. Neither is damage, so neither is
|
||||
listed. Only a transcript that has a genuine `msg_` message *and* junk after it
|
||||
is something this can or should touch.
|
||||
|
||||
### The cut turns are not thrown away
|
||||
|
||||
Truncating is the mechanical fix, but the turns being cut are the work itself —
|
||||
losing the conversation that produced a morning's changes is most of the damage,
|
||||
and a session that resumes with a hole in its memory is barely resumed at all.
|
||||
So `--apply` does three things before it deletes anything:
|
||||
|
||||
1. **Backs up** the original as `<session>.jsonl.pre-repair-backup-<stamp>`.
|
||||
2. **Writes the dropped turns out** as `<session>.recovered-<stamp>.md` — a
|
||||
readable record of what was asked, what was answered, and what was run. Tool
|
||||
*results* are left out; they are most of a transcript by volume and the least
|
||||
useful part of a summary.
|
||||
3. **Hands them back to the session** as a single appended note, so the agent
|
||||
that resumes knows what it just did.
|
||||
|
||||
That note is a `user` entry marked `isMeta` — the same marker Claude Code uses
|
||||
for its own local-command caveats, meaning "context, not something to answer".
|
||||
Critically it carries **no `message.id`**, so it cannot re-create the very
|
||||
condition being repaired. `--no-reinject` writes the Markdown but leaves the
|
||||
session untouched.
|
||||
|
||||
It refuses to touch a transcript written to in the last 90 seconds, since that
|
||||
one belongs to a session still alive.
|
||||
|
||||
### The bar notices for you
|
||||
|
||||
Nothing tells you a session is unresumable until you try to resume it, by which
|
||||
point you have usually forgotten which one it was. So the widget scans every
|
||||
project on a timer (and whenever the panel opens) and puts a dot on its icon
|
||||
when there is something to fix. Clicking through lists the affected sessions and
|
||||
offers to repair each one, after saying what it will drop and what it will keep.
|
||||
|
||||
The dot is a dot rather than a colour change, because recolouring the mark would
|
||||
misreport the active mode — which is the widget's actual job.
|
||||
|
||||
That scan is only affordable because it reads the *tail* of each transcript
|
||||
first: if the last message is Anthropic's, the transcript is healthy and the
|
||||
rest of the file is never opened. Since that is the overwhelmingly common case,
|
||||
the whole sweep costs ~60ms for 59 transcripts, against ~5s for the obvious
|
||||
version that reads every byte of every one.
|
||||
|
||||
This is why a switch now asks before it writes rather than reporting afterwards.
|
||||
So restart affected sessions — and `claude-mode sessions --restart` will do it
|
||||
for you:
|
||||
|
||||
- **CLI** — exit and relaunch `claude`
|
||||
- **VS Code** — `Ctrl+Shift+P` → *Developer: Reload Window*
|
||||
@@ -316,6 +456,297 @@ can change.
|
||||
`extraEnv` key (Z.AI's timeouts, LM Studio's attribution header) is removed when
|
||||
you switch away — even though no other preset knows that key exists.
|
||||
|
||||
## Linux / Omarchy
|
||||
|
||||
The `linux/` tree is a POSIX port of the same design: one write to
|
||||
`~/.claude/settings.json`, secrets kept out of it, presets shared verbatim with
|
||||
the Windows build.
|
||||
|
||||
```bash
|
||||
cd claude-code-switcher
|
||||
bash linux/install.sh # --skip-key-prompt to install without storing a key
|
||||
```
|
||||
|
||||
Installs to `~/.claude-mode/`, symlinks `~/.local/bin/claude-mode`, and adds the
|
||||
`claude` wrapper to `~/.bashrc` / `~/.zshrc` between markers.
|
||||
|
||||
Secrets have no DPAPI equivalent here, so the vault picks the best backend
|
||||
available and says which one it chose: macOS Keychain, libsecret, `pass`, or a
|
||||
0600 file that is honestly labelled as unencrypted.
|
||||
|
||||
### The menu follows your desktop theme
|
||||
|
||||
The sixteen ANSI colour slots carry no guarantee about relative brightness, and
|
||||
monochrome themes exploit that. Under Omarchy's Solitude, slot 36 — headings —
|
||||
resolves to `#707070` and slot 31 — `FAIL` — to `#565d60`. Against `#cacccc`
|
||||
body text on a `#101315` ground that is 3.8:1 and 2.8:1 where the body text is
|
||||
11.6:1, so headings render as fine print and an error becomes the quietest
|
||||
thing on screen. Exactly backwards.
|
||||
|
||||
So when Omarchy is present, the palette is derived from the theme it publishes
|
||||
at `~/.local/state/omarchy/current/theme/colors.toml` instead. Every role is
|
||||
measured against the background it will actually be drawn on and lifted toward
|
||||
the foreground when it falls short, which keeps hue where the theme has any and
|
||||
falls back to weight where it does not:
|
||||
|
||||
| role | before (Solitude) | after |
|
||||
|---|---|---|
|
||||
| heading | 3.8:1 | 9.4:1 |
|
||||
| `FAIL` | 2.8:1 | 5.2:1 |
|
||||
| help text | 2.2:1 | 2.2:1 (recessive on purpose, floored) |
|
||||
|
||||
Light themes are handled by the same arithmetic — `mode` in `colors.toml` is
|
||||
authoritative, so `catppuccin-latte` and `flexoki-light` keep their accents
|
||||
rather than washing out.
|
||||
|
||||
Overrides: `CLAUDE_MODE_THEME=/path/to/colors.toml` points it elsewhere,
|
||||
`NO_COLOR` turns it off. Without Omarchy, or on a terminal that cannot do
|
||||
truecolor, it falls back to the ANSI slots with the two roles the slots get
|
||||
wrong corrected — bright red for `FAIL`, bold on headings.
|
||||
|
||||
### `claude-mode health`
|
||||
|
||||
Rewrites `~/.claude-mode/health.json`, the machine-readable mirror of the
|
||||
active configuration: mode, preset, model map, context window, key backend, and
|
||||
the switchable preset catalogue. No key material. A switch and a `status` both
|
||||
refresh it; the command exists for anything that wants to force it.
|
||||
|
||||
## First-run setup
|
||||
|
||||
A shipped preset is a starting point, not a working configuration. OpenRouter
|
||||
and Z.AI have no key stored. LM Studio's model ids are whatever happened to be
|
||||
installed on the machine this was packaged on, which is almost certainly not
|
||||
yours.
|
||||
|
||||
So the shipped presets carry `configured: false`, and preflight treats that as a
|
||||
blocker with its own remedy:
|
||||
|
||||
```bash
|
||||
claude-mode setup openrouter # key, then models from OpenRouter's catalogue
|
||||
claude-mode setup zai # key, then per-tier GLM models
|
||||
claude-mode setup lmstudio # server URL, auth, then models from that server
|
||||
claude-mode setup anthropic # nothing to do; it uses your existing login
|
||||
```
|
||||
|
||||
Setup asks only what it cannot work out, shows the current model map before
|
||||
offering to change it, and picks from the provider's own catalogue rather than
|
||||
asking anyone to type a model id from memory. LM Studio maps one model across
|
||||
every tier, since a local server has one loaded at a time and per-tier mapping
|
||||
would just pay the load cost on every tier change; the remote gateways ask per
|
||||
tier, which is the point of them.
|
||||
|
||||
In a terminal, a switch that trips this offers to run setup there and then
|
||||
rather than printing a command to type next. It sets `configured: true` on the
|
||||
way out.
|
||||
|
||||
**Absent means configured.** Presets that predate this, and any built by hand
|
||||
with `preset new`, are nobody's business but yours and will not start demanding
|
||||
a wizard.
|
||||
|
||||
## Refusing a switch that would not work
|
||||
|
||||
A switch writes settings.json and is picked up by the *next* `claude` launch, so
|
||||
switching into a mode that cannot serve requests does not fail loudly — it
|
||||
succeeds, and every session started afterwards is broken in a way that points at
|
||||
Claude Code rather than at here.
|
||||
|
||||
LM Studio is the sharp case. Its token is an inline placeholder, so nothing
|
||||
about the switch needs the server to exist; point at a server that is not
|
||||
running and you get a config that looks perfectly healthy and answers nothing.
|
||||
|
||||
So the preconditions are checked before the write, not after:
|
||||
|
||||
| mode | checked | when it fails |
|
||||
|---|---|---|
|
||||
| any gateway | the preset has been through setup at least once | `claude-mode setup <mode>` |
|
||||
| `anthropic` | nothing to check | — |
|
||||
| `openrouter`, `zai` | a key exists for the preset's `keyRef`, and the helper is executable | `claude-mode set-key <ref>` |
|
||||
| `lmstudio` (anywhere) | the server answers, and accepts the credential the preset would send | see below |
|
||||
| any preset on a local base URL | same probe | start the server |
|
||||
| all | the preset exists and declares the provider being switched to | — |
|
||||
|
||||
The probe distinguishes four outcomes, because their remedies are opposites:
|
||||
|
||||
| result | means | remedy offered |
|
||||
|---|---|---|
|
||||
| `ok` | answered `/api/v0/models` or `/v1/models` | — |
|
||||
| `auth` | the server is up and refused the credential | store or fix the key, or turn auth on for the preset |
|
||||
| `notfound` | something is listening, but the API is not at that path | fix the base URL |
|
||||
| `refused` | nothing answered at all — down, asleep, DNS, TLS, timeout | start the server, or fix the address |
|
||||
|
||||
`claude-mode preflight <mode> [preset]` runs exactly these and prints the
|
||||
verdict as JSON without switching; it is what the bar widget calls before it
|
||||
offers to do anything. `--force` overrides the lot.
|
||||
|
||||
Remote *gateways* are not probed — OpenRouter or Z.AI being briefly unreachable
|
||||
is the network's problem and not worth blocking a config change over, where a
|
||||
missing key never fixes itself. LM Studio is probed wherever it lives, because
|
||||
an instance on a sleeping LAN box is exactly as absent as a loopback port with
|
||||
nothing behind it, and produces the identical silent breakage. Off-machine
|
||||
addresses get a longer timeout, not a pass.
|
||||
|
||||
### LM Studio somewhere other than this machine
|
||||
|
||||
It ships on loopback, but that is a default, not a constraint. A preset is just
|
||||
a base URL and an auth block, so all of these are the same two fields:
|
||||
|
||||
```bash
|
||||
claude-mode preset url lmstudio http://192.168.1.40:1234 # another box on the LAN
|
||||
claude-mode preset url lmstudio https://lms.example.net # through a tunnel or proxy
|
||||
claude-mode preset auth lmstudio key lmstudio # that server wants an API key
|
||||
claude-mode set-key lmstudio # store it (vault, not settings.json)
|
||||
claude-mode preset auth lmstudio none # back to the open-server default
|
||||
```
|
||||
|
||||
`auth none` writes LM Studio's inline placeholder token, which is not a secret
|
||||
and is what an unauthenticated server expects. `auth key` moves it to the vault
|
||||
like every other credential — a real key on a public address is a real key.
|
||||
Nothing here changes the shipped presets unless you ask it to; the local default
|
||||
stays exactly as it was.
|
||||
|
||||
## Sessions still on the old provider
|
||||
|
||||
A switch does not leave running sessions on the old provider — it breaks them.
|
||||
See [Restarting sessions](#restarting-sessions) for the mechanism: their
|
||||
endpoint is fixed at startup but their credential is re-fetched on a timer, so
|
||||
it switches under them and the endpoint they are still pointed at refuses it.
|
||||
Which is why this is a decision rather than a notification.
|
||||
|
||||
A switch stops and asks while any are running, offering to restart them (the
|
||||
only answer that ends with everything on the mode the bar now claims), close
|
||||
them, proceed anyway, or abort — and abort is the default. Non-interactively it
|
||||
refuses outright unless given `--yes`.
|
||||
|
||||
```
|
||||
claude-mode sessions
|
||||
running sessions (2)
|
||||
562250 pts/4 /home/smoido/Work working (this session - never touched)
|
||||
631644 pts/1 /home/smoido/Projects/api
|
||||
```
|
||||
|
||||
Sessions are found through `/proc/<pid>/exe`, which on Linux resolves to the
|
||||
real `claude` binary — a process-name match would sweep up every shell that
|
||||
merely mentions claude on its command line, including the one this is running
|
||||
from. Two things are then filtered out:
|
||||
|
||||
- **The calling session.** Killing the session that asked for the kill is not a
|
||||
thing anyone means, so it is listed and never signalled.
|
||||
- **Forks of a session.** A busy session spawns children off its own binary,
|
||||
and they inherit the same `exe`. Without excluding anything whose parent is
|
||||
itself claude, the count climbs and falls with how hard the machine is
|
||||
thinking — it read 2, 5, 11 and 40 on the same two sessions before this. A
|
||||
real session's parent is a terminal.
|
||||
|
||||
`working` is a sampled-CPU heuristic — two reads of `utime + stime` 300ms apart
|
||||
— so it is a good guess about which session is mid-turn, not a promise.
|
||||
|
||||
`--stop` sends SIGTERM (never SIGKILL; Claude Code writes out its transcript on
|
||||
the way down). `--restart` stops each session and reopens it — re-running the
|
||||
parent terminal's own command line where there is one, so the same terminal,
|
||||
flags and directory come back, and falling back to a fresh terminal in the
|
||||
session's directory otherwise. Interactive runs confirm first; `--yes` is for
|
||||
callers that have already asked, and `--dry-run` prints the plan and touches
|
||||
nothing.
|
||||
|
||||
## The Omarchy bar widget
|
||||
|
||||
An icon in the Omarchy top bar showing which provider the next `claude` launch
|
||||
will use, and a panel that switches it without a terminal.
|
||||
|
||||
```bash
|
||||
bash omarchy/install.sh
|
||||
```
|
||||
|
||||
Copies the plugin to `~/.config/omarchy/plugins/smoido.claude-mode/` and adds
|
||||
its id to the bar layout in `~/.config/omarchy/shell.json` (backed up first).
|
||||
Both hot-reload, so nothing needs restarting — except after a change to
|
||||
`Modes.js`, which the QML engine caches for the life of the process as a
|
||||
`.pragma library` (`omarchy restart shell`).
|
||||
|
||||
The icon is the mode, and it is the provider's own logo: the Claude burst, the
|
||||
OpenRouter arrow, the Z.AI Z, the LM Studio mark. They are drawn as vector paths
|
||||
with `QtQuick.Shapes` rather than set as font glyphs — three of the four have no
|
||||
Nerd Font pictograph at all, and being paths means they take the bar's
|
||||
foreground colour and follow the theme like everything else. Anything other than
|
||||
Anthropic takes the theme accent, so the bar stays quiet exactly when nothing
|
||||
unusual is configured.
|
||||
|
||||
Marks come from [simple-icons](https://simpleicons.org) (Claude, OpenRouter, LM
|
||||
Studio) and [lobe-icons](https://github.com/lobehub/lobe-icons) (Z.AI);
|
||||
trademarks belong to their owners. Each carries an optical scale factor, because
|
||||
equal nominal size is not equal apparent size — LM Studio's filled container
|
||||
covers 69% of its box against ~38% for the other three, and Z.AI and OpenRouter
|
||||
are wide-but-short marks whose ink spans only ~84% of the box height. The
|
||||
factors in `Modes.js` are the geometric mean of both corrections.
|
||||
|
||||
Three details keep them from looking ragged in a 13px slot, which is what every
|
||||
stock glyph in this bar measures:
|
||||
|
||||
- **No layer.** A layer rasterises the Shape at its own size and then scales the
|
||||
*texture*, so a 24px buffer minified to 13 resamples ~2 pixels into 1. Without
|
||||
one the scale is a transform on the geometry and rasterisation happens once,
|
||||
at final resolution.
|
||||
- **`Shape.CurveRenderer`** (Qt 6.6+) rasterises curves analytically instead of
|
||||
tessellating them into antialiased triangles. Measured against a cairo render
|
||||
of the same mark at the same size, the two now come out identical.
|
||||
- **An odd `iconSize`.** These marks are radially symmetric, so their vertical
|
||||
and horizontal arms sit on the centre line — which is a pixel *centre* at an
|
||||
odd size and the seam between two pixels at an even one, where each arm splits
|
||||
its coverage and greys out. Even values are rounded up.
|
||||
|
||||
- **Click** the icon for the panel: current mode, every mode with the active one
|
||||
ticked, and the model map behind a gateway. Picking a gateway unfolds its
|
||||
presets rather than switching blind; picking a preset starts the switch.
|
||||
|
||||
Choosing a target does not switch immediately — it runs the same two checks the
|
||||
CLI does, and both can stop it:
|
||||
|
||||
1. **Preflight.** If the mode has not been set up, has no key stored, or its
|
||||
server is not answering, the panel says which and offers the fix: *Set up
|
||||
<mode>…* opens a terminal and runs the whole first-run flow there (a bar popup
|
||||
can host neither a hidden key prompt nor a filter-select model list), *Store
|
||||
the key…* opens a terminal for just the prompt, *Check again* re-runs the
|
||||
preflight, and *Server settings…* opens the form below. The gear on any LM Studio preset row opens the same form without
|
||||
waiting for a failure — per row, because two LM Studio presets can point at
|
||||
two different machines.
|
||||
|
||||
The form holds the base URL, a *Use local default* reset, and a switch for
|
||||
whether that server needs an API key. Saving rewrites the preset and drops
|
||||
straight back into the switch that was blocked.
|
||||
2. **Running sessions.** If any are running, the panel lists them by terminal
|
||||
and directory, marks any that are mid-request, and asks: *Switch and restart*,
|
||||
*Switch and close*, *Switch only*, or *Cancel*. The session action is applied
|
||||
strictly **after** the write — restarting first would only bring them back up
|
||||
on the provider you just left.
|
||||
- **Right-click** switches straight back to Anthropic.
|
||||
- **Middle-click** re-reads state.
|
||||
- **Hover** for mode, preset, and the opus/sonnet mapping.
|
||||
|
||||
State comes from watching `health.json`, not from polling the CLI, so the
|
||||
widget costs nothing while idle and a switch made in a terminal shows up in the
|
||||
bar on its own. A failed switch — a preset whose key was never stored is the
|
||||
common one — surfaces the CLI's own error in the panel rather than looking
|
||||
like a click that did nothing.
|
||||
|
||||
Per-instance settings in the `shell.json` layout entry:
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `showLabel` | `false` | show the preset name beside the icon as well |
|
||||
| `iconSize` | `13` | mark size in px; rounded up to odd (see below) |
|
||||
| `root` | `~/.claude-mode` | where claude-mode is installed |
|
||||
|
||||
Placement is `right`, before `omarchy.agents`; override with `CM_BAR_SECTION`
|
||||
and `CM_BAR_BEFORE` when installing. Moving it later is a normal
|
||||
`omarchy bar move smoido.claude-mode --section <section>`.
|
||||
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/omarchy/plugins/smoido.claude-mode
|
||||
# then remove the {"id": "smoido.claude-mode"} entry from ~/.config/omarchy/shell.json
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```powershell
|
||||
|
||||
+2
-2
@@ -1251,7 +1251,7 @@ function Invoke-Models {
|
||||
|
||||
if ($mode -eq 'zai') {
|
||||
Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
|
||||
@('glm-5.2 - flagship coding model (opus/sonnet tier)',
|
||||
@('glm-5.3 - flagship coding model (opus/sonnet tier)',
|
||||
'glm-4.7 - fast/cheap tier (haiku tier)') | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ' Full list: https://docs.z.ai/devpack/tool/claude'
|
||||
return
|
||||
@@ -1856,7 +1856,7 @@ function Get-ModelChoices {
|
||||
elseif ($provider -eq 'zai') {
|
||||
# Z.AI publishes no public catalogue endpoint; these are the documented
|
||||
# coding-plan models. Anything else can still be typed manually.
|
||||
$out += [pscustomobject]@{ Id = 'glm-5.2'; Key = 'glm-5.2'; Label = 'glm-5.2'; Detail = @('flagship coding model - opus/sonnet tier') }
|
||||
$out += [pscustomobject]@{ Id = 'glm-5.3'; Key = 'glm-5.3'; Label = 'glm-5.3'; Detail = @('flagship coding model - opus/sonnet tier') }
|
||||
$out += [pscustomobject]@{ Id = 'glm-4.7'; Key = 'glm-4.7'; Label = 'glm-4.7'; Detail = @('fast/cheap tier - haiku') }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Incident: switching modes mid-session corrupts and can permanently break that session
|
||||
|
||||
**Date:** 2026-08-30/31
|
||||
**Affected session:** `1fffec9e-e2e3-4255-828c-a9ccfd8f631d` (project `/home/smoido/Work`)
|
||||
**Severity:** High — silent, mid-task corruption with no warning, and one failure path is not recoverable by normal means (requires manual transcript surgery).
|
||||
|
||||
## Summary
|
||||
|
||||
The user had a live, running Claude Code session open in `~/Work`. While that
|
||||
session was mid-task, `claude-mode` was used to switch the active provider
|
||||
away from `anthropic` and later back to `anthropic`. The running session was
|
||||
not restarted in between. This corrupted the session's credentials, and — because
|
||||
the session had picked up a different provider's message-ID format in the
|
||||
meantime — it became permanently unable to resume under native Anthropic auth,
|
||||
throwing a hard API error on every resume attempt.
|
||||
|
||||
This must not be possible to trigger silently. Whatever ships next needs to
|
||||
either prevent it, warn loudly before it happens, or make recovery automatic.
|
||||
|
||||
## Root cause / mechanism
|
||||
|
||||
`claude-mode` writes provider config into `~/.claude/settings.json`
|
||||
(`env` block: base URL, model IDs, etc., plus `apiKeyHelper` pointing at
|
||||
`claude-key-helper.sh`). A **running** `claude` process only reads part of
|
||||
this at startup:
|
||||
|
||||
- **Static, cached at startup:** `ANTHROPIC_BASE_URL`, the model ID env vars,
|
||||
the rest of the `env` block. A running session keeps whatever it started
|
||||
with here — switching modes does *not* change these for an
|
||||
already-running process.
|
||||
- **Not cached — re-fetched on a timer:** the credential. Claude Code
|
||||
re-invokes `apiKeyHelper` periodically (`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`
|
||||
is present in the 2.1.251 binary), and `claude-key-helper.sh` answers based
|
||||
on whatever `~/.claude-mode/state.json` says *at the moment it's called* —
|
||||
not at session startup.
|
||||
|
||||
So a mode switch reaches into a live session through the one part that was
|
||||
never cached:
|
||||
|
||||
| switching the global mode to... | what the *running* session gets on next credential refresh |
|
||||
|---|---|
|
||||
| `anthropic` | the helper returns nothing (native anthropic auth is expected to need no helper) → session has no credential at all |
|
||||
| any other provider/preset | the new key, but the session is still pointed at the **old** base URL (cached at startup) → that endpoint rejects the new key |
|
||||
|
||||
Either way, the session starts failing API calls from the moment the TTL
|
||||
next expires — mid-turn as easily as between turns. The one case that does
|
||||
*not* break: switching between two presets of the *same* provider that share
|
||||
a `keyRef` (same key, same endpoint survives).
|
||||
|
||||
`claude-mode status` and `claude-mode health` only ever report the *global*
|
||||
config's current state. Neither one has any way to know a specific already-running
|
||||
session exists, let alone that it's about to be (or has been) knocked over.
|
||||
|
||||
## What happened to this specific session (concrete failure chain)
|
||||
|
||||
1. Session `1fffec9e` was live in `~/Work`, working on `claude-code-switcher`
|
||||
itself.
|
||||
2. User ran a switch to the `openrouter` "default" preset (via `claude-mode`)
|
||||
to test something, while that session kept running.
|
||||
3. The running session's next few turns actually succeeded against
|
||||
openrouter (its cached base URL now matched, since the switch happened to
|
||||
land on the provider its env pointed at) — those turns got real
|
||||
completions back, but with **openrouter's message-ID format**
|
||||
(`gen-<timestamp>-<id>`), not Anthropic's (`msg_<id>`).
|
||||
4. A later call failed outright and Claude Code recorded a synthetic
|
||||
client-side placeholder turn (`"model":"<synthetic>"`,
|
||||
`"isApiErrorMessage":true`, `"error":"unknown"`) as the last message in
|
||||
the transcript — this is the visible "session disconnected mid-task"
|
||||
symptom.
|
||||
5. User switched the global mode back to `anthropic`.
|
||||
6. Resuming session `1fffec9e` (`claude -r 1fffec9e-... -p "..."`) now fails
|
||||
unconditionally with:
|
||||
```
|
||||
API Error: 400 diagnostics.previous_message_id: must be the `id` from a
|
||||
prior /v1/messages response (starts with `msg_`)
|
||||
```
|
||||
because native Anthropic's API requires `previous_message_id` to be a
|
||||
real Anthropic-issued ID, and the last message(s) in this session's
|
||||
transcript are not (`gen-...` or the synthetic placeholder ID).
|
||||
7. **There is no supported way to resume past this.** The only fix found was
|
||||
manual surgery on the session's `.jsonl` transcript file: locate the last
|
||||
message that still has a genuine `msg_...` id (in this case, several
|
||||
turns earlier, at a clean `end_turn` boundary well before the switch was
|
||||
even tested), back up the original file, and truncate the transcript to
|
||||
that point. That rolls the session back to its last-known-good state and
|
||||
makes it resumable again — at the cost of permanently losing every turn
|
||||
after that point (the actual code changes from that later work were
|
||||
separately safe in git, but the chat narrative was not recoverable).
|
||||
|
||||
## Impact
|
||||
|
||||
- Silent corruption: nothing warns the user before or during the switch that
|
||||
a live session exists and is about to break.
|
||||
- Depending on timing, the break can be "just" an auth failure (annoying,
|
||||
session still resumable once you're back on the mode it started with) **or**
|
||||
a hard, unrecoverable-by-normal-means failure (if the session round-tripped
|
||||
through a different provider's ID format before failing) that requires
|
||||
hand-editing a JSONL transcript to fix.
|
||||
- This applies to *every* running `claude` process on the machine at switch
|
||||
time, not just the one in the foreground shell — CLI, VS Code, desktop, any
|
||||
of them.
|
||||
|
||||
## Requirements for the fix
|
||||
|
||||
1. **Detect running sessions before switching.** `claude-mode` should scan
|
||||
for live `claude` processes (and ideally which project/cwd each belongs
|
||||
to) before performing a switch.
|
||||
2. **Warn or block, don't silently proceed.** At minimum, print a clear
|
||||
warning naming the affected session(s)/PIDs/cwds and what will happen to
|
||||
them (credential will be pulled out from under them on the next TTL
|
||||
refresh). Consider requiring `--force` (or an explicit confirmation) to
|
||||
proceed while sessions are running, and defaulting to "abort" otherwise.
|
||||
3. **Prefer a safe path when sessions are detected:** e.g. offer to let the
|
||||
user gracefully end/save those sessions first, or clearly instruct them
|
||||
to restart affected sessions immediately after the switch completes.
|
||||
4. **Document the real mechanism** (this file's "Root cause" section) in the
|
||||
tool's own help/README, replacing any prior claim that a switch "does not
|
||||
affect a running session" or that sessions "keep talking to the old
|
||||
provider until restarted" — both are wrong; the credential moves under
|
||||
them regardless.
|
||||
5. **Make the unrecoverable failure mode recoverable.** Add a `claude-mode
|
||||
repair` (or similar) command that:
|
||||
- finds a given session's transcript,
|
||||
- locates the last message with a valid `msg_...` id,
|
||||
- backs up the original file,
|
||||
- truncates to that point,
|
||||
so this doesn't require manual `jq`/`head`/`grep` surgery next time.
|
||||
This is the exact procedure used to fix session `1fffec9e` above.
|
||||
|
||||
## Repro steps (for verification once fixed)
|
||||
|
||||
1. Start a `claude` session in some project directory, mid-task.
|
||||
2. In a separate shell, run `claude-mode <some-other-provider>` (something
|
||||
with a different base URL/key format from the session's current mode).
|
||||
3. Let the running session's next API-key TTL refresh happen (or just issue
|
||||
another prompt in it) — observe the call fail.
|
||||
4. Switch back: `claude-mode anthropic`.
|
||||
5. Try `claude -r <that-session-id> -p "hi"` — currently fails with the
|
||||
`previous_message_id` 400 error if the session ever got a non-`msg_`
|
||||
completion in between. The fix should make step 2 impossible (or clearly
|
||||
confirmed) rather than needing step 5's failure to be caught after the
|
||||
fact.
|
||||
+27
-1
@@ -18,7 +18,33 @@ param(
|
||||
Set-StrictMode -Version 1.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$src = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoUrl = 'https://git.nebulm.com/smoido/claude-mode'
|
||||
|
||||
# Running from a checkout, the source is the files next to this script. Piped
|
||||
# in (irm ... | iex) there is no script file and no checkout, so fetch the
|
||||
# repository archive and install from that instead.
|
||||
$src = $null
|
||||
if ($PSScriptRoot -and (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'claude-mode.ps1'))) {
|
||||
$src = $PSScriptRoot
|
||||
}
|
||||
else {
|
||||
[Net.ServicePointManager]::SecurityProtocol =
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$tmp = Join-Path $env:TEMP ('claude-mode-src-' + [IO.Path]::GetRandomFileName().Replace('.', ''))
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
$zip = Join-Path $tmp 'repo.zip'
|
||||
Write-Host "no local checkout found - fetching source from $repoUrl" -ForegroundColor Cyan
|
||||
Invoke-WebRequest -Uri "$repoUrl/archive/master.zip" -OutFile $zip -UseBasicParsing
|
||||
Expand-Archive -LiteralPath $zip -DestinationPath $tmp
|
||||
# Forgejo nests everything under a repo-named folder; find it wherever it is.
|
||||
$extracted = Get-ChildItem -LiteralPath $tmp -Directory |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'claude-mode.ps1') } |
|
||||
Select-Object -First 1
|
||||
if ($extracted) { $src = $extracted.FullName }
|
||||
elseif (Test-Path -LiteralPath (Join-Path $tmp 'claude-mode.ps1')) { $src = $tmp }
|
||||
else { throw 'archive did not contain claude-mode.ps1 - repo layout changed?' }
|
||||
}
|
||||
|
||||
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
||||
|
||||
Write-Host "installing claude-mode -> $root" -ForegroundColor Cyan
|
||||
|
||||
+568
-38
@@ -274,10 +274,15 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
|
||||
claude-mode doctor verify auth, endpoint, model ids, env
|
||||
claude-mode repair strip [1m] tags from cached model ids
|
||||
claude-mode health refresh health.json (machine-readable state)
|
||||
claude-mode setup <mode> [--terminal] first-run setup: key, server, models
|
||||
claude-mode preflight <mode> [preset] check a mode can actually serve, without switching
|
||||
claude-mode sessions [--stop|--restart]
|
||||
running sessions; close or reopen them
|
||||
claude-mode repair-session [id] [--apply]
|
||||
make a session resumable again after a bad switch,
|
||||
keeping the cut turns as markdown + a context note
|
||||
claude-mode <mode> --force switch even if preflight says no
|
||||
claude-mode <mode> --yes switch without asking about running sessions
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -453,6 +458,13 @@ cm_preflight() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! preset_configured "$pf"; then
|
||||
cm_pf_set 'needs-setup' "$(mode_label "$mode" | cut -d- -f1 | sed 's/ *$//') has not been set up yet" \
|
||||
"The shipped preset is a starting point: it has no key stored, and its model ids are whatever was on the machine this was packaged on. Setup asks for what it needs and picks models from the provider's own catalogue." \
|
||||
"claude-mode setup $mode" 'setup'
|
||||
return 1
|
||||
fi
|
||||
|
||||
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
||||
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||
|
||||
@@ -539,14 +551,31 @@ cmd_preflight() {
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running sessions
|
||||
#
|
||||
# Claude Code reads settings.json once, at startup. A switch therefore does
|
||||
# nothing to a session already running - it keeps talking to the old provider on
|
||||
# the old key until it is restarted, which is the confusing part: the bar says
|
||||
# one thing and the session in front of you is doing another.
|
||||
# A switch breaks running sessions. Not "leaves them on the old provider" -
|
||||
# breaks them, and it is worth being exact about why, because the two halves of
|
||||
# the config behave differently.
|
||||
#
|
||||
# Worse for a session mid-request. The key it is using can be pulled out from
|
||||
# under it (anthropic mode deletes the helper outright), so an in-flight turn
|
||||
# can fail on the next tool call rather than at a clean boundary.
|
||||
# The static half - base URL, model ids, the env block - really is read once at
|
||||
# startup, and a running session keeps the values it started with.
|
||||
#
|
||||
# The credential is not. It is fetched by running apiKeyHelper, which Claude
|
||||
# Code re-invokes on a timer (CLAUDE_CODE_API_KEY_HELPER_TTL_MS, present in
|
||||
# 2.1.251), and the helper answers for whatever state.json says *now*. So a
|
||||
# switch reaches into a live session through the one thing that was never
|
||||
# cached:
|
||||
#
|
||||
# -> anthropic the helper returns nothing at all, by design, and the
|
||||
# session's next refresh comes back with no credential
|
||||
# -> another provider
|
||||
# the helper hands over the new provider's key while the
|
||||
# session is still pointed at the old base URL, which rejects
|
||||
# it
|
||||
#
|
||||
# Either way the session starts failing its calls, at whatever moment the TTL
|
||||
# happens to expire - mid-turn as easily as between turns. The one case that
|
||||
# does survive is a switch between two presets of the same provider sharing a
|
||||
# keyRef: same key, same endpoint, and the session simply carries on with the
|
||||
# model ids it started with.
|
||||
#
|
||||
# Sessions are found through /proc/<pid>/exe rather than by matching process
|
||||
# names. `claude` is a real ELF binary here, so the symlink resolves to it
|
||||
@@ -690,7 +719,7 @@ $rows
|
||||
EOF_ROWS
|
||||
|
||||
if [ "$action" = "list" ]; then
|
||||
printf '\n %sthese keep the provider they started with until restarted%s\n' "$C_DIM" "$C_RESET"
|
||||
printf '\n %stheir credential is re-fetched on a timer, so a switch breaks them%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
|
||||
@@ -793,24 +822,6 @@ EOF_ROWS
|
||||
return 0
|
||||
}
|
||||
|
||||
# Named after the switch, not before it: the switch has already happened, and
|
||||
# these are the sessions it did not reach.
|
||||
cm_report_live_sessions() {
|
||||
local rows n busy
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
[ "${n:-0}" -gt 0 ] || return 0
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
printf '\n'
|
||||
warn "$n Claude Code session(s) still running on the previous provider"
|
||||
if [ "${busy:-0}" -gt 0 ]; then
|
||||
warn "$busy of them is mid-request - it may fail on its next call rather than at a clean stop"
|
||||
fi
|
||||
printf ' %sclaude-mode sessions what is running%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --restart close and reopen them on the new provider%s\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
cm_terminal_cmd() {
|
||||
local t
|
||||
for t in "${TERMINAL:-}" foot alacritty ghostty kitty; do
|
||||
@@ -849,13 +860,30 @@ set_mode() {
|
||||
if [ "$CM_FORCE" -eq 0 ] && ! cm_preflight "$mode" "$preset_name"; then
|
||||
err "$CM_PF_TITLE"
|
||||
[ -n "$CM_PF_DETAIL" ] && say "$CM_PF_DETAIL"
|
||||
[ -n "$CM_PF_REMEDY" ] && printf ' %sfix:%s %s\n' "$C_DIM" "$C_RESET" "$CM_PF_REMEDY"
|
||||
printf ' %s--force switches anyway%s\n' "$C_DIM" "$C_RESET"
|
||||
return 1
|
||||
|
||||
# Standing in a terminal with the fix one keystroke away, printing
|
||||
# the command to type next is a poor substitute for running it.
|
||||
if ui_interactive && [ "$CM_PF_KIND" = "setup" ]; then
|
||||
printf '\n'
|
||||
if ask_yes "set up $mode now?"; then
|
||||
cmd_setup "$mode" "$preset_name" || return 1
|
||||
cm_preflight "$mode" "$preset_name" || {
|
||||
err "$CM_PF_TITLE"; return 1
|
||||
}
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
[ -n "$CM_PF_REMEDY" ] && printf ' %sfix:%s %s\n' "$C_DIM" "$C_RESET" "$CM_PF_REMEDY"
|
||||
printf ' %s--force switches anyway%s\n' "$C_DIM" "$C_RESET"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
[ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
|
||||
fi
|
||||
|
||||
cm_confirm_sessions "$mode" || return 1
|
||||
|
||||
backup="$(backup_settings)"
|
||||
|
||||
if ! "$PY" "$JSON" apply "$CM_SETTINGS" "$CM_STATE" "$mode" "$preset_file" "$CM_HELPER" >/dev/null; then
|
||||
@@ -903,7 +931,7 @@ set_mode() {
|
||||
check_stale_models "$mode"
|
||||
write_health "$mode" "$preset_name"
|
||||
|
||||
cm_report_live_sessions
|
||||
cm_apply_session_action
|
||||
|
||||
printf '\n %srestart claude (and reload the VS Code window) to pick this up%s\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
@@ -1088,9 +1116,26 @@ or_catalogue() {
|
||||
curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null
|
||||
}
|
||||
|
||||
# The credential a preset would send. Empty when there is nothing to send.
|
||||
cm_preset_token() {
|
||||
local pf="$1" am ref
|
||||
am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||
if [ "$am" = "vault" ]; then
|
||||
ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||
cm_vault_get "$ref" 2>/dev/null || true
|
||||
else
|
||||
jget "$pf" auth.token
|
||||
fi
|
||||
}
|
||||
|
||||
# The token is not optional decoration. An LM Studio server with authentication
|
||||
# switched on answers /api/v0/models with 401 like anything else, so without it
|
||||
# the catalogue comes back empty and every caller silently believes the server
|
||||
# has no models installed - on exactly the setups that need the list most.
|
||||
lms_catalogue() {
|
||||
local base="${1%/}"
|
||||
curl -fsS --max-time 10 "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null
|
||||
local base="${1%/}" token="${2:-}"
|
||||
curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
||||
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1162,14 +1207,14 @@ cmd_models() {
|
||||
case "$mode" in
|
||||
lmstudio)
|
||||
head_ "models installed in LM Studio at $(jget "$pf" baseUrl)"
|
||||
lms_catalogue "$(jget "$pf" baseUrl)" | while IFS=$'\t' read -r id st ctx; do
|
||||
lms_catalogue "$(jget "$pf" baseUrl)" "$(cm_preset_token "$pf")" | while IFS=$'\t' read -r id st ctx; do
|
||||
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
|
||||
printf ' %-58s %-11s %s\n' "$id" "$st" "$ctx"
|
||||
done
|
||||
;;
|
||||
zai)
|
||||
head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
|
||||
say 'glm-5.2 - flagship coding model (opus/sonnet tier)'
|
||||
say 'glm-5.3 - flagship coding model (opus/sonnet tier)'
|
||||
say 'glm-4.7 - fast/cheap tier (haiku tier)'
|
||||
;;
|
||||
*)
|
||||
@@ -1276,7 +1321,7 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
|
||||
fi
|
||||
|
||||
if [ "$mode" = "lmstudio" ]; then
|
||||
local cat; cat="$(lms_catalogue "$base")"
|
||||
local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"
|
||||
if [ -n "$cat" ]; then
|
||||
ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)"
|
||||
local t id row st ctx
|
||||
@@ -1617,10 +1662,10 @@ ui_pick_model() {
|
||||
while IFS=$'\t' read -r id st ctx; do
|
||||
[ -n "$id" ] || continue
|
||||
ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx"
|
||||
done < <(lms_catalogue "$base")
|
||||
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
|
||||
;;
|
||||
zai)
|
||||
ids+=('glm-5.2'); ui_add_item 'glm-5.2' 'flagship coding model - opus/sonnet tier'
|
||||
ids+=('glm-5.3'); ui_add_item 'glm-5.3' 'flagship coding model - opus/sonnet tier'
|
||||
ids+=('glm-4.7'); ui_add_item 'glm-4.7' 'fast/cheap tier - haiku'
|
||||
;;
|
||||
esac
|
||||
@@ -1764,6 +1809,468 @@ ui_menu() {
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First-run setup
|
||||
#
|
||||
# A shipped preset is a starting point, not a working configuration. OpenRouter
|
||||
# needs a key before it can serve anything; LM Studio needs to be told where the
|
||||
# server is and which of the models it actually has installed to use - and the
|
||||
# ids it ships with are whatever happened to be on the machine this was written
|
||||
# on, which is almost certainly not yours.
|
||||
#
|
||||
# So a preset says whether it has been through setup. `configured: false` is
|
||||
# written into the shipped presets and cleared once setup has run, and preflight
|
||||
# treats it as a blocker: better to be walked through it once than to switch
|
||||
# into something that half-works and produces a confusing failure later.
|
||||
#
|
||||
# Absent means configured. That is deliberate - presets that predate this, and
|
||||
# ones the user built by hand with `preset new`, are their own business and must
|
||||
# not suddenly start demanding a wizard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
preset_configured() {
|
||||
local v; v="$(jget "$1" configured)"
|
||||
[ "$v" = "false" ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
mark_configured() {
|
||||
"$PY" "$JSON" set-flag "$1" configured true >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A y/N prompt that defaults to no on anything that is not a clear yes.
|
||||
ask_yes() {
|
||||
local prompt="$1" reply
|
||||
printf ' %s [y/N] ' "$prompt"
|
||||
IFS= read -r reply || return 1
|
||||
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# Prompt with a default shown in brackets; empty input keeps the default.
|
||||
#
|
||||
# The prompt goes to stderr for the same reason warn/err do: this is called
|
||||
# inside $( ), where anything on stdout is captured as the return value. Printed
|
||||
# to stdout it came back as part of the answer - " server base URL [...]: " with
|
||||
# the typed URL glued on the end, which set-url then rejected.
|
||||
ask_value() {
|
||||
local prompt="$1" default="$2" reply
|
||||
if [ -n "$default" ]; then printf ' %s [%s]: ' "$prompt" "$default" >&2
|
||||
else printf ' %s: ' "$prompt" >&2; fi
|
||||
IFS= read -r reply || return 1
|
||||
[ -n "$reply" ] && printf '%s' "$reply" || printf '%s' "$default"
|
||||
}
|
||||
|
||||
setup_key() {
|
||||
local ref="$1" label="$2"
|
||||
if cm_vault_has "$ref"; then
|
||||
ok "a key is already stored for '$ref' ($(cm_vault_backend_label))"
|
||||
ask_yes "replace it?" || return 0
|
||||
else
|
||||
say "$label needs an API key. It goes into $(cm_vault_backend_label),"
|
||||
say 'not into settings.json.'
|
||||
fi
|
||||
cmd_set_key "$ref"
|
||||
}
|
||||
|
||||
# Offer the provider's own catalogue rather than asking someone to type a model
|
||||
# id from memory. Falls back to typing when the catalogue cannot be reached,
|
||||
# because being offline should not block finishing setup.
|
||||
setup_models() {
|
||||
local pf="$1" provider="$2" name="$3"
|
||||
printf '\n'
|
||||
say 'current model map:'
|
||||
local t v
|
||||
for t in "${TIERS[@]}"; do
|
||||
v="$(jget "$pf" "models.$t")"
|
||||
[ -n "$v" ] && printf ' %-8s %s\n' "$t" "$v"
|
||||
done
|
||||
printf '\n'
|
||||
ask_yes 'change which models back these tiers?' || return 0
|
||||
|
||||
if ! ui_interactive; then
|
||||
warn 'model picking needs an interactive terminal'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$provider" = "lmstudio" ]; then
|
||||
# One model for every tier is the normal shape for a local server: it
|
||||
# has one loaded at a time, and mapping tiers to different models just
|
||||
# means paying the load cost on every tier change.
|
||||
local base ids=() id st ctx
|
||||
base="$(jget "$pf" baseUrl)"
|
||||
while IFS=$'\t' read -r id st ctx; do
|
||||
[ -n "$id" ] || continue
|
||||
ids+=("$id")
|
||||
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
|
||||
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
warn 'the server returned no models; type an id by hand instead'
|
||||
local manual; manual="$(ask_value 'model id for every tier' "$(jget "$pf" models.opus)")"
|
||||
[ -n "$manual" ] && "$PY" "$JSON" set-all "$pf" "$manual" >/dev/null && ok "all tiers -> $manual"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ui_reset_items
|
||||
for id in "${ids[@]}"; do ui_add_item "$id" 'use this for every tier'; done
|
||||
if ui_filter_select "model for all tiers of '$name'" 'esc = keep current'; then
|
||||
"$PY" "$JSON" set-all "$pf" "${ids[$UI_SEL]}" >/dev/null
|
||||
ok "all tiers -> ${ids[$UI_SEL]}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remote gateways map a different model per tier, which is the whole point
|
||||
# of them, so each tier is asked for separately.
|
||||
local cur
|
||||
for t in "${TIERS[@]}"; do
|
||||
cur="$(jget "$pf" "models.$t")"
|
||||
ui_pick_model "$pf" "$t" "$cur" || continue
|
||||
[ -n "$UI_PICKED" ] || continue
|
||||
"$PY" "$JSON" set-tier "$pf" "$t" "$UI_PICKED" >/dev/null && ok "$t -> $UI_PICKED"
|
||||
done
|
||||
}
|
||||
|
||||
setup_lmstudio_server() {
|
||||
local pf="$1" name="$2" url probe token
|
||||
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234'
|
||||
|
||||
printf '\n'
|
||||
say 'LM Studio does not have to be on this machine - a LAN address or'
|
||||
say 'anything reachable through a tunnel or proxy works just as well.'
|
||||
url="$(ask_value 'server base URL' "$url")"
|
||||
"$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
|
||||
ok "baseUrl -> $url"
|
||||
|
||||
printf '\n'
|
||||
if ask_yes 'does that server require an API key?'; then
|
||||
local ref; ref="$(ask_value 'key name to store it under' 'lmstudio')"
|
||||
"$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
|
||||
ok "auth -> vault key '$ref'"
|
||||
cm_vault_has "$ref" || cmd_set_key "$ref"
|
||||
token="$(cm_vault_get "$ref" 2>/dev/null || true)"
|
||||
else
|
||||
"$PY" "$JSON" set-auth "$pf" none >/dev/null
|
||||
ok 'auth -> none (inline placeholder token)'
|
||||
token='lmstudio'
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
say "checking $url ..."
|
||||
probe="$(cm_probe_server "$url" "$token")"
|
||||
case "$probe" in
|
||||
ok) ok 'server answered' ;;
|
||||
auth) err 'the server refused that credential'; return 1 ;;
|
||||
notfound) err 'something answered there, but not an LM Studio API' ; return 1 ;;
|
||||
skip) warn 'curl is missing, so the server was not checked' ;;
|
||||
*) err 'nothing answered at that address'
|
||||
say 'start the server and run: claude-mode setup lmstudio'
|
||||
return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
local mode="${1:-}" name pf
|
||||
|
||||
case "$mode" in
|
||||
anthropic)
|
||||
head_ 'setup: anthropic'
|
||||
ok 'nothing to configure - it uses your existing Claude login'
|
||||
return 0 ;;
|
||||
openrouter|zai|lmstudio) ;;
|
||||
z.ai|z-ai) mode=zai ;;
|
||||
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
|
||||
*) err "unknown mode '$mode'"; return 1 ;;
|
||||
esac
|
||||
|
||||
name="$(resolve_preset "$mode" "${2:-}")" || return 1
|
||||
pf="$(preset_path "$name")"
|
||||
|
||||
if ! ui_interactive; then
|
||||
err 'setup needs an interactive terminal'
|
||||
say "run: claude-mode setup $mode"
|
||||
return 1
|
||||
fi
|
||||
|
||||
head_ "setup: $mode / preset '$name'"
|
||||
say "$(mode_label "$mode")"
|
||||
|
||||
case "$mode" in
|
||||
openrouter)
|
||||
printf '\n'
|
||||
setup_key openrouter 'OpenRouter'
|
||||
setup_models "$pf" openrouter "$name"
|
||||
;;
|
||||
zai)
|
||||
printf '\n'
|
||||
say 'get a key from https://z.ai/manage-apikey/apikey-list'
|
||||
setup_key zai 'Z.AI'
|
||||
setup_models "$pf" zai "$name"
|
||||
;;
|
||||
lmstudio)
|
||||
setup_lmstudio_server "$pf" "$name" || return 1
|
||||
setup_models "$pf" lmstudio "$name"
|
||||
;;
|
||||
esac
|
||||
|
||||
mark_configured "$pf"
|
||||
printf '\n'
|
||||
ok "$mode is set up"
|
||||
say "switch to it with: claude-mode $mode"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live sessions: asked before the write, not reported after it
|
||||
#
|
||||
# The damage a switch does to a running session is not limited to it failing
|
||||
# calls. If the session takes even one completion from the new provider before
|
||||
# anything notices, that provider's message-id format lands in its transcript -
|
||||
# OpenRouter issues `gen-<epoch>-<rand>` where Anthropic issues `msg_...` - and
|
||||
# native Anthropic then refuses to resume the session at all:
|
||||
#
|
||||
# API Error: 400 diagnostics.previous_message_id: must be the `id` from a
|
||||
# prior /v1/messages response (starts with `msg_`)
|
||||
#
|
||||
# There is no supported way back from that. The only fix is to truncate the
|
||||
# transcript to the last message Anthropic issued, losing everything after it
|
||||
# (see `claude-mode repair-session`). A confirmation that costs one keystroke is
|
||||
# cheap against a failure that costs an afternoon of conversation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CM_ASSUME_YES=0
|
||||
CM_SESSION_ACTION=none
|
||||
CM_SESSION_ROWS=''
|
||||
|
||||
cm_confirm_sessions() {
|
||||
local mode="$1" rows n busy reply
|
||||
CM_SESSION_ACTION=none
|
||||
CM_SESSION_ROWS=''
|
||||
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
[ "${n:-0}" -gt 0 ] || return 0
|
||||
CM_SESSION_ROWS="$rows"
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
|
||||
printf '\n'
|
||||
warn "$n Claude Code session(s) are running right now"
|
||||
local pid ppid tty b cwd isself pcmd tag
|
||||
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
tag=''
|
||||
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
|
||||
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this one)${C_RESET}"
|
||||
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
printf '\n'
|
||||
say 'Their key is re-fetched on a timer and will resolve to the new mode,'
|
||||
say 'which the endpoint they are still pointed at will not accept. If one'
|
||||
say 'of them takes a reply from the new provider first, that provider'"'"'s'
|
||||
say 'message-id format goes into its transcript and Anthropic will then'
|
||||
say 'refuse to resume that session at all - recoverable only by truncating'
|
||||
say 'it (claude-mode repair-session), which loses the turns after the cut.'
|
||||
[ "${busy:-0}" -gt 0 ] && warn "$busy of them is mid-request and is the most likely to be caught"
|
||||
|
||||
if [ "$CM_ASSUME_YES" -eq 1 ]; then
|
||||
say 'proceeding (--yes)'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ui_interactive; then
|
||||
printf '\n'
|
||||
err 'refusing to switch while sessions are running'
|
||||
say 'restart or close them first, or pass --yes to switch anyway'
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
say 'r switch, then close and reopen them on the new mode (safest)'
|
||||
say 'c switch, then close them'
|
||||
say 's switch and leave them running (risks the above)'
|
||||
say 'a abort'
|
||||
printf '\n [r/c/s/A] '
|
||||
IFS= read -r reply
|
||||
case "$reply" in
|
||||
r|R) CM_SESSION_ACTION=restart; return 0 ;;
|
||||
c|C) CM_SESSION_ACTION=stop; return 0 ;;
|
||||
s|S) CM_SESSION_ACTION=none; return 0 ;;
|
||||
*) say 'aborted; nothing was changed'; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run after the write, never before: a session reopened first would come back up
|
||||
# on the mode being left behind.
|
||||
cm_apply_session_action() {
|
||||
[ "$CM_SESSION_ACTION" = "none" ] && return 0
|
||||
[ -n "$CM_SESSION_ROWS" ] || return 0
|
||||
printf '\n'
|
||||
cm_session_act "$CM_SESSION_ACTION" "$CM_SESSION_ROWS" 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript repair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ~/.claude/projects/<cwd with every slash turned into a dash>
|
||||
cm_project_dir() {
|
||||
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$(printf '%s' "${1:-$PWD}" | sed 's|/|-|g')"
|
||||
}
|
||||
|
||||
cmd_repair_session() {
|
||||
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--apply) apply=1 ;;
|
||||
--dry-run) apply=0 ;;
|
||||
--no-reinject) reinject=0 ;;
|
||||
--list) target='--list' ;;
|
||||
--all) scan_all=1 ;;
|
||||
--json) as_json=1; scan_all=1 ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) target="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A session you need to repair is one you could not resume, which is a poor
|
||||
# position from which to remember which project it belonged to. --all drops
|
||||
# the working-directory scoping and reports only what is actually broken.
|
||||
if [ "$scan_all" -eq 1 ]; then
|
||||
local scan
|
||||
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" 2>/dev/null)" || {
|
||||
err 'could not scan transcripts'; return 1; }
|
||||
|
||||
if [ "$as_json" -eq 1 ]; then
|
||||
printf '%s\n' "$scan"
|
||||
return 0
|
||||
fi
|
||||
|
||||
head_ 'scanning every session transcript'
|
||||
printf '%s' "$scan" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
|
||||
for b in d['broken']:
|
||||
print(' %-38s %s' % (b['sessionId'], b['project']))
|
||||
prov = ', '.join(b['providers']) or 'another provider'
|
||||
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
|
||||
print()
|
||||
if not d['broken']:
|
||||
print(' %sok %s nothing to repair across %d transcript(s)' % (G,X,d['scanned']))
|
||||
else:
|
||||
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
|
||||
print(' %sclaude-mode repair-session <id> --apply%s' % (D,X))
|
||||
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
|
||||
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Claude Code keys transcripts by the directory the session was started in,
|
||||
# which is rarely the one you are standing in when you come to fix it. Walk
|
||||
# up first, and for a named session fall back to looking through every
|
||||
# project - the id is unique, so there is nothing ambiguous to resolve.
|
||||
local probe="$PWD"
|
||||
while [ -n "$probe" ]; do
|
||||
[ -d "$(cm_project_dir "$probe")" ] && { dir="$(cm_project_dir "$probe")"; break; }
|
||||
[ "$probe" = "/" ] && break
|
||||
probe="$(dirname "$probe")"
|
||||
done
|
||||
|
||||
if [ -n "$target" ] && [ "$target" != "--list" ]; then
|
||||
if [ -z "$dir" ] || [ ! -f "$dir/${target%.jsonl}.jsonl" ]; then
|
||||
local hit
|
||||
hit="$(ls -1 "$CM_SETTINGS_DIR"/projects/*/"${target%.jsonl}".jsonl 2>/dev/null | head -n1)"
|
||||
[ -n "$hit" ] && dir="$(dirname "$hit")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
err 'no session transcripts found for this directory'
|
||||
say 'run it from the project the session belongs to, or name the session id'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$target" ] || [ "$target" = "--list" ]; then
|
||||
head_ 'session transcripts here'
|
||||
local f v
|
||||
for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do
|
||||
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
|
||||
printf '%s' "$v" | "$PY" -c "
|
||||
import json,sys,os
|
||||
d=json.load(sys.stdin)
|
||||
state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found')
|
||||
print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||
"
|
||||
done
|
||||
printf '\n %sclaude-mode repair-session <session-id> --apply%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
file="$dir/${target%.jsonl}.jsonl"
|
||||
[ -f "$file" ] || { err "no transcript $file"; return 1; }
|
||||
|
||||
# A transcript that is still being appended to belongs to a session that is
|
||||
# still alive; truncating it underneath a running process helps nobody.
|
||||
age=$(( $(date +%s) - $(stat -c %Y "$file" 2>/dev/null || echo 0) ))
|
||||
if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then
|
||||
err "that transcript was written to ${age}s ago - it looks live"
|
||||
say 'close the session that owns it first'
|
||||
return 1
|
||||
fi
|
||||
|
||||
local flags=''
|
||||
[ "$apply" -eq 1 ] && flags="$flags --apply"
|
||||
[ "$reinject" -eq 0 ] && flags="$flags --no-reinject"
|
||||
# shellcheck disable=SC2086
|
||||
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
|
||||
err 'could not read that transcript'; return 1; }
|
||||
|
||||
printf '%s' "$verdict" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,R,D,X = '\033[32m','\033[33m','\033[91m','\033[90m','\033[0m'
|
||||
print()
|
||||
if d['healthy']:
|
||||
print(' %sok %s last message is Anthropic-issued; nothing to repair' % (G,X))
|
||||
raise SystemExit(0)
|
||||
if d['kind'] == 'gateway-native':
|
||||
prov = (d['foreignIds'][0]['model'] or 'a gateway') if d['foreignIds'] else 'a gateway'
|
||||
print(' %sok %s this session ran entirely on %s' % (G,X,prov))
|
||||
print(' %sits ids come from that provider by design; it resumes under it, not%s' % (D,X))
|
||||
print(' %sunder Anthropic. There is nothing here to repair.%s' % (D,X))
|
||||
raise SystemExit(0)
|
||||
if not d['repairable']:
|
||||
print(' %sok %s this transcript has no assistant replies to resume from' % (G,X))
|
||||
raise SystemExit(0)
|
||||
for f in d['foreignIds']:
|
||||
print(' %swarn%s line %d carries a %s id from %s' % (Y,X,f['line'],f['id'].split('-')[0]+'-',f['model'] or 'another provider'))
|
||||
n = sum(1 for s in d['syntheticIds'] if s['apiError'] and s['line'] > d['lastGoodLine'])
|
||||
if n:
|
||||
print(' %swarn%s %d client-side error placeholder(s) after the last good message' % (Y,X,n))
|
||||
if d['applied']:
|
||||
print(' %sok %s truncated to line %d, dropping %d' % (G,X,d['lastGoodLine'],d['dropLines']))
|
||||
print(' %sok %s original saved as %s' % (G,X,d['backup']))
|
||||
if d.get('recovered'):
|
||||
print(' %sok %s dropped turns written to %s' % (G,X,d['recovered']))
|
||||
if d.get('reinjected'):
|
||||
print(' %sok %s and handed back to the session as a context note' % (G,X))
|
||||
print()
|
||||
print(' %sthat session should resume, and will know what it did%s' % (D,X))
|
||||
else:
|
||||
print(' %swarn%s would truncate to line %d, dropping %d line(s)' % (Y,X,d['lastGoodLine'],d['dropLines']))
|
||||
print()
|
||||
print(' %sre-run with --apply: the original is backed up, the dropped turns are%s' % (D,X))
|
||||
print(' %ssaved as markdown, and handed back to the session as a context note%s' % (D,X))
|
||||
print(' %s(--no-reinject writes the file but leaves the session untouched)%s' % (D,X))
|
||||
"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1776,7 +2283,8 @@ init_root
|
||||
_args=()
|
||||
for _a in "$@"; do
|
||||
case "$_a" in
|
||||
--force) CM_FORCE=1 ;;
|
||||
--force) CM_FORCE=1 ;;
|
||||
-y|--yes) CM_ASSUME_YES=1 ;;
|
||||
*) _args+=("$_a") ;;
|
||||
esac
|
||||
done
|
||||
@@ -1817,7 +2325,29 @@ case "$cmd" in
|
||||
doctor) cmd_doctor ;;
|
||||
health) write_health "$(state_mode)" "$(state_preset)" ;;
|
||||
preflight) cmd_preflight "${1:-}" "${2:-}" ;;
|
||||
setup)
|
||||
# --terminal for callers with no stdin to offer. The bar widget
|
||||
# cannot host a hidden key prompt or a filter-select list, so it
|
||||
# asks a terminal to host the whole flow instead.
|
||||
_mode="${1:-}"; _term=0; _rest=''
|
||||
for _a in "$@"; do
|
||||
case "$_a" in
|
||||
--terminal) _term=1 ;;
|
||||
"$_mode") ;;
|
||||
*) _rest="$_a" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$_term" -eq 1 ]; then
|
||||
_t="$(cm_terminal_cmd)"
|
||||
setsid nohup "$_t" -e bash -lc \
|
||||
"'$0' setup '$_mode' $_rest; printf '\n press enter to close '; read -r _" \
|
||||
>/dev/null 2>&1 &
|
||||
ok "opened $_t to set up $_mode"
|
||||
else
|
||||
cmd_setup "$_mode" "$_rest"
|
||||
fi ;;
|
||||
sessions) cmd_sessions "$@" ;;
|
||||
repair-session) cmd_repair_session "$@" ;;
|
||||
repair)
|
||||
scope=''
|
||||
for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
|
||||
|
||||
@@ -16,6 +16,7 @@ Subcommands:
|
||||
presets <dir> "name<TAB>provider<TAB>desc"
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -579,10 +580,374 @@ def cmd_set_auth(argv):
|
||||
save(path, p)
|
||||
print(json.dumps(p["auth"]))
|
||||
|
||||
|
||||
def cmd_set_flag(argv):
|
||||
"""set-flag <preset> <key> true|false
|
||||
|
||||
Only used for `configured` so far. Kept generic because a preset-level
|
||||
boolean written by hand is exactly the kind of thing that ends up as a
|
||||
string "false", which is truthy everywhere that matters.
|
||||
"""
|
||||
path, key, val = argv[0], argv[1], argv[2]
|
||||
p = load(path)
|
||||
p[key] = (val == "true")
|
||||
save(path, p)
|
||||
|
||||
|
||||
def cmd_set_all(argv):
|
||||
"""set-all <preset> <model-id> - point every tier and the subagent at one id."""
|
||||
path, model = argv[0], argv[1]
|
||||
p = load(path)
|
||||
p["models"] = {t: model for t in TIERS}
|
||||
if p.get("subagentModel") and p["subagentModel"] != "inherit":
|
||||
p["subagentModel"] = model
|
||||
save(path, p)
|
||||
print(model)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session transcript repair
|
||||
#
|
||||
# Native Anthropic requires previous_message_id to be an id it issued itself -
|
||||
# one starting `msg_`. A session that took even one completion from a gateway
|
||||
# while the mode was switched under it has that provider's id format in its
|
||||
# transcript instead (OpenRouter issues `gen-<epoch>-<rand>`), and every attempt
|
||||
# to resume it afterwards fails with a 400 naming previous_message_id. Client-
|
||||
# side error placeholders, written as model `<synthetic>` with a UUID for an id,
|
||||
# do the same thing when one is last.
|
||||
#
|
||||
# The transcript is newline-delimited JSON, one independent object per line, so
|
||||
# rolling back to the last message Anthropic actually issued is a truncation.
|
||||
# Everything after it is lost - which is the cost, and why nothing here runs
|
||||
# without being asked twice.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _msg_id(obj):
|
||||
m = obj.get("message")
|
||||
if isinstance(m, dict) and m.get("id"):
|
||||
return str(m["id"])
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _blocks(msg):
|
||||
c = msg.get("content")
|
||||
if isinstance(c, str):
|
||||
return [{"type": "text", "text": c}]
|
||||
return c if isinstance(c, list) else []
|
||||
|
||||
|
||||
def _clip(t, n):
|
||||
t = " ".join(str(t).split())
|
||||
return t if len(t) <= n else t[:n - 1] + "\u2026"
|
||||
|
||||
|
||||
def summarise_dropped(lines, per_block=1400):
|
||||
"""Render the turns a repair is about to discard as readable Markdown.
|
||||
|
||||
Tool *results* are deliberately left out. They are the bulk of a transcript
|
||||
by volume and the least useful part of a summary - what matters on the way
|
||||
back in is what was asked, what was said, and what was run.
|
||||
"""
|
||||
out, tools = [], 0
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
t, msg = d.get("type"), (d.get("message") or {})
|
||||
if t == "user" and not d.get("isMeta"):
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text" and b.get("text", "").strip():
|
||||
out.append("### User\n\n" + _clip(b["text"], per_block))
|
||||
elif t == "assistant":
|
||||
if d.get("isApiErrorMessage"):
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text":
|
||||
out.append("> **API error:** " + _clip(b.get("text", ""), 300))
|
||||
continue
|
||||
said, ran = [], []
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text" and b.get("text", "").strip():
|
||||
said.append(_clip(b["text"], per_block))
|
||||
elif b.get("type") == "tool_use":
|
||||
inp = b.get("input") or {}
|
||||
hint = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("path") or ""
|
||||
ran.append("`%s`%s" % (b.get("name", "tool"),
|
||||
(" \u2014 " + _clip(hint, 120)) if hint else ""))
|
||||
tools += 1
|
||||
if said:
|
||||
out.append("### Claude\n\n" + "\n\n".join(said))
|
||||
if ran:
|
||||
out.append("Ran: " + ", ".join(ran[:12]) + (" \u2026" if len(ran) > 12 else ""))
|
||||
return "\n\n".join(out), tools
|
||||
|
||||
|
||||
def _meta_entry(template, parent_uuid, text):
|
||||
"""A user-role entry marked isMeta, which Claude Code treats as context
|
||||
rather than as something to answer - the same marker it uses for its own
|
||||
local-command caveats. It carries no message.id, so it cannot affect the
|
||||
previous_message_id that made the session unresumable in the first place.
|
||||
"""
|
||||
now = __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
e = {
|
||||
"parentUuid": parent_uuid,
|
||||
"isSidechain": False,
|
||||
"userType": template.get("userType", "external"),
|
||||
"cwd": template.get("cwd", ""),
|
||||
"sessionId": template.get("sessionId", ""),
|
||||
"version": template.get("version", ""),
|
||||
"gitBranch": template.get("gitBranch", ""),
|
||||
"type": "user",
|
||||
"isMeta": True,
|
||||
"uuid": str(__import__("uuid").uuid4()),
|
||||
"timestamp": now,
|
||||
"message": {"role": "user", "content": text},
|
||||
}
|
||||
return {k: v for k, v in e.items() if v != "" or k in ("gitBranch",)}
|
||||
|
||||
def cmd_repair_session(argv):
|
||||
"""repair-session <transcript.jsonl> [--apply] [--no-reinject]
|
||||
|
||||
Prints a JSON verdict. With --apply, writes the dropped turns out as
|
||||
Markdown, backs the file up, truncates it to the last Anthropic-issued
|
||||
message, and (unless --no-reinject) appends those turns back as a single
|
||||
isMeta note so the resumed session still knows what it did.
|
||||
"""
|
||||
path = argv[0]
|
||||
apply_it = "--apply" in argv[1:]
|
||||
reinject = "--no-reinject" not in argv[1:]
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
|
||||
last_good = -1 # index of the last line carrying a msg_ id
|
||||
foreign, synthetic = [], []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(obj)
|
||||
if mid is None:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append({"line": i + 1, "id": mid,
|
||||
"model": str((obj.get("message") or {}).get("model", ""))})
|
||||
else:
|
||||
synthetic.append({"line": i + 1, "id": mid,
|
||||
"apiError": bool(obj.get("isApiErrorMessage"))})
|
||||
|
||||
# Only the *last* id matters for resuming: an error placeholder in the
|
||||
# middle of a long-finished turn is history, not a blocker.
|
||||
tail_ids = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
healthy = (last_good >= 0 and not tail_ids)
|
||||
|
||||
# Not every transcript without an Anthropic id is damaged, and saying so
|
||||
# turns the scan into noise. A session that ran start to finish on a gateway
|
||||
# has `gen-` ids throughout by design: it resumes perfectly well under the
|
||||
# provider it was born on, has nothing to truncate back to, and is only a
|
||||
# problem if you try to resume it as Anthropic. Likewise a session that never
|
||||
# got a reply at all has no ids and nothing wrong with it.
|
||||
if healthy:
|
||||
kind = "healthy"
|
||||
elif last_good >= 0:
|
||||
kind = "repairable"
|
||||
elif foreign:
|
||||
kind = "gateway-native"
|
||||
elif synthetic:
|
||||
kind = "synthetic-only"
|
||||
else:
|
||||
kind = "no-messages"
|
||||
|
||||
out = {
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"lastGoodLine": last_good + 1 if last_good >= 0 else 0,
|
||||
"dropLines": 0 if healthy or last_good < 0 else len(lines) - (last_good + 1),
|
||||
"foreignIds": foreign,
|
||||
"syntheticIds": synthetic,
|
||||
"healthy": healthy,
|
||||
"kind": kind,
|
||||
"repairable": (not healthy) and last_good >= 0,
|
||||
"applied": False,
|
||||
"backup": "",
|
||||
"recovered": "",
|
||||
"reinjected": False,
|
||||
"droppedTools": 0,
|
||||
}
|
||||
|
||||
if apply_it and out["repairable"]:
|
||||
stamp = __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup = "%s.pre-repair-backup-%s" % (path, stamp)
|
||||
dropped = lines[last_good + 1:]
|
||||
keep = lines[:last_good + 1]
|
||||
|
||||
# The turns being cut are the work itself. Losing the conversation that
|
||||
# produced a morning's changes is most of the damage, so they are written
|
||||
# out as readable Markdown before anything is deleted...
|
||||
digest, ntools = summarise_dropped(dropped)
|
||||
recovered = "%s.recovered-%s.md" % (path.rsplit(".jsonl", 1)[0], stamp)
|
||||
header = ("# Recovered turns\n\n"
|
||||
"Cut from `%s` on %s, because the transcript could no longer be\n"
|
||||
"resumed. Everything below happened; none of it is in the session any more.\n\n"
|
||||
"---\n\n" % (os.path.basename(path), stamp))
|
||||
with open(recovered, "w", encoding="utf-8") as fh:
|
||||
fh.write(header + (digest or "_No readable messages in the dropped turns._\n"))
|
||||
|
||||
with open(backup, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
|
||||
# ...and then handed back to the session, so the agent that resumes it
|
||||
# knows what it just did rather than waking with a gap in its memory.
|
||||
if reinject and digest:
|
||||
template = _safe(keep[last_good])
|
||||
body = digest if len(digest) <= 24000 else (
|
||||
digest[:24000] + "\n\n\u2026 truncated; the full text is in " + recovered)
|
||||
note = ("<recovered-transcript>\n"
|
||||
"Context, not an instruction - do not act on it or reply to it.\n\n"
|
||||
"This session was rolled back to its last resumable point after a "
|
||||
"provider switch left it unable to resume. The turns below were part "
|
||||
"of this conversation and are no longer in it. The full text is at "
|
||||
+ recovered + "\n\n" + body + "\n</recovered-transcript>")
|
||||
keep.append(json.dumps(_meta_entry(template, template.get("uuid"), note)))
|
||||
out["reinjected"] = True
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(keep) + "\n")
|
||||
|
||||
out["applied"] = True
|
||||
out["backup"] = backup
|
||||
out["recovered"] = recovered
|
||||
out["droppedTools"] = ntools
|
||||
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def _safe(line):
|
||||
try:
|
||||
return json.loads(line)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _last_msg_id_from_tail(path, window=262144):
|
||||
"""The id of the last message-bearing line, read from the end of the file.
|
||||
|
||||
Classifying a transcript needs two facts: what the last message id is, and
|
||||
whether any Anthropic id exists at all. The first decides the common case on
|
||||
its own - if the last one is `msg_`, the transcript is healthy and the rest
|
||||
of the file never has to be touched. Since that is 40-odd of the 59 here,
|
||||
and some are 10MB, reading the tail first is the difference between a scan
|
||||
that can run on a timer and one that cannot.
|
||||
|
||||
Returns (id_or_None, saw_whole_file).
|
||||
"""
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "rb") as fh:
|
||||
start = max(0, size - window)
|
||||
fh.seek(start)
|
||||
chunk = fh.read()
|
||||
whole = start == 0
|
||||
if not whole:
|
||||
nl = chunk.find(b"\n") # drop the partial first line
|
||||
chunk = chunk[nl + 1:] if nl >= 0 else b""
|
||||
for raw in reversed(chunk.splitlines()):
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(raw.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if mid:
|
||||
return mid, whole
|
||||
return None, whole
|
||||
|
||||
|
||||
def cmd_scan_sessions(argv):
|
||||
"""scan-sessions <projects-dir> - JSON list of transcripts worth repairing.
|
||||
|
||||
Only `repairable` is reported: a transcript carrying a genuine Anthropic
|
||||
message with a different provider's output after it. A session that ran
|
||||
entirely on a gateway, or never got a reply, is not damage.
|
||||
"""
|
||||
root = argv[0]
|
||||
broken, scanned, skipped = [], 0, 0
|
||||
|
||||
for proj in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
if not os.path.isdir(proj):
|
||||
continue
|
||||
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
|
||||
scanned += 1
|
||||
try:
|
||||
last, whole = _last_msg_id_from_tail(path)
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
# Healthy is decided by the tail alone, and is the common case.
|
||||
if last is not None and last.startswith("msg_"):
|
||||
continue
|
||||
if last is None and whole:
|
||||
continue # no replies at all; nothing to fix
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
last_good, foreign = -1, []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if not mid:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append(str((d.get("message") or {}).get("model", "")))
|
||||
|
||||
if last_good < 0:
|
||||
continue # gateway-native or reply-less
|
||||
tail = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
if not tail:
|
||||
continue
|
||||
|
||||
broken.append({
|
||||
"sessionId": os.path.basename(path)[:-6],
|
||||
"project": os.path.basename(proj),
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"dropLines": len(lines) - (last_good + 1),
|
||||
"providers": sorted(set(f for f in foreign if f)),
|
||||
"mtime": int(os.path.getmtime(path)),
|
||||
})
|
||||
|
||||
print(json.dumps({"scanned": scanned, "skipped": skipped,
|
||||
"broken": broken, "count": len(broken)}))
|
||||
|
||||
COMMANDS = {
|
||||
"health": cmd_health,
|
||||
"preflight-json": cmd_preflight_json,
|
||||
"sessions-json": cmd_sessions_json,
|
||||
"repair-session": cmd_repair_session,
|
||||
"scan-sessions": cmd_scan_sessions,
|
||||
"stale-models": cmd_stale_models,
|
||||
"strip-tags": cmd_strip_tags,
|
||||
"or-models": cmd_or_models,
|
||||
@@ -592,6 +957,8 @@ COMMANDS = {
|
||||
"models": cmd_models,
|
||||
"set-tier": cmd_set_tier,
|
||||
"set-url": cmd_set_url,
|
||||
"set-flag": cmd_set_flag,
|
||||
"set-all": cmd_set_all,
|
||||
"set-auth": cmd_set_auth,
|
||||
"scaffold": cmd_scaffold,
|
||||
"get": cmd_get,
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs the claude-mode bar widget into the Omarchy shell.
|
||||
#
|
||||
# Two steps: drop the plugin into ~/.config/omarchy/plugins/, and add its id to
|
||||
# the bar layout in ~/.config/omarchy/shell.json. Both are idempotent, and the
|
||||
# shell hot-reloads each of them, so nothing has to be restarted.
|
||||
#
|
||||
# Only ~/.config is touched. /usr/share/omarchy is owned by the package and is
|
||||
# rewritten by `omarchy update`, so nothing may be installed there.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ID="smoido.claude-mode"
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$PLUGIN_ID"
|
||||
DEST_DIR="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
|
||||
SHELL_JSON="$HOME/.config/omarchy/shell.json"
|
||||
|
||||
# Where in the bar the widget lands, and which existing widget it sits before.
|
||||
# The AI-adjacent group on the right is the natural neighbourhood for it.
|
||||
SECTION="${CM_BAR_SECTION:-right}"
|
||||
BEFORE="${CM_BAR_BEFORE:-omarchy.agents}"
|
||||
|
||||
green() { printf ' \033[32mok \033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
|
||||
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
|
||||
|
||||
printf '\ninstalling %s -> %s\n' "$PLUGIN_ID" "$DEST_DIR"
|
||||
|
||||
[ -d "$SRC" ] || { fail "plugin source not found: $SRC"; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { fail 'python3 required'; exit 1; }
|
||||
|
||||
if [ ! -d "$HOME/.config/omarchy" ]; then
|
||||
fail 'no ~/.config/omarchy - this does not look like an Omarchy system'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- payload ---------------------------------------------------------------
|
||||
mkdir -p "$DEST_DIR"
|
||||
for f in manifest.json BarWidget.qml Panel.qml BrandIcon.qml Modes.js; do
|
||||
[ -f "$SRC/$f" ] || { fail "missing from payload: $f"; exit 1; }
|
||||
install -m 0644 "$SRC/$f" "$DEST_DIR/$f"
|
||||
done
|
||||
green 'plugin files copied'
|
||||
|
||||
# --- bar layout ------------------------------------------------------------
|
||||
# shell.json is the user's own file and may carry unrelated customisation, so
|
||||
# it is read, minimally amended, and written back rather than templated over.
|
||||
if [ ! -f "$SHELL_JSON" ]; then
|
||||
warn "no $SHELL_JSON - add the widget yourself once the shell writes one"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp "$SHELL_JSON" "$SHELL_JSON.bak.$(date +%s)"
|
||||
|
||||
python3 - "$SHELL_JSON" "$PLUGIN_ID" "$SECTION" "$BEFORE" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
path, plugin_id, section, before = sys.argv[1:5]
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
cfg = json.load(fh)
|
||||
|
||||
layout = cfg.setdefault("bar", {}).setdefault("layout", {})
|
||||
entries = layout.setdefault(section, [])
|
||||
|
||||
def has(entry_list):
|
||||
return any(isinstance(e, dict) and e.get("id") == plugin_id for e in entry_list)
|
||||
|
||||
# Already placed anywhere in the bar: leave the user's chosen position alone.
|
||||
for name, items in layout.items():
|
||||
if isinstance(items, list) and has(items):
|
||||
print(" already in bar layout (%s) - position left as-is" % name)
|
||||
raise SystemExit(0)
|
||||
|
||||
idx = len(entries)
|
||||
for i, e in enumerate(entries):
|
||||
if isinstance(e, dict) and e.get("id") == before:
|
||||
idx = i
|
||||
break
|
||||
|
||||
entries.insert(idx, {"id": plugin_id})
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cfg, fh, indent=2)
|
||||
fh.write("\n")
|
||||
print(" added to bar.layout.%s at position %d" % (section, idx))
|
||||
PYEOF
|
||||
|
||||
green 'shell.json updated (hot-reloads; no restart needed)'
|
||||
|
||||
printf '\ndone. The icon shows the active mode; click it to switch.\n'
|
||||
printf 'If it does not appear: omarchy-shell shell rescanPlugins\n'
|
||||
@@ -0,0 +1,330 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Modes.js" as Modes
|
||||
|
||||
// Bar slot for claude-mode: shows which provider the next `claude` launch will
|
||||
// use, and opens a panel that switches it.
|
||||
//
|
||||
// State comes from ~/.claude-mode/health.json, which claude-mode rewrites on
|
||||
// every switch and on every `status`. Watching that file rather than polling
|
||||
// the CLI keeps this widget at zero cost while idle, and means a switch made
|
||||
// in a terminal shows up here without anything having to tell us.
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "smoido.claude-mode"
|
||||
|
||||
readonly property string cmRoot: String(setting("root", Modes.defaultRoot()))
|
||||
// Off by default - the bar is scarce horizontal space and the mark already
|
||||
// says which provider is active. Turn it on per-instance in shell.json to get
|
||||
// the preset name back beside the icon.
|
||||
readonly property bool showLabel: setting("showLabel", false) === true
|
||||
|
||||
// Whole health.json, or null before the first successful read.
|
||||
property var health: null
|
||||
// state.json is the narrower fallback: it exists from install onward, where
|
||||
// health.json only appears once claude-mode has run at least once. Without
|
||||
// this the widget would render "unknown" on a fresh install until the first
|
||||
// switch, which is precisely when someone is most likely to look at it.
|
||||
property var fallbackState: null
|
||||
|
||||
readonly property string mode: {
|
||||
if (health && health.mode) return String(health.mode)
|
||||
if (fallbackState && fallbackState.mode) return String(fallbackState.mode)
|
||||
return ""
|
||||
}
|
||||
readonly property string preset: {
|
||||
if (mode === "anthropic") return ""
|
||||
if (health && health.preset) return String(health.preset)
|
||||
if (fallbackState && fallbackState.preset) return String(fallbackState.preset)
|
||||
return ""
|
||||
}
|
||||
readonly property bool known: mode !== ""
|
||||
|
||||
// Measured against the neighbours: every stock bar glyph in this shell paints
|
||||
// 11px of ink from a 13px font. These marks fill their box rather than
|
||||
// carrying a font's internal padding, so the box itself has to be the smaller
|
||||
// number or the widget sits visibly larger than everything beside it.
|
||||
// Odd on purpose. The marks are radially symmetric, so their vertical and
|
||||
// horizontal arms sit on the centre line - which lands on a pixel *centre* at
|
||||
// an odd size and on the boundary between two pixels at an even one, where
|
||||
// each arm splits its coverage and comes out grey on both sides.
|
||||
readonly property int iconPx: {
|
||||
var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
|
||||
return n % 2 === 0 ? n + 1 : n
|
||||
}
|
||||
readonly property string logo: Modes.logo(mode)
|
||||
readonly property string glyph: Modes.glyph(mode)
|
||||
readonly property string label: Modes.shortLabel(mode, preset)
|
||||
|
||||
// A gateway mode is spending money or leaning on a local server; native
|
||||
// Anthropic is the resting state. Only the former earns the accent, so the
|
||||
// bar stays quiet exactly when nothing unusual is configured.
|
||||
readonly property bool gateway: known && mode !== "anthropic"
|
||||
|
||||
readonly property color urgentColor: {
|
||||
var c = Color.urgent
|
||||
var mx = Math.max(c.r, c.g, c.b)
|
||||
var mn = Math.min(c.r, c.g, c.b)
|
||||
var l = (mx + mn) / 2
|
||||
var d = 1 - Math.abs(2 * l - 1)
|
||||
return (d > 0.0001 ? (mx - mn) / d : 0) >= 0.15 ? c : "#d2685f"
|
||||
}
|
||||
|
||||
readonly property color activeColor: !known
|
||||
? Color.muted
|
||||
: (gateway ? Color.accent : (bar ? bar.barForeground : Color.foreground))
|
||||
|
||||
// The bar's shared tooltip only paints for a target that reports itself
|
||||
// hovered through this exact property.
|
||||
readonly property bool tooltipHovered: visible && opacity > 0 && hoverHandler.hovered
|
||||
|
||||
function parseInto(prop, content) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(content || ""))
|
||||
root[prop] = (parsed && typeof parsed === "object") ? parsed : null
|
||||
} catch (e) {
|
||||
root[prop] = null
|
||||
}
|
||||
}
|
||||
|
||||
// `text()` is stale inside onFileChanged, so both first load and every later
|
||||
// change are routed through reload() -> onLoaded to always parse fresh bytes.
|
||||
FileView {
|
||||
id: healthFile
|
||||
path: root.cmRoot + "/health.json"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: reload()
|
||||
onLoaded: root.parseInto("health", text())
|
||||
onLoadFailed: root.health = null
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: stateFile
|
||||
path: root.cmRoot + "/state.json"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: reload()
|
||||
onLoaded: root.parseInto("fallbackState", text())
|
||||
onLoadFailed: root.fallbackState = null
|
||||
}
|
||||
|
||||
// claude-mode writes health.json itself, so a switch launched from the panel
|
||||
// lands back here through the FileView above. This only covers the case
|
||||
// where the file was never written at all.
|
||||
Process {
|
||||
id: seedProc
|
||||
command: [root.cmRoot + "/bin/claude-mode", "health"]
|
||||
running: false
|
||||
}
|
||||
|
||||
// ---- Broken transcripts
|
||||
//
|
||||
// A session cut short by a mode switch cannot be resumed, and nothing tells
|
||||
// you until you try - by which time you have usually forgotten which session
|
||||
// it was. So the bar checks periodically and marks itself when there is
|
||||
// something to fix.
|
||||
//
|
||||
// The scan reads the tail of each transcript first and only opens the whole
|
||||
// file when the tail already looks wrong, which is what makes it cheap enough
|
||||
// to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive
|
||||
// version that read every byte of every one.
|
||||
property var brokenSessions: []
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
running: false
|
||||
command: [root.cmRoot + "/bin/claude-mode", "repair-session", "--json"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var d = null
|
||||
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
|
||||
root.brokenSessions = (d && d.broken) ? d.broken : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSessions() { if (!scanProc.running) scanProc.running = true }
|
||||
|
||||
Timer {
|
||||
interval: 20 * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.scanSessions()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
healthFile.reload()
|
||||
stateFile.reload()
|
||||
if (!root.health) seedProc.running = true
|
||||
root.scanSessions()
|
||||
}
|
||||
|
||||
Component.onCompleted: Qt.callLater(root.refresh)
|
||||
|
||||
// ---- Bar content
|
||||
|
||||
readonly property int gap: Style.space(5)
|
||||
|
||||
readonly property real iconWidth: root.logo !== "" ? icon.implicitWidth : fallbackIcon.implicitWidth
|
||||
|
||||
implicitWidth: vertical
|
||||
? barSize
|
||||
: (iconWidth + (labelText.visible ? gap + labelText.implicitWidth : 0) + Style.space(10))
|
||||
implicitHeight: vertical ? (iconPx + Style.space(10)) : barSize
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: root.gap
|
||||
|
||||
Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: icon.width
|
||||
height: icon.height
|
||||
visible: root.logo !== ""
|
||||
|
||||
BrandIcon {
|
||||
id: icon
|
||||
anchors.fill: parent
|
||||
pathData: root.logo
|
||||
opticalScale: Modes.logoScale(root.mode)
|
||||
color: root.activeColor
|
||||
iconSize: root.iconPx
|
||||
}
|
||||
|
||||
// Recolouring the mark would misreport the mode, which is this widget's
|
||||
// whole job, so the warning gets its own dot instead.
|
||||
Rectangle {
|
||||
visible: root.brokenSessions.length > 0
|
||||
width: Math.max(4, Math.round(root.iconPx / 3.2))
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: root.urgentColor
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.rightMargin: -Math.round(width / 3)
|
||||
anchors.topMargin: -Math.round(width / 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Only reached when claude-mode is not installed - there is no brand to
|
||||
// draw for "no answer", so the pictograph stands in.
|
||||
Text {
|
||||
id: fallbackIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.logo === ""
|
||||
text: root.glyph
|
||||
color: root.activeColor
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.bar.iconFont
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.vertical && root.showLabel && root.label !== ""
|
||||
text: root.label
|
||||
color: root.activeColor
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.bar.iconFont - 1
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Interaction
|
||||
|
||||
// The bar decides a module is clickable - and gives it the pointing-hand
|
||||
// cursor - purely by whether it exposes triggerPress().
|
||||
function triggerPress(button) {
|
||||
if (root.bar) root.bar.hideTooltip(root)
|
||||
root.togglePanel()
|
||||
}
|
||||
|
||||
function tooltipLabel() {
|
||||
if (!root.known) return "claude-mode: not installed"
|
||||
var lines = [Modes.title(root.mode)]
|
||||
if (root.preset !== "") lines.push("preset " + root.preset)
|
||||
var models = root.health && root.health.models ? root.health.models : null
|
||||
if (models && models.opus) lines.push("opus " + models.opus)
|
||||
if (models && models.sonnet) lines.push("sonnet " + models.sonnet)
|
||||
if (root.brokenSessions.length > 0) {
|
||||
lines.push(root.brokenSessions.length === 1
|
||||
? "1 session needs repair"
|
||||
: root.brokenSessions.length + " sessions need repair")
|
||||
}
|
||||
return lines.join(" · ")
|
||||
}
|
||||
|
||||
function syncTooltip() {
|
||||
if (!bar || !hoverHandler.hovered) return
|
||||
var text = tooltipLabel()
|
||||
if (bar.tooltipTarget === root) {
|
||||
if (bar.tooltipText !== text) bar.tooltipText = text
|
||||
} else {
|
||||
bar.showTooltip(root, text)
|
||||
}
|
||||
}
|
||||
|
||||
onHealthChanged: syncTooltip()
|
||||
onBrokenSessionsChanged: syncTooltip()
|
||||
|
||||
// Hover must come from a HoverHandler: once triggerPress() exists the bar's
|
||||
// own slot MouseArea accepts hover events and swallows them before any
|
||||
// MouseArea here would see them. The call is deferred one turn because
|
||||
// `hovered` flips before tooltipHovered has propagated, and showTooltip()
|
||||
// silently refuses a target that does not yet report itself hovered.
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
onHoveredChanged: {
|
||||
if (!root.bar) return
|
||||
if (hovered) Qt.callLater(root.syncTooltip)
|
||||
else root.bar.hideTooltip(root)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.MiddleButton) root.refresh()
|
||||
else if (panelLoader.item) panelLoader.item.switchTo("anthropic", "")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Panel wiring
|
||||
|
||||
function injectPanel() {
|
||||
var target = panelLoader.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("settings" in target) target.settings = root.settings
|
||||
if ("anchorItem" in target) target.anchorItem = root
|
||||
if ("hostWidget" in target) target.hostWidget = root
|
||||
if ("widget" in target) target.widget = root
|
||||
}
|
||||
|
||||
function togglePanel() { if (panelLoader.item) panelLoader.item.toggle() }
|
||||
|
||||
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
||||
function open() { if (panelLoader.item) panelLoader.item.open() }
|
||||
function close() { if (panelLoader.item) panelLoader.item.close() }
|
||||
|
||||
onBarChanged: injectPanel()
|
||||
onSettingsChanged: injectPanel()
|
||||
|
||||
Loader {
|
||||
id: panelLoader
|
||||
active: true
|
||||
source: Qt.resolvedUrl("Panel.qml")
|
||||
visible: false
|
||||
onLoaded: {
|
||||
root.injectPanel()
|
||||
Qt.callLater(root.injectPanel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
|
||||
// One provider logo, drawn as a vector path and filled with whatever colour it
|
||||
// is handed - which is what lets these follow the theme the way a font glyph
|
||||
// would, instead of being a fixed-colour bitmap that looks pasted on.
|
||||
//
|
||||
// Every mark is authored in a 24x24 viewBox (see Modes.js) and scaled to the
|
||||
// requested size from there.
|
||||
//
|
||||
// Two things about how that scaling is done, both of which matter at bar sizes:
|
||||
//
|
||||
// 1. No `layer.enabled`. A layer renders the Shape into a texture at the item's
|
||||
// own size and then scales the *texture*, so a 24x24 buffer minified to 16px
|
||||
// resamples 1.5 pixels into 1. On the Claude burst, whose rays are under a
|
||||
// pixel wide at that size, that is not softening - rays merge, drop out, and
|
||||
// come back at uneven weights as the widget moves. Without the layer the
|
||||
// scale is a transform on the geometry instead, so rasterisation happens
|
||||
// once, at final resolution.
|
||||
//
|
||||
// 2. CurveRenderer rather than the default geometry renderer. It rasterises
|
||||
// curves analytically with its own antialiasing instead of tessellating them
|
||||
// into AA'd triangles, which is what keeps sub-pixel detail smooth rather
|
||||
// than stair-stepped. Qt 6.6+; this ships against 6.11.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string pathData: ""
|
||||
property real iconSize: Style.font.icon
|
||||
property color color: Color.foreground
|
||||
// Per-mark optical correction; see LOGO_SCALE in Modes.js for why equal
|
||||
// nominal size is not equal apparent size.
|
||||
property real opticalScale: 1.0
|
||||
|
||||
// Rounded so the mark is laid out on whole pixels. Thin strokes straddling a
|
||||
// pixel boundary lose half their coverage to each side and read as grey.
|
||||
readonly property int box: Math.round(iconSize)
|
||||
|
||||
implicitWidth: box
|
||||
implicitHeight: box
|
||||
visible: pathData !== ""
|
||||
|
||||
Shape {
|
||||
id: shape
|
||||
width: 24
|
||||
height: 24
|
||||
anchors.centerIn: parent
|
||||
antialiasing: true
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
scale: root.box * root.opticalScale / 24
|
||||
|
||||
ShapePath {
|
||||
// No fillRule set on purpose: these marks are authored for nonzero
|
||||
// winding, which is the default, and LM Studio's bars are knocked out
|
||||
// of its container by winding direction alone.
|
||||
fillColor: root.color
|
||||
strokeWidth: 0
|
||||
PathSvg { path: root.pathData }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.pragma library
|
||||
|
||||
// The four modes claude-mode understands, in the order the CLI menu lists
|
||||
// them. Kept here rather than in either QML file because the bar widget needs
|
||||
// the glyph and the panel needs the prose, and a second copy of this table is
|
||||
// exactly the sort of thing that drifts.
|
||||
|
||||
// Material Design icons from the Nerd Font patch set, written as codepoints
|
||||
// rather than literals so they survive any editor or transport that is not
|
||||
// UTF-8 clean. All verified present in JetBrainsMono Nerd Font, the shell's
|
||||
// default family.
|
||||
//
|
||||
// Deliberately none of them a robot: omarchy.agents already paints one, and it
|
||||
// commonly sits in the same stretch of bar, where two identical robots read as
|
||||
// one widget drawn twice. Same reason lmstudio is a server rack rather than a
|
||||
// screen - omarchy.monitor owns that silhouette.
|
||||
var GLYPH = {
|
||||
anthropic: String.fromCodePoint(0xF0674), // sparkles - the real thing
|
||||
openrouter: String.fromCodePoint(0xF0469), // router - remote gateway
|
||||
zai: String.fromCodePoint(0xF015F), // cloud - hosted GLM plan
|
||||
lmstudio: String.fromCodePoint(0xF048B), // server - local LM Studio
|
||||
unknown: String.fromCodePoint(0xF0625) // question - nothing readable
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand marks
|
||||
//
|
||||
// The real logo for each provider, as SVG path data in a 24x24 viewBox so the
|
||||
// whole set scales from a single number. Drawn with QtQuick.Shapes rather than
|
||||
// set as font glyphs: three of the four have no Nerd Font pictograph at all,
|
||||
// and a stand-in pictograph is both less recognisable and impossible to keep
|
||||
// visually consistent with the marks beside it.
|
||||
//
|
||||
// Sources: Claude, OpenRouter and LM Studio from simple-icons; Z.AI from
|
||||
// lobe-icons. All four are monochrome single-path marks, which is what lets
|
||||
// them take the bar's foreground colour and follow the theme. Trademarks
|
||||
// belong to their respective owners.
|
||||
//
|
||||
// LM Studio's mark keeps its rounded-square container: the four bars alone
|
||||
// read as a hamburger menu, and the container is what makes it that logo. It
|
||||
// relies on nonzero winding to knock the bars out, which is QtQuick.Shapes'
|
||||
// default, so no fill rule is set anywhere here.
|
||||
var LOGO = {
|
||||
anthropic: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z",
|
||||
openrouter: "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z",
|
||||
zai: "M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z",
|
||||
lmstudio: "M14.025 0c3.492 0 5.237 0 6.571.68a6.24 6.24 0 0 1 2.725 2.724C24 4.738 24 6.484 24 9.975v4.05c0 3.492 0 5.237-.68 6.571a6.24 6.24 0 0 1-2.724 2.725c-1.334.679-3.08.679-6.571.679h-4.05c-3.492 0-5.237 0-6.571-.68A6.24 6.24 0 0 1 .68 20.597C0 19.262 0 17.516 0 14.025v-4.05c0-3.492 0-5.237.68-6.571A6.23 6.23 0 0 1 3.404.68C4.738 0 6.484 0 9.975 0zM7.688 16.313a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625zm-3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm-3-3.75a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625z"
|
||||
}
|
||||
|
||||
// Equal nominal size is not equal apparent size, so each mark is corrected by
|
||||
// two measurements taken off a 200px render of it:
|
||||
//
|
||||
// bbox Z.AI and OpenRouter are wide-but-short marks - their ink spans only
|
||||
// ~84% of the box height, so at equal nominal size they read smaller.
|
||||
// ink LM Studio's filled container covers 69% of its box against ~38% for
|
||||
// the other three, so at equal nominal size it reads much heavier.
|
||||
//
|
||||
// The correction is the geometric mean of both signals, which pulls the short
|
||||
// marks up and the solid one down without letting either measure dominate.
|
||||
var LOGO_SCALE = {
|
||||
anthropic: 0.99,
|
||||
openrouter: 1.12,
|
||||
zai: 1.07,
|
||||
lmstudio: 0.86
|
||||
}
|
||||
|
||||
function logo(mode) {
|
||||
return LOGO[mode] || ""
|
||||
}
|
||||
|
||||
function logoScale(mode) {
|
||||
return LOGO_SCALE[mode] || 1.0
|
||||
}
|
||||
|
||||
var TITLE = {
|
||||
anthropic: "Anthropic",
|
||||
openrouter: "OpenRouter",
|
||||
zai: "Z.AI",
|
||||
lmstudio: "LM Studio"
|
||||
}
|
||||
|
||||
var BLURB = {
|
||||
anthropic: "Your subscription login. No gateway, no API key.",
|
||||
openrouter: "Remote gateway, pay per token, any vendor.",
|
||||
zai: "GLM coding plan on Z.AI's Anthropic endpoint.",
|
||||
lmstudio: "Local LM Studio server. Offline and free."
|
||||
}
|
||||
|
||||
var ORDER = ["anthropic", "openrouter", "zai", "lmstudio"]
|
||||
|
||||
// Everything except anthropic switches with a named preset.
|
||||
function needsPreset(mode) {
|
||||
return mode !== "" && mode !== "anthropic"
|
||||
}
|
||||
|
||||
function glyph(mode) {
|
||||
return GLYPH[mode] || GLYPH.unknown
|
||||
}
|
||||
|
||||
function title(mode) {
|
||||
return TITLE[mode] || "not installed"
|
||||
}
|
||||
|
||||
function blurb(mode) {
|
||||
return BLURB[mode] || ""
|
||||
}
|
||||
|
||||
// Bar label. The preset matters more than the provider once you are on a
|
||||
// gateway - "openrouter" tells you nothing you did not already know from the
|
||||
// icon, where "cheap" is the thing you actually want to catch sight of.
|
||||
function shortLabel(mode, preset) {
|
||||
if (mode === "") return ""
|
||||
if (mode === "anthropic") return "anthropic"
|
||||
return preset !== "" ? preset : mode
|
||||
}
|
||||
|
||||
function defaultRoot() {
|
||||
return homeDir() + "/.claude-mode"
|
||||
}
|
||||
|
||||
function homeDir() {
|
||||
// Qt exposes no home path to a .pragma library, and the shell always runs as
|
||||
// the owning user, so derive it the one way that holds in both places.
|
||||
var url = Qt.resolvedUrl(".").toString()
|
||||
var m = url.match(/^file:\/\/(\/home\/[^\/]+)\//)
|
||||
if (m) return m[1]
|
||||
m = url.match(/^file:\/\/(\/Users\/[^\/]+)\//)
|
||||
return m ? m[1] : "/root"
|
||||
}
|
||||
|
||||
// Presets from health.json that belong to one provider, default first so the
|
||||
// list opens on the one `claude-mode <mode>` would have picked.
|
||||
function presetsFor(health, mode, defaults) {
|
||||
var all = (health && health.presets) ? health.presets : []
|
||||
var out = []
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (String(all[i].provider) === mode) out.push(all[i])
|
||||
}
|
||||
var preferred = defaults[mode]
|
||||
out.sort(function (a, b) {
|
||||
if (a.name === preferred) return -1
|
||||
if (b.name === preferred) return 1
|
||||
return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Mirrors default_preset_for() in the CLI.
|
||||
var DEFAULT_PRESET = {
|
||||
openrouter: "default",
|
||||
zai: "zai",
|
||||
lmstudio: "lmstudio"
|
||||
}
|
||||
|
||||
function contextLabel(tokens) {
|
||||
if (!tokens) return ""
|
||||
if (tokens >= 1000000) return (Math.round(tokens / 100000) / 10) + "M context"
|
||||
if (tokens >= 1000) return Math.round(tokens / 1024) + "k context"
|
||||
return tokens + " context"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "smoido.claude-mode",
|
||||
"name": "Claude Mode",
|
||||
"version": "1.8.0",
|
||||
"author": "smoido",
|
||||
"description": "Which provider Claude Code is pointed at, and a one-click switch between them",
|
||||
"kinds": ["bar-widget"],
|
||||
"entryPoints": {
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Claude Mode",
|
||||
"description": "Active Claude Code provider and preset, with in-place switching",
|
||||
"category": "Info",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"description": "Minimum cost. Everything on flash-class models with 1M context.",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "openrouter"
|
||||
},
|
||||
"models": {
|
||||
"opus": "deepseek/deepseek-v4-pro",
|
||||
"sonnet": "deepseek/deepseek-v4-flash-0731",
|
||||
"haiku": "qwen/qwen3.7-flash",
|
||||
"fable": "openai/gpt-5.6-luna-pro"
|
||||
},
|
||||
"subagentModel": "qwen/qwen3.7-flash",
|
||||
"gatewayModelDiscovery": true,
|
||||
"contextTokens": 1000000
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"description": "Daily driver. Cheap flash models on the hot tiers, GLM on fable.",
|
||||
"description": "Daily driver. Flash-class models on the hot tiers, GLM on fable.",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "openrouter"
|
||||
},
|
||||
"models": {
|
||||
"opus": "deepseek/deepseek-v4-flash-0731",
|
||||
"sonnet": "qwen/qwen3.7-flash",
|
||||
"opus": "z-ai/glm-5.3-flash",
|
||||
"sonnet": "deepseek/deepseek-v4-flash-0731",
|
||||
"haiku": "openrouter/free",
|
||||
"fable": "z-ai/glm-5.2"
|
||||
"fable": "z-ai/glm-5.3"
|
||||
},
|
||||
"subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": true,
|
||||
"contextTokens": 1000000
|
||||
"contextTokens": 1000000,
|
||||
"configured": false
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"provider": "lmstudio",
|
||||
"description": "Local Qwen3.6-35B-A3B uncensored (heretic, MTP-preserved). Clean chat template - verified working with tools + streaming.",
|
||||
"baseUrl": "http://127.0.0.1:1234",
|
||||
"auth": {
|
||||
"mode": "literal",
|
||||
"token": "lmstudio"
|
||||
},
|
||||
"models": {
|
||||
"opus": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"sonnet": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"haiku": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"fable": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved"
|
||||
},
|
||||
"subagentModel": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"gatewayModelDiscovery": true,
|
||||
"extraEnv": {
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
},
|
||||
"contextTokens": 262144
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"provider": "lmstudio",
|
||||
"description": "Local KAT-Coder v2.5. NOTE: its chat template asserts message order - if you hit 'Unable to generate parser for this template', use the lmstudio-qwen preset.",
|
||||
"description": "Local LM Studio server. Set up picks the model from whatever the server actually has installed.",
|
||||
"baseUrl": "http://127.0.0.1:1234",
|
||||
"auth": {
|
||||
"mode": "literal",
|
||||
"token": "lmstudio"
|
||||
},
|
||||
"models": {
|
||||
"opus": "kwaipilot_kat-coder-v2.5-dev",
|
||||
"sonnet": "kwaipilot_kat-coder-v2.5-dev",
|
||||
"haiku": "kwaipilot_kat-coder-v2.5-dev",
|
||||
"fable": "kwaipilot_kat-coder-v2.5-dev"
|
||||
"opus": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"sonnet": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"haiku": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"fable": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved"
|
||||
},
|
||||
"subagentModel": "kwaipilot_kat-coder-v2.5-dev",
|
||||
"subagentModel": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
|
||||
"gatewayModelDiscovery": true,
|
||||
"extraEnv": {
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
},
|
||||
"contextTokens": 262144
|
||||
"contextTokens": 262144,
|
||||
"configured": false
|
||||
}
|
||||
|
||||
+5
-4
@@ -7,10 +7,10 @@
|
||||
"keyRef": "zai"
|
||||
},
|
||||
"models": {
|
||||
"opus": "glm-5.2",
|
||||
"sonnet": "glm-5.2",
|
||||
"opus": "glm-5.3",
|
||||
"sonnet": "glm-5.3",
|
||||
"haiku": "glm-4.7",
|
||||
"fable": "glm-5.2"
|
||||
"fable": "glm-5.3"
|
||||
},
|
||||
"subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": false,
|
||||
@@ -18,5 +18,6 @@
|
||||
"API_TIMEOUT_MS": "3000000",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
|
||||
},
|
||||
"contextTokens": 1000000
|
||||
"contextTokens": 1000000,
|
||||
"configured": false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user