Compare commits

..

No commits in common. "master" and "v0.0.1c" have entirely different histories.

8 changed files with 79 additions and 256 deletions

View File

@ -45,7 +45,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return
src/events.ts Async iterator over the NDJSON/SSE response body.
src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (toggle file) to a log file.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
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/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
@ -130,14 +130,9 @@ 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
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.
13. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from
**source** (not `dist/`) via a `tui.json` `plugin` entry that must be a **direct `file://` URL
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
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`
entry; it is exposed as `./tui` in `package.json`. opencode compiles the `.tsx` at load with
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.
`src/tui-shims.d.ts` is the only local stand-in for types; keep it in sync with what the file
@ -159,9 +154,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`).
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
`model.ts`/`log.ts` on every call from the toggle file, so `/cc-*` changes take effect
`model.ts`/`log.ts` on every call from the toggle file / env, so `/cc-*` changes take effect
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
`x-cmd-zdr` header > toggle file. Do not reintroduce load-time consts.
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts.
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/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates
@ -170,14 +165,6 @@ 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
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()`.
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

View File

@ -48,7 +48,7 @@ npm run build
"provider": {
"commandcode": {
"name": "Command Code",
"npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js",
"options": {
"apiKey": "{env:COMMANDCODE_API_KEY}"
},
@ -99,11 +99,6 @@ 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
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:
```powershell
@ -146,33 +141,22 @@ global config (`baseURL`/`ccVersion` are read from there too).
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
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
Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
request and stream event to a log file. Silent (and no file is created) unless enabled; the log
path is `<os tempdir>/commandcode-debug.log`. Every line is passed through `redact()` so
credentials never reach disk.
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file.
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
[Runtime toggles](#runtime-toggles)).
```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
wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
@ -181,8 +165,8 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue
## ZDR header toggle
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some
models. It can be enabled with the `/cc-zdr` slash command or
`providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash
command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
does not require restarting opencode.
Precedence, highest first:
@ -190,6 +174,7 @@ Precedence, highest first:
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`.
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
observable.
@ -199,32 +184,18 @@ observable.
`/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
the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with
`COMMANDCODE_TOGGLES_FILE` — see [Environment variables](#environment-variables)).
`COMMANDCODE_TOGGLES_FILE`); the environment variables above are used when a key is absent.
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
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`):
build step and no runtime dependency for it. Register it once:
```jsonc
// tui.json
{
"plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
}
```powershell
opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx
```
Use an absolute path with forward slashes (`file:///C:/...` on Windows, `file:///home/...` on
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
That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with
`--global`). Restart opencode after installing; then type `/cc-` for autocomplete. The plugin also
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
`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
@ -253,9 +224,7 @@ instead of being shown as zero.
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
event that may never reach the TUI bus — all debounced) and when the active session changes,
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
plus every 3 minutes as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the
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
(`tui-quota`) are appended to the debug log only when debug tracing is on.
@ -288,7 +257,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 |
| Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
| 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) |
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` |
| 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 |
| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
@ -325,7 +294,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON s
src/events.ts NDJSON/SSE line iterator over the upstream response body.
src/usage.ts finish event -> V3 usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (toggle file) to a log file.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
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/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
@ -391,10 +360,9 @@ 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. |
| 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. |
| Need to see what the provider sends/receives | Flip `/cc-debug` and read the appended log file (see [Debug tracing](#debug-tracing)). |
| Need to see what the provider sends/receives | Set `COMMANDCODE_DEBUG=1` 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. |
| 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

View File

@ -9,6 +9,9 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./tui": {
"import": "./src/tui.tsx"
}
},
"files": [

View File

@ -7,16 +7,15 @@
// 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)
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { readFileSync, writeFileSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import readline from "node:readline";
import { createCommandCode } from "../dist/index.js";
import { redact } from "../dist/redact.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 CONSOLE_URL = "https://commandcode.ai/studio/provider";
const CONCURRENCY = 6;
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
@ -37,15 +36,8 @@ const NO_IMAGE =
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
function readConfig(path) {
try {
const text = readFileSync(path, "utf8");
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;
}
const text = readFileSync(path, "utf8");
return { text, data: JSON.parse(text) };
}
function detectIndent(text) {
@ -368,36 +360,23 @@ function pickModels(catalog, existingIds) {
}
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 text;
let exists;
try {
({ text, data: config, exists } = readConfig(CONFIG_PATH));
({ text, data: config } = readConfig(CONFIG_PATH));
} catch (error) {
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
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);
if (!key) {
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.");
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey");
process.exit(1);
}
@ -468,9 +447,7 @@ async function main() {
config.provider.commandcode.models = merged;
const indent = detectIndent(text);
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 });
const trailing = text.endsWith("\n") ? "\n" : "";
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
console.log(

View File

@ -1,4 +1,4 @@
// Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file;
// silent (and no file created) by default. Uses only Node built-ins so the built output
// stays dependency-free.
@ -6,9 +6,9 @@ import { appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { redact } from "./redact.js";
import { debugEnabled } from "./toggles.js";
import { debugFromEnvOrFile } from "./toggles.js";
const FILE = join(tmpdir(), "commandcode-debug.log");
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
function serialize(value: unknown): string {
if (typeof value === "string") return value;
@ -34,14 +34,14 @@ function emit(scope: string, args: unknown[]): void {
}
}
/** Effective default tracing state (toggle file). Re-read on every call. */
/** Effective default tracing state (env var or toggle file). Re-read on every call. */
export function isDebugEnabled(): boolean {
return debugEnabled();
return debugFromEnvOrFile();
}
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
export function debug(scope: string, ...args: unknown[]): void {
if (!debugEnabled()) return;
if (!debugFromEnvOrFile()) return;
emit(scope, args);
}

View File

@ -21,12 +21,12 @@ import {
import { iterateEvents } from "./events.js";
import { debugWhen, isDebugEnabled } from "./log.js";
import { redact } from "./redact.js";
import { zdrEnabled } from "./toggles.js";
import { zdrFromEnvOrFile } from "./toggles.js";
import { transform } from "./transform.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).
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR.
// "undefined" means the header is omitted entirely.
function providerScoped(options: LanguageModelV3CallOptions): 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";
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
if (header !== undefined) return truthyHeader(header) ? "1" : "0";
return zdrEnabled() ? "1" : undefined;
return zdrFromEnvOrFile() ? "1" : undefined;
}
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
@ -109,50 +109,6 @@ function isRetryable(status: number): boolean {
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 {
if (retryAfter) {
const seconds = Number(retryAfter);
@ -230,7 +186,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
body: string,
options: LanguageModelV3CallOptions,
dbg: boolean,
): Promise<OpenedStream> {
): Promise<Response> {
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
let lastError: unknown;
const url = `${this.opts.baseURL}${GENERATE_PATH}`;
@ -291,60 +247,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
continue;
}
}
if (!response.ok || !response.body) {
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
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 };
return response;
}
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
@ -375,11 +279,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
const dbg = resolveDebug(options);
const body = transform(options, this.modelId);
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
const response = await this.fetchWithRetry(body, options, dbg);
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
const stream = toReadableStream(this.streamParts(prefix, events, dbg));
const stream = toReadableStream(this.streamParts(response.body, dbg));
return {
stream,
request: { body: JSON.parse(body) as unknown },
@ -453,11 +357,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
}
private async *streamParts(
prefix: Record<string, any>[],
events: AsyncGenerator<Record<string, any>>,
dbg: boolean,
): AsyncGenerator<LanguageModelV3StreamPart> {
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
yield { type: "stream-start", warnings: [] };
const textId = "text-0";
@ -472,7 +372,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
let marketCost: number | undefined;
let errored = false;
for await (const evt of replayEvents(prefix, events)) {
for await (const evt of iterateEvents(body, dbg)) {
const payload = JSON.stringify(evt);
debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
switch (evt.type) {

View File

@ -45,11 +45,17 @@ export function toggle(name: ToggleName): Toggles {
return writeToggles({ [name]: current[name] !== true });
}
/** Toggle state from the shared flag file; an absent key means off. */
export function zdrEnabled(): boolean {
return readToggles().zdr === true;
export function envFlag(name: string): boolean {
return /^(1|true|yes)$/i.test(process.env[name] ?? "");
}
export function debugEnabled(): boolean {
return readToggles().debug === true;
/** Flag file wins over the environment; unset file falls back to the env var. */
export function zdrFromEnvOrFile(): boolean {
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");
}

View File

@ -21,7 +21,7 @@ import { createSignal } from "solid-js";
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
import { redact } from "./redact.js";
import { debugEnabled, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
type ToastVariant = "info" | "success" | "warning" | "error";
@ -69,11 +69,9 @@ type TuiApi = {
const ID = "commandcode-toggles";
const CATEGORY = "CommandCode";
const SIDEBAR_ORDER = 90;
const DEFAULT_QUOTA_INTERVAL_MS = 300_000;
const MIN_REFRESH_INTERVAL_MS = 120_000;
const DEFAULT_QUOTA_INTERVAL_MS = 180_000;
// 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.
// 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;
function safeEnv(name: string): string | undefined {
@ -86,8 +84,8 @@ function safeEnv(name: string): string | undefined {
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
function trace(...args: unknown[]): void {
if (!debugEnabled()) return;
const file = join(tmpdir(), "commandcode-debug.log");
if (!debugFromEnvOrFile()) return;
const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log");
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
try {
@ -170,11 +168,6 @@ function quotaIntervalMs(): number {
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 {
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
const reset = formatReset(w.resetAtMs, nowMs);
@ -187,11 +180,9 @@ export const tui = async (api: TuiApi): Promise<void> => {
const [toggles, setToggles] = createSignal(readToggles());
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
const [now, setNow] = createSignal(Date.now());
const [updatedAt, setUpdatedAt] = createSignal(0);
let inflight = false;
let pending = false;
let lastRefreshAt = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let lastSessionId: string | undefined;
@ -203,11 +194,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
trace("coalesced", "inflight");
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;
try {
const { key: apiKey, source } = resolveApiKeySource(api);
@ -236,9 +222,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
trace("fetch-throw", message);
} finally {
inflight = false;
lastRefreshAt = Date.now();
setNow(Date.now());
setUpdatedAt(Date.now());
if (pending) {
pending = false;
trace("flush-pending");
@ -300,7 +284,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
) : quota() === null ? (
<text fg={theme.current.textMuted}>quota: loading…</text>
) : null}
<text fg={theme.current.textMuted}>updated @ {formatClock(updatedAt())}</text>
</box>
);
};
@ -382,7 +365,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
}
setQuota(result);
setNow(Date.now());
setUpdatedAt(Date.now());
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
},
},