Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1c057d0
feat(providers): model refs, resolution, run env, SQLite store, catal…
Its-My-Work Sep 24, 2026
47b097e
feat(providers): route every CLI run through the registry
Its-My-Work Sep 24, 2026
89832cd
feat(providers): accept provider refs wherever a model is chosen
Its-My-Work Sep 24, 2026
ee16b1e
feat(llm-bridge): Anthropic<->OpenAI bridge for the claude CLI
Its-My-Work Sep 24, 2026
5dbc389
test(llm-bridge): server and host suites; guard per-request failures
Its-My-Work Sep 24, 2026
e310656
feat(providers): providers UI — sidebar section, editor, model picker…
Its-My-Work Sep 24, 2026
b77ed6e
feat(providers): Kanban and Schedule offer every provider's model
Its-My-Work Sep 24, 2026
1570adc
fix(llm-bridge): provider quirks found in review
Its-My-Work Sep 24, 2026
fcb9e43
feat(llm-bridge): RunCtx.effort 'auto' = provider default
Its-My-Work Sep 24, 2026
da7a982
feat(providers): Telegram /model and /effort
Its-My-Work Sep 24, 2026
17e07cf
Merge llm-bridge package
Its-My-Work Sep 24, 2026
1fa9eda
feat(providers): wire the bridge in; pin the CLI; contract test in CI
Its-My-Work Sep 24, 2026
52b675e
docs(providers): docs/providers.md, CLAUDE.md conventions, changelog;…
Its-My-Work Sep 24, 2026
59ec35f
fix(providers): review findings
Its-My-Work Sep 24, 2026
47bc5cd
feat(ui): one model button instead of four chips + '⋯'
Its-My-Work Sep 24, 2026
4e6f327
feat(providers): the model's provider decides the engine; no engine dial
Its-My-Work Sep 24, 2026
c1530f8
test: pin an API default provider where runs go through a fake headle…
Its-My-Work Sep 24, 2026
39efb19
fix(ui): plan review card no longer spreads out when restored from hi…
Its-My-Work Sep 24, 2026
fd36eeb
ui: hide Remote Access and Telegram sections from the sidebar
Its-My-Work Sep 24, 2026
da6ebe7
ui: keep the ".." row in the file panel for an empty folder
Its-My-Work Sep 24, 2026
74b6313
ui: tidy the project switcher dropdown, remove the GitHub header link
Its-My-Work Sep 24, 2026
becc4dc
fix(compose): mount hh from projects/main/hh, not the nonexistent pro…
Its-My-Work Oct 7, 2026
069c8b7
Merge pull request #23 from Its-My-Work/fix/hh-workspace-mount
Its-My-Work Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ jobs:
- name: Install dependencies
run: npm ci || npm install

# The same CLI version the Dockerfile pins: test/llm-bridge-contract.test.js drives the
# real binary through the provider bridge against a fake OpenAI endpoint (no key, no
# network) and self-skips only when the binary is missing — this keeps it from skipping.
- name: Install Claude Code CLI (pinned)
run: npm install -g @anthropic-ai/claude-code@2.1.281

