add /cc-zdr, /cc-debug, /cc-status slash commands

This commit is contained in:
nhat.nguyenhong 2026-09-16 14:11:07 +00:00
parent 0872a2099b
commit 41fe0788a7
8 changed files with 305 additions and 45 deletions

View File

@ -44,6 +44,9 @@ 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/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
src/tui.ts opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status.
src/constants.ts Defaults, paths, headers, passthrough params, static config block. src/constants.ts Defaults, paths, headers, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check. scripts/smoke.mjs Live end-to-end check.
scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing). scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
@ -113,6 +116,20 @@ 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.
12. **The TUI plugin lives outside the provider entrypoint.** `src/tui.ts` compiles to
`dist/tui.js`, is exposed as `./tui` in `package.json`, and is installed with
`opencode plugin <path>`, which writes a `tui.json` `plugin` entry. Verified on opencode
1.18.31: the module's **default export must be `{ id, tui }`** (a bare `tui` named export is
imported but never invoked), `id` is required, and `tui` is `async (api) => {}`. Commands
register via `api.keymap.registerLayer({ commands })` with `{ name, run, title, desc,
category, namespace: "palette", slashName }`.
13. **Slash commands and `Ctrl+P` share one registry.** In opencode 1.x the slash menu queries
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`.
14. **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
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts.
## Change workflow ## Change workflow

View File

@ -147,7 +147,8 @@ unauthenticated and CommandCode will reject them.
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file. 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 Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
credentials never reach disk. credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
[Runtime toggles](#runtime-toggles)).
```powershell ```powershell
$env:COMMANDCODE_DEBUG = "1" $env:COMMANDCODE_DEBUG = "1"
@ -166,13 +167,40 @@ 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. Set `COMMANDCODE_ZDR=1` (or `true`/`yes`) when launching opencode to opt back in. The models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash
value is read once at provider load, so restart opencode after changing it. Per-provider command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
overrides still win: `headers: { "x-cmd-zdr": "..." }` in `opencode.json` takes precedence over does not require restarting opencode.
the environment variable.
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.
## Runtime toggles
`/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`); 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.ts`, built to
`dist/tui.js`). Register it once:
```powershell
opencode plugin file:///C:/DevTools/pienv/ccprovider/dist/tui.js
```
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. 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 against a remote server, the TUI-side file is not
visible to the provider on the server host — use the env vars or `providerOptions` there.
With `COMMANDCODE_DEBUG` enabled, the trace logs a `zdr on|off` line per request so the toggle
state is observable.
## Features ## Features
@ -192,6 +220,7 @@ state is observable.
| `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` |
| 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 |
### `tool_choice` handling ### `tool_choice` handling
@ -225,6 +254,8 @@ 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 (COMMANDCODE_DEBUG) to a log file.
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin.
src/tui.ts opencode TUI plugin registering /cc-zdr, /cc-debug, /cc-status.
src/constants.ts Defaults, header names, passthrough params, static config block. src/constants.ts Defaults, header names, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. scripts/smoke.mjs Live end-to-end check against api.commandcode.ai.
scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog. scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog.

View File

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

View File

@ -1,7 +1,10 @@
/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */ /** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */
import { debug } from "./log.js"; import { debugWhen, isDebugEnabled } from "./log.js";
export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, any>> { export async function* iterateEvents(
body: ReadableStream<Uint8Array>,
dbg: boolean = isDebugEnabled(),
): AsyncGenerator<Record<string, any>> {
const reader = body.getReader(); const reader = body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
@ -16,7 +19,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
while ((newline = buffer.indexOf("\n")) >= 0) { while ((newline = buffer.indexOf("\n")) >= 0) {
const raw = buffer.slice(0, newline); const raw = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1); buffer = buffer.slice(newline + 1);
const event = parseLine(raw); const event = parseLine(raw, dbg);
if (event) yield event; if (event) yield event;
} }
} }
@ -24,7 +27,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
// flush any trailing line without a newline // flush any trailing line without a newline
buffer += decoder.decode(); buffer += decoder.decode();
if (buffer.trim()) { if (buffer.trim()) {
const event = parseLine(buffer); const event = parseLine(buffer, dbg);
if (event) yield event; if (event) yield event;
} }
} finally { } finally {
@ -32,7 +35,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
} }
} }
function parseLine(raw: string): Record<string, any> | null { function parseLine(raw: string, dbg: boolean): Record<string, any> | null {
let line = raw.trim(); let line = raw.trim();
if (!line || line.startsWith(":") || line.startsWith("event:")) return null; if (!line || line.startsWith(":") || line.startsWith("event:")) return null;
if (line.startsWith("data:")) line = line.slice(5).trim(); if (line.startsWith("data:")) line = line.slice(5).trim();
@ -41,7 +44,7 @@ function parseLine(raw: string): Record<string, any> | null {
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
return parsed && typeof parsed === "object" ? parsed : null; return parsed && typeof parsed === "object" ? parsed : null;
} catch { } catch {
debug("events", "parse-skip", `line=${line.slice(0, 512)}`); debugWhen(dbg, "events", "parse-skip", `line=${line.slice(0, 512)}`);
return null; return null;
} }
} }

