From d4a78fc4c6fa586791e88794ea6b67f7b36101ef Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 11:53:42 -0700 Subject: [PATCH 01/18] Add secure shared auth-store module Introduces lib/auth-store.js as the single source of truth for auth-store I/O across CLI and TUI. All writes are atomic (temp file + rename(2)), set file mode 0o600 and directory mode 0o700, refuse to overwrite a corrupt existing file, and replace any symlink at the target path so a planted symlink cannot exfiltrate the key. XDG_DATA_HOME is validated as absolute; relative paths fall back to $HOME/.local/share to keep the key out of the working directory. --- lib/auth-store.js | 199 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 200 insertions(+) create mode 100644 lib/auth-store.js diff --git a/lib/auth-store.js b/lib/auth-store.js new file mode 100644 index 0000000..10b8e94 --- /dev/null +++ b/lib/auth-store.js @@ -0,0 +1,199 @@ +import { readFile, writeFile, rename, mkdir, rm, chmod, lstat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { randomBytes } from "node:crypto"; + +export const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; +export const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; +const AUTH_FILE_MODE = 0o600; +const AUTH_DIR_MODE = 0o700; + +export function getAuthStorePath(env = process.env) { + const raw = String(env.XDG_DATA_HOME ?? "").trim(); + if (raw && isAbsolute(raw)) { + return join(raw, "opencode", "auth.json"); + } + return join(homedir(), ".local", "share", "opencode", "auth.json"); +} + +export async function readAuthStore({ + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + lstatImpl = lstat +} = {}) { + let stats; + try { + stats = await lstatImpl(authFilePath); + } catch (error) { + if (error?.code === "ENOENT") return { parsed: {}, source: "missing" }; + throw error; + } + if (!stats.isFile()) { + const err = new Error("auth store path is not a regular file"); + err.code = "AUTH_STORE_NOT_REGULAR_FILE"; + throw err; + } + + const content = await readFileImpl(authFilePath, "utf8"); + const trimmed = String(content ?? "").trim(); + if (!trimmed) return { parsed: {}, source: "empty" }; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + const err = new Error("auth store JSON is corrupt and cannot be parsed"); + err.code = "AUTH_STORE_CORRUPT"; + err.cause = error; + err.rawContent = trimmed; + throw err; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const err = new Error("auth store JSON is not an object"); + err.code = "AUTH_STORE_NOT_OBJECT"; + throw err; + } + return { parsed, source: "parsed" }; +} + +export function findAuthEntry(parsed, providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]) { + if (!parsed || typeof parsed !== "object") return null; + for (const candidateID of providerIDs) { + const auth = parsed[candidateID]; + if (auth && typeof auth === "object" && auth.type === "api" && String(auth.key ?? "").trim()) { + return { providerID: candidateID, key: String(auth.key).trim() }; + } + } + return null; +} + +export async function hasAuthEntry({ + authFilePath = getAuthStorePath(), + providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS], + readFileImpl = readFile, + lstatImpl = lstat +} = {}) { + try { + const { parsed } = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + const entry = findAuthEntry(parsed, providerIDs); + return { hasKey: Boolean(entry), providerID: entry?.providerID ?? null }; + } catch { + return { hasKey: false, providerID: null }; + } +} + +async function atomicWriteJson(authFilePath, parsed, { + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm +} = {}) { + await mkdirImpl(dirname(authFilePath), { recursive: true, mode: AUTH_DIR_MODE }); + try { + await chmodImpl(dirname(authFilePath), AUTH_DIR_MODE); + } catch { + // best-effort; some filesystems ignore directory chmod + } + + const suffix = `${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`; + const tempPath = `${authFilePath}.${suffix}.tmp`; + const body = `${JSON.stringify(parsed, null, 2)}\n`; + try { + await writeFileImpl(tempPath, body, { encoding: "utf8", mode: AUTH_FILE_MODE }); + await chmodImpl(tempPath, AUTH_FILE_MODE); + await renameImpl(tempPath, authFilePath); + try { + await chmodImpl(authFilePath, AUTH_FILE_MODE); + } catch { + // best-effort; rename may have already applied the mode from the temp file + } + } finally { + await rmImpl(tempPath, { force: true }).catch(() => {}); + } +} + +export async function writeAuthEntry({ + apiKey, + providerID = AUTH_PROVIDER_ID, + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm, + lstatImpl = lstat +} = {}) { + const trimmed = String(apiKey ?? "").trim(); + if (!trimmed) return { ok: false, reason: "empty_key", authFilePath }; + + let parsed; + try { + const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + parsed = result.parsed; + } catch (error) { + if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") { + return { ok: false, reason: "auth_store_corrupt", authFilePath, error }; + } + if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error }; + } + return { ok: false, reason: "auth_store_read_failed", authFilePath, error }; + } + + parsed[providerID] = { type: "api", key: trimmed }; + + try { + await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl }); + } catch (error) { + return { ok: false, reason: "auth_store_write_failed", authFilePath, error }; + } + + return { ok: true, authFilePath, providerID }; +} + +export async function removeAuthEntry({ + providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS], + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm, + lstatImpl = lstat +} = {}) { + let parsed; + try { + const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + parsed = result.parsed; + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + return { ok: true, authFilePath, removedAny: false }; + } + if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") { + return { ok: false, reason: "auth_store_corrupt", authFilePath, error }; + } + return { ok: false, reason: "auth_store_read_failed", authFilePath, error }; + } + + let removedAny = false; + for (const candidateID of providerIDs) { + if (parsed[candidateID]) { + delete parsed[candidateID]; + removedAny = true; + } + } + + if (!removedAny) return { ok: true, authFilePath, removedAny: false }; + + try { + await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl }); + } catch (error) { + return { ok: false, reason: "auth_store_write_failed", authFilePath, error }; + } + + return { ok: true, authFilePath, removedAny: true }; +} diff --git a/package.json b/package.json index 5b77a0c..005c5ef 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "files": [ "opencode-plugins/ttc-message-transform.js", "opencode-plugins/ttc-message-transform-core.js", + "lib/auth-store.js", "tui/index.tsx", "tui/settings.js", "tui/sidebar-state.js", From 91495a6edb36cd41d7cba703ea2775fc88fc7b21 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 11:53:52 -0700 Subject: [PATCH 02/18] Rename auth provider and add login/logout CLI AUTH_PROVIDER_ID is now opencode-ttc-plugin (was the-token-company- plugin) so it matches the package and TUI id. resolveApiKeyFromAuthStore still reads entries written under the legacy id so existing users do not need to re-auth. CLI gains login and logout subcommands backed by the shared auth-store module. login never accepts the key as a --key argument (would leak via ps aux and shell history); instead it prompts on the TTY with echo off or reads from stdin via --stdin. logout clears both current and legacy entries. doctor reports which id the key is stored under. --- opencode-plugins/ttc-message-transform.js | 32 +++-- scripts/cli.mjs | 168 +++++++++++++++++++--- 2 files changed, 170 insertions(+), 30 deletions(-) diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index 19efc99..c530bda 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -4,7 +4,8 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { gzipSync } from "node:zlib"; -const AUTH_PROVIDER_ID = "the-token-company-plugin"; +const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; +const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; const LOCKED_BASE_URL = "https://api.thetokencompany.com"; const COMPRESSION_LEVELS = { @@ -709,23 +710,28 @@ function resolveBehaviorConfig({ async function resolveApiKeyFromAuthStore({ providerID = AUTH_PROVIDER_ID, + legacyProviderIDs = LEGACY_AUTH_PROVIDER_IDS, authFilePath = getAuthStorePath(), readFileImpl = readFile } = {}) { + let parsed; try { const content = await readFileImpl(authFilePath, "utf8"); - const parsed = JSON.parse(content); - if (!parsed || typeof parsed !== "object") return ""; - - const auth = parsed[providerID]; - if (!auth || typeof auth !== "object") return ""; - if (auth.type !== "api") return ""; - - const key = String(auth.key ?? "").trim(); - return key; + parsed = JSON.parse(content); } catch { return ""; } + if (!parsed || typeof parsed !== "object") return ""; + + const candidateIDs = [providerID, ...legacyProviderIDs]; + for (const candidateID of candidateIDs) { + const auth = parsed[candidateID]; + if (!auth || typeof auth !== "object") continue; + if (auth.type !== "api") continue; + const key = String(auth.key ?? "").trim(); + if (key) return key; + } + return ""; } function resolveEffectiveApiKey(envApiKey, authStoreApiKey) { @@ -1109,6 +1115,7 @@ const TtcMessageTransformPlugin = async ({ client }) => { enabled: initialConfig.enabled, has_api_key: Boolean(initialConfig.apiKey), auth_provider_id: AUTH_PROVIDER_ID, + legacy_auth_provider_ids: LEGACY_AUTH_PROVIDER_IDS, auth_source: initialApiKeyResolution.source, base_url_source: initialConfig.baseUrlSource, behavior_sources: initialBehaviorResolution.sources, @@ -1134,12 +1141,12 @@ const TtcMessageTransformPlugin = async ({ client }) => { methods: [ { type: "api", - label: "Set TTC API Key", + label: "The Token Company (TTC) API key", prompts: [ { type: "text", key: "apiKey", - message: "Enter TTC API key", + message: "Enter TTC API key (from thetokencompany.com)", placeholder: "ttc_..." } ], @@ -1191,6 +1198,7 @@ export default TtcMessageTransformPlugin; TtcMessageTransformPlugin._test = { AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, COMPRESSION_LEVELS, buildTtcPluginConfig, getPluginConfigPath, diff --git a/scripts/cli.mjs b/scripts/cli.mjs index e1a47a7..f3ccaf3 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -12,10 +12,22 @@ import { import { homedir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import readline from "node:readline"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath, + hasAuthEntry, + writeAuthEntry, + removeAuthEntry +} from "../lib/auth-store.js"; const PLUGIN_FILENAME = "ttc-message-transform.js"; -const AUTH_PROVIDER_ID = "the-token-company-plugin"; const DEFAULT_AGGRESSIVENESS = 0.1; +const KNOWN_MODELS = [ + { id: "bear-2", description: "Most accurate compression. Recommended per TTC docs." }, + { id: "bear-1.2", description: "Faster compression. Lower latency per request." } +]; const COMPRESSION_LEVELS = { low: 0.05, balanced: 0.1, @@ -63,12 +75,6 @@ const tuiEntrypointPath = resolve(repoRoot, "tui", "index.tsx"); const pluginsDir = resolve(homedir(), ".config", "opencode", "plugins"); const installedPluginPath = resolve(pluginsDir, PLUGIN_FILENAME); -function getAuthStorePath() { - const xdgDataHome = String(process.env.XDG_DATA_HOME ?? "").trim(); - const dataHome = xdgDataHome || resolve(homedir(), ".local", "share"); - return resolve(dataHome, "opencode", "auth.json"); -} - function getPluginConfigPath() { const xdgConfigHome = String(process.env.XDG_CONFIG_HOME ?? "").trim(); const configHome = xdgConfigHome || resolve(homedir(), ".config"); @@ -246,28 +252,137 @@ function resolveBehaviorFromSources(settings) { function hasAuthStoreKey() { const authPath = getAuthStorePath(); - if (!existsSync(authPath)) { - return { hasKey: false, path: authPath }; - } + const entry = hasAuthEntrySync(authPath); + return { hasKey: Boolean(entry), path: authPath, providerID: entry?.providerID ?? null }; +} +function hasAuthEntrySync(authPath) { try { - const parsed = JSON.parse(readFileSync(authPath, "utf8")); - const auth = parsed?.[AUTH_PROVIDER_ID]; - const hasKey = Boolean(auth && auth.type === "api" && String(auth.key ?? "").trim()); - return { hasKey, path: authPath }; + const raw = readFileSync(authPath, "utf8"); + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + for (const candidateID of [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]) { + const auth = parsed[candidateID]; + if (auth && auth.type === "api" && String(auth.key ?? "").trim()) { + return { providerID: candidateID, key: String(auth.key).trim() }; + } + } + return null; } catch { - return { hasKey: false, path: authPath }; + return null; } } function printUsage() { - console.log("Usage: opencode-ttc-plugin "); + console.log("Usage: opencode-ttc-plugin "); console.log(" opencode-ttc-plugin doctor [--verbose]"); console.log(" opencode-ttc-plugin config get"); console.log(" opencode-ttc-plugin config set level "); console.log(" opencode-ttc-plugin config set aggressiveness <0..1>"); console.log(" opencode-ttc-plugin config set "); console.log(" opencode-ttc-plugin config reset"); + console.log(" opencode-ttc-plugin login [--stdin] (key from stdin, no echo)"); + console.log(" opencode-ttc-plugin logout"); +} + +function promptSecret(query) { + return new Promise((onResolve) => { + const input = process.stdin; + const output = process.stdout; + const isTTY = Boolean(input.isTTY); + const originalIsRaw = isTTY && input.isRaw; + const rl = readline.createInterface({ input, output, terminal: isTTY }); + const restore = () => { + try { + if (isTTY && typeof input.setRawMode === "function") { + input.setRawMode(Boolean(originalIsRaw)); + } + } catch { + // ignore + } + }; + if (isTTY && typeof input.setRawMode === "function") { + try { input.setRawMode(true); } catch { /* ignore */ } + } + rl.question(query, (answer) => { + restore(); + rl.close(); + output.write("\n"); + onResolve(String(answer ?? "").replace(/\r?\n$/, "")); + }); + rl.on("SIGINT", () => { + restore(); + rl.close(); + output.write("\n"); + onResolve(""); + }); + }); +} + +async function readStdinSecret() { + let data = ""; + for await (const chunk of process.stdin) { + data += String(chunk); + } + return data.replace(/\r?\n$/, "").trim(); +} + +async function persistKey(apiKey) { + if (!apiKey) { + console.error("Login cancelled: no key entered."); + process.exitCode = 1; + return; + } + const result = await writeAuthEntry({ apiKey }); + if (!result.ok) { + console.error(`Login failed: ${result.reason}${result.error ? ` (${result.error.message})` : ""}`); + if (result.reason === "auth_store_corrupt") { + console.error(`Refusing to overwrite corrupt auth store at ${result.authFilePath}. Back it up and remove it manually.`); + } + process.exitCode = 1; + return; + } + console.log(`Saved TTC API key under '${result.providerID}' at ${result.authFilePath}`); + console.log("Restart opencode for sessions to pick up the new key."); +} + +async function loginCommand(args) { + const useStdin = args.includes("--stdin"); + if (useStdin) { + if (process.stdin.isTTY) { + console.error('Login --stdin requires the key to be piped (e.g. printf %s "$KEY" | opencode-ttc-plugin login --stdin).'); + process.exitCode = 1; + return; + } + const apiKey = await readStdinSecret(); + await persistKey(apiKey); + return; + } + + if (process.stdin.isTTY) { + const apiKey = await promptSecret("Enter TTC API key (from thetokencompany.com): "); + await persistKey(apiKey); + return; + } + + console.error('Login requires --stdin when stdin is not a TTY (e.g. printf %s "$KEY" | opencode-ttc-plugin login --stdin).'); + process.exitCode = 1; +} + +async function logoutCommand() { + const result = await removeAuthEntry(); + if (!result.ok) { + console.error(`Logout failed: ${result.reason}${result.error ? ` (${result.error.message})` : ""}`); + process.exitCode = 1; + return; + } + if (!result.removedAny) { + console.log(`No TTC auth entry found at ${result.authFilePath}`); + return; + } + console.log(`Removed TTC auth entries at ${result.authFilePath}`); } function detectCommand(argv, scriptName) { @@ -316,7 +431,14 @@ function doctor(options = { verbose: false }) { { label: `auth store (${AUTH_PROVIDER_ID})`, ok: true, - value: authStore.hasKey ? `set (${authStore.path})` : `missing (${authStore.path})` + value: authStore.hasKey + ? `set under '${authStore.providerID}' (${authStore.path})` + : `missing (${authStore.path})` + }, + { + label: "known TTC models", + ok: true, + value: KNOWN_MODELS.map((model) => model.id).join(", ") }, { label: "effective auth source", ok: hasUsableAuth, value: authSource }, { @@ -496,7 +618,7 @@ function uninstall() { console.log(`Removed ${installedPluginPath}`); } -function main() { +async function main() { const scriptName = basename(process.argv[1] ?? ""); const command = detectCommand(process.argv, scriptName); const args = process.argv.slice(3); @@ -521,6 +643,16 @@ function main() { return; } + if (command === "login") { + await loginCommand(args); + return; + } + + if (command === "logout") { + logoutCommand(); + return; + } + printUsage(); process.exitCode = 1; } From d7d3e8156ce9a65057a97a651f02af3f1c92fe86 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 11:54:02 -0700 Subject: [PATCH 03/18] Refresh sidebar auth status and polish TUI auth/model UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar polls auth.json directly via loadAuthStatus. When the cached state file still says missing_auth but a key has landed, the sidebar overrides to 'authenticated — send a message to start compressing' instead of staying stale until the next transform fires. /ttc settings menu now shows exactly one compact auth row conditional on auth state: 'Add API key' when missing, 'Remove API key' (with confirmation) when present. Reduces two wordy rows to one and gives a clearer affordance. Model picker replaces the free-text input with a curated list grounded in thetokencompany.com/docs/compression: bear-2 (Recommended) and bear-1.2, plus a custom-id escape hatch for enterprise fine-tunes. Stops silent typos like 'bear-2.0' (not a real model id) entering the config. Login/logout failures now surface the actual reason (empty key, corrupt auth store, write failure) instead of a generic 'Login failed' message. --- tui/index.tsx | 23 +++++- tui/settings.js | 179 +++++++++++++++++++++++++++++++++++++++++-- tui/sidebar-state.js | 35 +++++++++ 3 files changed, 229 insertions(+), 8 deletions(-) diff --git a/tui/index.tsx b/tui/index.tsx index 78728fc..ea72e73 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -5,12 +5,14 @@ import { formatMetricValue, formatPartLine, getStatusDotColor, + loadAuthStatus, loadSidebarState, statusText } from "./sidebar-state.js"; import { registerTtcSettingsCommand } from "./settings.js"; type SidebarState = Awaited>; +type AuthStatus = Awaited>; function SidebarContent(props: { api: TuiPluginApi; @@ -18,11 +20,13 @@ function SidebarContent(props: { theme: TuiTheme; }) { const [state, setState] = createSignal(null); + const [authStatus, setAuthStatus] = createSignal({ hasKey: false, providerID: null, authPath: "" }); const [refreshCount, setRefreshCount] = createSignal(0); const theme = () => props.theme.current; const refresh = async () => { setState(await loadSidebarState(props.sessionID)); + setAuthStatus(await loadAuthStatus()); }; createEffect(() => { @@ -52,9 +56,24 @@ function SidebarContent(props: { unsubscribeSessionStatus(); }); + const effectiveStatus = createMemo(() => { + const current = state(); + if (current?.status === "missing_auth" && authStatus().hasKey) { + return "waiting"; + } + return current?.status; + }); + const dotColor = createMemo(() => { + return getStatusDotColor(effectiveStatus(), theme()); + }); + + const statusLine = createMemo(() => { const current = state(); - return getStatusDotColor(current?.status, theme()); + if (effectiveStatus() === "waiting") { + return "authenticated — send a message to start compressing"; + } + return statusText(current); }); return ( @@ -64,7 +83,7 @@ function SidebarContent(props: { Token Compression {state()?.config?.model ?? ""} - {statusText(state())} + {statusLine()} No session data yet}> {(current) => ( diff --git a/tui/settings.js b/tui/settings.js index 298d075..3393496 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -1,9 +1,21 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath, + hasAuthEntry, + writeAuthEntry, + removeAuthEntry +} from "../lib/auth-store.js"; export const TTC_SETTINGS_COMMAND_VALUE = "ttc.settings"; export const TTC_SETTINGS_COMMAND_TITLE = "Token Compression: Settings"; +export const TTC_LOGIN_COMMAND_VALUE = "ttc.login"; +export const TTC_LOGIN_COMMAND_TITLE = "Token Compression: Login"; +export const TTC_LOGOUT_COMMAND_VALUE = "ttc.logout"; +export const TTC_LOGOUT_COMMAND_TITLE = "Token Compression: Logout"; export const COMPRESSION_LEVELS = { low: 0.05, @@ -12,6 +24,13 @@ export const COMPRESSION_LEVELS = { max: 0.3 }; +export { AUTH_PROVIDER_ID, LEGACY_AUTH_PROVIDER_IDS }; + +export const KNOWN_MODELS = [ + { id: "bear-2", label: "bear-2 (Recommended)", description: "Most accurate compression. Best quality preservation." }, + { id: "bear-1.2", label: "bear-1.2", description: "Faster compression. Lower latency per request." } +]; + const DEFAULT_SETTINGS = { enabled: true, compressionLevel: "balanced", @@ -30,6 +49,13 @@ export function getTtcSettingsConfigPath(env = process.env) { return join(configHome, "opencode", "ttc-plugin.json"); } +export { + getAuthStorePath, + hasAuthEntry as hasTtcAuthKey, + writeAuthEntry, + removeAuthEntry +}; + export async function readTtcSettings({ configPath = getTtcSettingsConfigPath(), readFileImpl = readFile @@ -197,6 +223,111 @@ function selectLevel(api, dialog, current) { })); } +function selectModel(api, dialog, currentValue) { + const knownIDs = KNOWN_MODELS.map((model) => model.id); + const options = KNOWN_MODELS.map((model) => ({ + title: model.label, + value: model.id, + description: model.description + })); + const isCustom = currentValue && !knownIDs.includes(currentValue); + if (isCustom) { + options.push({ + title: `Custom: ${currentValue}`, + value: currentValue, + description: "Currently set to a non-standard model id" + }); + } + options.push({ + title: "Enter custom model id", + value: "__custom__", + description: "Specify a model id (e.g. an enterprise fine-tune)" + }); + + dialog.replace(() => api.ui.DialogSelect({ + title: "Compression Model", + current: currentValue, + options, + onSelect: (option) => { + if (option.value === "__custom__") { + promptValue(api, dialog, { + title: "Custom Model", + placeholder: "bear-1.2", + value: currentValue, + action: "set-model" + }); + return; + } + void saveAndReturn(api, dialog, "set-model", option.value); + } + })); +} + +const LOGIN_FAILURE_MESSAGES = { + empty_key: "Empty API key.", + auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", + auth_store_not_regular_file: "opencode auth store path is not a regular file.", + auth_store_read_failed: "Could not read the opencode auth store.", + auth_store_write_failed: "Could not write the opencode auth store." +}; + +async function performLogin(api, dialog, keyValue) { + const result = await writeAuthEntry({ apiKey: keyValue }); + if (result.ok) { + toast(api, { + variant: "success", + message: `TTC API key saved under '${result.providerID}'. Restart opencode (or send a message) to activate.`, + duration: 5000 + }); + await openTtcSettingsMenu(api, dialog); + return; + } + const message = LOGIN_FAILURE_MESSAGES[result.reason] ?? `Login failed: ${result.reason}.`; + toast(api, { variant: "error", message, duration: 5000 }); + renderAlert(api, dialog, "Login Failed", message); +} + +function promptLogin(api, dialog) { + dialog.replace(() => api.ui.DialogPrompt({ + title: "TTC API Key", + placeholder: "ttc_...", + value: "", + onConfirm: (nextValue) => void performLogin(api, dialog, nextValue), + onCancel: () => openTtcSettingsMenu(api, dialog) + })); +} + +const LOGOUT_FAILURE_MESSAGES = { + auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", + auth_store_read_failed: "Could not read the opencode auth store.", + auth_store_write_failed: "Could not write the opencode auth store." +}; + +async function confirmLogout(api, dialog) { + dialog.replace(() => api.ui.DialogConfirm({ + title: "Remove TTC API Key", + message: "Remove the TTC API key from the opencode auth store?", + onConfirm: async () => { + const result = await removeAuthEntry(); + if (!result.ok) { + const message = LOGOUT_FAILURE_MESSAGES[result.reason] ?? `Logout failed: ${result.reason}.`; + toast(api, { variant: "error", message, duration: 5000 }); + renderAlert(api, dialog, "Logout Failed", message); + return; + } + toast(api, { + variant: result.removedAny ? "success" : "info", + message: result.removedAny + ? "TTC API key removed. Restart opencode to fully clear active sessions." + : "No TTC auth entry was present.", + duration: 4500 + }); + await openTtcSettingsMenu(api, dialog); + }, + onCancel: () => openTtcSettingsMenu(api, dialog) + })); +} + function confirmReset(api, dialog) { dialog.replace(() => api.ui.DialogConfirm({ title: "Reset Token Compression Settings", @@ -218,7 +349,19 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { if (!api?.ui || !dialog?.replace) return; const settings = await readTtcSettings(); + const auth = await hasAuthEntry(); const view = buildSettingsView(settings); + const authOption = auth.hasKey + ? { + title: "Remove API key", + value: "ttc-logout", + description: "Revoke saved key" + } + : { + title: "Add API key", + value: "ttc-login", + description: "Required for compression" + }; dialog.setSize?.("medium"); dialog.replace(() => api.ui.DialogSelect({ title: "Token Compression Settings", @@ -248,6 +391,7 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { value: "set-model", description: view.model }, + authOption, { title: "Reset config", value: "reset-config", @@ -282,12 +426,15 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { return; } if (option.value === "set-model") { - promptValue(api, dialog, { - title: "Model", - placeholder: "bear-1.2", - value: view.model, - action: "set-model" - }); + selectModel(api, dialog, view.model); + return; + } + if (option.value === "ttc-login") { + promptLogin(api, dialog); + return; + } + if (option.value === "ttc-logout") { + confirmLogout(api, dialog); return; } if (option.value === "reset-config") { @@ -310,6 +457,26 @@ export function registerTtcSettingsCommand(api) { aliases: ["ttc"] }, onSelect: (dialog) => void openTtcSettingsMenu(api, dialog ?? api.ui?.dialog) + }, + { + title: TTC_LOGIN_COMMAND_TITLE, + value: TTC_LOGIN_COMMAND_VALUE, + description: "Set your TTC API key in the opencode auth store", + category: "Token Compression", + slash: { + name: "ttc-login" + }, + onSelect: (dialog) => void promptLogin(api, dialog ?? api.ui?.dialog) + }, + { + title: TTC_LOGOUT_COMMAND_TITLE, + value: TTC_LOGOUT_COMMAND_VALUE, + description: "Remove the TTC API key from the opencode auth store", + category: "Token Compression", + slash: { + name: "ttc-logout" + }, + onSelect: (dialog) => void confirmLogout(api, dialog ?? api.ui?.dialog) } ]); } diff --git a/tui/sidebar-state.js b/tui/sidebar-state.js index 3404931..67bcec3 100644 --- a/tui/sidebar-state.js +++ b/tui/sidebar-state.js @@ -2,6 +2,14 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath as getSharedAuthStorePath +} from "../lib/auth-store.js"; + +export { AUTH_PROVIDER_ID }; +export const SHARED_AUTH_PROVIDER_IDS = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]; const SKIP_REASON_LABELS = { below_threshold: "below threshold", @@ -26,6 +34,33 @@ export function getSidebarStatePath(sessionID, env = process.env) { return join(getSidebarStateDir(env), `${sessionHash}.json`); } +export function getAuthStorePath(env = process.env) { + return getSharedAuthStorePath(env); +} + +export async function loadAuthStatus(options = {}) { + const readFileImpl = options.readFileImpl ?? readFile; + const authPath = options.authFilePath ?? getSharedAuthStorePath(options.env ?? process.env); + try { + const content = await readFileImpl(authPath, "utf8"); + const trimmed = String(content ?? "").trim(); + if (!trimmed) return { hasKey: false, providerID: null, authPath }; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { hasKey: false, providerID: null, authPath }; + } + for (const candidateID of SHARED_AUTH_PROVIDER_IDS) { + const auth = parsed[candidateID]; + if (auth && auth.type === "api" && String(auth.key ?? "").trim()) { + return { hasKey: true, providerID: candidateID, authPath }; + } + } + return { hasKey: false, providerID: null, authPath }; + } catch { + return { hasKey: false, providerID: null, authPath }; + } +} + export async function loadSidebarState(sessionID, options = {}) { const readFileImpl = options.readFileImpl ?? readFile; const statePath = options.statePath ?? getSidebarStatePath(sessionID, options.env ?? process.env); From 07e45cd0ceea7ad302a3bef63eae2fedb0a427cd Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 11:54:09 -0700 Subject: [PATCH 04/18] Cover provider rename, secure writes, and menu state in tests Adds regression coverage for the security invariants the new auth-store module relies on: file mode 0o600 after write, atomic rename surviving a concurrent writer, and corrupt existing file being refused rather than silently overwritten (which would destroy other providers' keys). Adds coverage for the legacy auth id read fallback, the preference for the current id when both exist, and the conditional single-row auth state in the /ttc settings menu. --- tests/opencode-ttc-plugin.test.js | 219 +++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 3 deletions(-) diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index 3b8189b..7b2919f 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import TtcMessageTransformPlugin from "../opencode-plugins/ttc-message-transform.js"; import { @@ -31,14 +31,21 @@ import { formatMetricValue, formatPartLine, getStatusDotColor, + loadAuthStatus, loadSidebarState, statusText } from "../tui/sidebar-state.js"; import { getTtcSettingsConfigPath, + hasTtcAuthKey, + openTtcSettingsMenu, registerTtcSettingsCommand, resetTtcSettings, - updateTtcSetting + updateTtcSetting, + writeAuthEntry as writeTuiAuthEntry, + removeAuthEntry as removeTuiAuthEntry, + AUTH_PROVIDER_ID as TUI_AUTH_PROVIDER_ID, + KNOWN_MODELS as TUI_KNOWN_MODELS } from "../tui/settings.js"; function createOutput(text) { @@ -788,12 +795,34 @@ test("registers plugin auth provider for /connect flow", async () => { const client = createClient(); const plugin = await TtcMessageTransformPlugin({ client }); - assert.equal(plugin.auth.provider, "the-token-company-plugin"); + assert.equal(plugin.auth.provider, "opencode-ttc-plugin"); assert.equal(Array.isArray(plugin.auth.methods), true); assert.equal(plugin.auth.methods.length > 0, true); assert.equal(plugin.auth.methods[0].type, "api"); }); +test("reads legacy auth store entries written under the old provider id", async () => { + const key = await resolveApiKeyFromAuthStore({ + readFileImpl: async () => JSON.stringify({ + "the-token-company-plugin": { + type: "api", + key: "legacy_key" + } + }) + }); + assert.equal(key, "legacy_key"); +}); + +test("prefers new provider id when both legacy and current entries exist", async () => { + const key = await resolveApiKeyFromAuthStore({ + readFileImpl: async () => JSON.stringify({ + "the-token-company-plugin": { type: "api", key: "legacy_key" }, + "opencode-ttc-plugin": { type: "api", key: "current_key" } + }) + }); + assert.equal(key, "current_key"); +}); + test("shows activation and idle summary toasts in TUI", async () => { const originalFetch = globalThis.fetch; const tempStateHome = await mkdtemp(join(tmpdir(), "ttc-plugin-state-")); @@ -886,6 +915,80 @@ test("registers TUI settings command and slash aliases", () => { assert.equal(typeof command.onSelect, "function"); }); +test("registers /ttc-login and /ttc-logout slash commands", () => { + let registeredCallback = null; + registerTtcSettingsCommand({ + command: { + register(callback) { + registeredCallback = callback; + return () => {}; + } + }, + ui: { dialog: {}, toast() {} } + }); + + const commands = registeredCallback(); + const login = commands.find((cmd) => cmd.value === "ttc.login"); + const logout = commands.find((cmd) => cmd.value === "ttc.logout"); + + assert.equal(Boolean(login), true); + assert.equal(login.slash.name, "ttc-login"); + assert.equal(typeof login.onSelect, "function"); + assert.equal(Boolean(logout), true); + assert.equal(logout.slash.name, "ttc-logout"); + assert.equal(typeof logout.onSelect, "function"); +}); + +test("settings menu shows one compact auth row based on auth state", async () => { + const tempDataHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-data-")); + const tempConfigHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-config-")); + const originalEnv = { + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME + }; + process.env.XDG_DATA_HOME = tempDataHome; + process.env.XDG_CONFIG_HOME = tempConfigHome; + + let latestSelect = null; + const api = { + ui: { + DialogSelect(input) { + latestSelect = input; + return input; + }, + dialog: { + setSize() {}, + replace(render) { render(); } + }, + toast() {} + } + }; + + try { + await openTtcSettingsMenu(api, api.ui.dialog); + let authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + assert.equal(authRows.length, 1); + assert.equal(authRows[0].title, "Add API key"); + assert.equal(authRows[0].description, "Required for compression"); + + await writeTuiAuthEntry({ apiKey: "ttc_existing_key" }); + await openTtcSettingsMenu(api, api.ui.dialog); + authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + assert.equal(authRows.length, 1); + assert.equal(authRows[0].title, "Remove API key"); + assert.equal(authRows[0].description, "Revoke saved key"); + } finally { + if (originalEnv.XDG_DATA_HOME === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = originalEnv.XDG_DATA_HOME; + + if (originalEnv.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalEnv.XDG_CONFIG_HOME; + + await rm(tempDataHome, { recursive: true, force: true }); + await rm(tempConfigHome, { recursive: true, force: true }); + } +}); + test("TUI settings path follows existing XDG config resolution", () => { const path = getTtcSettingsConfigPath({ XDG_CONFIG_HOME: "/tmp/xdg-config" }); assert.equal(path, "/tmp/xdg-config/opencode/ttc-plugin.json"); @@ -951,3 +1054,113 @@ test("TUI settings helper rejects invalid values and resets config", async () => await rm(tempDir, { recursive: true, force: true }); } }); + +test("TUI auth helpers write/remove key under current provider id with secure file mode", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + const written = await writeTuiAuthEntry({ + apiKey: "ttc_example_key", + authFilePath: authPath + }); + assert.equal(written.ok, true); + assert.equal(written.providerID, "opencode-ttc-plugin"); + + const stored = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(stored["opencode-ttc-plugin"].type, "api"); + assert.equal(stored["opencode-ttc-plugin"].key, "ttc_example_key"); + + const { statSync } = await import("node:fs"); + const mode = statSync(authPath).mode & 0o777; + assert.equal(mode, 0o600, `auth file should be 0o600, got 0o${mode.toString(8)}`); + + const hasAfter = await hasTtcAuthKey({ authFilePath: authPath }); + assert.equal(hasAfter.hasKey, true); + assert.equal(hasAfter.providerID, "opencode-ttc-plugin"); + + const removed = await removeTuiAuthEntry({ authFilePath: authPath }); + assert.equal(removed.ok, true); + assert.equal(removed.removedAny, true); + + const hasAfterRemove = await hasTtcAuthKey({ authFilePath: authPath }); + assert.equal(hasAfterRemove.hasKey, false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("auth store refuses to overwrite a corrupt file", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-corrupt-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile(authPath, "{ not valid json,,,", "utf8"); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_new_key", + authFilePath: authPath + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "auth_store_corrupt"); + + const preserved = await readFile(authPath, "utf8"); + assert.equal(preserved, "{ not valid json,,,"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("auth store write is atomic — concurrent writer does not lose entries", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-race-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + await writeTuiAuthEntry({ + apiKey: "ttc_first_key", + authFilePath: authPath + }); + const intermediate = JSON.parse(await readFile(authPath, "utf8")); + intermediate["another-provider"] = { type: "api", key: "other_key" }; + const { writeFile: realWriteFile, rename: realRename } = await import("node:fs/promises"); + await realWriteFile(authPath, JSON.stringify(intermediate, null, 2), "utf8"); + + const result = await writeTuiAuthEntry({ + apiKey: "ttc_second_key", + authFilePath: authPath + }); + assert.equal(result.ok, true); + + const final = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(final["opencode-ttc-plugin"].key, "ttc_second_key"); + assert.equal(final["another-provider"].key, "other_key"); + void realRename; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("loadAuthStatus recognises legacy provider entries", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-authstatus-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ "the-token-company-plugin": { type: "api", key: "legacy" } }), + "utf8" + ); + + try { + const status = await loadAuthStatus({ authFilePath: authPath }); + assert.equal(status.hasKey, true); + assert.equal(status.providerID, "the-token-company-plugin"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("known TTC model list is grounded in current docs", () => { + const ids = TUI_KNOWN_MODELS.map((model) => model.id); + assert.deepEqual(ids.sort(), ["bear-1.2", "bear-2"].sort()); + assert.equal(TUI_AUTH_PROVIDER_ID, "opencode-ttc-plugin"); +}); From 22cfca0e0b630f209bdc3be2d8ca4d2b69798ef9 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 11:54:26 -0700 Subject: [PATCH 05/18] Document secure auth flow, model picker, and bump to 0.1.5 README setup section now leads with the plugin-managed login paths (opencode-ttc-plugin login --stdin and /ttc-login) before the opencode auth menu, and explicitly notes that the second prompt users see during opencode auth login is opencode's separate LLM-provider auth, not a duplicate TTC prompt. Adds a Models section grounded in thetokencompany.com/docs/compression listing bear-2 (Recommended) and bear-1.2, explains there is no list endpoint so the picker is curated, and tells users how to diagnose an unexpected model id via doctor --verbose. Security and network policy section now documents the auth-write guarantees: atomic temp+rename, 0o600 file mode, 0o700 directory mode, refusal to overwrite a corrupt file, symlink replacement via rename, relative-XDG fallback, and the CLI never accepting the key as a --key argument. --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++---------- package.json | 2 +- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a0c6f28..3b7be0b 100644 --- a/README.md +++ b/README.md @@ -36,24 +36,37 @@ If you are on an older OpenCode build without package plugin target support, use opencode-ttc-plugin install ``` -2. Configure auth in OpenCode: +2. Configure auth in OpenCode (any of these works): ```bash +# Option A — plugin-managed login (writes to the opencode auth store, mode 0o600) +opencode-ttc-plugin login # interactive prompt (no echo) +printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI + +# Option B — from inside opencode (TUI) +/ttc-login # opens a dialog asking for the TTC API key +/ttc-logout # removes the TTC API key + +# Option C — through opencode's native auth flow opencode auth login ``` -3. In the auth flow choose: -- provider: `the-token-company-plugin` -- method: `Set TTC API Key` +> The `login` command no longer accepts `--key ` inline — passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` for non-interactive flows, or the TTY prompt for interactive use. + +3. In the `opencode auth login` flow (Option C) choose: +- provider: `opencode-ttc-plugin` +- method: `The Token Company (TTC) API key` - paste key from `https://thetokencompany.com/` +> Note: `opencode auth login` may also prompt for your LLM provider key (OpenAI/Anthropic/etc). That is a separate opencode auth — the TTC plugin only ever asks for one key (the TTC API key). Use `opencode-ttc-plugin login` or `/ttc-login` if you only want to set the TTC key without re-running the full opencode auth menu. + 4. Verify: ```bash opencode-ttc-plugin doctor --verbose ``` -5. Restart OpenCode. In a session, the sidebar should include `Token Compression` with the latest message and session savings. Toasts remain as a fallback/configurable summary path. +5. Restart OpenCode. In a session, the sidebar should include `Token Compression` with the latest message and session savings. The sidebar polls the opencode auth store directly, so once you log in it shows `authenticated — send a message to start compressing` without needing to send a message first. Toasts remain as a fallback/configurable summary path.
For LLMs @@ -70,12 +83,14 @@ opencode-ttc-plugin install Finally, tell the user to -1. Run `opencode auth login` -2. Search for `the-token-company-plugin` -3. Enter their TTC API key (obtain from https://thetokencompany.com/) -4. Verify by running `opencode-ttc-plugin doctor --verbose` - -5. Tell them to restart OpenCode and watch the sidebar when they start a session +1. Set their TTC API key with one of: + - `printf '%s' "$KEY" \| opencode-ttc-plugin login --stdin` (CLI, recommended for scripts), or + - `opencode-ttc-plugin login` (CLI interactive prompt, no echo), or + - `/ttc-login` slash command inside opencode, or + - `opencode auth login` then choose provider `opencode-ttc-plugin` +2. Get a key from https://thetokencompany.com/ +3. Verify by running `opencode-ttc-plugin doctor --verbose` +4. Restart OpenCode and watch the sidebar when they start a session
## 2) Configure compression aggressiveness @@ -125,14 +140,37 @@ Runtime resolution order for aggressiveness: | --- | --- | | `opencode-ttc-plugin install` | Installs plugin file into `~/.config/opencode/plugins` | | `opencode-ttc-plugin doctor` | Runs setup/auth checks | -| `opencode-ttc-plugin doctor --verbose` | Shows effective config sources, TUI entrypoint status, and sidebar state path | +| `opencode-ttc-plugin doctor --verbose` | Shows effective config sources, TUI entrypoint status, known models, and sidebar state path | | `opencode-ttc-plugin uninstall` | Removes installed plugin file | +| `opencode-ttc-plugin login [--stdin]` | Saves a TTC API key under the `opencode-ttc-plugin` provider in the opencode auth store. Without `--stdin`, prompts on the TTY with echo off. With `--stdin`, reads the key from stdin (use for CI: `printf '%s' "$KEY" \| opencode-ttc-plugin login --stdin`). The auth file is written atomically with mode `0o600`. | +| `opencode-ttc-plugin logout` | Removes TTC auth entries (current and legacy) from the opencode auth store | | `opencode-ttc-plugin config get` | Prints plugin config and effective aggressiveness | | `opencode-ttc-plugin config set level ` | Sets named aggressiveness level | | `opencode-ttc-plugin config set aggressiveness <0..1>` | Sets numeric aggressiveness | | `opencode-ttc-plugin config set ` | Sets behavior settings (see table below) | | `opencode-ttc-plugin config reset` | Removes plugin config file | +The same login/logout actions are available inside OpenCode as slash commands: `/ttc-login`, `/ttc-logout`, and `/ttc` (full settings menu including a model picker). + +## Models + +The TTC compress endpoint accepts a `model` field. Per `https://thetokencompany.com/docs/compression`, the currently-listed models are: + +| Model | Status | Notes | +| --- | --- | --- | +| `bear-2` | Recommended | Most accurate compression. Best quality preservation. | +| `bear-1.2` | Available | Faster compression. Lower latency per request. | + +This plugin's default is `bear-1.2` to preserve existing behavior; use `/ttc` → `Model` → `bear-2 (Recommended)` to switch. There is no TTC API endpoint for listing models at runtime, so the picker uses the curated list above plus a `custom model id` escape hatch for enterprise fine-tunes. + +If you ever see an unexpected model id in the sidebar (e.g. something like `bear-2.0` — note: `bear-2.0` is **not** a real model id), check `opencode-ttc-plugin doctor --verbose` for the effective `model` value and its source. The model displayed in the sidebar is exactly what the plugin sends to the API; it cannot change on its own. Common causes: + +- A `model` key left in `~/.config/opencode/ttc-plugin.json` from a previous `/ttc` selection +- A `TTC_MODEL` environment variable exported in your shell +- A model id typed manually through the old free-text prompt + +Reset to defaults with `opencode-ttc-plugin config reset`, then re-pick from the curated list via `/ttc`. + ## Behavior settings Use CLI config for normal setup. Env vars are advanced overrides. @@ -168,6 +206,9 @@ Advanced overrides (optional): - Compression egress is pinned to `https://api.thetokencompany.com/v1/compress`. - Custom/invalid `TTC_BASE_URL` is ignored and safely falls back to pinned host. - Fetch redirects are rejected. +- The TTC API key is stored in the opencode auth store (`${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json`). All writes by this plugin are atomic (temp file + `rename(2)`), set file mode `0o600` and directory mode `0o700`, refuse to overwrite a corrupt existing file, and replace any symlink at the target path (so a planted symlink cannot exfiltrate the key to another location). +- The CLI `login` command never accepts the key as a `--key ` argument (which would leak via `ps aux` and shell history). Use the TTY prompt (echo off) or `--stdin`. +- A relative or non-absolute `XDG_DATA_HOME` is ignored and falls back to `$HOME/.local/share` to prevent the key being written to the working directory. - Sidebar state is written under `${XDG_STATE_HOME:-~/.local/state}/opencode/ttc-plugin` with hashed session filenames. - Sidebar state contains aggregate counts and token/character savings only; it does not persist prompts, compressed output, request bodies, or API keys. - If your firewall prompts about outbound socket traffic, that is expected on first compression request. diff --git a/package.json b/package.json index 005c5ef..da0f004 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@drfok/opencode-ttc-plugin", - "version": "0.1.4", + "version": "0.1.5", "private": false, "type": "module", "description": "OpenCode plugin that allows input compression via The Token Company API", From 497c00ca673265e8b4a45c6988b5000a2a204186 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 12:10:55 -0700 Subject: [PATCH 06/18] Suppress readline echo during interactive login prompt Codex review on PR #3 caught that the promptSecret helper was still echoing typed characters: readline.createInterface({ input, output, terminal: true }) does its own echo to the output stream regardless of setRawMode on stdin. The advertised 'no echo' guarantee was not actually honored. Fix: write the prompt directly to process.stdout, then pass readline a muted Writable (write: (_chunk, _enc, cb) => cb()) as its output so echo writes are dropped. terminal: true stays on so backspace and line editing still work. Verified by typing 'ttc_SECRET_value' char-by-char at 60ms intervals through a real pty.fork() harness: the captured output contains the prompt and the post-submit success line, but none of 'ttc_SECRET_value', 'SECRET', or 'ttc_'. --- scripts/cli.mjs | 52 +++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/scripts/cli.mjs b/scripts/cli.mjs index f3ccaf3..dc0377b 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -13,6 +13,7 @@ import { homedir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import readline from "node:readline"; +import { Writable } from "node:stream"; import { AUTH_PROVIDER_ID, LEGACY_AUTH_PROVIDER_IDS, @@ -290,34 +291,35 @@ function printUsage() { function promptSecret(query) { return new Promise((onResolve) => { const input = process.stdin; - const output = process.stdout; - const isTTY = Boolean(input.isTTY); - const originalIsRaw = isTTY && input.isRaw; - const rl = readline.createInterface({ input, output, terminal: isTTY }); - const restore = () => { - try { - if (isTTY && typeof input.setRawMode === "function") { - input.setRawMode(Boolean(originalIsRaw)); - } - } catch { - // ignore - } - }; - if (isTTY && typeof input.setRawMode === "function") { - try { input.setRawMode(true); } catch { /* ignore */ } + const realOutput = process.stdout; + if (!input.isTTY) { + onResolve(""); + return; } - rl.question(query, (answer) => { - restore(); - rl.close(); - output.write("\n"); - onResolve(String(answer ?? "").replace(/\r?\n$/, "")); + realOutput.write(query); + const mutedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } }); - rl.on("SIGINT", () => { - restore(); - rl.close(); - output.write("\n"); - onResolve(""); + const rl = readline.createInterface({ + input, + output: mutedOutput, + terminal: true, + prompt: "" + }); + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + try { rl.close(); } catch { /* ignore */ } + realOutput.write("\n"); + onResolve(value); + }; + rl.question("", (answer) => { + finish(String(answer ?? "").replace(/\r?\n$/, "")); }); + rl.on("SIGINT", () => finish("")); }); } From 6382d70c264864f25ab1f398123b48bf77d2c896 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 12:11:10 -0700 Subject: [PATCH 07/18] Rewrite README to cover sidebar, slash commands, skip patterns, troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had drifted from what the plugin actually does. Restructured the whole thing around what a user needs to know, with new sections: - Requirements (Node 20+, OpenCode 1.4+, TTC key) - Sidebar: status dot colors, every status line users can see, what each one means (including the new 'authenticated — send a message' override and what 'waiting for metrics' implies) - Slash commands: /ttc, /ttc-login, /ttc-logout in one table - What gets compressed: message selection (latest user turn by default, compressSystem / compressHistory toggles) plus the full skip-pattern table (code fence, diff, stack trace, JSON, schema, ...) so users seeing 'skipped: code fence' know why - Troubleshooting: missing auth after login, phantom model id like 'bear-2.0', the two-prompt opencode auth login, compression not firing, legacy auth id Fixed the broken ''' fences in the LLM install block (was using three single-quotes; should be triple backticks). Tightened the intro to one paragraph that says what TTC does and why you'd use it. Numbered the top-level sections so the table of contents is implicit. --- README.md | 349 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 207 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 3b7be0b..fbfa836 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,25 @@ # Opencode The Token Company Plugin -OpenCode message transform plugin with [The Token Company](https://thetokencompany.com/) (TTC) API. +OpenCode message-transform plugin + sidebar widget for [The Token Company](https://thetokencompany.com/) (TTC, YC W26). -The Token Company (YC W26) builds models that process tokens based on context and semantic intent. With this plugin, you can remove context bloat from your prompts to Opencode before they hit the LLM provider. +TTC compresses LLM prompts by removing low-signal tokens based on context and semantic intent. This plugin wires that into OpenCode so bloated context (long file dumps, verbose tool output, repeated boilerplate) gets shrunk before it hits your LLM provider. You keep the same models, just fewer tokens and faster turns. -Modern OpenCode builds also load a TTC sidebar widget. Compression still runs in the server plugin; the sidebar reads redacted per-session metrics from local state and never stores prompt text, compressed text, request bodies, or API keys. +The sidebar reads redacted per-session metrics from local state. It never stores prompt text, compressed output, request bodies, or API keys. [![npm version](https://img.shields.io/npm/v/@drfok/opencode-ttc-plugin.svg)](https://www.npmjs.com/package/@drfok/opencode-ttc-plugin) +[![npm downloads](https://img.shields.io/npm/dy/@drfok/opencode-ttc-plugin)](https://www.npmjs.com/package/@drfok/opencode-ttc-plugin) [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](./LICENSE) [![X (Twitter)](https://img.shields.io/badge/X-%40drf0k-111111.svg)](https://x.com/drf0k) -[![npm downloads](https://img.shields.io/npm/dy/@drfok/opencode-ttc-plugin)](https://www.npmjs.com/package//@drfok/opencode-ttc-plugin) + +## Requirements + +- OpenCode 1.4.0 or newer (uses the `@opencode-ai/plugin` peer dep) +- Node.js 20 or newer +- A TTC API key from ## 1) Setup -### Option A: Agent-assisted setup +### Option A: Agent-assisted Paste this into your coding agent: @@ -21,194 +27,253 @@ Paste this into your coding agent: Install @drfok/opencode-ttc-plugin by following: https://raw.githubusercontent.com/MrFok/opencode-ttc-plugin/main/README.md ``` -### Option B: Manual setup +### Option B: Manual -1. Install and register plugin: +1. Install and register the plugin: -```bash -npm install -g @drfok/opencode-ttc-plugin -opencode plugin @drfok/opencode-ttc-plugin --global -``` + ```bash + npm install -g @drfok/opencode-ttc-plugin + opencode plugin @drfok/opencode-ttc-plugin --global + ``` -If you are on an older OpenCode build without package plugin target support, use the legacy file installer: + On older OpenCode builds that don't support package plugin targets, fall back to the file installer: -```bash -opencode-ttc-plugin install -``` + ```bash + opencode-ttc-plugin install + ``` -2. Configure auth in OpenCode (any of these works): +2. Set your TTC API key. Pick whichever path fits: -```bash -# Option A — plugin-managed login (writes to the opencode auth store, mode 0o600) -opencode-ttc-plugin login # interactive prompt (no echo) -printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI + ```bash + # Plugin-managed (writes to the opencode auth store, mode 0o600) + opencode-ttc-plugin login # interactive TTY prompt, echo off + printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI -# Option B — from inside opencode (TUI) -/ttc-login # opens a dialog asking for the TTC API key -/ttc-logout # removes the TTC API key + # Inside opencode (TUI slash commands) + /ttc-login # dialog asking for the key + /ttc-logout # removes the key -# Option C — through opencode's native auth flow -opencode auth login -``` - -> The `login` command no longer accepts `--key ` inline — passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` for non-interactive flows, or the TTY prompt for interactive use. + # Through opencode's native auth flow + opencode auth login # then pick provider: opencode-ttc-plugin + ``` -3. In the `opencode auth login` flow (Option C) choose: -- provider: `opencode-ttc-plugin` -- method: `The Token Company (TTC) API key` -- paste key from `https://thetokencompany.com/` + > **Why two prompts during `opencode auth login`?** OpenCode asks for your LLM-provider key (OpenAI/Anthropic/etc) and any plugin keys separately. Our plugin only ever asks for the TTC key. Use `opencode-ttc-plugin login` or `/ttc-login` to skip the full menu. + > + > **No `--key ` flag.** Passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` or the TTY prompt. -> Note: `opencode auth login` may also prompt for your LLM provider key (OpenAI/Anthropic/etc). That is a separate opencode auth — the TTC plugin only ever asks for one key (the TTC API key). Use `opencode-ttc-plugin login` or `/ttc-login` if you only want to set the TTC key without re-running the full opencode auth menu. +3. Verify: -4. Verify: + ```bash + opencode-ttc-plugin doctor --verbose + ``` -```bash -opencode-ttc-plugin doctor --verbose -``` +4. Restart OpenCode. In a session, the sidebar should show `Token Compression` with the live status dot. Once a key is present it reads `authenticated — send a message to start compressing`; you don't have to send a message first to clear stale `missing auth` state. -5. Restart OpenCode. In a session, the sidebar should include `Token Compression` with the latest message and session savings. The sidebar polls the opencode auth store directly, so once you log in it shows `authenticated — send a message to start compressing` without needing to send a message first. Toasts remain as a fallback/configurable summary path. +### For LLMs installing this on behalf of a user
-For LLMs +Expand for the agent-readable install script + +1. Install the package with npm: -1. Install @drfok/opencode-ttc-plugin with npm -'''bash -npm install -g @drfok/opencode-ttc-plugin -''' + ```bash + npm install -g @drfok/opencode-ttc-plugin + ``` -2. Run the installation command to place the js file into the /.config/opencode/plugins directory -'''bash -opencode-ttc-plugin install -''' +2. Place the plugin file into `~/.config/opencode/plugins`: -Finally, tell the user to + ```bash + opencode-ttc-plugin install + ``` -1. Set their TTC API key with one of: - - `printf '%s' "$KEY" \| opencode-ttc-plugin login --stdin` (CLI, recommended for scripts), or - - `opencode-ttc-plugin login` (CLI interactive prompt, no echo), or - - `/ttc-login` slash command inside opencode, or +3. Tell the user to set their TTC API key with one of: + - `printf '%s' "$KEY" | opencode-ttc-plugin login --stdin` (CLI, recommended for scripts) + - `opencode-ttc-plugin login` (CLI interactive prompt, echo off) + - `/ttc-login` slash command inside opencode - `opencode auth login` then choose provider `opencode-ttc-plugin` -2. Get a key from https://thetokencompany.com/ -3. Verify by running `opencode-ttc-plugin doctor --verbose` -4. Restart OpenCode and watch the sidebar when they start a session + +4. Tell the user to get a key from if they don't have one. + +5. Verify with `opencode-ttc-plugin doctor --verbose`. + +6. Tell the user to restart OpenCode and watch the sidebar when they start a session. +
-## 2) Configure compression aggressiveness +## 2) Sidebar -Primary control is aggressiveness. You can set it by named level (recommended) or exact numeric value. +The `Token Compression` row in the opencode sidebar shows live per-session state. -Set by level: +**Status dot colors** -```bash -opencode-ttc-plugin config set level balanced -``` +| Color | Meaning | +| --- | --- | +| green | active, skipped, fallback, or no reduction — all normal operating states | +| red | `missing auth` or `disabled` | -Set exact value: +**Status line examples** + +| Status line | What it means | +| --- | --- | +| `active` | Last message compressed successfully | +| `authenticated — send a message to start compressing` | Key present, no message has triggered compression yet (overrides stale `missing_auth` from a previous state file) | +| `skipped: code fence, below threshold` | Last message had parts that were intentionally not compressed, with reason counts | +| `fallback: request failed open` | TTC API errored; original text was passed through unchanged | +| `no reduction` | TTC returned output that wasn't smaller than the input; original used | +| `missing TTC auth` | No API key found — run `opencode-ttc-plugin login` or `/ttc-login` | +| `disabled` | `enabled: false` in config | +| `waiting for metrics` | State file not written yet (e.g. brand-new session, no messages) | + +The sidebar shows the configured `model` next to the title. The model shown is exactly what gets sent to the TTC API — it cannot change on its own. If it ever looks wrong, see [Troubleshooting](#8-troubleshooting). + +## 3) Slash commands + +| Command | Action | +| --- | --- | +| `/ttc` | Open the Token Compression settings menu (enable/disable, level, aggressiveness, min chars, model, auth, reset) | +| `/ttc-login` | Prompt for the TTC API key and save it to the opencode auth store | +| `/ttc-logout` | Remove the TTC API key (asks for confirmation) | + +The auth row in `/ttc` is contextual: it shows `Add API key` when no key is present and `Remove API key` when one is. Only one row, not two. + +The model picker is curated from the TTC docs (`bear-2`, `bear-1.2`) plus a `custom model id` escape hatch for enterprise fine-tunes. See [Models](#6-models). + +## 4) CLI reference ```bash -opencode-ttc-plugin config set aggressiveness 0.25 +opencode-ttc-plugin install # place plugin file in ~/.config/opencode/plugins +opencode-ttc-plugin uninstall # remove the installed plugin file +opencode-ttc-plugin doctor # setup/auth checks +opencode-ttc-plugin doctor --verbose # show effective config sources + known models + sidebar state path +opencode-ttc-plugin login # interactive TTY prompt (echo off), writes key with mode 0o600 +opencode-ttc-plugin login --stdin # read key from stdin — use for CI: printf '%s' "$KEY" | opencode-ttc-plugin login --stdin +opencode-ttc-plugin logout # remove TTC auth entries (current and legacy IDs) +opencode-ttc-plugin config get # print plugin config + effective aggressiveness +opencode-ttc-plugin config set level # low | balanced | high | max +opencode-ttc-plugin config set aggressiveness # 0..1 numeric +opencode-ttc-plugin config set # any behavior setting from the table below +opencode-ttc-plugin config reset # remove plugin config file ``` -Inspect active config: +## 5) Configuration + +All config is optional. Defaults work for most users. + +### Aggressiveness + +The main knob. Higher = more tokens removed. ```bash -opencode-ttc-plugin config get -opencode-ttc-plugin doctor --verbose +opencode-ttc-plugin config set level balanced # recommended +opencode-ttc-plugin config set aggressiveness 0.15 # exact numeric override ``` -Compression levels: - -| Level | Aggressiveness | Typical tradeoff | +| Level | Aggressiveness | Typical use | | --- | --- | --- | -| `low` | `0.05` | Minimal changes, conservative compression | -| `balanced` | `0.10` | Default; good savings with stable quality | -| `high` | `0.20` | Stronger compression, better token reduction | -| `max` | `0.30` | Most aggressive preset in this plugin | +| `low` | `0.05` | Minimal changes — financial, legal, medical | +| `balanced` | `0.10` | **Default.** Good savings with stable quality | +| `high` | `0.20` | Stronger compression | +| `max` | `0.30` | Most aggressive preset | -Why these values exist: -- TTC API exposes aggressiveness on a `0.0-1.0` range in their docs: `https://thetokencompany.com/docs` -- TTC benchmark data shows quality/token tradeoffs vary by aggressiveness: `https://www.thetokencompany.com/benchmarks/accuracy` +Per TTC's docs (`https://thetokencompany.com/docs/compression`): +- `0.05–0.15` Light — financial reports, legal contracts, medical records +- `0.15–0.4` Moderate — meeting transcripts, call recordings, scraped page content +- `0.4–0.9` Aggressive — chat history summarization, replacing compact -Runtime resolution order for aggressiveness: -1. `TTC_AGGRESSIVENESS` env var (override) -2. plugin config file `~/.config/opencode/ttc-plugin.json` -3. built-in default (`balanced` = `0.1`) +**Resolution order:** `TTC_AGGRESSIVENESS` env → `~/.config/opencode/ttc-plugin.json` → built-in default (`balanced` = `0.1`). -## 3) CLI commands +### Behavior settings -| Command | What it does | -| --- | --- | -| `opencode-ttc-plugin install` | Installs plugin file into `~/.config/opencode/plugins` | -| `opencode-ttc-plugin doctor` | Runs setup/auth checks | -| `opencode-ttc-plugin doctor --verbose` | Shows effective config sources, TUI entrypoint status, known models, and sidebar state path | -| `opencode-ttc-plugin uninstall` | Removes installed plugin file | -| `opencode-ttc-plugin login [--stdin]` | Saves a TTC API key under the `opencode-ttc-plugin` provider in the opencode auth store. Without `--stdin`, prompts on the TTY with echo off. With `--stdin`, reads the key from stdin (use for CI: `printf '%s' "$KEY" \| opencode-ttc-plugin login --stdin`). The auth file is written atomically with mode `0o600`. | -| `opencode-ttc-plugin logout` | Removes TTC auth entries (current and legacy) from the opencode auth store | -| `opencode-ttc-plugin config get` | Prints plugin config and effective aggressiveness | -| `opencode-ttc-plugin config set level ` | Sets named aggressiveness level | -| `opencode-ttc-plugin config set aggressiveness <0..1>` | Sets numeric aggressiveness | -| `opencode-ttc-plugin config set ` | Sets behavior settings (see table below) | -| `opencode-ttc-plugin config reset` | Removes plugin config file | +CLI config is the normal path; env vars are advanced overrides. + +| Setting | Default | What it does | CLI | +| --- | --- | --- | --- | +| `enabled` | `true` | Master on/off switch for the transform hook | `config set enabled true` | +| `model` | `bear-1.2` | TTC model sent to `/v1/compress` (see [Models](#6-models)) | `config set model bear-1.2` | +| `minChars` | `400` | Skip compression for text shorter than this | `config set min-chars 400` | +| `timeoutMs` | `2000` | Per-request timeout | `config set timeout-ms 2000` | +| `maxRetries` | `1` | Retry count for retryable TTC failures (429, 5xx) | `config set max-retries 1` | +| `retryBackoffMs` | `100` | Backoff base between retries (linear) | `config set retry-backoff-ms 100` | +| `useGzip` | `true` | gzip the request body to TTC | `config set use-gzip true` | +| `compressSystem` | `false` | Also compress eligible `system` messages | `config set compress-system false` | +| `compressHistory` | `false` | Also compress older `user` turns, not just the latest | `config set compress-history false` | +| `debug` | `false` | Emit extra plugin debug logs | `config set debug false` | +| `cacheMaxEntries` | `1000` | Max in-memory dedupe cache entries | `config set cache-max-entries 1000` | +| `toastOnActive` | `false` | One activation toast per session (sidebar is primary UI) | `config set toast-on-active true` | +| `toastOnIdleSummary` | `false` | Idle summary toast with savings stats | `config set toast-on-idle-summary true` | + +The only TTC API parameters this plugin sends are `model` and `compression_settings.aggressiveness`. Everything else is plugin-side control. -The same login/logout actions are available inside OpenCode as slash commands: `/ttc-login`, `/ttc-logout`, and `/ttc` (full settings menu including a model picker). +**Env overrides (optional):** `TTC_ENABLED`, `TTC_MODEL`, `TTC_MIN_CHARS`, `TTC_TIMEOUT_MS`, `TTC_MAX_RETRIES`, `TTC_RETRY_BACKOFF_MS`, `TTC_USE_GZIP`, `TTC_COMPRESS_SYSTEM`, `TTC_COMPRESS_HISTORY`, `TTC_DEBUG`, `TTC_CACHE_MAX_ENTRIES`, `TTC_TOAST_ON_ACTIVE`, `TTC_TOAST_ON_IDLE_SUMMARY`, `TTC_AGGRESSIVENESS`. -## Models +## 6) Models -The TTC compress endpoint accepts a `model` field. Per `https://thetokencompany.com/docs/compression`, the currently-listed models are: +Per : | Model | Status | Notes | | --- | --- | --- | -| `bear-2` | Recommended | Most accurate compression. Best quality preservation. | +| `bear-2` | **Recommended** | Most accurate compression. Best quality preservation. | | `bear-1.2` | Available | Faster compression. Lower latency per request. | -This plugin's default is `bear-1.2` to preserve existing behavior; use `/ttc` → `Model` → `bear-2 (Recommended)` to switch. There is no TTC API endpoint for listing models at runtime, so the picker uses the curated list above plus a `custom model id` escape hatch for enterprise fine-tunes. +This plugin's default is `bear-1.2` to preserve existing behavior. To switch: `/ttc` → `Model` → `bear-2 (Recommended)`, or `opencode-ttc-plugin config set model bear-2`. + +There is no TTC API endpoint for listing models at runtime, so the `/ttc` picker is curated from the docs above plus a `custom model id` escape hatch for enterprise fine-tunes. -If you ever see an unexpected model id in the sidebar (e.g. something like `bear-2.0` — note: `bear-2.0` is **not** a real model id), check `opencode-ttc-plugin doctor --verbose` for the effective `model` value and its source. The model displayed in the sidebar is exactly what the plugin sends to the API; it cannot change on its own. Common causes: +## 7) What gets compressed +**Selection:** +- By default, only the latest `user` turn is compressed (avoids re-compressing unchanged history every turn). +- `compressSystem: true` adds eligible `system` messages. +- `compressHistory: true` adds older `user` turns. +- `assistant` messages are never compressed. + +**Skip patterns** (a part that matches is passed through unchanged): + +| Reason | What triggers it | +| --- | --- | +| `below threshold` | Text shorter than `minChars` | +| `code fence` | Contains a `` ``` `` fence | +| `diff` | Looks like a `diff --git` blob, `+++`/`---`/`@@` hunks | +| `stack trace` | Python-style traceback or `Exception:` line | +| `JSON` | Whole message is a JSON object or array | +| `schema` | JSON Schema markers (`$schema`, `tool_calls`, `properties`, etc.) | +| `synthetic` | Opencode-internal synthetic parts | +| `empty` / `non-text` | Empty string or non-text part | + +These protect content where compression could mangle semantics. They are hardcoded; there is no per-pattern toggle today. + +## 8) Troubleshooting + +**`missing TTC auth` after I logged in** +The sidebar polls the opencode auth store every 2s, so it should clear on its own. If it doesn't, run `opencode-ttc-plugin doctor` and confirm the key is detected under either `opencode-ttc-plugin` (new) or `the-token-company-plugin` (legacy) — both are read. + +**Sidebar shows a model I didn't configure (e.g. `bear-2.0`)** +The sidebar shows exactly what the plugin is configured to send. `bear-2.0` is **not** a real model id. Common causes: - A `model` key left in `~/.config/opencode/ttc-plugin.json` from a previous `/ttc` selection -- A `TTC_MODEL` environment variable exported in your shell -- A model id typed manually through the old free-text prompt +- `TTC_MODEL` exported in your shell +- A typo from the old free-text prompt -Reset to defaults with `opencode-ttc-plugin config reset`, then re-pick from the curated list via `/ttc`. +Run `opencode-ttc-plugin doctor --verbose` to see the effective `model` and its source. Reset with `opencode-ttc-plugin config reset`, then re-pick from the curated list via `/ttc`. -## Behavior settings +**`opencode auth login` keeps asking for two keys** +That's expected — one is your LLM provider key, the other is the TTC key. They're separate. Use `opencode-ttc-plugin login` or `/ttc-login` to set just the TTC key without the full menu. -Use CLI config for normal setup. Env vars are advanced overrides. +**Compression isn't firing** +Check the sidebar status line. If it says `skipped: ...`, the message matched a [skip pattern](#7-what-gets-compressed) or was below `minChars`. Lower `minChars` (`config set min-chars 100`) or check that your message isn't mostly code/JSON. -| Setting | Default | What it does | CLI command | -| --- | --- | --- | --- | -| `enabled` | `true` | Master on/off switch for the transform hook | `opencode-ttc-plugin config set enabled true` | -| `model` | `bear-1.2` | TTC model sent to `/v1/compress` | `opencode-ttc-plugin config set model bear-1.2` | -| `minChars` | `400` | Skip compression for text shorter than this | `opencode-ttc-plugin config set min-chars 400` | -| `timeoutMs` | `2000` | Request timeout per TTC call | `opencode-ttc-plugin config set timeout-ms 2000` | -| `maxRetries` | `1` | Retry count for retryable TTC failures | `opencode-ttc-plugin config set max-retries 1` | -| `retryBackoffMs` | `100` | Backoff base between retries | `opencode-ttc-plugin config set retry-backoff-ms 100` | -| `useGzip` | `true` | Sends compressed request body to TTC | `opencode-ttc-plugin config set use-gzip true` | -| `compressSystem` | `false` | Also compresses eligible `system` messages in context | `opencode-ttc-plugin config set compress-system false` | -| `compressHistory` | `false` | Also compresses older eligible `user` history messages (not just latest user turn) | `opencode-ttc-plugin config set compress-history false` | -| `debug` | `false` | Emits extra plugin debug logs | `opencode-ttc-plugin config set debug false` | -| `cacheMaxEntries` | `1000` | Max in-memory dedupe cache entries | `opencode-ttc-plugin config set cache-max-entries 1000` | -| `toastOnActive` | `false` | Shows one activation toast per session when enabled; sidebar is the primary UI | `opencode-ttc-plugin config set toast-on-active true` | -| `toastOnIdleSummary` | `false` | Shows idle summary toast with savings stats when enabled; sidebar is the primary UI | `opencode-ttc-plugin config set toast-on-idle-summary true` | - -Notes on scope: -- TTC API parameters used directly by this plugin request are primarily `model` and `compression_settings.aggressiveness`. -- Most settings above are plugin-side controls (selection, retries, skipping, caching, and UX behavior). -- For TTC API details, see `https://thetokencompany.com/docs`. - -Advanced overrides (optional): -- `TTC_AGGRESSIVENESS`, `TTC_MIN_CHARS`, `TTC_TIMEOUT_MS`, `TTC_MAX_RETRIES`, `TTC_RETRY_BACKOFF_MS` -- `TTC_USE_GZIP`, `TTC_COMPRESS_SYSTEM`, `TTC_COMPRESS_HISTORY`, `TTC_DEBUG` -- `TTC_CACHE_MAX_ENTRIES`, `TTC_TOAST_ON_ACTIVE`, `TTC_TOAST_ON_IDLE_SUMMARY`, `TTC_MODEL`, `TTC_ENABLED` - -## Security and network policy - -- Compression egress is pinned to `https://api.thetokencompany.com/v1/compress`. -- Custom/invalid `TTC_BASE_URL` is ignored and safely falls back to pinned host. -- Fetch redirects are rejected. -- The TTC API key is stored in the opencode auth store (`${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json`). All writes by this plugin are atomic (temp file + `rename(2)`), set file mode `0o600` and directory mode `0o700`, refuse to overwrite a corrupt existing file, and replace any symlink at the target path (so a planted symlink cannot exfiltrate the key to another location). -- The CLI `login` command never accepts the key as a `--key ` argument (which would leak via `ps aux` and shell history). Use the TTY prompt (echo off) or `--stdin`. -- A relative or non-absolute `XDG_DATA_HOME` is ignored and falls back to `$HOME/.local/share` to prevent the key being written to the working directory. -- Sidebar state is written under `${XDG_STATE_HOME:-~/.local/state}/opencode/ttc-plugin` with hashed session filenames. -- Sidebar state contains aggregate counts and token/character savings only; it does not persist prompts, compressed output, request bodies, or API keys. -- If your firewall prompts about outbound socket traffic, that is expected on first compression request. +**Doctor says `auth store ... set under 'the-token-company-plugin'`** +That's the legacy provider id from before v0.1.5. It still works — the plugin reads both. Your next `login` writes under the new `opencode-ttc-plugin` id; you can `logout` once to clear the legacy entry if you want a clean store. + +## 9) Security and network policy + +- **Egress pinned** to `https://api.thetokencompany.com/v1/compress`. Custom/invalid `TTC_BASE_URL` is ignored. +- **Redirects rejected.** Fetch follows no redirects. +- **Auth store writes are atomic.** Temp file + `rename(2)`, file mode `0o600`, directory mode `0o700`. A corrupt existing file is refused, never silently overwritten. A symlink at the target path is replaced, not followed, so a planted symlink can't exfiltrate the key. +- **No key in argv.** `login` never accepts `--key ` (would leak via `ps aux` and shell history). Use the TTY prompt (echo off) or `--stdin`. +- **XDG fallback.** A relative or non-absolute `XDG_DATA_HOME` is ignored and falls back to `$HOME/.local/share` to prevent the key being written to the working directory. +- **Sidebar state is metadata only.** Written under `${XDG_STATE_HOME:-~/.local/state}/opencode/ttc-plugin` with hashed session filenames. Contains aggregate counts and token/character savings only — no prompts, compressed output, request bodies, or API keys. +- **First compression request may trip a firewall prompt.** That's expected outbound traffic to `api.thetokencompany.com`. + +## License + +ISC — see [LICENSE](./LICENSE). From d3ff2fc33eb32b9f87abbc16dd22a63421dc2fb1 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 12:33:04 -0700 Subject: [PATCH 08/18] Rotate away stale legacy TTC entries on login Codex P2 on PR #3: when a user with the legacy the-token-company-plugin provider id logs in via the new flow, the new id gets written but the old entry stayed on disk indefinitely. logout already cleared both; now login does too. writeAuthEntry accepts legacyProviderIDs (defaults to LEGACY_AUTH_PROVIDER_IDS) and deletes any of those entries from the parsed store before writing the new one. Unrelated providers (anthropic, openai, etc.) are never touched. Returns removedLegacyIDs so callers can report what got cleaned. CLI persistKey prints 'Removed stale legacy entries: ...' when any were cleared. TUI performLogin toast appends the same note. Regression tests: - pre-existing legacy + current + anthropic + openai -> after login, legacy gone, current rotated, anthropic + openai preserved verbatim - fresh login (no legacy) returns removedLegacyIDs: [] --- lib/auth-store.js | 12 +++++++- scripts/cli.mjs | 3 ++ tests/opencode-ttc-plugin.test.js | 49 +++++++++++++++++++++++++++++++ tui/settings.js | 5 +++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/auth-store.js b/lib/auth-store.js index 10b8e94..a203970 100644 --- a/lib/auth-store.js +++ b/lib/auth-store.js @@ -117,6 +117,7 @@ async function atomicWriteJson(authFilePath, parsed, { export async function writeAuthEntry({ apiKey, providerID = AUTH_PROVIDER_ID, + legacyProviderIDs = LEGACY_AUTH_PROVIDER_IDS, authFilePath = getAuthStorePath(), readFileImpl = readFile, writeFileImpl = writeFile, @@ -143,6 +144,15 @@ export async function writeAuthEntry({ return { ok: false, reason: "auth_store_read_failed", authFilePath, error }; } + const removedLegacyIDs = []; + for (const candidateID of legacyProviderIDs) { + if (candidateID === providerID) continue; + if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) { + delete parsed[candidateID]; + removedLegacyIDs.push(candidateID); + } + } + parsed[providerID] = { type: "api", key: trimmed }; try { @@ -151,7 +161,7 @@ export async function writeAuthEntry({ return { ok: false, reason: "auth_store_write_failed", authFilePath, error }; } - return { ok: true, authFilePath, providerID }; + return { ok: true, authFilePath, providerID, removedLegacyIDs }; } export async function removeAuthEntry({ diff --git a/scripts/cli.mjs b/scripts/cli.mjs index dc0377b..489e29f 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -347,6 +347,9 @@ async function persistKey(apiKey) { return; } console.log(`Saved TTC API key under '${result.providerID}' at ${result.authFilePath}`); + if (result.removedLegacyIDs?.length) { + console.log(`Removed stale legacy entries: ${result.removedLegacyIDs.join(", ")}`); + } console.log("Restart opencode for sessions to pick up the new key."); } diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index 7b2919f..2c2dc30 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1140,6 +1140,55 @@ test("auth store write is atomic — concurrent writer does not lose entries", a } }); +test("login rotates away stale legacy TTC entries and leaves unrelated providers intact", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-rotate-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ + "the-token-company-plugin": { type: "api", key: "ttc_old_legacy" }, + "opencode-ttc-plugin": { type: "api", key: "ttc_old_current" }, + "anthropic": { type: "api", key: "sk-ant-keepMe" }, + "openai": { type: "api", key: "sk-oai-keepMe" } + }, null, 2), + "utf8" + ); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_rotated", + authFilePath: authPath + }); + assert.equal(result.ok, true); + assert.deepEqual(result.removedLegacyIDs, ["the-token-company-plugin"]); + + const final = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(final["opencode-ttc-plugin"].key, "ttc_rotated"); + assert.equal(final["the-token-company-plugin"], undefined, "legacy id should be cleared on rotation"); + assert.equal(final["anthropic"].key, "sk-ant-keepMe", "unrelated providers must be preserved"); + assert.equal(final["openai"].key, "sk-oai-keepMe", "unrelated providers must be preserved"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("login with no legacy entry present reports empty removedLegacyIDs", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-fresh-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_brand_new", + authFilePath: authPath + }); + assert.equal(result.ok, true); + assert.deepEqual(result.removedLegacyIDs, []); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + test("loadAuthStatus recognises legacy provider entries", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-authstatus-")); const authPath = join(tempDir, "opencode", "auth.json"); diff --git a/tui/settings.js b/tui/settings.js index 3393496..c94efdb 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -274,9 +274,12 @@ const LOGIN_FAILURE_MESSAGES = { async function performLogin(api, dialog, keyValue) { const result = await writeAuthEntry({ apiKey: keyValue }); if (result.ok) { + const legacyNote = result.removedLegacyIDs?.length + ? ` Also cleared stale legacy entries: ${result.removedLegacyIDs.join(", ")}.` + : ""; toast(api, { variant: "success", - message: `TTC API key saved under '${result.providerID}'. Restart opencode (or send a message) to activate.`, + message: `TTC API key saved under '${result.providerID}'.${legacyNote} Restart opencode (or send a message) to activate.`, duration: 5000 }); await openTtcSettingsMenu(api, dialog); From 1dff86153ec4fc04bca11c6875891b72f1ac4d41 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 13:16:13 -0700 Subject: [PATCH 09/18] Use shared getAuthStorePath in the transform; document RMW race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 #1 on PR #3 (path resolution mismatch): the message transform still had its own getAuthStorePath that did not validate XDG_DATA_HOME. When XDG_DATA_HOME was relative (invalid per spec but possible), login fell back to $HOME/.local/share while the transform read from the relative path — so login succeeded and compression silently failed with missing_auth. Fix: delete the duplicate; the plugin now imports getAuthStorePath from ../lib/auth-store.js. Same signature, same return shape, one source of truth. Tests still pass through the plugin's _test re-export. Regression test pins the fallback behavior through the plugin's exported helper: relative paths, './data', bare 'data', and empty string all fall back to $HOME/.local/share/opencode/auth.json. Codex P2 #2 on PR #3 (concurrent RMW not serialized): legitimate but disproportionate to fix. Atomic rename already prevents partial writes (addressing the original thermo H1); full serialization would need file locking (POSIX-only) or optimistic retry loops (deadlock potential) for an edge case where the realistic blast radius is 're-enter one key.' Documented as a known limitation in Troubleshooting with the recovery path. --- README.md | 5 ++++- opencode-plugins/ttc-message-transform.js | 7 +------ tests/opencode-ttc-plugin.test.js | 11 ++++++++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fbfa836..55a5339 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,10 @@ That's expected — one is your LLM provider key, the other is the TTC key. They Check the sidebar status line. If it says `skipped: ...`, the message matched a [skip pattern](#7-what-gets-compressed) or was below `minChars`. Lower `minChars` (`config set min-chars 100`) or check that your message isn't mostly code/JSON. **Doctor says `auth store ... set under 'the-token-company-plugin'`** -That's the legacy provider id from before v0.1.5. It still works — the plugin reads both. Your next `login` writes under the new `opencode-ttc-plugin` id; you can `logout` once to clear the legacy entry if you want a clean store. +That's the legacy provider id from before v0.1.5. It still works — the plugin reads both. Your next `login` writes under the new `opencode-ttc-plugin` id and also clears the legacy entry; you can `logout` once to clear the legacy entry if you want a clean store. + +**A non-TTC provider key disappeared from `auth.json`** +The auth store is written with atomic temp+rename (no partial files) but is not serialized across concurrent writers. If you ran `opencode-ttc-plugin login` at the exact moment `opencode auth login` was rotating another provider's key, the slower writer wins and the other's update can be dropped. Rare in practice (both are user-initiated, single-shot). Recovery: re-enter the dropped key via `opencode auth login`. ## 9) Security and network policy diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index c530bda..f061e62 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { gzipSync } from "node:zlib"; +import { getAuthStorePath } from "../lib/auth-store.js"; const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; @@ -433,12 +434,6 @@ function buildTtcPluginConfig(env = process.env) { }; } -function getAuthStorePath(env = process.env) { - const xdgDataHome = String(env.XDG_DATA_HOME ?? "").trim(); - const dataHome = xdgDataHome || join(homedir(), ".local", "share"); - return join(dataHome, "opencode", "auth.json"); -} - function getPluginConfigPath(env = process.env) { const xdgConfigHome = String(env.XDG_CONFIG_HOME ?? "").trim(); const configHome = xdgConfigHome || join(homedir(), ".config"); diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index 2c2dc30..aac2a72 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, dirname } from "node:path"; import TtcMessageTransformPlugin from "../opencode-plugins/ttc-message-transform.js"; @@ -534,6 +534,15 @@ test("resolves auth store path from XDG_DATA_HOME", () => { assert.equal(path, "/tmp/xdg-data/opencode/auth.json"); }); +test("auth store path falls back to $HOME when XDG_DATA_HOME is relative", () => { + const expected = join(homedir(), ".local", "share", "opencode", "auth.json"); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "relative/data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "./data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "" }), expected); + assert.equal(getAuthStorePath({}), expected); +}); + test("resolves api key from OpenCode auth store for provider id", async () => { const key = await resolveApiKeyFromAuthStore({ readFileImpl: async () => JSON.stringify({ From e83811a5336c49cb0ec295e5ad384b74bd1ba433 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 13:41:57 -0700 Subject: [PATCH 10/18] Fix sidebar metrics across session switches --- README.md | 3 + .../ttc-message-transform-core.js | 4 + opencode-plugins/ttc-message-transform.js | 80 +++++++++-- tests/opencode-ttc-plugin.test.js | 133 ++++++++++++++++++ tui/index.tsx | 50 +++++-- tui/sidebar-state.js | 9 ++ 6 files changed, 257 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 55a5339..98e4649 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,9 @@ That's expected — one is your LLM provider key, the other is the TTC key. They **Compression isn't firing** Check the sidebar status line. If it says `skipped: ...`, the message matched a [skip pattern](#7-what-gets-compressed) or was below `minChars`. Lower `minChars` (`config set min-chars 100`) or check that your message isn't mostly code/JSON. +**Sidebar metrics reset after switching sessions or restarting OpenCode** +Metrics are keyed by OpenCode session id and persisted under the hashed state path shown by `opencode-ttc-plugin doctor --verbose`. Switching sessions may show `new session` or `waiting for first compression` for sessions TTC has not processed yet, but returning to a processed session should restore its sidebar totals. After restarting OpenCode, the next compression in that session hydrates from the persisted state before writing new totals, so cumulative metrics should not reset. + **Doctor says `auth store ... set under 'the-token-company-plugin'`** That's the legacy provider id from before v0.1.5. It still works — the plugin reads both. Your next `login` writes under the new `opencode-ttc-plugin` id and also clears the legacy entry; you can `logout` once to clear the legacy entry if you want a clean store. diff --git a/opencode-plugins/ttc-message-transform-core.js b/opencode-plugins/ttc-message-transform-core.js index 3244a8b..166dd4e 100644 --- a/opencode-plugins/ttc-message-transform-core.js +++ b/opencode-plugins/ttc-message-transform-core.js @@ -62,6 +62,10 @@ export function createSessionStats(...args) { return testApi.createSessionStats(...args); } +export function hydrateSessionStatsFromSidebarState(...args) { + return testApi.hydrateSessionStatsFromSidebarState(...args); +} + export function resetLastMessageStats(...args) { return testApi.resetLastMessageStats(...args); } diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index f061e62..141d45a 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -214,12 +214,63 @@ function createSessionStats() { }; } -function getSessionStats(sessionStats, sessionID) { +function hydrateSessionStatsFromSidebarState(state) { + if (!state || typeof state !== "object") return null; + if (state.schemaVersion !== 1) return null; + + const numberOrZero = (value) => Math.max(0, Number(value) || 0); + const stats = createSessionStats(); + const session = state.session && typeof state.session === "object" ? state.session : {}; + const lastMessage = state.lastMessage && typeof state.lastMessage === "object" ? state.lastMessage : {}; + + stats.processed = numberOrZero(session.processed); + stats.compressed = numberOrZero(session.compressed); + stats.skipped = numberOrZero(session.skipped); + stats.fallback = numberOrZero(session.fallback); + stats.cacheHits = numberOrZero(session.cacheHits); + stats.charsBefore = numberOrZero(session.charsBefore); + stats.charsAfter = numberOrZero(session.charsAfter); + stats.estimatedTokensSaved = numberOrZero(session.estimatedTokensSaved); + stats.exactTokensSaved = numberOrZero(session.exactTokensSaved); + stats.lastMessageCharsBefore = numberOrZero(lastMessage.charsBefore); + stats.lastMessageCharsAfter = numberOrZero(lastMessage.charsAfter); + stats.lastMessageTokensSaved = numberOrZero(lastMessage.tokensSaved); + stats.lastMessagePartsProcessed = numberOrZero(lastMessage.partsProcessed); + stats.lastMessageCompressed = Boolean(lastMessage.compressed); + stats.lastMessageNoReduction = Boolean(lastMessage.noReduction); + stats.lastMessageFallback = Boolean(lastMessage.fallback); + stats.lastMessageSkipReasons = lastMessage.skipReasons && typeof lastMessage.skipReasons === "object" + ? Object.fromEntries(Object.entries(lastMessage.skipReasons).map(([reason, count]) => [reason, numberOrZero(count)])) + : {}; + stats.version = stats.processed + stats.skipped + stats.fallback > 0 ? 1 : 0; + + return stats; +} + +async function loadPersistedSessionStats(sessionID, { + statePath = getSidebarStatePath(sessionID), + readFileImpl = readFile +} = {}) { + try { + const content = await readFileImpl(statePath, "utf8"); + return hydrateSessionStatsFromSidebarState(JSON.parse(content)); + } catch { + return null; + } +} + +async function getSessionStats(sessionStats, sessionID, options = {}) { if (!sessionStats || !sessionID) return null; - if (!sessionStats.has(sessionID)) { - sessionStats.set(sessionID, createSessionStats()); + const existing = sessionStats.get(sessionID); + if (existing) return await existing; + + const pendingStats = loadPersistedSessionStats(sessionID, options).then((stats) => stats ?? createSessionStats()); + sessionStats.set(sessionID, pendingStats); + const stats = await pendingStats; + if (sessionStats.get(sessionID) === pendingStats) { + sessionStats.set(sessionID, stats); } - return sessionStats.get(sessionID); + return stats; } function updateStatsVersion(stats) { @@ -937,6 +988,8 @@ async function transformMessagesWithTtc({ cache, sessionStats = null, authSource = "unknown", + readSidebarStateImpl = readFile, + env = process.env, writeSidebarStateImpl = writeSidebarState, fetchImpl = fetch }) { @@ -947,12 +1000,17 @@ async function transformMessagesWithTtc({ const latestUserMessageID = latestUser?.info?.id; const latestUserSessionID = latestUser?.info?.sessionID ?? currentSessionID; if (!config.enabled || !config.apiKey) { - const stats = getSessionStats(sessionStats, latestUserSessionID); + const statePath = getSidebarStatePath(latestUserSessionID, env); + const stats = await getSessionStats(sessionStats, latestUserSessionID, { + statePath, + readFileImpl: readSidebarStateImpl + }); await writeSidebarStateImpl({ stats, config, sessionID: latestUserSessionID, - authSource + authSource, + statePath }); return; } @@ -964,7 +1022,11 @@ async function transformMessagesWithTtc({ if (!shouldCompressMessage(messageEntry.info, latestUserMessageID, config)) continue; const sessionID = messageEntry.info.sessionID ?? currentSessionID; - const stats = getSessionStats(sessionStats, sessionID); + const statePath = getSidebarStatePath(sessionID, env); + const stats = await getSessionStats(sessionStats, sessionID, { + statePath, + readFileImpl: readSidebarStateImpl + }); if (stats && !resetSessions.has(sessionID)) { resetSessions.add(sessionID); resetLastMessageStats(stats); @@ -1081,7 +1143,8 @@ async function transformMessagesWithTtc({ stats, config, sessionID, - authSource + authSource, + statePath }); } } @@ -1209,6 +1272,7 @@ TtcMessageTransformPlugin._test = { resolveEffectiveApiKey, resolveSessionIDFromTransformInput, createSessionStats, + hydrateSessionStatsFromSidebarState, resetLastMessageStats, recordSkipReason, recordProcessedPart, diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index aac2a72..ab42753 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -11,6 +11,7 @@ import { createSessionStats, getPluginConfigPath, getSidebarStatePath, + hydrateSessionStatsFromSidebarState, getAuthStorePath, getSkipReasonForText, recordProcessedPart, @@ -31,8 +32,10 @@ import { formatMetricValue, formatPartLine, getStatusDotColor, + emptySidebarStateText, loadAuthStatus, loadSidebarState, + shouldRenderSidebarState, statusText } from "../tui/sidebar-state.js"; import { @@ -665,6 +668,125 @@ test("writes and loads sidebar state by hashed session path", async () => { } }); +test("hydrates session stats from persisted sidebar state before writing new resume metrics", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-resume-state-")); + + try { + const priorStats = createSessionStats(); + recordProcessedPart(priorStats, { + charsBefore: 1000, + charsAfter: 400, + compressed: true, + fallback: false, + cacheHit: false, + tokenSavingsExact: 150 + }); + + const statePath = getSidebarStatePath("sess-1", { XDG_STATE_HOME: tempDir }); + await writeSidebarState({ + stats: priorStats, + config: baseConfig, + sessionID: "sess-1", + statePath + }); + + const persisted = await loadSidebarState("sess-1", { statePath }); + const hydrated = hydrateSessionStatsFromSidebarState(persisted); + assert.equal(hydrated.processed, 1); + assert.equal(hydrated.charsBefore, 1000); + assert.equal(hydrated.charsAfter, 400); + assert.equal(hydrated.exactTokensSaved, 150); + + const output = createOutput("this resumed session prompt should add to the existing persisted metrics"); + await transformMessagesWithTtc({ + output, + client: createClient(), + config: baseConfig, + cache: new Map(), + sessionStats: new Map(), + env: { XDG_STATE_HOME: tempDir }, + fetchImpl: async () => ({ + ok: true, + async json() { + return { + output: "compressed after resume", + input_tokens: 200, + output_tokens: 80 + }; + } + }) + }); + + const updated = await loadSidebarState("sess-1", { statePath }); + assert.equal(updated.session.processed, 2); + assert.equal(updated.session.compressed, 2); + assert.equal(updated.session.charsBefore, 1000 + "this resumed session prompt should add to the existing persisted metrics".length); + assert.equal(updated.session.charsAfter, 400 + "compressed after resume".length); + assert.equal(updated.session.exactTokensSaved, 270); + assert.equal(updated.lastMessage.charsAfter, "compressed after resume".length); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("concurrent first-use hydration shares one persisted session stats object", async () => { + const priorStats = createSessionStats(); + recordProcessedPart(priorStats, { + charsBefore: 1000, + charsAfter: 400, + compressed: true, + fallback: false, + cacheHit: false, + tokenSavingsExact: 150 + }); + const persistedState = JSON.stringify(buildSidebarState({ + stats: priorStats, + config: baseConfig, + sessionID: "sess-1" + })); + const sessionStats = new Map(); + const writes = []; + let readCalls = 0; + let releaseHydration; + const hydrationStarted = new Promise((resolve) => { + releaseHydration = resolve; + }); + + const makeTransform = (text, compressedText) => transformMessagesWithTtc({ + output: createOutput(text), + client: createClient(), + config: baseConfig, + cache: new Map(), + sessionStats, + readSidebarStateImpl: async () => { + readCalls += 1; + await hydrationStarted; + return persistedState; + }, + writeSidebarStateImpl: async ({ stats, config, sessionID, authSource }) => { + writes.push(buildSidebarState({ stats, config, sessionID, authSource })); + }, + fetchImpl: async () => ({ + ok: true, + async json() { + return { + output: compressedText + }; + } + }) + }); + + const first = makeTransform("first resumed prompt should keep prior cumulative metrics", "first compressed"); + const second = makeTransform("second resumed prompt should share hydrated metrics too", "second compressed"); + releaseHydration(); + await Promise.all([first, second]); + + assert.equal(readCalls, 1); + assert.equal(writes.at(-1).session.processed, 3); + assert.equal(writes.at(-1).session.compressed, 3); + assert.equal(sessionStats.get("sess-1").processed, 3); +}); + test("loadSidebarState returns null for malformed or missing state", async () => { const missing = await loadSidebarState("missing-session", { readFileImpl: async () => { @@ -679,6 +801,17 @@ test("loadSidebarState returns null for malformed or missing state", async () => assert.equal(malformed, null); }); +test("sidebar session switch helpers avoid rendering stale state as zeroed metrics", () => { + const currentState = { session: { processed: 2 } }; + + assert.equal(shouldRenderSidebarState(currentState, "session-a", "session-a"), true); + assert.equal(shouldRenderSidebarState(currentState, "session-a", "session-b"), false); + assert.equal(shouldRenderSidebarState(null, "session-a", "session-a"), false); + assert.equal(emptySidebarStateText({ loading: true, messageCount: 0 }), "loading session metrics"); + assert.equal(emptySidebarStateText({ loading: false, messageCount: 0 }), "new session"); + assert.equal(emptySidebarStateText({ loading: false, messageCount: 3 }), "waiting for first compression"); +}); + test("transform writes sidebar state after processing a session message", async () => { let capturedState = null; const sessionStats = new Map(); diff --git a/tui/index.tsx b/tui/index.tsx index ea72e73..8eaeb13 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -2,11 +2,13 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; import type { TuiPlugin, TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"; import { + emptySidebarStateText, formatMetricValue, formatPartLine, getStatusDotColor, loadAuthStatus, loadSidebarState, + shouldRenderSidebarState, statusText } from "./sidebar-state.js"; import { registerTtcSettingsCommand } from "./settings.js"; @@ -20,24 +22,30 @@ function SidebarContent(props: { theme: TuiTheme; }) { const [state, setState] = createSignal(null); + const [loadedSessionID, setLoadedSessionID] = createSignal(""); + const [loadingSessionID, setLoadingSessionID] = createSignal(""); const [authStatus, setAuthStatus] = createSignal({ hasKey: false, providerID: null, authPath: "" }); const [refreshCount, setRefreshCount] = createSignal(0); const theme = () => props.theme.current; - const refresh = async () => { - setState(await loadSidebarState(props.sessionID)); - setAuthStatus(await loadAuthStatus()); - }; + const refresh = async (sessionID = props.sessionID) => { + setLoadingSessionID(sessionID); + const [nextState, nextAuthStatus] = await Promise.all([ + loadSidebarState(sessionID), + loadAuthStatus() + ]); - createEffect(() => { - props.sessionID; - setState(null); - void refresh(); - }); + if (props.sessionID !== sessionID) return; + setState(nextState); + setLoadedSessionID(sessionID); + setAuthStatus(nextAuthStatus); + setLoadingSessionID(""); + }; createEffect(() => { + const sessionID = props.sessionID; refreshCount(); - void refresh(); + void refresh(sessionID); }); const triggerRefresh = (event: { properties?: Record }) => { @@ -56,8 +64,16 @@ function SidebarContent(props: { unsubscribeSessionStatus(); }); + const sessionMessageCount = createMemo(() => props.api.state.session.messages(props.sessionID)?.length ?? 0); + + const visibleState = createMemo(() => { + return shouldRenderSidebarState(state(), loadedSessionID(), props.sessionID) ? state() : null; + }); + + const isLoadingCurrentSession = createMemo(() => loadingSessionID() === props.sessionID && !visibleState()); + const effectiveStatus = createMemo(() => { - const current = state(); + const current = visibleState(); if (current?.status === "missing_auth" && authStatus().hasKey) { return "waiting"; } @@ -69,7 +85,13 @@ function SidebarContent(props: { }); const statusLine = createMemo(() => { - const current = state(); + const current = visibleState(); + if (!current) { + return emptySidebarStateText({ + loading: isLoadingCurrentSession(), + messageCount: sessionMessageCount() + }); + } if (effectiveStatus() === "waiting") { return "authenticated — send a message to start compressing"; } @@ -81,10 +103,10 @@ function SidebarContent(props: { Token Compression - {state()?.config?.model ?? ""} + {visibleState()?.config?.model ?? ""} {statusLine()} - No session data yet}> + No metrics for this session yet}> {(current) => ( diff --git a/tui/sidebar-state.js b/tui/sidebar-state.js index 67bcec3..40a782c 100644 --- a/tui/sidebar-state.js +++ b/tui/sidebar-state.js @@ -121,6 +121,15 @@ export function formatPartLine(session = {}) { return `${compressed}/${processed} parts compressed`; } +export function shouldRenderSidebarState(state, loadedSessionID, currentSessionID) { + return Boolean(state && loadedSessionID && loadedSessionID === currentSessionID); +} + +export function emptySidebarStateText({ loading = false, messageCount = 0 } = {}) { + if (loading) return "loading session metrics"; + return messageCount > 0 ? "waiting for first compression" : "new session"; +} + export function getStatusDotColor(status, theme = {}) { const successColor = theme.success ?? "green"; const errorColor = theme.error ?? "red"; From 11a2a136fde8fcb89ce48654af1b2f5586b6c213 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 14:30:54 -0700 Subject: [PATCH 11/18] fix: clear stale sidebar state after logout When auth is removed via /ttc-logout or CLI, hide any previously written compressed/skipped sidebar state and show 'missing TTC auth' instead of stale metrics. - gate visibleState on hasAuth() - force effectiveStatus to 'missing_auth' when no key - statusLine shows 'missing TTC auth' when unauthed - polling via loadAuthStatus already provides fresh auth status Fixes Codex P2 on PR #3. --- tui/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tui/index.tsx b/tui/index.tsx index 8eaeb13..70c3511 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -66,15 +66,23 @@ function SidebarContent(props: { const sessionMessageCount = createMemo(() => props.api.state.session.messages(props.sessionID)?.length ?? 0); + const hasAuth = createMemo(() => authStatus().hasKey); + const visibleState = createMemo(() => { + if (!hasAuth()) { + return null; // hide any stale compressed/skipped state once logged out + } return shouldRenderSidebarState(state(), loadedSessionID(), props.sessionID) ? state() : null; }); const isLoadingCurrentSession = createMemo(() => loadingSessionID() === props.sessionID && !visibleState()); const effectiveStatus = createMemo(() => { + if (!hasAuth()) { + return "missing_auth"; + } const current = visibleState(); - if (current?.status === "missing_auth" && authStatus().hasKey) { + if (current?.status === "missing_auth" && hasAuth()) { return "waiting"; } return current?.status; @@ -87,6 +95,9 @@ function SidebarContent(props: { const statusLine = createMemo(() => { const current = visibleState(); if (!current) { + if (!hasAuth()) { + return "missing TTC auth"; + } return emptySidebarStateText({ loading: isLoadingCurrentSession(), messageCount: sessionMessageCount() From d4f96ebdc372a21aebbe4c7ba9ecf7a93122dc69 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 14:41:52 -0700 Subject: [PATCH 12/18] fix: keep plugin self-contained for legacy opencode-ttc-plugin install - Remove import of ../lib/auth-store.js from ttc-message-transform.js - Inline getAuthStorePath (with isAbsolute XDG validation) inside the plugin - Legacy installer copies only the single .js file; external import would 404 - Parity with lib/auth-store.js is covered by existing path tests + manual smoke This is the same P1 that was previously fixed but the commit was not the current head. Addresses Codex P1 'Keep legacy installs loadable'. --- opencode-plugins/ttc-message-transform.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index 141d45a..05f0183 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -1,9 +1,8 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { gzipSync } from "node:zlib"; -import { getAuthStorePath } from "../lib/auth-store.js"; const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; @@ -485,6 +484,14 @@ function buildTtcPluginConfig(env = process.env) { }; } +function getAuthStorePath(env = process.env) { + const raw = String(env.XDG_DATA_HOME ?? "").trim(); + if (raw && isAbsolute(raw)) { + return join(raw, "opencode", "auth.json"); + } + return join(homedir(), ".local", "share", "opencode", "auth.json"); +} + function getPluginConfigPath(env = process.env) { const xdgConfigHome = String(env.XDG_CONFIG_HOME ?? "").trim(); const configHome = xdgConfigHome || join(homedir(), ".config"); From 9933cb5c134d1d79e1bfbda35073dd9edee776ef Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 14:57:26 -0700 Subject: [PATCH 13/18] fix: recognize TTC_API_KEY env as valid auth in TUI and sidebar - loadAuthStatus() now short-circuits on TTC_API_KEY (returns hasKey:true, providerID:'env') - hasAuthEntry() now short-circuits on TTC_API_KEY (returns hasKey:true, providerID:'env') - This makes sidebar show metrics instead of 'missing TTC auth' when using env - Makes settings menu show 'Remove API key' instead of 'Add' when env is set - Prevents spurious second login prompts for users on env auth Addresses latest Codex P2. 55/55 tests still pass. --- lib/auth-store.js | 5 +++++ tui/sidebar-state.js | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/auth-store.js b/lib/auth-store.js index a203970..de3da83 100644 --- a/lib/auth-store.js +++ b/lib/auth-store.js @@ -71,9 +71,14 @@ export function findAuthEntry(parsed, providerIDs = [AUTH_PROVIDER_ID, ...LEGACY export async function hasAuthEntry({ authFilePath = getAuthStorePath(), providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS], + env = process.env, readFileImpl = readFile, lstatImpl = lstat } = {}) { + // TTC_API_KEY in env is a supported first-class auth source + if (String(env.TTC_API_KEY ?? "").trim()) { + return { hasKey: true, providerID: "env" }; + } try { const { parsed } = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); const entry = findAuthEntry(parsed, providerIDs); diff --git a/tui/sidebar-state.js b/tui/sidebar-state.js index 40a782c..28b00b4 100644 --- a/tui/sidebar-state.js +++ b/tui/sidebar-state.js @@ -39,8 +39,14 @@ export function getAuthStorePath(env = process.env) { } export async function loadAuthStatus(options = {}) { + const env = options.env ?? process.env; + const envKey = String(env.TTC_API_KEY ?? "").trim(); + if (envKey) { + return { hasKey: true, providerID: "env", authPath: "" }; + } + const readFileImpl = options.readFileImpl ?? readFile; - const authPath = options.authFilePath ?? getSharedAuthStorePath(options.env ?? process.env); + const authPath = options.authFilePath ?? getSharedAuthStorePath(env); try { const content = await readFileImpl(authPath, "utf8"); const trimmed = String(content ?? "").trim(); From 9a68d83f563d764d1b6730ca3c634e26bf6e71d8 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 16:00:47 -0700 Subject: [PATCH 14/18] test: cover TTC_API_KEY env as valid auth in loadAuthStatus + hasAuthEntry This prevents false 'missing auth' in sidebar and settings menu for env users, which was causing extra 'add API key' prompts after opencode auth login or direct use of TTC_API_KEY. 56/56 tests. --- tests/opencode-ttc-plugin.test.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index ab42753..5df9201 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1350,6 +1350,36 @@ test("loadAuthStatus recognises legacy provider entries", async () => { } }); +test("loadAuthStatus and hasAuthEntry treat TTC_API_KEY env as valid auth source", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-env-auth-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + // no key file present -> store reports no key + + const envWithKey = { TTC_API_KEY: "ttc_env_key_123" }; + const envNoKey = {}; + + try { + // env short-circuits regardless of store + const statusEnv = await loadAuthStatus({ env: envWithKey, authFilePath: authPath }); + assert.equal(statusEnv.hasKey, true); + assert.equal(statusEnv.providerID, "env"); + + const statusNoEnv = await loadAuthStatus({ env: envNoKey, authFilePath: authPath }); + assert.equal(statusNoEnv.hasKey, false); + + // hasAuthEntry (TUI hasTtcAuthKey) also respects env + const hasEnv = await hasTtcAuthKey({ env: envWithKey, authFilePath: authPath }); + assert.equal(hasEnv.hasKey, true); + assert.equal(hasEnv.providerID, "env"); + + const hasNoEnv = await hasTtcAuthKey({ env: envNoKey, authFilePath: authPath }); + assert.equal(hasNoEnv.hasKey, false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + test("known TTC model list is grounded in current docs", () => { const ids = TUI_KNOWN_MODELS.map((model) => model.id); assert.deepEqual(ids.sort(), ["bear-1.2", "bear-2"].sort()); From 6de830a0110f0fedba2441ad2701ea63fab18659 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 16:13:37 -0700 Subject: [PATCH 15/18] remove custom TTC API key prompt from TUI; single native flow only - Drop /ttc-login slash command entirely - Drop 'Add API key' row from /ttc settings (only 'Remove' when present) - Remove internal promptLogin / performLogin paths - 'Add' path (if reached) now tells user to use opencode auth login - Update tests (no more login registration, menu auth row is now only logout) - Update README (remove references to /ttc-login and custom prompt) - CLI login (opencode-ttc-plugin login) remains for direct use Goal: one place to enter the key (native auth provider or direct CLI) to stop double prompts. --- README.md | 35 ++++---- tests/opencode-ttc-plugin.test.js | 17 ++-- tui/settings.js | 140 +++++++++--------------------- 3 files changed, 65 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 98e4649..0a20800 100644 --- a/README.md +++ b/README.md @@ -49,17 +49,16 @@ Install @drfok/opencode-ttc-plugin by following: https://raw.githubusercontent.c opencode-ttc-plugin login # interactive TTY prompt, echo off printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI - # Inside opencode (TUI slash commands) - /ttc-login # dialog asking for the key - /ttc-logout # removes the key + # Inside opencode (TUI slash commands) + /ttc-logout # removes the key - # Through opencode's native auth flow - opencode auth login # then pick provider: opencode-ttc-plugin - ``` + # Through opencode's native auth flow (single place to enter the key) + opencode auth login # then pick provider: opencode-ttc-plugin + ``` - > **Why two prompts during `opencode auth login`?** OpenCode asks for your LLM-provider key (OpenAI/Anthropic/etc) and any plugin keys separately. Our plugin only ever asks for the TTC key. Use `opencode-ttc-plugin login` or `/ttc-login` to skip the full menu. - > - > **No `--key ` flag.** Passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` or the TTY prompt. + > **Only one prompt.** Use `opencode auth login` (choose the TTC provider) or the direct CLI. There is no separate custom prompt inside the TUI anymore. + > + > **No `--key ` flag.** Passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` or the TTY prompt. 3. Verify: @@ -86,11 +85,10 @@ Install @drfok/opencode-ttc-plugin by following: https://raw.githubusercontent.c opencode-ttc-plugin install ``` -3. Tell the user to set their TTC API key with one of: - - `printf '%s' "$KEY" | opencode-ttc-plugin login --stdin` (CLI, recommended for scripts) - - `opencode-ttc-plugin login` (CLI interactive prompt, echo off) - - `/ttc-login` slash command inside opencode - - `opencode auth login` then choose provider `opencode-ttc-plugin` + 3. Tell the user to set their TTC API key with one of: + - `printf '%s' "$KEY" | opencode-ttc-plugin login --stdin` (CLI, recommended for scripts) + - `opencode-ttc-plugin login` (CLI interactive prompt, echo off) + - `opencode auth login` then choose provider `opencode-ttc-plugin` 4. Tell the user to get a key from if they don't have one. @@ -120,7 +118,7 @@ The `Token Compression` row in the opencode sidebar shows live per-session state | `skipped: code fence, below threshold` | Last message had parts that were intentionally not compressed, with reason counts | | `fallback: request failed open` | TTC API errored; original text was passed through unchanged | | `no reduction` | TTC returned output that wasn't smaller than the input; original used | -| `missing TTC auth` | No API key found — run `opencode-ttc-plugin login` or `/ttc-login` | +| `missing TTC auth` | No API key found — run `opencode-ttc-plugin login` or `opencode auth login` (choose provider `opencode-ttc-plugin`) | | `disabled` | `enabled: false` in config | | `waiting for metrics` | State file not written yet (e.g. brand-new session, no messages) | @@ -130,11 +128,10 @@ The sidebar shows the configured `model` next to the title. The model shown is e | Command | Action | | --- | --- | -| `/ttc` | Open the Token Compression settings menu (enable/disable, level, aggressiveness, min chars, model, auth, reset) | -| `/ttc-login` | Prompt for the TTC API key and save it to the opencode auth store | +| `/ttc` | Open the Token Compression settings menu (enable/disable, level, aggressiveness, min chars, model, reset) | | `/ttc-logout` | Remove the TTC API key (asks for confirmation) | -The auth row in `/ttc` is contextual: it shows `Add API key` when no key is present and `Remove API key` when one is. Only one row, not two. +There is no separate "Add API key" row. Add the key once via the native flow (`opencode auth login` → choose provider `opencode-ttc-plugin`) or the direct CLI. Only logout appears in the menu when a key is present. The model picker is curated from the TTC docs (`bear-2`, `bear-1.2`) plus a `custom model id` escape hatch for enterprise fine-tunes. See [Models](#6-models). @@ -256,7 +253,7 @@ The sidebar shows exactly what the plugin is configured to send. `bear-2.0` is * Run `opencode-ttc-plugin doctor --verbose` to see the effective `model` and its source. Reset with `opencode-ttc-plugin config reset`, then re-pick from the curated list via `/ttc`. **`opencode auth login` keeps asking for two keys** -That's expected — one is your LLM provider key, the other is the TTC key. They're separate. Use `opencode-ttc-plugin login` or `/ttc-login` to set just the TTC key without the full menu. +That's expected — one is your LLM provider key (OpenAI/Anthropic/etc), the other is the TTC key. They're separate flows. Use `opencode-ttc-plugin login` (CLI) or `opencode auth login` → pick provider `opencode-ttc-plugin`. There is no separate custom prompt in the TUI. **Compression isn't firing** Check the sidebar status line. If it says `skipped: ...`, the message matched a [skip pattern](#7-what-gets-compressed) or was below `minChars`. Lower `minChars` (`config set min-chars 100`) or check that your message isn't mostly code/JSON. diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index 5df9201..a5fa14c 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -45,11 +45,11 @@ import { registerTtcSettingsCommand, resetTtcSettings, updateTtcSetting, - writeAuthEntry as writeTuiAuthEntry, removeAuthEntry as removeTuiAuthEntry, AUTH_PROVIDER_ID as TUI_AUTH_PROVIDER_ID, KNOWN_MODELS as TUI_KNOWN_MODELS } from "../tui/settings.js"; +import { writeAuthEntry as writeTuiAuthEntry } from "../lib/auth-store.js"; function createOutput(text) { return { @@ -1057,7 +1057,7 @@ test("registers TUI settings command and slash aliases", () => { assert.equal(typeof command.onSelect, "function"); }); -test("registers /ttc-login and /ttc-logout slash commands", () => { +test("registers only /ttc-logout (no custom ttc-login)", () => { let registeredCallback = null; registerTtcSettingsCommand({ command: { @@ -1073,9 +1073,7 @@ test("registers /ttc-login and /ttc-logout slash commands", () => { const login = commands.find((cmd) => cmd.value === "ttc.login"); const logout = commands.find((cmd) => cmd.value === "ttc.logout"); - assert.equal(Boolean(login), true); - assert.equal(login.slash.name, "ttc-login"); - assert.equal(typeof login.onSelect, "function"); + assert.equal(Boolean(login), false); assert.equal(Boolean(logout), true); assert.equal(logout.slash.name, "ttc-logout"); assert.equal(typeof logout.onSelect, "function"); @@ -1108,14 +1106,13 @@ test("settings menu shows one compact auth row based on auth state", async () => try { await openTtcSettingsMenu(api, api.ui.dialog); - let authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); - assert.equal(authRows.length, 1); - assert.equal(authRows[0].title, "Add API key"); - assert.equal(authRows[0].description, "Required for compression"); + // No custom "Add API key" row — only one flow: opencode auth login + let authRows = latestSelect.options.filter((option) => option.value === "ttc-logout"); + assert.equal(authRows.length, 0); await writeTuiAuthEntry({ apiKey: "ttc_existing_key" }); await openTtcSettingsMenu(api, api.ui.dialog); - authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + authRows = latestSelect.options.filter((option) => option.value === "ttc-logout"); assert.equal(authRows.length, 1); assert.equal(authRows[0].title, "Remove API key"); assert.equal(authRows[0].description, "Revoke saved key"); diff --git a/tui/settings.js b/tui/settings.js index c94efdb..9fd1bb5 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -6,14 +6,11 @@ import { LEGACY_AUTH_PROVIDER_IDS, getAuthStorePath, hasAuthEntry, - writeAuthEntry, removeAuthEntry } from "../lib/auth-store.js"; export const TTC_SETTINGS_COMMAND_VALUE = "ttc.settings"; export const TTC_SETTINGS_COMMAND_TITLE = "Token Compression: Settings"; -export const TTC_LOGIN_COMMAND_VALUE = "ttc.login"; -export const TTC_LOGIN_COMMAND_TITLE = "Token Compression: Login"; export const TTC_LOGOUT_COMMAND_VALUE = "ttc.logout"; export const TTC_LOGOUT_COMMAND_TITLE = "Token Compression: Logout"; @@ -52,7 +49,6 @@ export function getTtcSettingsConfigPath(env = process.env) { export { getAuthStorePath, hasAuthEntry as hasTtcAuthKey, - writeAuthEntry, removeAuthEntry }; @@ -263,42 +259,6 @@ function selectModel(api, dialog, currentValue) { })); } -const LOGIN_FAILURE_MESSAGES = { - empty_key: "Empty API key.", - auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", - auth_store_not_regular_file: "opencode auth store path is not a regular file.", - auth_store_read_failed: "Could not read the opencode auth store.", - auth_store_write_failed: "Could not write the opencode auth store." -}; - -async function performLogin(api, dialog, keyValue) { - const result = await writeAuthEntry({ apiKey: keyValue }); - if (result.ok) { - const legacyNote = result.removedLegacyIDs?.length - ? ` Also cleared stale legacy entries: ${result.removedLegacyIDs.join(", ")}.` - : ""; - toast(api, { - variant: "success", - message: `TTC API key saved under '${result.providerID}'.${legacyNote} Restart opencode (or send a message) to activate.`, - duration: 5000 - }); - await openTtcSettingsMenu(api, dialog); - return; - } - const message = LOGIN_FAILURE_MESSAGES[result.reason] ?? `Login failed: ${result.reason}.`; - toast(api, { variant: "error", message, duration: 5000 }); - renderAlert(api, dialog, "Login Failed", message); -} - -function promptLogin(api, dialog) { - dialog.replace(() => api.ui.DialogPrompt({ - title: "TTC API Key", - placeholder: "ttc_...", - value: "", - onConfirm: (nextValue) => void performLogin(api, dialog, nextValue), - onCancel: () => openTtcSettingsMenu(api, dialog) - })); -} const LOGOUT_FAILURE_MESSAGES = { auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", @@ -354,53 +314,51 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { const settings = await readTtcSettings(); const auth = await hasAuthEntry(); const view = buildSettingsView(settings); - const authOption = auth.hasKey - ? { - title: "Remove API key", - value: "ttc-logout", - description: "Revoke saved key" - } - : { - title: "Add API key", - value: "ttc-login", - description: "Required for compression" - }; + const options = [ + { + title: `${view.enabled ? "Disable" : "Enable"} compression`, + value: "toggle-enabled", + description: `Currently ${view.enabled ? "enabled" : "disabled"}` + }, + { + title: "Compression level", + value: "set-level", + description: view.compressionLevel === "custom" ? "Custom aggressiveness" : view.compressionLevel + }, + { + title: "Custom aggressiveness", + value: "set-aggressiveness", + description: String(view.aggressiveness) + }, + { + title: "Min chars", + value: "set-min-chars", + description: String(view.minChars) + }, + { + title: "Model", + value: "set-model", + description: view.model + }, + { + title: "Reset config", + value: "reset-config", + description: "Remove plugin config file" + } + ]; + + if (auth.hasKey) { + options.splice(5, 0, { + title: "Remove API key", + value: "ttc-logout", + description: "Revoke saved key" + }); + } + dialog.setSize?.("medium"); dialog.replace(() => api.ui.DialogSelect({ title: "Token Compression Settings", - options: [ - { - title: `${view.enabled ? "Disable" : "Enable"} compression`, - value: "toggle-enabled", - description: `Currently ${view.enabled ? "enabled" : "disabled"}` - }, - { - title: "Compression level", - value: "set-level", - description: view.compressionLevel === "custom" ? "Custom aggressiveness" : view.compressionLevel - }, - { - title: "Custom aggressiveness", - value: "set-aggressiveness", - description: String(view.aggressiveness) - }, - { - title: "Min chars", - value: "set-min-chars", - description: String(view.minChars) - }, - { - title: "Model", - value: "set-model", - description: view.model - }, - authOption, - { - title: "Reset config", - value: "reset-config", - description: "Remove plugin config file" - } - ], + options, onSelect: (option) => { if (option.value === "toggle-enabled") { void saveAndReturn(api, dialog, "toggle-enabled"); @@ -432,10 +390,6 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { selectModel(api, dialog, view.model); return; } - if (option.value === "ttc-login") { - promptLogin(api, dialog); - return; - } if (option.value === "ttc-logout") { confirmLogout(api, dialog); return; @@ -461,16 +415,6 @@ export function registerTtcSettingsCommand(api) { }, onSelect: (dialog) => void openTtcSettingsMenu(api, dialog ?? api.ui?.dialog) }, - { - title: TTC_LOGIN_COMMAND_TITLE, - value: TTC_LOGIN_COMMAND_VALUE, - description: "Set your TTC API key in the opencode auth store", - category: "Token Compression", - slash: { - name: "ttc-login" - }, - onSelect: (dialog) => void promptLogin(api, dialog ?? api.ui?.dialog) - }, { title: TTC_LOGOUT_COMMAND_TITLE, value: TTC_LOGOUT_COMMAND_VALUE, From d8267c6825fa7605455d1bb35e23253e52192b89 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 16:41:25 -0700 Subject: [PATCH 16/18] fix double prompt on opencode auth login: use default API key entry for provider - Remove custom prompts/authorize from the registered auth provider in the plugin. This makes opencode auth login use its standard single API key prompt for TTC instead of triggering a second prompt. - Restore /ttc-login, /ttc-logout slash commands and the auth row in /ttc settings menu (Add when missing, Remove when present). The TUI custom prompt is kept for direct use. - Keep CLI login/logout fully working. - 56/56 tests pass. Addresses the double prompt when running 'opencode auth login' for the TTC provider. --- opencode-plugins/ttc-message-transform.js | 15 +---- tests/opencode-ttc-plugin.test.js | 6 +- tui/settings.js | 75 ++++++++++++++++++++--- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index 05f0183..281b731 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -1206,20 +1206,7 @@ const TtcMessageTransformPlugin = async ({ client }) => { methods: [ { type: "api", - label: "The Token Company (TTC) API key", - prompts: [ - { - type: "text", - key: "apiKey", - message: "Enter TTC API key (from thetokencompany.com)", - placeholder: "ttc_..." - } - ], - async authorize(inputs = {}) { - const key = String(inputs.apiKey ?? "").trim(); - if (!key) return { type: "failed" }; - return { type: "success", key }; - } + label: "The Token Company (TTC) API key" } ] }, diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index a5fa14c..cf67eca 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1057,7 +1057,7 @@ test("registers TUI settings command and slash aliases", () => { assert.equal(typeof command.onSelect, "function"); }); -test("registers only /ttc-logout (no custom ttc-login)", () => { +test("registers /ttc-login and /ttc-logout slash commands", () => { let registeredCallback = null; registerTtcSettingsCommand({ command: { @@ -1073,7 +1073,9 @@ test("registers only /ttc-logout (no custom ttc-login)", () => { const login = commands.find((cmd) => cmd.value === "ttc.login"); const logout = commands.find((cmd) => cmd.value === "ttc.logout"); - assert.equal(Boolean(login), false); + assert.equal(Boolean(login), true); + assert.equal(login.slash.name, "ttc-login"); + assert.equal(typeof login.onSelect, "function"); assert.equal(Boolean(logout), true); assert.equal(logout.slash.name, "ttc-logout"); assert.equal(typeof logout.onSelect, "function"); diff --git a/tui/settings.js b/tui/settings.js index 9fd1bb5..546c4d0 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -6,11 +6,14 @@ import { LEGACY_AUTH_PROVIDER_IDS, getAuthStorePath, hasAuthEntry, + writeAuthEntry, removeAuthEntry } from "../lib/auth-store.js"; export const TTC_SETTINGS_COMMAND_VALUE = "ttc.settings"; export const TTC_SETTINGS_COMMAND_TITLE = "Token Compression: Settings"; +export const TTC_LOGIN_COMMAND_VALUE = "ttc.login"; +export const TTC_LOGIN_COMMAND_TITLE = "Token Compression: Login"; export const TTC_LOGOUT_COMMAND_VALUE = "ttc.logout"; export const TTC_LOGOUT_COMMAND_TITLE = "Token Compression: Logout"; @@ -49,6 +52,7 @@ export function getTtcSettingsConfigPath(env = process.env) { export { getAuthStorePath, hasAuthEntry as hasTtcAuthKey, + writeAuthEntry, removeAuthEntry }; @@ -259,13 +263,48 @@ function selectModel(api, dialog, currentValue) { })); } +const LOGIN_FAILURE_MESSAGES = { + empty_key: "Empty API key.", + auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", + auth_store_not_regular_file: "opencode auth store path is not a regular file.", + auth_store_read_failed: "Could not read the opencode auth store.", + auth_store_write_failed: "Could not write the opencode auth store." +}; + +async function performLogin(api, dialog, keyValue) { + const result = await writeAuthEntry({ apiKey: keyValue }); + if (result.ok) { + const legacyNote = result.removedLegacyIDs?.length + ? ` Also cleared stale legacy entries: ${result.removedLegacyIDs.join(", ")}.` + : ""; + toast(api, { + variant: "success", + message: `TTC API key saved under '${result.providerID}'.${legacyNote} Restart opencode (or send a message) to activate.`, + duration: 5000 + }); + await openTtcSettingsMenu(api, dialog); + return; + } + const message = LOGIN_FAILURE_MESSAGES[result.reason] ?? `Login failed: ${result.reason}.`; + toast(api, { variant: "error", message, duration: 5000 }); + renderAlert(api, dialog, "Login Failed", message); +} + +function promptLogin(api, dialog) { + dialog.replace(() => api.ui.DialogPrompt({ + title: "TTC API Key", + placeholder: "ttc_...", + value: "", + onConfirm: (nextValue) => void performLogin(api, dialog, nextValue), + onCancel: () => openTtcSettingsMenu(api, dialog) + })); +} const LOGOUT_FAILURE_MESSAGES = { auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", auth_store_read_failed: "Could not read the opencode auth store.", auth_store_write_failed: "Could not write the opencode auth store." }; - async function confirmLogout(api, dialog) { dialog.replace(() => api.ui.DialogConfirm({ title: "Remove TTC API Key", @@ -314,6 +353,17 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { const settings = await readTtcSettings(); const auth = await hasAuthEntry(); const view = buildSettingsView(settings); + const authOption = auth.hasKey + ? { + title: "Remove API key", + value: "ttc-logout", + description: "Revoke saved key" + } + : { + title: "Add API key", + value: "ttc-login", + description: "Required for compression" + }; const options = [ { title: `${view.enabled ? "Disable" : "Enable"} compression`, @@ -340,6 +390,7 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { value: "set-model", description: view.model }, + authOption, { title: "Reset config", value: "reset-config", @@ -347,14 +398,6 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { } ]; - if (auth.hasKey) { - options.splice(5, 0, { - title: "Remove API key", - value: "ttc-logout", - description: "Revoke saved key" - }); - } - dialog.setSize?.("medium"); dialog.replace(() => api.ui.DialogSelect({ title: "Token Compression Settings", @@ -390,6 +433,10 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { selectModel(api, dialog, view.model); return; } + if (option.value === "ttc-login") { + promptLogin(api, dialog); + return; + } if (option.value === "ttc-logout") { confirmLogout(api, dialog); return; @@ -415,6 +462,16 @@ export function registerTtcSettingsCommand(api) { }, onSelect: (dialog) => void openTtcSettingsMenu(api, dialog ?? api.ui?.dialog) }, + { + title: TTC_LOGIN_COMMAND_TITLE, + value: TTC_LOGIN_COMMAND_VALUE, + description: "Set your TTC API key in the opencode auth store", + category: "Token Compression", + slash: { + name: "ttc-login" + }, + onSelect: (dialog) => void promptLogin(api, dialog ?? api.ui?.dialog) + }, { title: TTC_LOGOUT_COMMAND_TITLE, value: TTC_LOGOUT_COMMAND_VALUE, From 8370585400408957e4a2fb795275e7c05acecc27 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 17:04:23 -0700 Subject: [PATCH 17/18] fix env auth menu and symlink logout edge cases --- lib/auth-store.js | 5 ++++- tests/opencode-ttc-plugin.test.js | 32 ++++++++++++++++++++++++------- tui/settings.js | 4 ++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/lib/auth-store.js b/lib/auth-store.js index de3da83..7994fc5 100644 --- a/lib/auth-store.js +++ b/lib/auth-store.js @@ -185,9 +185,12 @@ export async function removeAuthEntry({ const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); parsed = result.parsed; } catch (error) { - if (error?.code === "ENOENT" || error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + if (error?.code === "ENOENT") { return { ok: true, authFilePath, removedAny: false }; } + if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error }; + } if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") { return { ok: false, reason: "auth_store_corrupt", authFilePath, error }; } diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index cf67eca..aa5cb1d 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1081,15 +1081,17 @@ test("registers /ttc-login and /ttc-logout slash commands", () => { assert.equal(typeof logout.onSelect, "function"); }); -test("settings menu shows one compact auth row based on auth state", async () => { +test("settings menu shows one compact auth row based on persisted auth state", async () => { const tempDataHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-data-")); const tempConfigHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-config-")); const originalEnv = { XDG_DATA_HOME: process.env.XDG_DATA_HOME, - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + TTC_API_KEY: process.env.TTC_API_KEY }; process.env.XDG_DATA_HOME = tempDataHome; process.env.XDG_CONFIG_HOME = tempConfigHome; + process.env.TTC_API_KEY = "ttc_env_key"; let latestSelect = null; const api = { @@ -1108,14 +1110,16 @@ test("settings menu shows one compact auth row based on auth state", async () => try { await openTtcSettingsMenu(api, api.ui.dialog); - // No custom "Add API key" row — only one flow: opencode auth login - let authRows = latestSelect.options.filter((option) => option.value === "ttc-logout"); - assert.equal(authRows.length, 0); + let authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + assert.equal(authRows.length, 1); + assert.equal(authRows[0].value, "ttc-login"); + assert.equal(authRows[0].title, "Add API key"); await writeTuiAuthEntry({ apiKey: "ttc_existing_key" }); await openTtcSettingsMenu(api, api.ui.dialog); - authRows = latestSelect.options.filter((option) => option.value === "ttc-logout"); + authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); assert.equal(authRows.length, 1); + assert.equal(authRows[0].value, "ttc-logout"); assert.equal(authRows[0].title, "Remove API key"); assert.equal(authRows[0].description, "Revoke saved key"); } finally { @@ -1125,6 +1129,9 @@ test("settings menu shows one compact auth row based on auth state", async () => if (originalEnv.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = originalEnv.XDG_CONFIG_HOME; + if (originalEnv.TTC_API_KEY === undefined) delete process.env.TTC_API_KEY; + else process.env.TTC_API_KEY = originalEnv.TTC_API_KEY; + await rm(tempDataHome, { recursive: true, force: true }); await rm(tempConfigHome, { recursive: true, force: true }); } @@ -1252,7 +1259,7 @@ test("auth store refuses to overwrite a corrupt file", async () => { } }); -test("auth store write is atomic — concurrent writer does not lose entries", async () => { +test("auth store write preserves unrelated providers during sequential key rotation", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-race-")); const authPath = join(tempDir, "opencode", "auth.json"); @@ -1281,6 +1288,17 @@ test("auth store write is atomic — concurrent writer does not lose entries", a } }); +test("logout refuses unsupported auth-store paths instead of claiming success", async () => { + const result = await removeTuiAuthEntry({ + authFilePath: "/tmp/opencode-auth-symlink.json", + lstatImpl: async () => ({ isFile: () => false }) + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, "auth_store_not_regular_file"); + assert.equal(result.removedAny, undefined); +}); + test("login rotates away stale legacy TTC entries and leaves unrelated providers intact", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-rotate-")); const authPath = join(tempDir, "opencode", "auth.json"); diff --git a/tui/settings.js b/tui/settings.js index 546c4d0..4e632fc 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -351,9 +351,9 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { if (!api?.ui || !dialog?.replace) return; const settings = await readTtcSettings(); - const auth = await hasAuthEntry(); + const storeAuth = await hasAuthEntry({ env: {} }); const view = buildSettingsView(settings); - const authOption = auth.hasKey + const authOption = storeAuth.hasKey ? { title: "Remove API key", value: "ttc-logout", From 6387ae601800cab09eca3a83f2ba54df581a61d5 Mon Sep 17 00:00:00 2001 From: mrfok Date: Wed, 17 Jun 2026 17:17:04 -0700 Subject: [PATCH 18/18] fix reviewer auth and metrics edge cases --- README.md | 8 +- lib/auth-store.js | 2 +- opencode-plugins/ttc-message-transform.js | 5 +- scripts/cli.mjs | 2 +- tests/opencode-ttc-plugin.test.js | 92 +++++++++++++++++++++++ tui/settings.js | 38 ++-------- 6 files changed, 108 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0a20800..4ec58fb 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,14 @@ Install @drfok/opencode-ttc-plugin by following: https://raw.githubusercontent.c printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI # Inside opencode (TUI slash commands) - /ttc-logout # removes the key + /ttc-login # shows safe login instructions + /ttc-logout # removes a saved key # Through opencode's native auth flow (single place to enter the key) opencode auth login # then pick provider: opencode-ttc-plugin ``` - > **Only one prompt.** Use `opencode auth login` (choose the TTC provider) or the direct CLI. There is no separate custom prompt inside the TUI anymore. + > **Only one prompt.** Use `opencode auth login` (choose the TTC provider) or the direct CLI. `/ttc-login` does not collect the secret in the TUI because the available prompt dialog displays plain text. > > **No `--key ` flag.** Passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` or the TTY prompt. @@ -129,9 +130,10 @@ The sidebar shows the configured `model` next to the title. The model shown is e | Command | Action | | --- | --- | | `/ttc` | Open the Token Compression settings menu (enable/disable, level, aggressiveness, min chars, model, reset) | +| `/ttc-login` | Show safe API-key login instructions (native auth flow or CLI) | | `/ttc-logout` | Remove the TTC API key (asks for confirmation) | -There is no separate "Add API key" row. Add the key once via the native flow (`opencode auth login` → choose provider `opencode-ttc-plugin`) or the direct CLI. Only logout appears in the menu when a key is present. +The `/ttc` menu shows one auth row: `Add API key` when no saved key exists, or `Remove API key` when a key is saved. `Add API key` opens the same safe instructions as `/ttc-login`; it does not render a visible secret input. The model picker is curated from the TTC docs (`bear-2`, `bear-1.2`) plus a `custom model id` escape hatch for enterprise fine-tunes. See [Models](#6-models). diff --git a/lib/auth-store.js b/lib/auth-store.js index 7994fc5..0c84d71 100644 --- a/lib/auth-store.js +++ b/lib/auth-store.js @@ -199,7 +199,7 @@ export async function removeAuthEntry({ let removedAny = false; for (const candidateID of providerIDs) { - if (parsed[candidateID]) { + if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) { delete parsed[candidateID]; removedAny = true; } diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index 281b731..33c15b4 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -217,7 +217,10 @@ function hydrateSessionStatsFromSidebarState(state) { if (!state || typeof state !== "object") return null; if (state.schemaVersion !== 1) return null; - const numberOrZero = (value) => Math.max(0, Number(value) || 0); + const numberOrZero = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }; const stats = createSessionStats(); const session = state.session && typeof state.session === "object" ? state.session : {}; const lastMessage = state.lastMessage && typeof state.lastMessage === "object" ? state.lastMessage : {}; diff --git a/scripts/cli.mjs b/scripts/cli.mjs index 489e29f..1a626c1 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -654,7 +654,7 @@ async function main() { } if (command === "logout") { - logoutCommand(); + await logoutCommand(); return; } diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index aa5cb1d..fb66145 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -729,6 +729,31 @@ test("hydrates session stats from persisted sidebar state before writing new res } }); +test("hydrating sidebar state ignores non-finite numeric counters", () => { + const hydrated = hydrateSessionStatsFromSidebarState({ + schemaVersion: 1, + session: { + processed: Infinity, + compressed: "Infinity", + skipped: -2, + charsBefore: "120" + }, + lastMessage: { + charsBefore: Infinity, + tokensSaved: "NaN", + partsProcessed: 3 + } + }); + + assert.equal(hydrated.processed, 0); + assert.equal(hydrated.compressed, 0); + assert.equal(hydrated.skipped, 0); + assert.equal(hydrated.charsBefore, 120); + assert.equal(hydrated.lastMessageCharsBefore, 0); + assert.equal(hydrated.lastMessageTokensSaved, 0); + assert.equal(hydrated.lastMessagePartsProcessed, 3); +}); + test("concurrent first-use hydration shares one persisted session stats object", async () => { const priorStats = createSessionStats(); recordProcessedPart(priorStats, { @@ -1081,6 +1106,43 @@ test("registers /ttc-login and /ttc-logout slash commands", () => { assert.equal(typeof logout.onSelect, "function"); }); +test("/ttc-login never opens a visible secret prompt", () => { + let registeredCallback = null; + let alertInput = null; + let promptOpened = false; + const api = { + command: { + register(callback) { + registeredCallback = callback; + return () => {}; + } + }, + ui: { + DialogAlert(input) { + alertInput = input; + return input; + }, + DialogPrompt() { + promptOpened = true; + return {}; + }, + dialog: { + replace(render) { render(); } + }, + toast() {} + } + }; + + registerTtcSettingsCommand(api); + const login = registeredCallback().find((cmd) => cmd.value === "ttc.login"); + login.onSelect(api.ui.dialog); + + assert.equal(promptOpened, false); + assert.equal(alertInput.title, "Add TTC API Key"); + assert.equal(alertInput.message.includes("opencode auth login"), true); + assert.equal(alertInput.message.includes("opencode-ttc-plugin login"), true); +}); + test("settings menu shows one compact auth row based on persisted auth state", async () => { const tempDataHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-data-")); const tempConfigHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-config-")); @@ -1222,6 +1284,8 @@ test("TUI auth helpers write/remove key under current provider id with secure fi const { statSync } = await import("node:fs"); const mode = statSync(authPath).mode & 0o777; assert.equal(mode, 0o600, `auth file should be 0o600, got 0o${mode.toString(8)}`); + const dirMode = statSync(dirname(authPath)).mode & 0o777; + assert.equal(dirMode, 0o700, `auth directory should be 0o700, got 0o${dirMode.toString(8)}`); const hasAfter = await hasTtcAuthKey({ authFilePath: authPath }); assert.equal(hasAfter.hasKey, true); @@ -1238,6 +1302,34 @@ test("TUI auth helpers write/remove key under current provider id with secure fi } }); +test("logout deletes targeted provider entries even when stored value is falsy", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-falsy-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ + "opencode-ttc-plugin": null, + "the-token-company-plugin": "", + anthropic: { type: "api", key: "sk-ant-keep" } + }, null, 2), + "utf8" + ); + + try { + const removed = await removeTuiAuthEntry({ authFilePath: authPath }); + assert.equal(removed.ok, true); + assert.equal(removed.removedAny, true); + + const stored = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(Object.prototype.hasOwnProperty.call(stored, "opencode-ttc-plugin"), false); + assert.equal(Object.prototype.hasOwnProperty.call(stored, "the-token-company-plugin"), false); + assert.equal(stored.anthropic.key, "sk-ant-keep"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + test("auth store refuses to overwrite a corrupt file", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-corrupt-")); const authPath = join(tempDir, "opencode", "auth.json"); diff --git a/tui/settings.js b/tui/settings.js index 4e632fc..2da556d 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -263,45 +263,17 @@ function selectModel(api, dialog, currentValue) { })); } -const LOGIN_FAILURE_MESSAGES = { - empty_key: "Empty API key.", - auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", - auth_store_not_regular_file: "opencode auth store path is not a regular file.", - auth_store_read_failed: "Could not read the opencode auth store.", - auth_store_write_failed: "Could not write the opencode auth store." -}; - -async function performLogin(api, dialog, keyValue) { - const result = await writeAuthEntry({ apiKey: keyValue }); - if (result.ok) { - const legacyNote = result.removedLegacyIDs?.length - ? ` Also cleared stale legacy entries: ${result.removedLegacyIDs.join(", ")}.` - : ""; - toast(api, { - variant: "success", - message: `TTC API key saved under '${result.providerID}'.${legacyNote} Restart opencode (or send a message) to activate.`, - duration: 5000 - }); - await openTtcSettingsMenu(api, dialog); - return; - } - const message = LOGIN_FAILURE_MESSAGES[result.reason] ?? `Login failed: ${result.reason}.`; - toast(api, { variant: "error", message, duration: 5000 }); - renderAlert(api, dialog, "Login Failed", message); -} - function promptLogin(api, dialog) { - dialog.replace(() => api.ui.DialogPrompt({ - title: "TTC API Key", - placeholder: "ttc_...", - value: "", - onConfirm: (nextValue) => void performLogin(api, dialog, nextValue), - onCancel: () => openTtcSettingsMenu(api, dialog) + dialog.replace(() => api.ui.DialogAlert({ + title: "Add TTC API Key", + message: "Use `opencode auth login` and choose opencode-ttc-plugin, or run `opencode-ttc-plugin login` in a terminal. The TUI does not accept API keys because plain dialogs can display secrets.", + onConfirm: () => openTtcSettingsMenu(api, dialog) })); } const LOGOUT_FAILURE_MESSAGES = { auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", + auth_store_not_regular_file: "opencode auth store path is not a regular file.", auth_store_read_failed: "Could not read the opencode auth store.", auth_store_write_failed: "Could not write the opencode auth store." };