mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
chore: one changeset for the release, and no new em-dashes
Consolidates the four contributor changesets, and rewords the em-dashes this release added in comments and docs. The five in test/settings-save-failure-ui.test.ts stay: they assert existing toast text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb3b08d58b
commit
4960bee779
36 files changed
+117
-106
No files matched your search
@@ -1,9 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Keep parenthesised filenames with spaces clickable as one complete file path, including `.conf` configuration files. For an ambiguous hard wrap, check the original concatenated filename through the existing guarded attachment route when clicked, and try the spaced filename only after a not-found response. Every original response other than not-found retains the normal opener, including workspace files whose extensions the attachment registry rejects. A spaced alternative opens only after successful registration. Preserve clickable row positions and share the spaced-path detector between the terminal and Response Viewer.
|
||||
|
||||
Restrict comma-separated-list detection to parenthesised candidates so existing own-line, list-marker and labelled paths with commas remain whole. A rejected workspace path is named in the attachment-lookup error without describing it as outside the workspace.
|
||||
|
||||
Keep the normal opener for the original filename when both hard-wrap candidates are missing, and exclude path labels and list markers inside parentheses from the lookup key.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Custom endpoints now hand their context window to the CLIs that ask for it. OpenCode and Codex receive the discovered context length on injection (#749), and Codex launches on the selected custom model instead of its default (#750). Endpoint discovery reads each model's context size from `/v1/models` (with a sanity ceiling), with a per-endpoint "Default context length" field for gateways that answer nothing, and boot recovery keeps the saved figure (#754). The "Choose a model" picker also grew a search box that filters the rows as you type.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
The "Choose a model" picker now says which model is actually in use on an endpoint that cannot report it. Only llama-swap answers that question, so behind a gateway such as Bifrost (and behind a bare llama.cpp, Ollama or vLLM) nothing was marked and the list fell back to plain discovery order, apart from the one CLI that happened to have launched there before. A live session whose own injected endpoint is that host now promotes its model as **"In use by a session"**, under its own tag and behind llama-swap's own "Currently loaded" answer, reading only what Codeman injected rather than text scraped off a terminal, and never a stopped or exited session. When nothing is known the picker says so instead of leaving an unmarked wall; its wording claims only that nothing was reported, which stays true for an endpoint that is down, since the route answers every probe failure as `isLlamaSwap: false`. Pinned by test/custom-model-run-menu-ui.test.ts.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
### Thanks
|
||||
|
||||
- @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
|
||||
- @Randalix for the Edit history panel (#693).
|
||||
- @aakhter for relative links that follow your `cd` (#740).
|
||||
- @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
|
||||
- @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
|
||||
- @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
|
||||
|
||||
**Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "<message>" [--urgent] [--file <path>]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
|
||||
|
||||
**Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
|
||||
|
||||
**Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
|
||||
|
||||
**Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
|
||||
|
||||
**Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
|
||||
|
||||
**Fixes.**
|
||||
|
||||
- **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
|
||||
- **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
|
||||
- **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
|
||||
- **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
|
||||
- **Credential files stay out of the file views.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
|
||||
- **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
|
||||
|
||||
**Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
|
||||
|
||||
**Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Terminal links: a relative path printed in the terminal now resolves against the directory the session's pane is in now (tmux `#{pane_current_path}`), not only the session's start directory, so after a `cd` in a shell the link opens the file that is actually there. The value is read from the pane-exit watcher's existing batched pane read (no new tmux call, no new SSE event), published as a runtime-only `cwd` field on the session state, and only changes the base a relative path joins: the workspace confinement and the attachment routing are unchanged. Remote SSH, Docker, direct-PTY and rediscovered sessions keep resolving against the working directory.
|
||||
@@ -232,9 +232,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
|
||||
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||
- **Discord bridge** — opt-in, two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
|
||||
- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
|
||||
- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md)
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones
|
||||
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
|
||||
|
||||
@@ -1259,7 +1259,7 @@ authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id`
|
||||
`total_slots`) for whatever the command did not answer, and OVERWRITES the
|
||||
listed figure for that model; the model's own listing
|
||||
(`context_length`, then `context_window`) only ever fills the gap when
|
||||
neither probe does, and is recorded as a single-slot figure —
|
||||
neither probe does, and is recorded as a single-slot figure:
|
||||
llama-swap lists the PER-SLOT window, so whenever the listed figure is
|
||||
the one taken the stored slot count is 1. A server with no `status`
|
||||
field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no
|
||||
|
||||
@@ -636,17 +636,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
|
||||
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
|
||||
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
|
||||
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) — and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
|
||||
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
|
||||
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
|
||||
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Right now every Codeman session mode defaults to its native cloud backend
|
||||
with no way to redirect a session at any other endpoint from the UI — the
|
||||
closest existing precedent was DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
|
||||
been superseded by deepseek's per-session patch recipe — see the table
|
||||
been superseded by deepseek's per-session patch recipe, see the table
|
||||
below).
|
||||
|
||||
**Scope note**: this plan originally said "local LLM." It now covers any
|
||||
@@ -114,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch.
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml` — a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY` — a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192` — the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
|
||||
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ hosts — these are machine-level infra, not per-user settings.
|
||||
servers state no context-window field in the plain `GET /v1/models`
|
||||
response at all; current llama-swap states one per model (both
|
||||
`context_length` and `context_window`) and gateways like Bifrost pass
|
||||
`context_window` through — that listed figure is the *lowest*-precedence
|
||||
`context_window` through: that listed figure is the *lowest*-precedence
|
||||
source, used only when neither probe below answers, and because llama-swap
|
||||
documents it as the PER-SLOT window, a model whose figure comes from the
|
||||
listing is recorded with a slot count of 1 so the slot check never divides a
|
||||
@@ -82,7 +82,7 @@ llama-swap treats `?model=` as a routing hint and asking about a model that
|
||||
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
|
||||
effect of what should be read-only discovery. A server with no `status` field
|
||||
on any entry at all (not llama-swap) gets no launch-command/`/props`
|
||||
enrichment, only whatever its listings state — rather than guessing. A
|
||||
enrichment, only whatever its listings state, rather than guessing. A
|
||||
model's previously-learned context length survives a
|
||||
later cycle where it wasn't the loaded one; it's dropped only once the model
|
||||
disappears from the endpoint's list entirely. Stored per model in
|
||||
@@ -344,9 +344,9 @@ agent, so the selection would report success and change nothing.
|
||||
**Claude gets two more env vars when known/applicable, both declared on its
|
||||
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
|
||||
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length —
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
|
||||
the discovered figure (see the discovery section above), falling back to
|
||||
the endpoint's user-set `defaultContextLength` — whenever one is known.
|
||||
the endpoint's user-set `defaultContextLength`, whenever one is known.
|
||||
Without
|
||||
it, Claude Code assumes a large (200k) window for any unrecognized custom
|
||||
model id and never compacts, which reliably overflows a much smaller real
|
||||
@@ -602,7 +602,7 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
|
||||
template (`DeepSeek API error (HTTP ${status})`) matches the originally
|
||||
reported symptom exactly. The fix is no longer the original env redirect:
|
||||
deepseek's `customModelInjection` is a `configDir` recipe — a per-session
|
||||
deepseek's `customModelInjection` is a `configDir` recipe: a per-session
|
||||
`DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
|
||||
`codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
|
||||
suffix from the shared `renderConfigFile` step every file template goes
|
||||
@@ -610,7 +610,7 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
|
||||
`settings.yaml` back out of the real dsh home so installed bundles,
|
||||
transcripts and user settings all survive the redirect (missing
|
||||
directory entries are created first — a home that has never run a
|
||||
directory entries are created first: a home that has never run a
|
||||
turn would otherwise get a dangling link whose every write through
|
||||
it failed with ENOENT). **Verified end-to-end against a real dsh
|
||||
install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
|
||||
@@ -619,7 +619,7 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST /v1/chat/completions` with the right model, the bearer key and
|
||||
`max_tokens: 8192`; the reply renders and the context window shows in
|
||||
the footer. What such a session SHARES with the real home is
|
||||
exactly the linked entries — `profiles/`, `sessions/`,
|
||||
exactly the linked entries: `profiles/`, `sessions/`,
|
||||
`settings.yaml`. The last is shared only until the session writes
|
||||
it: dsh saves settings with a temp file and a rename
|
||||
(`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
|
||||
|
||||
@@ -50,7 +50,7 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
||||
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
|
||||
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
|
||||
// `:` — Ollama-style ids like `qwen3:32b` must survive onto the command line.
|
||||
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
|
||||
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
|
||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||
|
||||
@@ -594,13 +594,13 @@ const capabilitiesSchema = z
|
||||
launchModel: launchModelTemplate,
|
||||
// State that must survive the isolated-directory redirect, symlinked by name from
|
||||
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
|
||||
// segments only — the consumer joins them, so a traversal here would escape the
|
||||
// segments only: the consumer joins them, so a traversal here would escape the
|
||||
// real home. See the field's comment in cli-registry/types.ts.
|
||||
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
|
||||
// Which of `linkFromRealHome` are directories (missing ones are created before
|
||||
// linking; missing files are skipped). Same segment pattern, see types.ts.
|
||||
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
|
||||
// The real home's fallback location, relative to the user's home: ONE segment —
|
||||
// The real home's fallback location, relative to the user's home, ONE segment:
|
||||
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
|
||||
// bare `.` are not; the consumer joins it under the user's home, a traversal
|
||||
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
|
||||
|
||||
@@ -1581,7 +1581,7 @@ const DEEPSEEK: CliEntry = {
|
||||
// deliberately replaces the user's own for the session rather than linking it.
|
||||
// `sessions` and `profiles` are directories: dsh creates them itself on a first
|
||||
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
|
||||
// dangling link whose every write fails ENOENT — the consumer creates missing
|
||||
// dangling link whose every write fails ENOENT: the consumer creates missing
|
||||
// directory targets and skips missing file targets like `settings.yaml`.
|
||||
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
|
||||
linkFromRealHomeDirs: ['profiles', 'sessions'],
|
||||
|
||||
@@ -861,11 +861,11 @@ export interface CliCapabilities {
|
||||
*
|
||||
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
|
||||
* copilot declares it — its OpenAI-style route is `<base>/v1/chat/completions`, and a
|
||||
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
|
||||
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
|
||||
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
|
||||
* would be wrong, not just redundant. The per-session config-file recipes do not need
|
||||
* this flag at all — `renderConfigFile()` applies the suffix inside every template
|
||||
* this flag at all: `renderConfigFile()` applies the suffix inside every template
|
||||
* (deepseek's patch included), so no configDir entry carries a raw base URL.
|
||||
*/
|
||||
customModelInjection:
|
||||
@@ -890,11 +890,11 @@ export interface CliCapabilities {
|
||||
launchModel?: string;
|
||||
/**
|
||||
* Names of entries under this CLI's REAL home that must be symlinked into the
|
||||
* isolated directory after the generated file is written — the same trick
|
||||
* isolated directory after the generated file is written: the same trick
|
||||
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
|
||||
* home carries state that must survive the redirect (deepseek's `sessions` transcript
|
||||
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
|
||||
* applying and — for `sessions` — get deleted by `removeConfigDir` on clear/close).
|
||||
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
|
||||
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
|
||||
* in-session settings write turns `settings.yaml` into a local file.
|
||||
* Never include `fileName` itself: the generated file owns that name in the isolated
|
||||
@@ -905,7 +905,7 @@ export interface CliCapabilities {
|
||||
/**
|
||||
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
|
||||
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
|
||||
* dsh does itself on a first turn) before linking — linking a nonexistent directory
|
||||
* dsh does itself on a first turn) before linking: linking a nonexistent directory
|
||||
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
|
||||
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
|
||||
* links serve no purpose the CLI could not do itself). Must be a subset of
|
||||
|
||||
@@ -109,7 +109,7 @@ export interface CustomModelHost {
|
||||
/**
|
||||
* The context window to apply for `modelId` on `host`: the discovered per-model figure when
|
||||
* discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
|
||||
* (no override applied, never a guess). ONE helper for every read site — the swap route,
|
||||
* (no override applied, never a guess). ONE helper for every read site: the swap route,
|
||||
* the quickstart route and boot recovery each re-write the CLI's config files, and a site
|
||||
* that forgot the fallback silently loses the endpoint default on the next respawn.
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ export function customModelConfigDir(sessionId: string): string {
|
||||
* (the dir-redirect var plus any `extraEnv` the config file references by
|
||||
* name). The generated files stay under `baseDir`, but a recipe that declares
|
||||
* `linkFromRealHome` additionally links (and, for missing directory targets,
|
||||
* creates) entries in the CLI's REAL home — the caller is still responsible
|
||||
* creates) entries in the CLI's REAL home: the caller is still responsible
|
||||
* for choosing an isolated `baseDir` (never the user's real `~/.codex`,
|
||||
* `~/.pi`, etc.), it is just no longer the only directory touched.
|
||||
*
|
||||
@@ -46,7 +46,7 @@ export function applyConfigDirInjection(
|
||||
injection: ConfigDirInjection,
|
||||
/**
|
||||
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
|
||||
* set one — a user who relocated the CLI's real home (an allowlisted key like
|
||||
* set one: a user who relocated the CLI's real home (an allowlisted key like
|
||||
* DSH_HOME) must have their state linked from THAT directory, not the server's.
|
||||
*/
|
||||
sessionHomeOverride?: string
|
||||
@@ -66,10 +66,10 @@ export function applyConfigDirInjection(
|
||||
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
|
||||
* generated config file and not the state that has to survive the session (deepseek:
|
||||
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
|
||||
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts —
|
||||
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
|
||||
* this consumer is recipe-driven and names no CLI itself.
|
||||
*
|
||||
* Home resolution: the session's own override first (unless it IS `baseDir` — a re-apply
|
||||
* Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
|
||||
* onto a session already holding a previous injection's value must not self-link), then
|
||||
* the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
|
||||
* are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
|
||||
@@ -101,24 +101,24 @@ function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
|
||||
try {
|
||||
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
|
||||
} catch {
|
||||
// ENOENT — nothing is here yet, the only state in which a link gets created
|
||||
// ENOENT: nothing is here yet, the only state in which a link gets created
|
||||
}
|
||||
if (!existsSync(target)) {
|
||||
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
|
||||
// Missing DIRECTORY target: a dangling directory link is not inert — the CLI's own
|
||||
// Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
|
||||
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
|
||||
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
|
||||
// what dsh itself does on a first turn.
|
||||
try {
|
||||
mkdirSync(target, { recursive: true, mode: 0o700 });
|
||||
} catch {
|
||||
return; // e.g. the target's parent refuses writes — no link rather than a dangling one
|
||||
return; // e.g. the target's parent refuses writes: no link rather than a dangling one
|
||||
}
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only — a platform that refuses symlinks keeps the documented
|
||||
// best-effort only: a platform that refuses symlinks keeps the documented
|
||||
// side effect for this session rather than failing the whole apply over it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@ export interface ConfigDirInjection {
|
||||
* `customModelInjection.launchModel` template, never hand-built per CLI.
|
||||
*/
|
||||
launchModel?: string;
|
||||
/** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHome`. */
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
|
||||
linkFromRealHome?: string[];
|
||||
/** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/** Carried from the registry recipe — see `cli-registry/types.ts` `realHomeFallback`. */
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size — opencode then keeps its own default guess, which is the
|
||||
* for an unknown or non-finite size: opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
@@ -189,8 +189,8 @@ function opencodeModelLimit(contextLength: number | undefined): { context: numbe
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
// `limit.context` is the FULL window as far as opencode is concerned — it reserves for
|
||||
// the reply itself — so the discovered total is passed through, never a shrunk figure.
|
||||
// `limit.context` is the FULL window as far as opencode is concerned: it reserves for
|
||||
// the reply itself, so the discovered total is passed through, never a shrunk figure.
|
||||
return { context: total, output };
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ function renderConfigFile(
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
// By NAME only (never the literal key — see CUSTOM_MODEL_API_KEY_ENV_VAR).
|
||||
// By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
|
||||
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
|
||||
* start and end time. Whether a candidate file was actually produced by the call (its
|
||||
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
|
||||
* the remote host — this module does no IO.
|
||||
* the remote host: this module does no IO.
|
||||
*
|
||||
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
|
||||
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
|
||||
@@ -50,7 +50,7 @@ export interface EditHunk {
|
||||
}
|
||||
|
||||
export interface EditRecord {
|
||||
/** The `tool_use_id` of the edit — unique per edit, used to deduplicate. */
|
||||
/** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
|
||||
id: string;
|
||||
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
|
||||
ts: number;
|
||||
@@ -103,7 +103,7 @@ const EXT_END = '(?![\\w-])(?!\\.\\w)';
|
||||
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`) — may contain spaces up to the extension.
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
|
||||
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
|
||||
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
|
||||
// timestamped name (`plot-12:30.png`) is still found.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*
|
||||
* Sources are the main transcript (`main`) and each subagent transcript
|
||||
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
|
||||
* - `offset` — the next byte to read forward; always a line boundary.
|
||||
* - `floor` — the lowest byte already parsed; > 0 while older lines are unread
|
||||
* - `offset`: the next byte to read forward; always a line boundary.
|
||||
* - `floor`: the lowest byte already parsed; > 0 while older lines are unread
|
||||
* ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
|
||||
* The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
|
||||
* current conversation, a file without one from `budget` bytes before its end, and hands the
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ function breathe(): Promise<void> {
|
||||
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
|
||||
* (files claude sent to the user skip the window: they exist and were shown on purpose).
|
||||
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version — the
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version: the
|
||||
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
|
||||
* in call order so a batch dedupes itself the same way.
|
||||
*/
|
||||
|
||||
@@ -1013,7 +1013,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Resolves the raw route payload rather than a model id so the caller can tell the
|
||||
* three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what
|
||||
* the route answers for EVERY failure it catches (a server without a `/running`
|
||||
* route, a refused or unresolvable endpoint, a 401 from a wrong key) — safe to
|
||||
* route, a refused or unresolvable endpoint, a 401 from a wrong key): safe to
|
||||
* explain in the UI as long as the wording also fits an endpoint that is down; and
|
||||
* `null`, only when Codeman's own request to the route failed or the client-side
|
||||
* bound below won the race, which stays silent, since the endpoint may merely have
|
||||
@@ -1175,7 +1175,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
})
|
||||
.join('');
|
||||
modal.classList.add('active');
|
||||
// A fresh open always starts unfiltered with the caret in the search box — the
|
||||
// A fresh open always starts unfiltered with the caret in the search box: the
|
||||
// list was just rebuilt, so clearing the input is the whole reset; focus is the
|
||||
// user's next keystroke away. Inside the generation guard above, so a
|
||||
// superseded probe can neither focus nor clear a modal that now shows another
|
||||
@@ -1203,7 +1203,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
|
||||
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
|
||||
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
|
||||
* these rows — without it the filter would silently do nothing.
|
||||
* these rows: without it the filter would silently do nothing.
|
||||
*/
|
||||
filterCustomModelPick(value) {
|
||||
const list = document.getElementById('customModelPickList');
|
||||
|
||||
@@ -7815,7 +7815,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
}
|
||||
|
||||
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
|
||||
styling (same reasoning as the .set-scope pill above) — only its own spacing
|
||||
styling (same reasoning as the .set-scope pill above): only its own spacing
|
||||
and the no-matches line are added here. 0.35rem is the standard gap the
|
||||
modal's neighbouring form fields already use (`.form-hint`'s own margin-top):
|
||||
the global `* { margin: 0 }` reset left the box sitting flush under the CLI
|
||||
@@ -22681,7 +22681,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
|
||||
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
|
||||
|
||||
/* "Edit history" header button — opt-in (App Settings → Header & Panels), hidden by default.
|
||||
/* "Edit history" header button: opt-in (App Settings → Header & Panels), hidden by default.
|
||||
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
|
||||
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
|
||||
(mobile.css). */
|
||||
|
||||
@@ -364,7 +364,7 @@ async function discoverModels(
|
||||
// itself calls loaded. Listed figures above are unaffected: reading them triggers nothing.
|
||||
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
|
||||
const slotCounts: Record<string, number> = {};
|
||||
// Ids whose figure THIS round's loaded-model probe answered — the listed figure remains
|
||||
// Ids whose figure THIS round's loaded-model probe answered: the listed figure remains
|
||||
// the source only for the ids NOT in this set (see the per-slot correction below).
|
||||
const ctxFromProbe = new Set<string>();
|
||||
if (hasStatusField) {
|
||||
@@ -412,13 +412,13 @@ async function discoverModels(
|
||||
}
|
||||
}
|
||||
// llama-swap documents the LISTED `context_length` as the PER-SLOT window, so whenever
|
||||
// the listed figure is the one taken this round — the model was not loaded, or the
|
||||
// probe answered nothing — its slot count is 1 by definition: the floor check must NOT
|
||||
// the listed figure is the one taken this round (the model was not loaded, or the
|
||||
// probe answered nothing), its slot count is 1 by definition: the floor check must NOT
|
||||
// divide a per-slot figure a second time. Without recording 1 here, the count a loaded
|
||||
// round stored survives the merge (applyDiscoveredModels keeps what a later round does
|
||||
// not overwrite): 262144 @ 4 slots stored, model unloaded, listed 65536 → 65536 with 4
|
||||
// slots → a false 16384-per-slot warning. Even a count this round's launch command
|
||||
// parsed must yield — pairing a per-slot listed figure with the cmd's --parallel is the
|
||||
// parsed must yield: pairing a per-slot listed figure with the cmd's --parallel is the
|
||||
// same double division. Same rule the /props branch above applies. Ids whose figure the
|
||||
// probe DID answer keep their probe-derived count untouched.
|
||||
for (const id of Object.keys(contextLengths)) {
|
||||
|
||||
+2
-2
@@ -1535,7 +1535,7 @@ export class WebServer extends EventEmitter {
|
||||
// already fired once) and the session-env real-home lookup: the injected VALUES are
|
||||
// never persisted, so after a real restart the session can no longer carry its
|
||||
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
|
||||
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`) — the lookup could only ever hit on the first
|
||||
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
|
||||
// apply in-process. Real-home resolution therefore falls back to the SERVER's
|
||||
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
return applyCustomModelInjection(
|
||||
@@ -1556,7 +1556,7 @@ export class WebServer extends EventEmitter {
|
||||
* bookkeeping must come from the CURRENT recipe, not from the saved record: this is the
|
||||
* first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session
|
||||
* patch home), so a selection saved by an old build restores with e.g. no `configDir`
|
||||
* while the new recipe writes one — and clearing it later could then never delete the
|
||||
* while the new recipe writes one, and clearing it later could then never delete the
|
||||
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
|
||||
* still carry `setenv`s from both, and a clear has to unset every one.
|
||||
*/
|
||||
|
||||
@@ -66,7 +66,7 @@ function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => v
|
||||
|
||||
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
|
||||
it('rejects a realHomeFallback that is not a single relative path segment', () => {
|
||||
// The consumer joins it under the user's home, so any of these escapes that home —
|
||||
// The consumer joins it under the user's home, so any of these escapes that home:
|
||||
// and bare `.` IS that home: linking through it would expose the whole tree
|
||||
// unshielded instead of just the declared segments.
|
||||
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
|
||||
|
||||
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
|
||||
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
|
||||
// loaded under `--ctx-size 262144 --parallel 4` — a TOTAL window shared across slots,
|
||||
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
|
||||
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
|
||||
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
|
||||
// survived, the floor check would divide the per-slot figure a second time
|
||||
@@ -524,7 +524,7 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
|
||||
const [round2] = await readCustomModelHosts(dir);
|
||||
expect(round2.modelContextLengths).toEqual({ m: 65536 });
|
||||
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
|
||||
// the floor check reads 65536/request — exactly what the server gives a single request.
|
||||
// the floor check reads 65536/request: exactly what the server gives a single request.
|
||||
expect(round2.modelSlotCounts).toEqual({ m: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('buildCustomModelInjection', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
// `limit.context` IS the full window to opencode — it reserves for the reply itself —
|
||||
// `limit.context` IS the full window to opencode (it reserves for the reply itself),
|
||||
// so the discovered total passes through; `output` only caps the reply at a quarter.
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
|
||||
});
|
||||
@@ -236,7 +236,7 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
|
||||
expect(result.files[0].content).not.toContain('my-key');
|
||||
const patch = JSON.parse(result.files[0].content);
|
||||
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY —
|
||||
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
|
||||
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
|
||||
// api.deepseek.com on an in-session /model switch back to the official route.
|
||||
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
|
||||
* context-length resolution the routes use: the model's discovered figure, falling back to
|
||||
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
|
||||
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone — so a session
|
||||
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session
|
||||
* on a model that only the endpoint default covers came back from a Codeman restart with
|
||||
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
|
||||
* re-arming exactly the window overflow the var exists to prevent.
|
||||
*
|
||||
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
|
||||
* store and the Session it is handed), so it is driven straight off the prototype with an
|
||||
* empty receiver — `restoreMuxSessions()` itself cannot be reached under vitest, the same
|
||||
* empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same
|
||||
* constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
@@ -22,7 +22,7 @@ import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>;
|
||||
|
||||
/** The real method, with an empty receiver — see the file header for why that is sound. */
|
||||
/** The real method, with an empty receiver, see the file header for why that is sound. */
|
||||
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
|
||||
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
|
||||
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Boot recovery re-derives a saved custom-model selection through
|
||||
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
|
||||
* applied — not from the saved record. A selection persisted while deepseek still used
|
||||
* applied, not from the saved record. A selection persisted while deepseek still used
|
||||
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
|
||||
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
|
||||
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
||||
@@ -11,11 +11,11 @@
|
||||
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
|
||||
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
|
||||
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
|
||||
* recipe's `realHomeFallback` (~/.dsh) — see `_rebuildCustomModelEnv`'s comment.
|
||||
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
|
||||
*
|
||||
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
|
||||
* everything from the endpoint store and the Session it is handed), so it is driven off
|
||||
* the prototype with a minimal receiver — `restoreMuxSessions()` itself cannot be
|
||||
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
|
||||
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
@@ -29,7 +29,7 @@ import { Session } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
/** The real pair, on a receiver that only carries the rebuild helper — see the header. */
|
||||
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
|
||||
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const proto = WebServer.prototype as unknown as {
|
||||
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
// Only for the [hidden] CSS guard below — the filter toggles the `hidden` property, and
|
||||
// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and
|
||||
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
|
||||
// `!important` re-assertion in styles.css.
|
||||
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
|
||||
@@ -697,7 +697,7 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
|
||||
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
|
||||
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
|
||||
// mobile.css), which outranks the UA `[hidden]` rule — without this scoped
|
||||
// mobile.css), which outranks the UA `[hidden]` rule: without this scoped
|
||||
// !important re-assertion, toggling `hidden` would change nothing on screen.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
@@ -848,8 +848,8 @@ describe('Custom Model Endpoint Profiles: gateways without /running fall back to
|
||||
expect(unknown.hasAttribute('hidden')).toBe(false);
|
||||
expect(unknown.textContent).toContain('did not report which model it has loaded');
|
||||
// What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts)
|
||||
// turns EVERY upstream failure into `isLlamaSwap: false` — a gateway with no
|
||||
// `/running` route, a closed port, an unresolvable host, a 401 — so this stub is
|
||||
// turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no
|
||||
// `/running` route, a closed port, an unresolvable host, a 401), so this stub is
|
||||
// also exactly what a DOWN endpoint produces, which is why the line's wording says
|
||||
// only "did not report" and never why.
|
||||
// The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block`
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('DeepSeek custom endpoint isolation', () => {
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
||||
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
|
||||
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
|
||||
// lstat guard must skip every existing link, which the old existsSync check missed —
|
||||
// lstat guard must skip every existing link, which the old existsSync check missed:
|
||||
// recreating them would raise EEXIST.
|
||||
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
|
||||
@@ -87,7 +87,7 @@ describe('DeepSeek custom endpoint isolation', () => {
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
||||
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
||||
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
|
||||
// The missing FILE target is skipped outright: not a dangling link either — lstat
|
||||
// The missing FILE target is skipped outright, not a dangling link either: lstat
|
||||
// must find nothing, so a later apply (once the file exists for real) links it.
|
||||
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
|
||||
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
|
||||
* temp transcripts — what the fakes in edit-history-sync.test.ts cannot show (file order,
|
||||
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
|
||||
* a new conversation next to the old one, every line read exactly once).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -101,7 +101,7 @@ export class MockSession extends EventEmitter {
|
||||
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
|
||||
claudeConfigDirOverride: string | undefined = undefined;
|
||||
|
||||
/** Mirrors Session.claudeConfigDir — the override, trimmed; undefined when unset. */
|
||||
/** Mirrors Session.claudeConfigDir: the override, trimmed; undefined when unset. */
|
||||
claudeConfigDir(): string | undefined {
|
||||
return this.claudeConfigDirOverride?.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -241,8 +241,8 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
expect(existsSync(written)).toBe(true);
|
||||
|
||||
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
|
||||
// the pure build BEFORE any write, so the refusal must leave the dir — and the file
|
||||
// the live CLI reads — exactly as they were, rather than replacing and deleting the
|
||||
// the pure build BEFORE any write, so the refusal must leave the dir, and the file
|
||||
// the live CLI reads, exactly as they were, rather than replacing and deleting the
|
||||
// CODEX_HOME of a session that stays on qwen3:32b.
|
||||
const before = readFileSync(written, 'utf8');
|
||||
const res = await app.inject({
|
||||
|
||||
Reference in new issue
Block a user