Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d518871b | |||
| 053e34d350 | |||
| 78a4f42bc0 | |||
| ac29d26e12 | |||
| a1fe7c7a87 | |||
| cc7b9fc600 |
25
AGENTS.md
25
AGENTS.md
@ -45,7 +45,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return
|
|||||||
src/events.ts Async iterator over the NDJSON/SSE response body.
|
src/events.ts Async iterator over the NDJSON/SSE response body.
|
||||||
src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
|
src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
|
||||||
src/redact.ts Credential scrubbing for error surfaces.
|
src/redact.ts Credential scrubbing for error surfaces.
|
||||||
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
|
src/log.ts Opt-in tracing (toggle file) to a log file.
|
||||||
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
|
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
|
||||||
src/quota.ts Live 5-hour/weekly/monthly quota from the CommandCode alpha billing API.
|
src/quota.ts Live 5-hour/weekly/monthly quota from the CommandCode alpha billing API.
|
||||||
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
|
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
|
||||||
@ -130,9 +130,14 @@ These are load-bearing. Breaking one causes silent failures in opencode.
|
|||||||
`provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to
|
`provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to
|
||||||
discover models (only internal providers can register `discoverModels`). Refresh the map with
|
discover models (only internal providers can register `discoverModels`). Refresh the map with
|
||||||
`npm run sync-models`; do not expect a discovery hook in `src/` to populate it.
|
`npm run sync-models`; do not expect a discovery hook in `src/` to populate it.
|
||||||
13. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from
|
13. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from
|
||||||
**source** (not `dist/`) via `opencode plugin <path>`, which writes a `tui.json` `plugin`
|
**source** (not `dist/`) via a `tui.json` `plugin` entry that must be a **direct `file://` URL
|
||||||
entry; it is exposed as `./tui` in `package.json`. opencode compiles the `.tsx` at load with
|
to `src/tui.tsx`** (global `~/.config/opencode/tui.json` or project `.opencode/tui.json`),
|
||||||
|
wired by hand: `opencode plugin <path>` cannot install a bare `.tsx` (it needs a `package.json`
|
||||||
|
manifest next to the target), and the spec must never point at the **repo root** — a directory
|
||||||
|
spec is also picked up as a server plugin via `package.json` `main` → `dist/index.js` (the
|
||||||
|
provider, not a plugin). Do not re-add a `./tui` export for this; it is deliberately removed.
|
||||||
|
opencode compiles the `.tsx` at load with
|
||||||
its Bun/Solid transform and maps `solid-js` / `@opentui/solid` to its internal modules, so the
|
its Bun/Solid transform and maps `solid-js` / `@opentui/solid` to its internal modules, so the
|
||||||
package stays dependency-free — do not add a build step or real imports of those packages.
|
package stays dependency-free — do not add a build step or real imports of those packages.
|
||||||
`src/tui-shims.d.ts` is the only local stand-in for types; keep it in sync with what the file
|
`src/tui-shims.d.ts` is the only local stand-in for types; keep it in sync with what the file
|
||||||
@ -154,9 +159,9 @@ These are load-bearing. Breaking one causes silent failures in opencode.
|
|||||||
the same `namespace: "palette"` commands the palette lists (both filter out `hidden: true`).
|
the same `namespace: "palette"` commands the palette lists (both filter out `hidden: true`).
|
||||||
A slash-only entry is not expressible; registering `/cc-*` also adds them to `Ctrl+P`.
|
A slash-only entry is not expressible; registering `/cc-*` also adds them to `Ctrl+P`.
|
||||||
15. **Toggles are resolved per request, never at load.** `zdr` and `debug` are read in
|
15. **Toggles are resolved per request, never at load.** `zdr` and `debug` are read in
|
||||||
`model.ts`/`log.ts` on every call from the toggle file / env, so `/cc-*` changes take effect
|
`model.ts`/`log.ts` on every call from the toggle file, so `/cc-*` changes take effect
|
||||||
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
|
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
|
||||||
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts.
|
`x-cmd-zdr` header > toggle file. Do not reintroduce load-time consts.
|
||||||
16. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads
|
16. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads
|
||||||
`/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and
|
`/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and
|
||||||
`/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates
|
`/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates
|
||||||
@ -165,6 +170,14 @@ These are load-bearing. Breaking one causes silent failures in opencode.
|
|||||||
plan; never hardcode a plan→cap table. The TUI resolves the key from `api.state` (provider
|
plan; never hardcode a plan→cap table. The TUI resolves the key from `api.state` (provider
|
||||||
entry, then `config.provider.commandcode.options`, then `COMMANDCODE_API_KEY`), expanding
|
entry, then `config.provider.commandcode.options`, then `COMMANDCODE_API_KEY`), expanding
|
||||||
`{env:VAR}` itself, and must never log the key — quota errors go through `redact()`.
|
`{env:VAR}` itself, and must never log the key — quota errors go through `redact()`.
|
||||||
|
17. **Retryable upstream failures can arrive inside an HTTP 200 stream.** CommandCode's gateway
|
||||||
|
answers `200` and then emits an SSE `error` event carrying `statusCode`/`isRetryable` (e.g.
|
||||||
|
`{"type":"server_error","message":"Invalid error response format: Gateway request failed",
|
||||||
|
"statusCode":520,"isRetryable":true}`). `fetchWithRetry` buffers only the `start`/`start-step`
|
||||||
|
preamble and, if the first real event is such a retryable error and attempts remain, cancels the
|
||||||
|
body and retries with backoff. Once any content event is seen the error is surfaced, not
|
||||||
|
retried (retrying mid-stream would duplicate output). Non-retryable in-stream errors (400s)
|
||||||
|
surface immediately. Keep the preamble set in sync if upstream adds new pre-content events.
|
||||||
|
|
||||||
## Change workflow
|
## Change workflow
|
||||||
|
|
||||||
|
|||||||
88
README.md
88
README.md
@ -48,7 +48,7 @@ npm run build
|
|||||||
"provider": {
|
"provider": {
|
||||||
"commandcode": {
|
"commandcode": {
|
||||||
"name": "Command Code",
|
"name": "Command Code",
|
||||||
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js",
|
"npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
|
||||||
"options": {
|
"options": {
|
||||||
"apiKey": "{env:COMMANDCODE_API_KEY}"
|
"apiKey": "{env:COMMANDCODE_API_KEY}"
|
||||||
},
|
},
|
||||||
@ -99,6 +99,11 @@ without writing. Each row shows the model id and name plus a second line with it
|
|||||||
(`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an
|
(`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an
|
||||||
interactive terminal.
|
interactive terminal.
|
||||||
|
|
||||||
|
Before anything else the script requires a CommandCode API key (from
|
||||||
|
`provider.commandcode.options.apiKey` or `COMMANDCODE_API_KEY`). If neither is present — including
|
||||||
|
when `opencode.json` does not exist yet — it exits and points you at the CommandCode Console
|
||||||
|
(<https://commandcode.ai/studio/provider>) to generate one. It never writes without a key.
|
||||||
|
|
||||||
Then restart opencode and confirm:
|
Then restart opencode and confirm:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@ -141,22 +146,33 @@ global config (`baseURL`/`ccVersion` are read from there too).
|
|||||||
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
|
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
|
||||||
unauthenticated and CommandCode will reject them.
|
unauthenticated and CommandCode will reject them.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
All environment variables read by the provider, its scripts, and its TUI plugin. String values in
|
||||||
|
`options` also accept `{env:NAME}`, which opencode/the TUI expand generically (not a fixed set).
|
||||||
|
|
||||||
|
CommandCode-specific:
|
||||||
|
|
||||||
|
| Name | Purpose | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `COMMANDCODE_API_KEY` | CommandCode API key for `smoke`/`quota`/`sync-models` and the TUI's key resolution; referenced from config as `{env:COMMANDCODE_API_KEY}`. | — |
|
||||||
|
| `COMMANDCODE_TOGGLES_FILE` | Overrides the shared toggle-file path. | `$XDG_CONFIG_HOME/opencode/commandcode-toggles.json` |
|
||||||
|
| `COMMANDCODE_QUOTA_INTERVAL_MS` | Sidebar fallback quota refresh interval in ms. | `300000` (floor `120000`) |
|
||||||
|
|
||||||
|
Standard:
|
||||||
|
|
||||||
|
| Name | Purpose | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `XDG_CONFIG_HOME` | Base config dir for the toggle file. | `~/.config` |
|
||||||
|
| `NO_COLOR` | Disables ANSI colors in the `sync-models` picker (any value). | unset |
|
||||||
|
| `TERM` | `dumb` disables ANSI colors in the `sync-models` picker. | — |
|
||||||
|
|
||||||
## Debug tracing
|
## Debug tracing
|
||||||
|
|
||||||
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file.
|
Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
|
||||||
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
|
request and stream event to a log file. Silent (and no file is created) unless enabled; the log
|
||||||
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
|
path is `<os tempdir>/commandcode-debug.log`. Every line is passed through `redact()` so
|
||||||
[Runtime toggles](#runtime-toggles)).
|
credentials never reach disk.
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:COMMANDCODE_DEBUG = "1"
|
|
||||||
opencode run "Reply with exactly: pong" -m commandcode/deepseek/deepseek-v4.1-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
| Env var | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `COMMANDCODE_DEBUG` | unset | `1`/`true`/`yes` enables tracing. |
|
|
||||||
| `COMMANDCODE_DEBUG_FILE` | `<os tempdir>/commandcode-debug.log` | Where the trace is appended. |
|
|
||||||
|
|
||||||
The log captures the request (model id, body byte length), each HTTP attempt (status, retry
|
The log captures the request (model id, body byte length), each HTTP attempt (status, retry
|
||||||
wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
|
wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
|
||||||
@ -165,8 +181,8 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue
|
|||||||
## ZDR header toggle
|
## ZDR header toggle
|
||||||
|
|
||||||
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some
|
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some
|
||||||
models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash
|
models. It can be enabled with the `/cc-zdr` slash command or
|
||||||
command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
|
`providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
|
||||||
does not require restarting opencode.
|
does not require restarting opencode.
|
||||||
|
|
||||||
Precedence, highest first:
|
Precedence, highest first:
|
||||||
@ -174,7 +190,6 @@ Precedence, highest first:
|
|||||||
1. `providerOptions.commandcode.zdr` (`true`/`false`), e.g. a model variant or agent option.
|
1. `providerOptions.commandcode.zdr` (`true`/`false`), e.g. a model variant or agent option.
|
||||||
2. An explicit `x-cmd-zdr` header in `opencode.json` `options.headers` or the call's `headers`.
|
2. An explicit `x-cmd-zdr` header in `opencode.json` `options.headers` or the call's `headers`.
|
||||||
3. The toggle file (`zdr` key — see [Runtime toggles](#runtime-toggles)).
|
3. The toggle file (`zdr` key — see [Runtime toggles](#runtime-toggles)).
|
||||||
4. `COMMANDCODE_ZDR=1|true|yes` in the environment.
|
|
||||||
|
|
||||||
With tracing enabled, the trace logs a `zdr on|off` line per request so the toggle state is
|
With tracing enabled, the trace logs a `zdr on|off` line per request so the toggle state is
|
||||||
observable.
|
observable.
|
||||||
@ -184,18 +199,32 @@ observable.
|
|||||||
`/cc-zdr` and `/cc-debug` flip the `zdr` and `debug` values in a small JSON file the provider
|
`/cc-zdr` and `/cc-debug` flip the `zdr` and `debug` values in a small JSON file the provider
|
||||||
reads on every request, so both settings change without restarting opencode. `/cc-status` shows
|
reads on every request, so both settings change without restarting opencode. `/cc-status` shows
|
||||||
the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with
|
the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with
|
||||||
`COMMANDCODE_TOGGLES_FILE`); the environment variables above are used when a key is absent.
|
`COMMANDCODE_TOGGLES_FILE` — see [Environment variables](#environment-variables)).
|
||||||
|
|
||||||
These commands come from a small TUI plugin shipped in this package (`src/tui.tsx`). It is loaded
|
These commands come from a small TUI plugin shipped in this package (`src/tui.tsx`). It is loaded
|
||||||
from **source** — opencode compiles the TSX and provides the Solid runtime itself — so there is no
|
from **source** — opencode compiles the TSX and provides the Solid runtime itself — so there is no
|
||||||
build step and no runtime dependency for it. Register it once:
|
build step and no runtime dependency for it. Register it once by adding the plugin file's absolute
|
||||||
|
`file://` URL to the `plugin` list in `tui.json` (global `~/.config/opencode/tui.json`, or
|
||||||
|
project-local `.opencode/tui.json`):
|
||||||
|
|
||||||
```powershell
|
```jsonc
|
||||||
opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx
|
// tui.json
|
||||||
|
{
|
||||||
|
"plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with
|
Use an absolute path with forward slashes (`file:///C:/...` on Windows, `file:///home/...` on
|
||||||
`--global`). Restart opencode after installing; then type `/cc-` for autocomplete. The plugin also
|
Linux/macOS). Two rules for the entry:
|
||||||
|
|
||||||
|
- It must point at **`src/tui.tsx` itself**. `opencode plugin <module>` cannot install this form:
|
||||||
|
the CLI requires a `package.json` manifest next to the target, so a bare `.tsx` must be wired in
|
||||||
|
by hand as above.
|
||||||
|
- Never point `plugin` at the **repo root**. A directory spec makes opencode also treat the package
|
||||||
|
as a server plugin (via `package.json` `main` → `dist/index.js`), which is the AI SDK provider,
|
||||||
|
not a plugin — it loads as dead weight into every session.
|
||||||
|
|
||||||
|
Restart opencode after registering the plugin; then type `/cc-` for autocomplete. The plugin also
|
||||||
renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, plus live quota, above the
|
renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, plus live quota, above the
|
||||||
built-in panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the
|
built-in panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the
|
||||||
`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
|
`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
|
||||||
@ -224,7 +253,9 @@ instead of being shown as zero.
|
|||||||
The sidebar refreshes after each completed turn (`session.idle`, plus `session.status`,
|
The sidebar refreshes after each completed turn (`session.idle`, plus `session.status`,
|
||||||
`session.updated`, and `message.updated` as fallbacks since `session.idle` is a server-plugin
|
`session.updated`, and `message.updated` as fallbacks since `session.idle` is a server-plugin
|
||||||
event that may never reach the TUI bus — all debounced) and when the active session changes,
|
event that may never reach the TUI bus — all debounced) and when the active session changes,
|
||||||
plus every 3 minutes as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the
|
but never more than once every 2 minutes; every 5 minutes as a fallback
|
||||||
|
(`COMMANDCODE_QUOTA_INTERVAL_MS` overrides the fallback interval — see
|
||||||
|
[Environment variables](#environment-variables)) and the
|
||||||
countdown ticks every 30 seconds. Key resolution and fetch failures are redacted and shown
|
countdown ticks every 30 seconds. Key resolution and fetch failures are redacted and shown
|
||||||
inline; the panel never stays on `loading…` and never blocks the provider. Extra trace lines
|
inline; the panel never stays on `loading…` and never blocks the provider. Extra trace lines
|
||||||
(`tui-quota`) are appended to the debug log only when debug tracing is on.
|
(`tui-quota`) are appended to the debug log only when debug tracing is on.
|
||||||
@ -257,7 +288,7 @@ The API key is never logged; quota errors pass through `redact()` like every oth
|
|||||||
| Finish reasons | Yes — unified (`stop`, `length`, `tool-calls`, `content-filter`, `error`, `other`) plus raw |
|
| Finish reasons | Yes — unified (`stop`, `length`, `tool-calls`, `content-filter`, `error`, `other`) plus raw |
|
||||||
| Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
|
| Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
|
||||||
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
|
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
|
||||||
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` |
|
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After`; also retryable `error` events that arrive inside an HTTP 200 stream before any content (gateway 520s) |
|
||||||
| Credential redaction | Yes — error bodies are scrubbed before surfacing |
|
| Credential redaction | Yes — error bodies are scrubbed before surfacing |
|
||||||
| Runtime toggles | Yes — `/cc-zdr` and `/cc-debug` flip the shared toggle file without a restart |
|
| Runtime toggles | Yes — `/cc-zdr` and `/cc-debug` flip the shared toggle file without a restart |
|
||||||
| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
|
| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
|
||||||
@ -294,7 +325,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON s
|
|||||||
src/events.ts NDJSON/SSE line iterator over the upstream response body.
|
src/events.ts NDJSON/SSE line iterator over the upstream response body.
|
||||||
src/usage.ts finish event -> V3 usage; finish-reason unification.
|
src/usage.ts finish event -> V3 usage; finish-reason unification.
|
||||||
src/redact.ts Credential scrubbing for error surfaces.
|
src/redact.ts Credential scrubbing for error surfaces.
|
||||||
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
|
src/log.ts Opt-in tracing (toggle file) to a log file.
|
||||||
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin.
|
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin.
|
||||||
src/quota.ts Live 5-hour/weekly/monthly quota from the alpha billing endpoints.
|
src/quota.ts Live 5-hour/weekly/monthly quota from the alpha billing endpoints.
|
||||||
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
|
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
|
||||||
@ -360,9 +391,10 @@ opencode run "Use the glob tool to list *.mjs and report the filenames." -m comm
|
|||||||
| `stream ended ... no finish` / truncated output | Upstream closed the connection early. The provider emits a synthetic `finish`, but the response is incomplete; retry the turn. |
|
| `stream ended ... no finish` / truncated output | Upstream closed the connection early. The provider emits a synthetic `finish`, but the response is incomplete; retry the turn. |
|
||||||
| Images are ignored by the model | The selected model is not vision-capable. Mark it with `"attachment": true` and `modalities.input: ["text","image"]` in `models`, and pick a vision model id. |
|
| Images are ignored by the model | The selected model is not vision-capable. Mark it with `"attachment": true` and `modalities.input: ["text","image"]` in `models`, and pick a vision model id. |
|
||||||
| Upstream 400 about tool calls | A `tool-call` or `tool-result` without a matching pair slipped through. Pairing is enforced in `src/transform.ts`; report a repro if it still occurs. |
|
| Upstream 400 about tool calls | A `tool-call` or `tool-result` without a matching pair slipped through. Pairing is enforced in `src/transform.ts`; report a repro if it still occurs. |
|
||||||
| Need to see what the provider sends/receives | Set `COMMANDCODE_DEBUG=1` and read the appended log file (see [Debug tracing](#debug-tracing)). |
|
| Need to see what the provider sends/receives | Flip `/cc-debug` and read the appended log file (see [Debug tracing](#debug-tracing)). |
|
||||||
| `tool_choice` seemingly ignored | Expected for `required`; upstream cannot force a call. `none` and named-tool are emulated via the tool list. |
|
| `tool_choice` seemingly ignored | Expected for `required`; upstream cannot force a call. `none` and named-tool are emulated via the tool list. |
|
||||||
| Config change had no effect | opencode reads config once at startup. Restart it. |
|
| Config change had no effect | opencode reads config once at startup. Restart it. |
|
||||||
|
| `/cc-*` commands and the sidebar panel are missing | TUI plugin not registered: add a direct `file://` URL to `src/tui.tsx` in `tui.json` (see [Runtime toggles](#runtime-toggles)). A repo-root or `src/tui.tsx`-less entry does not load. |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,6 @@
|
|||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
},
|
|
||||||
"./tui": {
|
|
||||||
"import": "./src/tui.tsx"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -7,15 +7,16 @@
|
|||||||
// the result into the global opencode config. Deselecting a model removes it from the config.
|
// the result into the global opencode config. Deselecting a model removes it from the config.
|
||||||
//
|
//
|
||||||
// Run: node scripts/sync-models.mjs (build first: npm run build)
|
// Run: node scripts/sync-models.mjs (build first: npm run build)
|
||||||
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { createCommandCode } from "../dist/index.js";
|
import { createCommandCode } from "../dist/index.js";
|
||||||
import { redact } from "../dist/redact.js";
|
import { redact } from "../dist/redact.js";
|
||||||
import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js";
|
import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js";
|
||||||
|
|
||||||
const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json");
|
const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json");
|
||||||
|
const CONSOLE_URL = "https://commandcode.ai/studio/provider";
|
||||||
const CONCURRENCY = 6;
|
const CONCURRENCY = 6;
|
||||||
|
|
||||||
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
|
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
|
||||||
@ -36,8 +37,15 @@ const NO_IMAGE =
|
|||||||
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
|
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
|
||||||
|
|
||||||
function readConfig(path) {
|
function readConfig(path) {
|
||||||
|
try {
|
||||||
const text = readFileSync(path, "utf8");
|
const text = readFileSync(path, "utf8");
|
||||||
return { text, data: JSON.parse(text) };
|
return { text, data: JSON.parse(text), exists: true };
|
||||||
|
} catch (error) {
|
||||||
|
// A fresh install has no opencode.json yet; treat it as empty so the console
|
||||||
|
// guidance / env-key path can still run. Bad JSON or unreadable files still throw.
|
||||||
|
if (error.code === "ENOENT") return { text: "", data: {}, exists: false };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectIndent(text) {
|
function detectIndent(text) {
|
||||||
@ -360,23 +368,36 @@ function pickModels(catalog, existingIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
||||||
console.error("Interactive picker needs a TTY; nothing was written.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let config;
|
let config;
|
||||||
let text;
|
let text;
|
||||||
|
let exists;
|
||||||
try {
|
try {
|
||||||
({ text, data: config } = readConfig(CONFIG_PATH));
|
({ text, data: config, exists } = readConfig(CONFIG_PATH));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mandatory first step: without a CommandCode key there is nothing to fetch or
|
||||||
|
// probe, so point a fresh user at the Console before doing anything else.
|
||||||
const key = resolveKey(config, process.env);
|
const key = resolveKey(config, process.env);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey");
|
const where = exists
|
||||||
|
? `No CommandCode API key in ${CONFIG_PATH}.`
|
||||||
|
: `No opencode.json found at ${CONFIG_PATH}.`;
|
||||||
|
console.error(
|
||||||
|
`${where}\n` +
|
||||||
|
`Generate an API key in the CommandCode Console: ${CONSOLE_URL}\n` +
|
||||||
|
(exists
|
||||||
|
? `Add it as provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`
|
||||||
|
: `Create that file with provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`) +
|
||||||
|
`\nNothing was written.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||||
|
console.error("Interactive picker needs a TTY; nothing was written.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,7 +468,9 @@ async function main() {
|
|||||||
config.provider.commandcode.models = merged;
|
config.provider.commandcode.models = merged;
|
||||||
|
|
||||||
const indent = detectIndent(text);
|
const indent = detectIndent(text);
|
||||||
const trailing = text.endsWith("\n") ? "\n" : "";
|
const trailing = text === "" || text.endsWith("\n") ? "\n" : "";
|
||||||
|
// The config directory may not exist yet on a fresh install; create it before the atomic write.
|
||||||
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
|
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
12
src/log.ts
12
src/log.ts
@ -1,4 +1,4 @@
|
|||||||
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file;
|
// Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
|
||||||
// silent (and no file created) by default. Uses only Node built-ins so the built output
|
// silent (and no file created) by default. Uses only Node built-ins so the built output
|
||||||
// stays dependency-free.
|
// stays dependency-free.
|
||||||
|
|
||||||
@ -6,9 +6,9 @@ import { appendFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { redact } from "./redact.js";
|
import { redact } from "./redact.js";
|
||||||
import { debugFromEnvOrFile } from "./toggles.js";
|
import { debugEnabled } from "./toggles.js";
|
||||||
|
|
||||||
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
|
const FILE = join(tmpdir(), "commandcode-debug.log");
|
||||||
|
|
||||||
function serialize(value: unknown): string {
|
function serialize(value: unknown): string {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
@ -34,14 +34,14 @@ function emit(scope: string, args: unknown[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effective default tracing state (env var or toggle file). Re-read on every call. */
|
/** Effective default tracing state (toggle file). Re-read on every call. */
|
||||||
export function isDebugEnabled(): boolean {
|
export function isDebugEnabled(): boolean {
|
||||||
return debugFromEnvOrFile();
|
return debugEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
|
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
|
||||||
export function debug(scope: string, ...args: unknown[]): void {
|
export function debug(scope: string, ...args: unknown[]): void {
|
||||||
if (!debugFromEnvOrFile()) return;
|
if (!debugEnabled()) return;
|
||||||
emit(scope, args);
|
emit(scope, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
src/model.ts
118
src/model.ts
@ -21,12 +21,12 @@ import {
|
|||||||
import { iterateEvents } from "./events.js";
|
import { iterateEvents } from "./events.js";
|
||||||
import { debugWhen, isDebugEnabled } from "./log.js";
|
import { debugWhen, isDebugEnabled } from "./log.js";
|
||||||
import { redact } from "./redact.js";
|
import { redact } from "./redact.js";
|
||||||
import { zdrFromEnvOrFile } from "./toggles.js";
|
import { zdrEnabled } from "./toggles.js";
|
||||||
import { transform } from "./transform.js";
|
import { transform } from "./transform.js";
|
||||||
import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js";
|
import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js";
|
||||||
|
|
||||||
// x-cmd-zdr breaks some models; off by default (server.py never sends it).
|
// x-cmd-zdr breaks some models; off by default (server.py never sends it).
|
||||||
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR.
|
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
|
||||||
// "undefined" means the header is omitted entirely.
|
// "undefined" means the header is omitted entirely.
|
||||||
function providerScoped(options: LanguageModelV3CallOptions): Record<string, unknown> | undefined {
|
function providerScoped(options: LanguageModelV3CallOptions): Record<string, unknown> | undefined {
|
||||||
return options.providerOptions?.["commandcode"] as Record<string, unknown> | undefined;
|
return options.providerOptions?.["commandcode"] as Record<string, unknown> | undefined;
|
||||||
@ -44,7 +44,7 @@ function resolveZdr(
|
|||||||
if (typeof scoped === "boolean") return scoped ? "1" : "0";
|
if (typeof scoped === "boolean") return scoped ? "1" : "0";
|
||||||
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
|
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
|
||||||
if (header !== undefined) return truthyHeader(header) ? "1" : "0";
|
if (header !== undefined) return truthyHeader(header) ? "1" : "0";
|
||||||
return zdrFromEnvOrFile() ? "1" : undefined;
|
return zdrEnabled() ? "1" : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
|
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
|
||||||
@ -109,6 +109,50 @@ function isRetryable(status: number): boolean {
|
|||||||
return status === 429 || (status >= 500 && status < 600);
|
return status === 429 || (status >= 500 && status < 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upstream can answer HTTP 200 and then fail inside the stream (SSE `error` event).
|
||||||
|
// CommandCode's gateway marks these with `statusCode`/`isRetryable`; retry before content.
|
||||||
|
type StreamErrorInfo = { message: string; statusCode?: number; isRetryable?: boolean };
|
||||||
|
|
||||||
|
function streamErrorInfo(evt: Record<string, any>): StreamErrorInfo | null {
|
||||||
|
if (evt?.["type"] !== "error") return null;
|
||||||
|
const err = evt["error"];
|
||||||
|
if (err === null || typeof err !== "object") return { message: String(err) };
|
||||||
|
const record = err as Record<string, unknown>;
|
||||||
|
const message = typeof record["message"] === "string" ? record["message"] : JSON.stringify(err);
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
...(typeof record["statusCode"] === "number" ? { statusCode: record["statusCode"] } : {}),
|
||||||
|
...(typeof record["isRetryable"] === "boolean" ? { isRetryable: record["isRetryable"] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableStreamError(info: StreamErrorInfo): boolean {
|
||||||
|
return info.isRetryable === true || (info.statusCode !== undefined && isRetryable(info.statusCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only `start`/`start-step` are safe to buffer while looking for an immediate stream error;
|
||||||
|
// once real output appears a retry would duplicate the response.
|
||||||
|
const STREAM_PREAMBLE = new Set(["start", "start-step"]);
|
||||||
|
const MAX_PEEK_EVENTS = 32;
|
||||||
|
|
||||||
|
type OpenedStream = {
|
||||||
|
response: Response;
|
||||||
|
prefix: Record<string, any>[];
|
||||||
|
events: AsyncGenerator<Record<string, any>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function* emptyEvents(): AsyncGenerator<Record<string, any>> {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* replayEvents(
|
||||||
|
prefix: Record<string, any>[],
|
||||||
|
rest: AsyncGenerator<Record<string, any>>,
|
||||||
|
): AsyncGenerator<Record<string, any>> {
|
||||||
|
for (const evt of prefix) yield evt;
|
||||||
|
for await (const evt of rest) yield evt;
|
||||||
|
}
|
||||||
|
|
||||||
function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number {
|
function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number {
|
||||||
if (retryAfter) {
|
if (retryAfter) {
|
||||||
const seconds = Number(retryAfter);
|
const seconds = Number(retryAfter);
|
||||||
@ -186,7 +230,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
body: string,
|
body: string,
|
||||||
options: LanguageModelV3CallOptions,
|
options: LanguageModelV3CallOptions,
|
||||||
dbg: boolean,
|
dbg: boolean,
|
||||||
): Promise<Response> {
|
): Promise<OpenedStream> {
|
||||||
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
|
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
const url = `${this.opts.baseURL}${GENERATE_PATH}`;
|
const url = `${this.opts.baseURL}${GENERATE_PATH}`;
|
||||||
@ -247,8 +291,60 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
|
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
|
||||||
return response;
|
return { response, prefix: [], events: emptyEvents() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 200 can still hide an upstream failure. Buffer only the preamble; if the first
|
||||||
|
// real event is a retryable stream error, retry before anything reaches the client.
|
||||||
|
const events = iterateEvents(response.body, dbg);
|
||||||
|
const prefix: Record<string, any>[] = [];
|
||||||
|
let streamRetry: StreamErrorInfo | undefined;
|
||||||
|
while (prefix.length < MAX_PEEK_EVENTS) {
|
||||||
|
const next = await events.next();
|
||||||
|
if (next.done) break;
|
||||||
|
const evt = next.value;
|
||||||
|
prefix.push(evt);
|
||||||
|
const info = streamErrorInfo(evt);
|
||||||
|
if (info) {
|
||||||
|
if (isRetryableStreamError(info) && attempt < this.opts.maxRetries) streamRetry = info;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!STREAM_PREAMBLE.has(evt["type"])) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRetry) {
|
||||||
|
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
|
||||||
|
debugWhen(
|
||||||
|
dbg,
|
||||||
|
"fetch",
|
||||||
|
"stream-retry",
|
||||||
|
`url=${url}`,
|
||||||
|
`attempt=${attempt}`,
|
||||||
|
`status=${streamRetry.statusCode ?? "unknown"}`,
|
||||||
|
`message=${redact(streamRetry.message)}`,
|
||||||
|
`waitMs=${wait}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await events.return(undefined as never);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await response.body.cancel();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
throw options.abortSignal.reason ?? new Error("Aborted");
|
||||||
|
}
|
||||||
|
await sleep(wait);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
|
||||||
|
return { response, prefix, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
|
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
|
||||||
@ -279,11 +375,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
const dbg = resolveDebug(options);
|
const dbg = resolveDebug(options);
|
||||||
const body = transform(options, this.modelId);
|
const body = transform(options, this.modelId);
|
||||||
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
|
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
|
||||||
const response = await this.fetchWithRetry(body, options, dbg);
|
const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
|
||||||
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
|
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
|
||||||
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
|
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
|
||||||
|
|
||||||
const stream = toReadableStream(this.streamParts(response.body, dbg));
|
const stream = toReadableStream(this.streamParts(prefix, events, dbg));
|
||||||
return {
|
return {
|
||||||
stream,
|
stream,
|
||||||
request: { body: JSON.parse(body) as unknown },
|
request: { body: JSON.parse(body) as unknown },
|
||||||
@ -357,7 +453,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
|
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
|
private async *streamParts(
|
||||||
|
prefix: Record<string, any>[],
|
||||||
|
events: AsyncGenerator<Record<string, any>>,
|
||||||
|
dbg: boolean,
|
||||||
|
): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||||
yield { type: "stream-start", warnings: [] };
|
yield { type: "stream-start", warnings: [] };
|
||||||
|
|
||||||
const textId = "text-0";
|
const textId = "text-0";
|
||||||
@ -372,7 +472,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
let marketCost: number | undefined;
|
let marketCost: number | undefined;
|
||||||
let errored = false;
|
let errored = false;
|
||||||
|
|
||||||
for await (const evt of iterateEvents(body, dbg)) {
|
for await (const evt of replayEvents(prefix, events)) {
|
||||||
const payload = JSON.stringify(evt);
|
const payload = JSON.stringify(evt);
|
||||||
debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
|
debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
|
||||||
switch (evt.type) {
|
switch (evt.type) {
|
||||||
|
|||||||
@ -45,17 +45,11 @@ export function toggle(name: ToggleName): Toggles {
|
|||||||
return writeToggles({ [name]: current[name] !== true });
|
return writeToggles({ [name]: current[name] !== true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function envFlag(name: string): boolean {
|
/** Toggle state from the shared flag file; an absent key means off. */
|
||||||
return /^(1|true|yes)$/i.test(process.env[name] ?? "");
|
export function zdrEnabled(): boolean {
|
||||||
|
return readToggles().zdr === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flag file wins over the environment; unset file falls back to the env var. */
|
export function debugEnabled(): boolean {
|
||||||
export function zdrFromEnvOrFile(): boolean {
|
return readToggles().debug === true;
|
||||||
const file = readToggles().zdr;
|
|
||||||
return typeof file === "boolean" ? file : envFlag("COMMANDCODE_ZDR");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugFromEnvOrFile(): boolean {
|
|
||||||
const file = readToggles().debug;
|
|
||||||
return typeof file === "boolean" ? file : envFlag("COMMANDCODE_DEBUG");
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/tui.tsx
26
src/tui.tsx
@ -21,7 +21,7 @@ import { createSignal } from "solid-js";
|
|||||||
|
|
||||||
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
|
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
|
||||||
import { redact } from "./redact.js";
|
import { redact } from "./redact.js";
|
||||||
import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
|
import { debugEnabled, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
|
||||||
|
|
||||||
type ToastVariant = "info" | "success" | "warning" | "error";
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
@ -69,9 +69,11 @@ type TuiApi = {
|
|||||||
const ID = "commandcode-toggles";
|
const ID = "commandcode-toggles";
|
||||||
const CATEGORY = "CommandCode";
|
const CATEGORY = "CommandCode";
|
||||||
const SIDEBAR_ORDER = 90;
|
const SIDEBAR_ORDER = 90;
|
||||||
const DEFAULT_QUOTA_INTERVAL_MS = 180_000;
|
const DEFAULT_QUOTA_INTERVAL_MS = 300_000;
|
||||||
|
const MIN_REFRESH_INTERVAL_MS = 120_000;
|
||||||
// Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server
|
// Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server
|
||||||
// plugin event and may never fire here; these keep the panel fresh regardless.
|
// plugin event and may never fire here; these keep the panel fresh regardless.
|
||||||
|
// All event-driven refreshes are floored to MIN_REFRESH_INTERVAL_MS (hard skip).
|
||||||
const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const;
|
const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const;
|
||||||
|
|
||||||
function safeEnv(name: string): string | undefined {
|
function safeEnv(name: string): string | undefined {
|
||||||
@ -84,8 +86,8 @@ function safeEnv(name: string): string | undefined {
|
|||||||
|
|
||||||
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
|
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
|
||||||
function trace(...args: unknown[]): void {
|
function trace(...args: unknown[]): void {
|
||||||
if (!debugFromEnvOrFile()) return;
|
if (!debugEnabled()) return;
|
||||||
const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log");
|
const file = join(tmpdir(), "commandcode-debug.log");
|
||||||
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
|
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
|
||||||
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
|
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
|
||||||
try {
|
try {
|
||||||
@ -168,6 +170,11 @@ function quotaIntervalMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS;
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatClock(ms: number): string {
|
||||||
|
if (!(ms > 0)) return "--:--:--";
|
||||||
|
return new Date(ms).toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
function shortWindow(w: QuotaWindow, nowMs: number): string {
|
function shortWindow(w: QuotaWindow, nowMs: number): string {
|
||||||
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
|
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
|
||||||
const reset = formatReset(w.resetAtMs, nowMs);
|
const reset = formatReset(w.resetAtMs, nowMs);
|
||||||
@ -180,9 +187,11 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
const [toggles, setToggles] = createSignal(readToggles());
|
const [toggles, setToggles] = createSignal(readToggles());
|
||||||
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
|
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
|
||||||
const [now, setNow] = createSignal(Date.now());
|
const [now, setNow] = createSignal(Date.now());
|
||||||
|
const [updatedAt, setUpdatedAt] = createSignal(0);
|
||||||
|
|
||||||
let inflight = false;
|
let inflight = false;
|
||||||
let pending = false;
|
let pending = false;
|
||||||
|
let lastRefreshAt = 0;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let lastSessionId: string | undefined;
|
let lastSessionId: string | undefined;
|
||||||
|
|
||||||
@ -194,6 +203,11 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
trace("coalesced", "inflight");
|
trace("coalesced", "inflight");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const since = Date.now() - lastRefreshAt;
|
||||||
|
if (lastRefreshAt > 0 && since < MIN_REFRESH_INTERVAL_MS) {
|
||||||
|
trace("rate-limited", `remainingMs=${MIN_REFRESH_INTERVAL_MS - since}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
inflight = true;
|
inflight = true;
|
||||||
try {
|
try {
|
||||||
const { key: apiKey, source } = resolveApiKeySource(api);
|
const { key: apiKey, source } = resolveApiKeySource(api);
|
||||||
@ -222,7 +236,9 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
trace("fetch-throw", message);
|
trace("fetch-throw", message);
|
||||||
} finally {
|
} finally {
|
||||||
inflight = false;
|
inflight = false;
|
||||||
|
lastRefreshAt = Date.now();
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
|
setUpdatedAt(Date.now());
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending = false;
|
pending = false;
|
||||||
trace("flush-pending");
|
trace("flush-pending");
|
||||||
@ -284,6 +300,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
) : quota() === null ? (
|
) : quota() === null ? (
|
||||||
<text fg={theme.current.textMuted}>quota: loading…</text>
|
<text fg={theme.current.textMuted}>quota: loading…</text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<text fg={theme.current.textMuted}>updated @ {formatClock(updatedAt())}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -365,6 +382,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
}
|
}
|
||||||
setQuota(result);
|
setQuota(result);
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
|
setUpdatedAt(Date.now());
|
||||||
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
|
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user