# APP_DIR / CCS_DESKTOP repoint the data directory and turn the auth wall
# off. Nothing sets them on a runner, but the suites are written to be run
# without them and this keeps CI identical to the documented local command.
Expand All @@ -38,6 +44,7 @@ jobs:
- name: Syntax check every entry point
run: |
for f in server.js auth.js claude-cli.js claude-ssh.js claude-interactive.js \
claude-code-manager.js agent-dag.js config-resolve.js terminal-bridge.js; do
claude-code-manager.js agent-dag.js config-resolve.js terminal-bridge.js \
providers.js providers-store.js providers-catalog.js llm-bridge/*.js; do
[ -f "$f" ] && node -c "$f"
done
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## Unreleased

### Providers: any model, any mode (not only Claude)

The studio could reach exactly one endpoint — whatever `ANTHROPIC_BASE_URL` said at boot —
and offered four hardcoded aliases. It now keeps a list of **providers** and every run picks
its own: chat, multi-agent, rooms, `@@bots`, Kanban tasks, schedules, Telegram and the
utility calls. Every run is still the `claude` CLI (tools, MCP, hooks, resume, compaction
unchanged); `llm-bridge/` translates for OpenAI-compatible endpoints. See `docs/providers.md`.

- **Types:** the CLI login (Claude subscription), Anthropic API, Anthropic-compatible
endpoints (gateways, DeepSeek/Kimi/GLM/MiniMax), OpenAI-compatible endpoints (OpenAI,
OpenRouter, Gemini, Qwen, Groq, Mistral, xAI, Ollama, LM Studio…), with presets.
- **Nothing to migrate by hand.** `ANTHROPIC_BASE_URL` becomes the default provider on first
start; a bare `sonnet` in an existing row keeps meaning what it meant.
- **A model is one string** (`provider::model`), so it travels through every existing column,
WS frame, MCP tool and plan file.
- **UI:** a Providers section (test before save, catalogue import, capabilities and prices,
probe, utility model, 30-day usage); a `⋯` model picker next to the toolbar chips; the bot
editor, Kanban and Schedule list every provider's models; Telegram `/model` and `/effort`.
- **Every parameter reaches the model:** role models for the CLI's own background calls and
sub-agents, the real context window (compaction), the output cap, effort taken from the
run (the CLI sends "high" by itself), images inside tool results, reasoning echo, tool
schema sanitising, stop reasons, cached-token usage, error mapping the CLI understands.
- **Keys never enter the agent's environment** — the CLI gets a short-lived run token for a
loopback bridge. Cost and context % off Anthropic come from the bridge's usage ledger.
- **Found on the way:** the Subscription engine now honours `--effort` and a bot's model on a
task; its spawn script unsets provider variables tmux could hand the pane; `/api/translate`
no longer inherits server secrets; `set_ui_state` knows `fable`; Dispatch and Telegram send
the chat's effort; a routing refusal stops the turn instead of burning the auto-continues.
- **No engine dial.** The toolbar's API/Subscription switch (and ★ default), the mobile one,
Kanban's, Schedule's and the bot editor's are gone: the chosen model's provider decides —
the CLI login ("Claude (CLI login)", marked *subscription*) runs on tmux, every other
provider (marked *API*, e.g. the "Claude API" preset with an Anthropic key) headless. A
one-time boot migration turns every explicit "Subscription" into a `claude::<model>` pin,
and the `defaultEngine` setting / `PUT /api/default-engine` are removed.
- **Bot editor:** one Model button with the toolbar's picker ("Same as chat" on top); the
starter-prompt templates (Analyst, Writer, Reviewer, Explainer) are gone — they overwrote
the bot's own prompt.
- The Claude Code CLI is pinned (`CLAUDE_CODE_VERSION=2.1.281`) and CI runs a contract test
that drives the real binary through the bridge.

## 7.17.0

### Add project from a Git URL (#94)
Expand Down
41 changes: 28 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Environment (`.env`, see `.env.example`):
- `MULTI_AGENT_CONCURRENCY` — max `claude` subprocesses alive at once inside one wave,
default 3. A wave still runs every member; this only bounds how many at a time.

**Models** — the UI exposes four choices: `haiku`, `sonnet` (default), `opus`, `fable`. The short aliases are mapped in `claude-cli.js` (`MODEL_MAP`, lines 81-89) and passed to the `claude` CLI **as-is** (`opus`→`opus`, `sonnet`→`sonnet`, `haiku`→`haiku`, `fable`→`fable`); the CLI resolves each alias internally. `server.js` defines no model map — it defers to `claude-cli.js`. Dated model IDs are kept commented out in `MODEL_MAP` and are not used.
**Models** — a model is ONE string: a bare alias (`haiku`, `sonnet` (default), `opus`, `fable`, or an old gateway id) resolved on the **default provider**, or a provider ref `provider::model` (`deepseek::deepseek-chat`). The toolbar's one model button opens a picker that sends a bare alias for the default provider's aliases and a ref for everything else. `providers.js` resolves a value per run (`resolveModel`), `server.js resolveRunTarget()` turns that into the child's `--model` + environment, and `claude-cli.js` asks it through `setRunTargetHook` on EVERY `send()` — do not add a second routing path. See **Providers & LLM bridge** below and `docs/providers.md`.

## MCP & Skills

Expand Down Expand Up @@ -135,8 +135,9 @@ splice the room's rounds into the thread the bot uses when mentioned normally.
Not built: the room's artifact is not yet consumed by a DAG node (roadmap item 6b).

### Engines
- `api` (default) — headless `claude -p` via `runCliSingle` in server.js
- `subscription` — persistent interactive tmux session (one per chat) via `claude-interactive.js`, billed on the Claude Max subscription (UI button "Subscription"). MCP servers passed via `--mcp-config` at spawn; systemPrompt via `--append-system-prompt` at spawn; config changes (skills/mode/MCP/model) auto-respawn the tmux session with `--resume` — verified to keep writing to the same transcript jsonl. Image attachments saved to temp files, paths appended to the prompt. maxTurns is not applicable. Choice persisted per session in `sessions.run_engine` (the `engine` column belongs to telegram-bot.js — do not reuse it).
Which engine a run uses is decided by its MODEL's provider (`runEngineFor`, see Providers below) — there is no engine button.
- `api` — headless `claude -p` via `runCliSingle` in server.js; every provider except the CLI login
- `subscription` — persistent interactive tmux session (one per chat) via `claude-interactive.js`, billed on the Claude Max subscription; the models of the CLI-login provider when tmux exists. MCP servers passed via `--mcp-config` at spawn; systemPrompt via `--append-system-prompt` at spawn; config changes (skills/mode/MCP/model) auto-respawn the tmux session with `--resume` — verified to keep writing to the same transcript jsonl. Image attachments saved to temp files, paths appended to the prompt. maxTurns is not applicable. The engine of the last turn is recorded in `sessions.run_engine` (the `engine` column belongs to telegram-bot.js — do not reuse it).

**Interactive prompts (#20).** A subscription turn can stop on a blocking select
widget (permission question, plan approval, AskUserQuestion). `paneAwaitingInput()`
Expand Down Expand Up @@ -204,7 +205,7 @@ shrink its xterm to that pane. The user saw a 46-column strip and nothing else.
can pin deterministically.


**Platform support — capability-checked, not OS-sniffed.** `/api/version` returns `tmuxAvailable` (server runs `tmux -V` once at boot); the UI disables the "Subscription" button when false. Works on macOS / Linux / Docker (tmux in Dockerfile) / Windows-via-WSL or Git-Bash. Native Windows without tmux → button disabled, user stays on `api`. There is intentionally no Windows special-casing — the capability flag covers every case.
**Platform support — capability-checked, not OS-sniffed.** `/api/version` returns `tmuxAvailable` (server runs `tmux -V` once at boot); without tmux the CLI-login models run headless (`runEngineFor`, `engineOfModel` in the SPA). Works on macOS / Linux / Docker (tmux in Dockerfile) / Windows-via-WSL or Git-Bash. Native Windows without tmux → every run is `api`. There is intentionally no Windows special-casing — the capability flag covers every case.

### WebSocket Protocol — Do Not Break
The entire UI depends on this exact message contract:
Expand Down Expand Up @@ -245,7 +246,20 @@ sonnet
haiku
fable
```
These short aliases are exactly what `claude-cli.js` (`MODEL_MAP`) passes to the CLI — pass them through **as-is**; the `claude` binary resolves each one internally. Do not use dated model-ID suffixes in CLI flags (the dated IDs are kept commented out in `MODEL_MAP`).
On a provider that serves Claude (the CLI login, Anthropic, a Claude gateway) these aliases are passed to the CLI **as-is** and the `claude` binary resolves them. On any other provider they map through that provider's roles (`haiku→fast`, `sonnet/fable→main`, `opus→strong`) — `providers.resolveModel()` does it, nothing else should. Do not use dated model-ID suffixes in CLI flags.

### Providers & LLM bridge

`providers.js` (pure rules) · `providers-store.js` (SQLite: `providers`, `provider_models`, `provider_settings`, `llm_usage`) · `providers-catalog.js` (GET /models) · `llm-bridge/` (a separate `node` process on 127.0.0.1, spawned like the MCP helpers). Full picture: `docs/providers.md`.

- **One choke point.** `ClaudeCLI.send()` calls the routing hook for every headless run; `/api/translate` (the only raw spawn) asks the same hook. A new call site that spawns `claude` some other way skips routing, the bridge token and the usage ledger.
- **The child never sees a provider key.** A bridge run gets `ANTHROPIC_BASE_URL=<bridge>` and a short-lived `ccsr_` run token; `buildRunEnv()` strips every inherited `ANTHROPIC_*` first. Keep new provider variables in `PROVIDER_ENV_VARS` or they leak from the server env.
- **The engine is the model's provider, never a dial.** `server.js runEngineFor(model)` (`providers.engineForModel`) is the one rule for chats, tasks, bots and Telegram: the CLI-login provider (`claude-subscription`, marked *subscription* in every list) runs on the tmux engine when tmux exists, everything else headless. Do not reintroduce an engine field — `sessions.run_engine` only records the last turn's engine, `tasks`/`bots.run_engine` are no longer read (boot migration `migrateEngineDial`). The spawn script unsets every provider variable because tmux hands a pane its SERVER's environment.
- **A test server that drives runs through a fake headless `claude` spreads `test/_api-provider-env.js` into its env.** With no gateway the default provider is the CLI login, and in CI (tmux installed, no `ANTHROPIC_BASE_URL`) its models go to the tmux engine — the suite then passes on a developer box whose shell exports `ANTHROPIC_BASE_URL` and fails only in CI.
- **Effort comes from the run, not the request.** The CLI sends `effort: "high"` even with no flag, so the bridge takes `RunCtx.effort`; `Auto` reaches it as `'auto'` (send none).
- **A routing refusal is a stop, not a retry.** `auth-errors.js` classifies it (`provider_unavailable`, `provider_key`) so the auto-continue ladders stop at once.
- **Cost and window off Anthropic come from the ledger**, not from the CLI (it prices unknown ids with its Claude table and assumes 200K): `withProviderUsage()`.
- **The CLI version is pinned** (Dockerfile `CLAUDE_CODE_VERSION`, CI). `test/llm-bridge-contract.test.js` drives the real binary through the bridge; bump the pin and that test together.

### Remote shell environment (issue #59)

Expand Down Expand Up @@ -610,9 +624,10 @@ user had just picked in the modal — and silent about a bot's model, which OVER
the session's inside the runner. `kbRunBadges()` in `public/kanban.html` replaces it.

- **The card mirrors `startTask`, it does not re-derive.** Model is
`taskBot?.model || session?.model || task.model || 'sonnet'`; effort and engine come
off the TASK row (`task.effort`, `task.run_engine`), which is the #79 rule that those
dials drive every run regardless of which session the task uses. The two live in
`taskBot?.model || session?.model || task.model || 'sonnet'`; effort comes off the
TASK row (`task.effort`), which is the #79 rule that it drives every run regardless of
which session the task uses; the engine is derived from that same model
(`runEngineFor` ↔ `kbEngineOf`) — `task.run_engine` is no longer read. The two live in
different files, so `test/kanban-run-badges.test.js` pins the runner's expression as
source text as well as the helper's behaviour — a reorder there makes the card lie
and nothing else notices.
Expand All @@ -629,11 +644,11 @@ the session's inside the runner. `kbRunBadges()` in `public/kanban.html` replace
run is exactly the thing a silent badge would hide.
- **The chain card (`renderChainCard`) still shows `chain.model` alone.** Deliberately
out of scope; a chain has no bot and no per-task engine.
- **Choosing a non-Claude PROVIDER per task is NOT this.** The Kanban worker parses
`claude`'s `stream-json`, and external agents (`config.externalAgents`) reach the
studio only through `/api/delegate` and the terminal, which is fire-and-forget with
no turn budget, no retry and no session resume. Wiring one into `taskWorker` is a new
execution backend, not a dropdown.
- **External agents are still NOT a task backend.** A task can run any provider's model
(the model picker), but external agents (`config.externalAgents`) reach the studio only
through `/api/delegate` and the terminal, which is fire-and-forget with no turn budget,
no retry and no session resume. Wiring one into `taskWorker` is a new execution
backend, not a dropdown.

### Add project from a Git URL (issue #94)

Expand Down
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ RUN apt-get update && apt-get install -y \
git curl python3 python3-pip build-essential tmux \
&& rm -rf /var/lib/apt/lists/*

# Install Claude Code CLI globally
RUN npm install -g @anthropic-ai/claude-code
# Install Claude Code CLI globally — PINNED. The provider bridge (llm-bridge/) translates
# the requests this exact CLI sends; an unpinned install lets any rebuild pull a CLI whose
# request shape nobody has run the contract test against (test/llm-bridge-contract.test.js).
# Bump deliberately, together with that test: docker build --build-arg CLAUDE_CODE_VERSION=…
ARG CLAUDE_CODE_VERSION=2.1.281
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}

# Install opencode CLI globally — an externalAgents engine (config.json), so it
# must be baked into the image rather than installed live: /app is an image
Expand Down
25 changes: 24 additions & 1 deletion auth-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const AUTH_ERROR_RE = new RegExp([
"run\\s+`?claude\\s+login`?\\s+to\\s+(?:re-?)?authenticate",
"not\\s+logged\\s+in\\s+to\\s+claude",
"credentials\\s+(?:are\\s+)?(?:invalid|expired)",
// The studio's own provider router refusing a run (server.js resolveRunTarget): the
// run never started, and retrying cannot start it — same class as a dead login.
"needs\\s+the\\s+llm\\s+bridge,\\s+which\\s+is\\s+not\\s+running",
"model\\s+\"[^\"\\n]{1,200}\"\\s+cannot\\s+run:",
].join('|'), 'i');

/**
Expand All @@ -72,6 +76,21 @@ function isAuthError(text = '') {
// Each carries the ACTION that resolves it, because "authentication failed" without
// a next step is the complaint the issue was filed about.
const AUTH_KINDS = [
{
kind: 'provider_unavailable',
re: /needs\s+the\s+llm\s+bridge,\s+which\s+is\s+not\s+running|model\s+"[^"\n]{1,200}"\s+cannot\s+run:/i,
label: 'The chat\'s model cannot run right now',
hint: 'Open Providers in the sidebar: the provider needs the LLM bridge or has no model enabled — or pick a model of another provider.',
},
{
// A provider from the registry, reached through llm-bridge: the bridge prefixes every
// upstream error with `provider "<label>": `, so this is the key of THAT provider —
// `claude login` would change nothing.
kind: 'provider_key',
re: /provider\s+"[^"\n]{1,60}"\s*:\s*(?:invalid\s+api\s+key|invalid\s+bearer\s+token|authentication)/i,
label: 'The provider rejected its API key',
hint: 'Open Providers in the sidebar and check that provider\'s API key — or switch the chat to a model of another provider.',
},
{
kind: 'oauth_refresh_failed',
re: /oauth\s+session\s+expired|could\s+not\s+be\s+refreshed|failed\s+to\s+refresh\s+(?:the\s+)?(?:oauth\s+)?(?:access\s+)?token|refresh\s+token\s+(?:is\s+)?(?:missing|expired|invalid|revoked)|oauth\s+token\s+(?:has\s+)?expired/i,
Expand Down Expand Up @@ -150,7 +169,11 @@ function detectAuthError({ texts = [], subtype, isError } = {}) {
* @returns {string}
*/
function authErrorNotice(info) {
return `\n\n---\n🔐 **${info.label}.** ${info.hint}\n\nThis is not retried: re-authentication needs a human, so retrying would only repeat the same failure.\n\n> ${info.message}\n\n`;
// A provider stop is a configuration choice, not a login — say which human action it needs.
const why = (info.kind === 'provider_unavailable' || info.kind === 'provider_key')
? 'This is not retried: the provider setup needs a human, so retrying would only repeat the same failure.'
: 'This is not retried: re-authentication needs a human, so retrying would only repeat the same failure.';
return `\n\n---\n🔐 **${info.label}.** ${info.hint}\n\n${why}\n\n> ${info.message}\n\n`;
}

module.exports = {
Expand Down
Loading
Loading