View File

@ -1,12 +1,13 @@
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1; silent (and no file // Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file;
// created) by default. Uses only Node built-ins so the built output stays dependency-free. // silent (and no file created) by default. Uses only Node built-ins so the built output
// stays dependency-free.
import { appendFileSync } from "node:fs"; 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";
const ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_DEBUG"] ?? "");
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log"); const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
function serialize(value: unknown): string { function serialize(value: unknown): string {
@ -18,9 +19,7 @@ function serialize(value: unknown): string {
} }
} }
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */ function emit(scope: string, args: unknown[]): void {
export function debug(scope: string, ...args: unknown[]): void {
if (!ENABLED) return;
const parts = args.map(serialize); const parts = args.map(serialize);
const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`; const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`;
try { try {
@ -34,3 +33,20 @@ export function debug(scope: string, ...args: unknown[]): void {
} }
} }
} }
/** Effective default tracing state (env var or toggle file). Re-read on every call. */
export function isDebugEnabled(): boolean {
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 (!debugFromEnvOrFile()) return;
emit(scope, args);
}
/** Trace with a per-request override, so providerOptions/history state is not global. */
export function debugWhen(enabled: boolean, scope: string, ...args: unknown[]): void {
if (!enabled) return;
emit(scope, args);
}

View File

@ -18,14 +18,38 @@ import {
GENERATE_PATH, GENERATE_PATH,
} from "./constants.js"; } from "./constants.js";
import { iterateEvents } from "./events.js"; import { iterateEvents } from "./events.js";
import { debug } 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 { transform } from "./transform.js"; import { transform } from "./transform.js";
import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js"; import { finishReasonFrom, usageFromFinish, type FinishEvent } 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).
// COMMANDCODE_ZDR=1|true|yes opts back in. Read at load, like COMMANDCODE_DEBUG. // Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR.
const ZDR_ENABLED = /^(1|true|yes)$/i.test(process.env["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;
}
function truthyHeader(value: string): boolean {
return !/^(0|false|no|off)$/i.test(value);
}
function resolveZdr(
options: LanguageModelV3CallOptions,
providerHeaders: Record<string, string>,
): string | undefined {
const scoped = providerScoped(options)?.["zdr"];
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 zdrFromEnvOrFile() ? "1" : undefined;
}
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
const scoped = providerScoped(options)?.["debug"];
return typeof scoped === "boolean" ? scoped : isDebugEnabled();
}
export type CommandCodeOptions = { export type CommandCodeOptions = {
name?: string; name?: string;
@ -47,7 +71,7 @@ type ResolvedOptions = {
retryMaxDelay: number; retryMaxDelay: number;
}; };
function headerValue(headers: Record<string, string>, key: string): string | undefined { function headerValue(headers: Record<string, string | undefined>, key: string): string | undefined {
const wanted = key.toLowerCase(); const wanted = key.toLowerCase();
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v; for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v;
return undefined; return undefined;
@ -135,7 +159,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
supportedUrls: Record<string, RegExp[]> = {}; supportedUrls: Record<string, RegExp[]> = {};
private requestHeaders(extra?: Record<string, string | undefined>): Record<string, string> { private requestHeaders(
extra: Record<string, string | undefined> | undefined,
zdr: string | undefined,
dbg: boolean,
): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-command-code-version": this.opts.ccVersion, "x-command-code-version": this.opts.ccVersion,
@ -143,24 +171,28 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
"x-project-slug": "project", "x-project-slug": "project",
"x-taste-learning": "true", "x-taste-learning": "true",
"x-co-flag": "false", "x-co-flag": "false",
...(ZDR_ENABLED ? { "x-cmd-zdr": "1" } : {}),
...this.opts.headers, ...this.opts.headers,
}; };
debug("fetch", "zdr", ZDR_ENABLED ? "on" : "off"); if (zdr !== undefined) headers["x-cmd-zdr"] = zdr;
debugWhen(dbg, "fetch", "zdr", zdr === "1" ? "on" : "off");
const auth = bearer(this.opts.apiKey); const auth = bearer(this.opts.apiKey);
if (auth) headers["Authorization"] = auth; if (auth) headers["Authorization"] = auth;
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v; for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v;
return headers; return headers;
} }
private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise<Response> { private async fetchWithRetry(
const headers = this.requestHeaders(options.headers); body: string,
options: LanguageModelV3CallOptions,
dbg: boolean,
): Promise<Response> {
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}`;
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
if (options.abortSignal?.aborted) { if (options.abortSignal?.aborted) {
debug("fetch", "aborted", `attempt=${attempt}`); debugWhen(dbg, "fetch", "aborted", `attempt=${attempt}`);
throw options.abortSignal.reason ?? new Error("Aborted"); throw options.abortSignal.reason ?? new Error("Aborted");
} }
const started = Date.now(); const started = Date.now();
@ -175,7 +207,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} catch (error) { } catch (error) {
lastError = error; lastError = error;
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay); const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
debug( debugWhen(
dbg,
"fetch", "fetch",
"network-error", "network-error",
`url=${url}`, `url=${url}`,
@ -194,7 +227,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
if (isRetryable(status) && attempt < this.opts.maxRetries) { if (isRetryable(status) && attempt < this.opts.maxRetries) {
const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay); const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay);
if (wait >= 0) { if (wait >= 0) {
debug( debugWhen(
dbg,
"fetch", "fetch",
"retry", "retry",
`url=${url}`, `url=${url}`,
@ -212,14 +246,14 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
continue; continue;
} }
} }
debug("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;
} }
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable"); throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
} }
private async errorFrom(response: Response): Promise<Error> { private async errorFrom(response: Response, dbg: boolean): Promise<Error> {
let body = ""; let body = "";
try { try {
body = await response.text(); body = await response.text();
@ -236,18 +270,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
/* keep raw body */ /* keep raw body */
} }
const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`); const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`);
debug("error", `status=${response.status}`, `body=${surfaced}`); debugWhen(dbg, "error", `status=${response.status}`, `body=${surfaced}`);
return new Error(surfaced); return new Error(surfaced);
} }
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> { async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
const dbg = resolveDebug(options);
const body = transform(options, this.modelId); const body = transform(options, this.modelId);
debug("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); const response = await this.fetchWithRetry(body, options, dbg);
if (!response.ok || !response.body) throw await this.errorFrom(response); if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
debug("doStream", "ok", `status=${response.status}`); debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
const stream = toReadableStream(this.streamParts(response.body)); const stream = toReadableStream(this.streamParts(response.body, dbg));
return { return {
stream, stream,
request: { body: JSON.parse(body) as unknown }, request: { body: JSON.parse(body) as unknown },
@ -256,6 +291,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} }
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> { async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
const dbg = resolveDebug(options);
const { stream, request, response } = await this.doStream(options); const { stream, request, response } = await this.doStream(options);
let text = ""; let text = "";
@ -302,7 +338,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
if (text) content.push({ type: "text", text }); if (text) content.push({ type: "text", text });
content.push(...toolCalls); content.push(...toolCalls);
debug( debugWhen(
dbg,
"doGenerate", "doGenerate",
"done", "done",
`model=${this.modelId}`, `model=${this.modelId}`,
@ -316,7 +353,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
return { content, finishReason, usage, warnings, request, response }; return { content, finishReason, usage, warnings, request, response };
} }
private async *streamParts(body: ReadableStream<Uint8Array>): AsyncGenerator<LanguageModelV3StreamPart> { private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
yield { type: "stream-start", warnings: [] }; yield { type: "stream-start", warnings: [] };
const textId = "text-0"; const textId = "text-0";
@ -329,9 +366,9 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
let finishReason: LanguageModelV3FinishReason | undefined; let finishReason: LanguageModelV3FinishReason | undefined;
let errored = false; let errored = false;
for await (const evt of iterateEvents(body)) { for await (const evt of iterateEvents(body, dbg)) {
const payload = JSON.stringify(evt); const payload = JSON.stringify(evt);
debug("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) {
case "text-start": case "text-start":
if (!textOpen) { if (!textOpen) {
@ -422,12 +459,12 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} }
if (errored) { if (errored) {
debug("stream", "terminal", "errored"); debugWhen(dbg, "stream", "terminal", "errored");
return; return;
} }
if (textOpen) yield { type: "text-end", id: textId }; if (textOpen) yield { type: "text-end", id: textId };
if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId }; if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId };
debug("stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`); debugWhen(dbg, "stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`);
yield { yield {
type: "finish", type: "finish",
usage: usage ?? zeroUsage(), usage: usage ?? zeroUsage(),

61
src/toggles.ts Normal file
View File

@ -0,0 +1,61 @@
// Runtime toggle state shared between the provider (server side) and the slash-command
// TUI plugin (client side) through a small JSON flag file. Node built-ins only so the
// built provider stays dependency-free; never throws on a missing or malformed file.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export type Toggles = { zdr?: boolean; debug?: boolean };
export type ToggleName = keyof Toggles;
export function togglesPath(): string {
const override = process.env["COMMANDCODE_TOGGLES_FILE"];
if (override) return override;
const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
return join(configHome, "opencode", "commandcode-toggles.json");
}
export function readToggles(): Toggles {
try {
const parsed = JSON.parse(readFileSync(togglesPath(), "utf8")) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return {};
const out: Toggles = {};
if (typeof parsed["zdr"] === "boolean") out.zdr = parsed["zdr"];
if (typeof parsed["debug"] === "boolean") out.debug = parsed["debug"];
return out;
} catch {
return {};
}
}
export function writeToggles(patch: Toggles): Toggles {
const merged = { ...readToggles(), ...patch };
const path = togglesPath();
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
renameSync(tmp, path);
return merged;
}
export function toggle(name: ToggleName): Toggles {
const current = readToggles();
return writeToggles({ [name]: current[name] !== true });
}
export function envFlag(name: string): boolean {
return /^(1|true|yes)$/i.test(process.env[name] ?? "");
}
/** 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");
}

91
src/tui.ts Normal file
View File

@ -0,0 +1,91 @@
// opencode TUI plugin exposing /cc-zdr, /cc-debug and /cc-status. It flips the shared
// toggle file (see ./toggles.ts) that the provider reads on every request, so a change
// takes effect without restarting opencode.
//
// opencode loads this module's default export (`{ id, tui }`) from a `tui.json` plugin
// entry; verified against @opencode-ai/plugin@1.18.31. The local structural types below
// keep this package runtime-dependency-free. Note: in this opencode version slash
// commands and the Ctrl+P palette read the same `namespace: "palette"` command list, so
// these entries appear in both; `hidden: true` would remove them from both.
import { readToggles, toggle, type ToggleName } from "./toggles.js";
type ToastVariant = "info" | "success" | "warning" | "error";
type TuiCommand = {
name: string;
title?: string;
desc?: string;
category?: string;
namespace?: string;
slashName?: string;
slashAliases?: string[];
suggested?: boolean;
hidden?: boolean;
enabled?: boolean | (() => boolean);
run: () => void | Promise<void>;
};
type TuiApi = {
keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void };
ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void };
};
const ID = "commandcode-toggles";
const CATEGORY = "CommandCode";
function state(value: boolean | undefined): string {
return value === true ? "on" : "off";
}
function summary(): string {
const toggles = readToggles();
return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`;
}
export const tui = async (api: TuiApi): Promise<void> => {
const flip = (name: ToggleName): void => {
const next = toggle(name);
api.ui.toast({
title: CATEGORY,
message: `${name} ${state(next[name])} (${summary()})`,
variant: next[name] === true ? "success" : "info",
});
};
api.keymap.registerLayer({
commands: [
{
name: `${ID}.zdr`,
title: "CommandCode: toggle ZDR header",
desc: "Send the x-cmd-zdr header on requests",
category: CATEGORY,
namespace: "palette",
slashName: "cc-zdr",
run: () => flip("zdr"),
},
{
name: `${ID}.debug`,
title: "CommandCode: toggle debug tracing",
desc: "Write a redacted request and stream trace to a log file",
category: CATEGORY,
namespace: "palette",
slashName: "cc-debug",
run: () => flip("debug"),
},
{
name: `${ID}.status`,
title: "CommandCode: show toggle status",
desc: "Show the current zdr and debug state",
category: CATEGORY,
namespace: "palette",
slashName: "cc-status",
run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }),
},
],
});
};
export const id = ID;
export default { id: ID, tui };