From 894a0f929b559844f170a979cb531e3da5842048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Wed, 14 Jan 2026 20:48:20 +0100 Subject: [PATCH 1/6] First iteration of OpenAI multi account support --- CLAUDE.md | 81 +++++ packages/opencode/IMPLEMENTATION_PLAN.md | 32 ++ packages/opencode/bin/opencode | 13 +- packages/opencode/src/auth/index.ts | 332 ++++++++++++++++++++- packages/opencode/src/cli/cmd/auth.ts | 144 +++++++-- packages/opencode/src/plugin/codex.ts | 256 +++++++++++----- packages/opencode/src/provider/provider.ts | 9 +- packages/opencode/src/session/llm.ts | 4 +- packages/plugin/src/index.ts | 2 + 9 files changed, 763 insertions(+), 110 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/opencode/IMPLEMENTATION_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..2685ec3cdcfc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +```bash +# Core development +bun install # install dependencies +bun dev # run opencode TUI (in packages/opencode dir) +bun dev # run against specific directory +bun dev . # run in repo root + +# Type checking +bun turbo typecheck # typecheck all packages + +# Building +./packages/opencode/script/build.ts --single # build standalone executable + +# Web app (packages/app) +bun run --cwd packages/app dev # dev server at localhost:5173 + +# Desktop app (packages/desktop) - requires Tauri/Rust +bun run --cwd packages/desktop tauri dev # native app + dev server +bun run --cwd packages/desktop tauri build # production build + +# SDK regeneration (after API changes) +./packages/sdk/js/script/build.ts # regenerate JS SDK +./script/generate.ts # regenerate SDK and related files +``` + +## Architecture + +**Monorepo** using Bun workspaces with Turbo for task orchestration. + +**Key packages:** +- `packages/opencode` - Core CLI, server, and TUI (main business logic) +- `packages/app` - Web UI components (SolidJS) +- `packages/desktop` - Native desktop app (Tauri v2 wrapping app) +- `packages/console` - Cloud SaaS (Cloudflare Workers, PlanetScale) +- `packages/sdk` - TypeScript SDK (@opencode-ai/sdk) +- `packages/plugin` - Plugin system (@opencode-ai/plugin) + +**Client-Server model:** +- HTTP server (`packages/opencode/src/server/server.ts`) built on Hono +- TUI client (`packages/opencode/src/cli/cmd/tui/`) using SolidJS + OpenTUI +- Sessions, tools, and LLM requests flow through the server + +**Agent system** (`packages/opencode/src/agent/`): +- `build` - default agent with full access +- `plan` - read-only for analysis/exploration +- `general` - subagent for complex searches (invoke with @general) + +**Tool system** (`packages/opencode/src/tool/`): 40+ tools (bash, edit, read, grep, glob, write, lsp, etc.) with permission checks and plugin extensibility. + +**Provider system** (`packages/opencode/src/provider/`): 20+ LLM providers via ai SDK (Claude, OpenAI, Google, Bedrock, etc.) + +## Code Style + +- Prefer `const` over `let`; use ternary or early returns to avoid mutation +- Avoid `else` statements; use early returns +- Avoid unnecessary destructuring; prefer `obj.a` over `const { a } = obj` for context +- Prefer `.catch()` over try/catch +- Single-word variable names when descriptive enough +- Use Bun APIs (e.g., `Bun.file()`) when available +- Avoid `any` type + +## Git/PR Guidelines + +- Default branch: `dev` +- All PRs must reference an existing issue (`Fixes #123` or `Closes #123`) +- Conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Keep PRs small and focused; no AI-generated walls of text +- UI changes need screenshots/videos + +## Important Notes + +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE +- After API/SDK changes in server.ts, run `./script/generate.ts` +- Tests run from individual packages, not root (`bun run test` from root will fail) diff --git a/packages/opencode/IMPLEMENTATION_PLAN.md b/packages/opencode/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000000..4ba2b31beab0 --- /dev/null +++ b/packages/opencode/IMPLEMENTATION_PLAN.md @@ -0,0 +1,32 @@ +title: Rotation plan +description: Steps for email checks and account switching + +--- + +## Define config + +Add `experimental.codex.rotation` with `first` (default) and `round-robin` in `src/config/config.ts`. Keep it relaxed and obvious in config docs or inline descriptions. + +--- + +## Validate email + +Require a non-empty email in OAuth results and fail the login if missing in `src/cli/cmd/auth.ts` and `src/plugin/codex.ts`. Also guard `Auth.setCodexAccount` to reject empty emails and avoid overwriting accounts with `unknown` values. + +--- + +## Handle limits + +Persist `rateLimit` metadata until `resetAt` passes, and only clear on expiry in `src/auth/index.ts`. Make `auth list` tolerant of missing `resetAt` when rendering status in `src/cli/cmd/auth.ts`. + +--- + +## Add round robin + +Extend `Auth.getNextAvailableCodexAccount` with a mode argument and store the next index after selection. Use config in `src/plugin/codex.ts` to choose `first` or `round-robin` when retrying after a 429. + +--- + +## Verify + +Manually login two accounts and confirm both emails render in `auth list`. Trigger a 429 and confirm automatic switch plus correct strategy by toggling config. diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index e35cc00944d6..0a92b54f683a 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -1,12 +1,13 @@ #!/usr/bin/env node -const childProcess = require("child_process") -const fs = require("fs") -const path = require("path") -const os = require("os") +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { + const result = spawnSync(target, process.argv.slice(2), { stdio: "inherit", }) if (result.error) { @@ -22,7 +23,7 @@ if (envPath) { run(envPath) } -const scriptPath = fs.realpathSync(__filename) +const scriptPath = fs.realpathSync(fileURLToPath(import.meta.url)) const scriptDir = path.dirname(scriptPath) const platformMap = { diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3fd28305368e..e2c794475d2b 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -32,19 +32,56 @@ export namespace Auth { }) .meta({ ref: "WellKnownAuth" }) - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) + // Multi-account support for Codex OAuth + export const CodexAccountRateLimit = z.object({ + limited: z.boolean(), + resetAt: z.number().optional(), + lastError: z.string().optional(), + }) + + export const CodexAccount = z.object({ + id: z.string(), + email: z.string(), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + rateLimit: CodexAccountRateLimit.optional(), + }) + export type CodexAccount = z.infer + + export const CodexMultiAccount = z + .object({ + type: z.literal("codex-multi"), + accounts: z.array(CodexAccount), + activeIndex: z.number().default(0), + }) + .meta({ ref: "CodexMultiAccount" }) + export type CodexMultiAccount = z.infer + + export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown, CodexMultiAccount]).meta({ ref: "Auth" }) export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") + async function readRaw(): Promise> { + const file = Bun.file(filepath) + return file.json().catch(() => ({}) as Record) + } + + async function writeRaw(data: Record): Promise { + const file = Bun.file(filepath) + await Bun.write(file, JSON.stringify(data, null, 2)) + await fs.chmod(file.name!, 0o600) + } + export async function get(providerID: string) { const auth = await all() return auth[providerID] } export async function all(): Promise> { - const file = Bun.file(filepath) - const data = await file.json().catch(() => ({}) as Record) + const data = await readRaw() return Object.entries(data).reduce( (acc, [key, value]) => { const parsed = Info.safeParse(value) @@ -57,17 +94,290 @@ export namespace Auth { } export async function set(key: string, info: Info) { - const file = Bun.file(filepath) - const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2)) - await fs.chmod(file.name!, 0o600) + const data = await readRaw() + data[key] = info + await writeRaw(data) } export async function remove(key: string) { - const file = Bun.file(filepath) - const data = await all() + const data = await readRaw() delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2)) - await fs.chmod(file.name!, 0o600) + await writeRaw(data) + } + + // ===== Codex Multi-Account Methods ===== + + export async function getCodexAuth(): Promise { + const { auth } = await readCodexAuthAndData() + return auth + } + + async function normalizeCodexAuth( + auth: CodexMultiAccount, + data?: Record, + ): Promise { + let mutated = false + const now = Date.now() + + if (auth.accounts.length === 0) { + if (auth.activeIndex !== 0) { + auth.activeIndex = 0 + mutated = true + } + } else { + const clamped = Math.max(0, Math.min(auth.activeIndex, auth.accounts.length - 1)) + if (clamped !== auth.activeIndex) { + auth.activeIndex = clamped + mutated = true + } + } + + for (const account of auth.accounts) { + if (account.rateLimit?.limited && account.rateLimit.resetAt && account.rateLimit.resetAt <= now) { + account.rateLimit = undefined + mutated = true + } + } + + if (mutated) { + const next = data ?? (await readRaw()) + next["codex"] = auth + await writeRaw(next) + } + + return auth + } + + async function migrateCodexAuth( + legacy: z.infer, + data: Record, + sourceKey?: "codex" | "openai", + ): Promise { + const migrated: CodexMultiAccount = { + type: "codex-multi", + accounts: [ + { + id: crypto.randomUUID(), + email: legacy.accountId || "account-1", + refresh: legacy.refresh, + access: legacy.access, + expires: legacy.expires, + accountId: legacy.accountId, + }, + ], + activeIndex: 0, + } + data["codex"] = migrated + if (sourceKey && sourceKey !== "codex") delete data[sourceKey] + await writeRaw(data) + return migrated + } + + async function readCodexAuthAndData(): Promise<{ data: Record; auth?: CodexMultiAccount }> { + const data = await readRaw() + const codex = data["codex"] + if (codex) { + const parsed = CodexMultiAccount.safeParse(codex) + if (parsed.success) { + const auth = await normalizeCodexAuth(parsed.data, data) + return { data, auth } + } + + // Check for legacy single-account format and migrate + const legacy = Oauth.safeParse(codex) + if (legacy.success) { + const auth = await migrateCodexAuth(legacy.data, data, "codex") + return { data, auth } + } + + return { data } + } + + // Migrate legacy OpenAI OAuth (single account) into Codex multi-account + const legacyOpenAI = Oauth.safeParse(data["openai"]) + if (legacyOpenAI.success) { + const auth = await migrateCodexAuth(legacyOpenAI.data, data, "openai") + return { data, auth } + } + + return { data } + } + + export async function getCodexAccounts(): Promise { + const auth = await getCodexAuth() + return auth?.accounts ?? [] + } + + export async function getActiveCodexAccount(): Promise { + const auth = await getCodexAuth() + if (!auth || auth.accounts.length === 0) return undefined + const index = Math.min(Math.max(auth.activeIndex, 0), auth.accounts.length - 1) + return auth.accounts[index] + } + + export async function setCodexAccount(account: Omit & { id?: string }): Promise { + const { data, auth: existing } = await readCodexAuthAndData() + const auth = existing ?? { type: "codex-multi", accounts: [], activeIndex: 0 } + + // Check for existing account with same email or accountId (update instead of duplicate) + const existingIndex = auth.accounts.findIndex( + (a) => a.email === account.email || (!!account.accountId && a.accountId === account.accountId), + ) + const newAccount: CodexAccount = { + id: account.id || crypto.randomUUID(), + email: account.email, + refresh: account.refresh, + access: account.access, + expires: account.expires, + accountId: account.accountId, + } + + if (existingIndex >= 0) { + // Update existing account tokens + auth.accounts[existingIndex] = { ...auth.accounts[existingIndex], ...newAccount, id: auth.accounts[existingIndex].id } + auth.activeIndex = existingIndex + } else { + // Add new account + auth.accounts.push(newAccount) + auth.activeIndex = auth.accounts.length - 1 + } + + data["codex"] = auth + await writeRaw(data) + } + + export async function removeCodexAccount(id: string): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const index = auth.accounts.findIndex((a) => a.id === id) + if (index < 0) return + + auth.accounts.splice(index, 1) + + // Adjust activeIndex if needed + if (auth.accounts.length === 0) { + auth.activeIndex = 0 + } else if (auth.activeIndex >= auth.accounts.length) { + auth.activeIndex = auth.accounts.length - 1 + } else if (index < auth.activeIndex) { + auth.activeIndex-- + } + + if (auth.accounts.length === 0) { + delete data["codex"] + } else { + data["codex"] = auth + } + await writeRaw(data) + } + + export async function setActiveCodexIndex(index: number): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth || auth.accounts.length === 0) return + + auth.activeIndex = Math.max(0, Math.min(index, auth.accounts.length - 1)) + data["codex"] = auth + await writeRaw(data) + } + + export async function markCodexAccountRateLimited(id: string, resetAt?: number): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const account = auth.accounts.find((a) => a.id === id) + if (!account) return + + account.rateLimit = { + limited: true, + resetAt: resetAt ?? Date.now() + 5 * 60 * 60 * 1000, // default 5 hours + } + + data["codex"] = auth + await writeRaw(data) + } + + export async function clearCodexAccountRateLimit(id: string): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const account = auth.accounts.find((a) => a.id === id) + if (!account) return + + account.rateLimit = undefined + data["codex"] = auth + await writeRaw(data) + } + + export async function getNextAvailableCodexAccount(): Promise<{ account: CodexAccount; index: number } | undefined> { + const { data, auth } = await readCodexAuthAndData() + if (!auth || auth.accounts.length === 0) return undefined + + const now = Date.now() + let mutated = false + + // Clear expired rate limits + for (const account of auth.accounts) { + if (account.rateLimit?.limited && account.rateLimit.resetAt && account.rateLimit.resetAt <= now) { + account.rateLimit = undefined + mutated = true + } + } + + // Find first available account (not rate limited) + for (let i = 0; i < auth.accounts.length; i++) { + const account = auth.accounts[i] + if (!account.rateLimit?.limited) { + if (i !== auth.activeIndex) { + auth.activeIndex = i + mutated = true + } + if (mutated) { + data["codex"] = auth + await writeRaw(data) + } + return { account, index: i } + } + } + + if (mutated) { + data["codex"] = auth + await writeRaw(data) + } + + return undefined + } + + export async function updateCodexAccountTokens( + id: string, + tokens: { access: string; refresh: string; expires: number; accountId?: string }, + ): Promise { + const { data, auth } = await readCodexAuthAndData() + + if (!auth) { + const legacy = Oauth.safeParse(data["openai"]) + if (!legacy.success) return + data["openai"] = { + ...legacy.data, + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + ...(tokens.accountId ? { accountId: tokens.accountId } : {}), + } + await writeRaw(data) + return + } + + const account = + auth.accounts.find((a) => a.id === id) ?? (id === "legacy" && auth.accounts.length === 1 ? auth.accounts[0] : undefined) + if (!account) return + + account.access = tokens.access + account.refresh = tokens.refresh + account.expires = tokens.expires + if (tokens.accountId) account.accountId = tokens.accountId + + data["codex"] = auth + await writeRaw(data) } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c711..9306c7b8c757 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -35,6 +35,29 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } const method = plugin.auth.methods[index] + // Check for existing Codex accounts if this is an OpenAI OAuth login + const isCodexOAuth = provider === "openai" && method.type === "oauth" + let shouldReplaceAll = false + + if (isCodexOAuth) { + const existingAccounts = await Auth.getCodexAccounts() + if (existingAccounts.length > 0) { + const emails = existingAccounts.map((a) => a.email).join(", ") + const action = await prompts.select({ + message: `Found ${existingAccounts.length} existing ChatGPT account(s): ${emails}`, + options: [ + { label: "Add new account", value: "add" }, + { label: "Replace all accounts", value: "replace" }, + ], + }) + if (prompts.isCancel(action)) throw new UI.CancelledError() + shouldReplaceAll = action === "replace" + if (shouldReplaceAll) { + await Auth.remove("codex") + } + } + } + // Handle prompts for all auth types await Bun.sleep(10) const inputs: Record = {} @@ -81,7 +104,19 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + + // Special handling for Codex multi-account + if (isCodexOAuth && "refresh" in result) { + const email = (result as any).email || (result as any).accountId || "unknown" + await Auth.setCodexAccount({ + email, + refresh: result.refresh, + access: result.access, + expires: result.expires, + accountId: (result as any).accountId, + }) + spinner.stop(`Login successful (${email})`) + } else if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", @@ -90,14 +125,14 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): expires, ...extraFields, }) - } - if ("key" in result) { + spinner.stop("Login successful") + } else if ("key" in result) { await Auth.set(saveProvider, { type: "api", key: result.key, }) + spinner.stop("Login successful") } - spinner.stop("Login successful") } } @@ -113,7 +148,19 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + + // Special handling for Codex multi-account + if (isCodexOAuth && "refresh" in result) { + const email = (result as any).email || (result as any).accountId || "unknown" + await Auth.setCodexAccount({ + email, + refresh: result.refresh, + access: result.access, + expires: result.expires, + accountId: (result as any).accountId, + }) + prompts.log.success(`Login successful (${email})`) + } else if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", @@ -122,14 +169,14 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): expires, ...extraFields, }) - } - if ("key" in result) { + prompts.log.success("Login successful") + } else if ("key" in result) { await Auth.set(saveProvider, { type: "api", key: result.key, }) + prompts.log.success("Login successful") } - prompts.log.success("Login successful") } } @@ -177,15 +224,38 @@ export const AuthListCommand = cmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const codexAccounts = await Auth.getCodexAccounts() const results = Object.entries(await Auth.all()) const database = await ModelsDev.get() + let count = 0 for (const [providerID, result] of results) { + // Skip codex multi-account - we'll show individual accounts + if (providerID === "codex" && result.type === "codex-multi") continue + const name = database[providerID]?.name || providerID prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + count++ } - prompts.outro(`${results.length} credentials`) + // Show individual Codex accounts + if (codexAccounts.length > 0) { + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + for (let i = 0; i < codexAccounts.length; i++) { + const account = codexAccounts[i] + const isActive = i === activeIndex + const status = account.rateLimit?.limited + ? ` [rate limited until ${new Date(account.rateLimit.resetAt!).toLocaleTimeString()}]` + : "" + const activeMarker = isActive ? " *" : "" + prompts.log.info(`ChatGPT (${account.email})${activeMarker}${status} ${UI.Style.TEXT_DIM}oauth`) + count++ + } + } + + prompts.outro(`${count} credential${count === 1 ? "" : "s"}`) // Environment variables section const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -379,22 +449,56 @@ export const AuthLogoutCommand = cmd({ describe: "log out from a configured provider", async handler() { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } + + // Build options list with special handling for Codex multi-account + const codexAccounts = await Auth.getCodexAccounts() const database = await ModelsDev.get() - const providerID = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ + const credentials = await Auth.all() + + type CredentialOption = { label: string; value: string; isCodexAccount?: boolean; accountId?: string } + const options: CredentialOption[] = [] + + for (const [key, value] of Object.entries(credentials)) { + // Skip codex entry - we'll list individual accounts instead + if (key === "codex" && value.type === "codex-multi") continue + + options.push({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, - })), + }) + } + + // Add individual Codex accounts + for (const account of codexAccounts) { + const status = account.rateLimit?.limited ? " [rate limited]" : "" + options.push({ + label: `ChatGPT (${account.email})${status}` + UI.Style.TEXT_DIM + " (oauth)", + value: `codex:${account.id}`, + isCodexAccount: true, + accountId: account.id, + }) + } + + if (options.length === 0) { + prompts.log.error("No credentials found") + return + } + + const selected = await prompts.select({ + message: "Select credential to remove", + options: options.map((o) => ({ label: o.label, value: o.value })), }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + // Handle Codex account removal + if (selected.startsWith("codex:")) { + const accountId = selected.replace("codex:", "") + await Auth.removeCodexAccount(accountId) + } else { + await Auth.remove(selected) + } + prompts.outro("Logout successful") }, }) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 91e66197fc47..306e33d2ad00 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,7 +1,9 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" -import { OAUTH_DUMMY_KEY } from "../auth" +import { Auth, OAUTH_DUMMY_KEY } from "../auth" import { ProviderTransform } from "../provider/transform" +import { Bus } from "../bus" +import { TuiEvent } from "../cli/cmd/tui/event" const log = Log.create({ service: "plugin.codex" }) @@ -82,6 +84,46 @@ export function extractAccountId(tokens: TokenResponse): string | undefined { return undefined } +export function extractEmail(tokens: TokenResponse): string | undefined { + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token) + if (claims?.email) return claims.email + } + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token) + if (claims?.email) return claims.email + } + return undefined +} + +function isCodexRateLimitError(response: Response, body?: string): boolean { + if (response.status === 429) return true + if (!body) return false + const lower = body.toLowerCase() + return ( + lower.includes("5-hour message limit") || + lower.includes("weekly cap") || + lower.includes("quota exhausted") || + lower.includes("rate limit") || + lower.includes("usage limit") + ) +} + +function parseCodexResetTime(body?: string): number | undefined { + if (!body) return undefined + // Parse "try again in Xh Ym" or "try again in X hours" + const hourMatch = body.match(/try again in (\d+)\s*h/i) + if (hourMatch) { + return Date.now() + parseInt(hourMatch[1]) * 60 * 60 * 1000 + } + const minuteMatch = body.match(/try again in (\d+)\s*m/i) + if (minuteMatch) { + return Date.now() + parseInt(minuteMatch[1]) * 60 * 1000 + } + // Default to 5 hours if we can't parse + return Date.now() + 5 * 60 * 60 * 1000 +} + function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", @@ -351,7 +393,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { provider: "openai", async loader(getAuth, provider) { const auth = await getAuth() - if (auth.type !== "oauth") return {} + // Check for Codex multi-account first + const codexAuth = await Auth.getCodexAuth() + const hasCodexAccounts = !!codexAuth?.accounts.length + const hasLegacyOAuth = auth?.type === "oauth" + + // Support both legacy oauth and new codex-multi format + if (!hasLegacyOAuth && !hasCodexAccounts) return {} // Filter models to only allowed Codex models for OAuth const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"]) @@ -402,86 +450,150 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } } - return { - apiKey: OAUTH_DUMMY_KEY, - async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { - // Remove dummy API key authorization header - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.delete("authorization") - init.headers.delete("Authorization") - } else if (Array.isArray(init.headers)) { - init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") - } else { - delete init.headers["authorization"] - delete init.headers["Authorization"] - } + const codexFetch = async (requestInput: RequestInfo | URL, init?: RequestInit): Promise => { + // Remove dummy API key authorization header + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.delete("authorization") + init.headers.delete("Authorization") + } else if (Array.isArray(init.headers)) { + init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") + } else { + delete init.headers["authorization"] + delete init.headers["Authorization"] } + } + // Get active account from multi-account storage + let account = await Auth.getActiveCodexAccount() + if (!account) { + // Fallback to legacy single-account mode const currentAuth = await getAuth() - if (currentAuth.type !== "oauth") return fetch(requestInput, init) - - // Cast to include accountId field - const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - - // Check if token needs refresh - if (!currentAuth.access || currentAuth.expires < Date.now()) { - log.info("refreshing codex access token") - const tokens = await refreshAccessToken(currentAuth.refresh) - const newAccountId = extractAccountId(tokens) || authWithAccount.accountId - await input.client.auth.set({ - path: { id: "codex" }, - body: { - type: "oauth", - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - ...(newAccountId && { accountId: newAccountId }), - }, - }) - currentAuth.access = tokens.access_token - authWithAccount.accountId = newAccountId + if (!currentAuth || currentAuth.type !== "oauth") return fetch(requestInput, init) + account = { + id: "legacy", + email: "unknown", + refresh: currentAuth.refresh, + access: currentAuth.access, + expires: currentAuth.expires, + accountId: (currentAuth as any).accountId, } + } - // Build headers - const headers = new Headers() - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((value, key) => headers.set(key, value)) - } else if (Array.isArray(init.headers)) { - for (const [key, value] of init.headers) { - if (value !== undefined) headers.set(key, String(value)) - } - } else { - for (const [key, value] of Object.entries(init.headers)) { - if (value !== undefined) headers.set(key, String(value)) - } + // Check if token needs refresh + if (!account.access || account.expires < Date.now()) { + log.info("refreshing codex access token", { email: account.email }) + const tokens = await refreshAccessToken(account.refresh) + const newAccountId = extractAccountId(tokens) || account.accountId + const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const refresh = tokens.refresh_token ?? account.refresh + await Auth.updateCodexAccountTokens(account.id, { + access: tokens.access_token, + refresh, + expires: expiresAt, + accountId: newAccountId, + }) + account.access = tokens.access_token + account.refresh = refresh + account.expires = expiresAt + account.accountId = newAccountId + } + + // Build headers + const headers = new Headers() + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => headers.set(key, value)) + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + if (value !== undefined) headers.set(key, String(value)) + } + } else { + for (const [key, value] of Object.entries(init.headers)) { + if (value !== undefined) headers.set(key, String(value)) } } + } + + // Set authorization header with access token + headers.set("authorization", `Bearer ${account.access}`) - // Set authorization header with access token - headers.set("authorization", `Bearer ${currentAuth.access}`) + // Set ChatGPT-Account-Id header for organization subscriptions + if (account.accountId) { + headers.set("ChatGPT-Account-Id", account.accountId) + } + + // Rewrite URL to Codex endpoint + const parsed = + requestInput instanceof URL + ? requestInput + : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) + const url = + parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") + ? new URL(CODEX_API_ENDPOINT) + : parsed + + const response = await fetch(url, { + ...init, + headers, + }) - // Set ChatGPT-Account-Id header for organization subscriptions - if (authWithAccount.accountId) { - headers.set("ChatGPT-Account-Id", authWithAccount.accountId) + // Check for rate limit error and handle account switching + if (!response.ok) { + const body = await response.clone().text().catch(() => undefined) + if (isCodexRateLimitError(response, body)) { + const resetTime = parseCodexResetTime(body) + log.info("codex rate limit hit", { email: account.email, resetTime }) + + // Mark current account as rate limited + await Auth.markCodexAccountRateLimited(account.id, resetTime) + + // Try to switch to next available account + const next = await Auth.getNextAvailableCodexAccount() + if (next) { + log.info("switching to next codex account", { email: next.account.email }) + Bus.publish(TuiEvent.ToastShow, { + variant: "warning", + message: `Rate limited. Switched to ${next.account.email}`, + }) + // Retry the request with the new account + return codexFetch(requestInput, init) + } + + // All accounts are rate limited - notify user + const accounts = await Auth.getCodexAccounts() + if (accounts.length > 0) { + const resetTimes = accounts + .map((a) => a.rateLimit?.resetAt) + .filter((time): time is number => typeof time === "number") + if (resetTimes.length > 0) { + const nextReset = Math.min(...resetTimes) + const waitMinutes = Math.max(1, Math.ceil((nextReset - Date.now()) / 60000)) + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: `All accounts rate limited. Next available in ${waitMinutes}m`, + }) + } else { + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: "All accounts rate limited.", + }) + } + } else { + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: "Rate limited. Please try again later.", + }) + } } + } - // Rewrite URL to Codex endpoint - const parsed = - requestInput instanceof URL - ? requestInput - : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) - const url = - parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(CODEX_API_ENDPOINT) - : parsed - - return fetch(url, { - ...init, - headers, - }) - }, + return response + } + + return { + apiKey: OAUTH_DUMMY_KEY, + fetch: codexFetch, } }, methods: [ @@ -504,12 +616,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { const tokens = await callbackPromise stopOAuthServer() const accountId = extractAccountId(tokens) + const email = extractEmail(tokens) return { type: "success" as const, refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, accountId, + email, } }, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c7559003e8fe..8fd088c726f8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -828,9 +828,16 @@ export namespace Provider { // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise let hasAuth = false + let hasCodexAccounts = false const auth = await Auth.get(providerID) if (auth) hasAuth = true + if (!hasAuth && providerID === "openai") { + const codexAuth = await Auth.getCodexAuth() + hasCodexAccounts = !!codexAuth?.accounts.length + if (hasCodexAccounts) hasAuth = true + } + // Special handling for github-copilot: also check for enterprise auth if (providerID === "github-copilot" && !hasAuth) { const enterpriseAuth = await Auth.get("github-copilot-enterprise") @@ -841,7 +848,7 @@ export namespace Provider { if (!plugin.auth.loader) continue // Load for the main provider if auth exists - if (auth) { + if (auth || hasCodexAccounts) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) mergeProvider(plugin.auth.provider, { source: "custom", diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ebc22637e102..f0e0d07b004c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -61,7 +61,9 @@ export namespace LLM { Provider.getProvider(input.model.providerID), Auth.get(input.model.providerID), ]) - const isCodex = provider.id === "openai" && auth?.type === "oauth" + const codexAuth = provider.id === "openai" ? await Auth.getCodexAuth() : undefined + const hasCodexAccounts = provider.id === "openai" && !!codexAuth?.accounts.length + const isCodex = provider.id === "openai" && (auth?.type === "oauth" || hasCodexAccounts) const system = SystemPrompt.header(input.model.providerID) system.push( diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e57eff579e63..c2181a13d33e 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -115,6 +115,7 @@ export type AuthOuathResult = { url: string; instructions: string } & ( access: string expires: number accountId?: string + email?: string } | { key: string } )) @@ -135,6 +136,7 @@ export type AuthOuathResult = { url: string; instructions: string } & ( access: string expires: number accountId?: string + email?: string } | { key: string } )) From dfe0c7930449402bd0731099ec6924ab5c46e889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Wed, 14 Jan 2026 20:57:45 +0100 Subject: [PATCH 2/6] chore: update Bun package manager to v1.3.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d134a187a7cd..361987355eed 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.6", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", From 2f104e2fa7507e690b4177294e58e8a6e1a3895b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Wed, 14 Jan 2026 22:09:03 +0100 Subject: [PATCH 3/6] feat(auth): add command to switch active OpenAI OAuth account --- packages/opencode/src/cli/cmd/auth.ts | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 9306c7b8c757..940467df4af1 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -206,11 +206,55 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): return false } +export const AuthSwitchCommand = cmd({ + command: "switch", + describe: "switch active OpenAI OAuth account", + async handler() { + UI.empty() + prompts.intro("Switch account") + + const codexAccounts = await Auth.getCodexAccounts() + if (codexAccounts.length === 0) { + prompts.log.error("No ChatGPT accounts found") + prompts.outro("Done") + return + } + + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + const selected = await prompts.select({ + message: "Select active ChatGPT account", + options: codexAccounts.map((account, index) => { + const isActive = index === activeIndex + const status = account.rateLimit?.limited ? " [rate limited]" : "" + return { + label: `ChatGPT (${account.email})${status}` + (isActive ? " *" : ""), + value: index.toString(), + } + }), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + const nextIndex = Number.parseInt(selected, 10) + if (!Number.isNaN(nextIndex)) { + await Auth.setActiveCodexIndex(nextIndex) + } + + prompts.outro("Active account updated") + }, +}) + export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthListCommand) + .command(AuthSwitchCommand) + .demandCommand(), async handler() {}, }) From 696ba14d9ab796be99b5fd73fd6e025a49de0fea Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 30 Jan 2026 15:43:29 -0500 Subject: [PATCH 4/6] ci: increase ARM runner to 8 vCPUs for faster Tauri builds --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3974d23ffc1f..a1b492258b73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404-arm + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: From 8c00bd6499e35b3d26884365b231bbc24134c383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Mon, 9 Feb 2026 22:18:13 +0100 Subject: [PATCH 5/6] changes --- packages/opencode/src/cli/cmd/codex.ts | 72 ++++++++++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 74 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/codex.ts diff --git a/packages/opencode/src/cli/cmd/codex.ts b/packages/opencode/src/cli/cmd/codex.ts new file mode 100644 index 000000000000..a0fc2f9de63e --- /dev/null +++ b/packages/opencode/src/cli/cmd/codex.ts @@ -0,0 +1,72 @@ +import path from "path" +import { cmd } from "./cmd" +import { UI } from "../ui" +import { Config } from "../../config/config" +import { Instance } from "../../project/instance" +import { BunProc } from "../../bun" +import { Filesystem } from "../../util/filesystem" +import { fileURLToPath } from "url" + +async function resolvePluginPath(entry: string): Promise { + if (entry.startsWith("file://")) { + return fileURLToPath(entry) + } + const lastAt = entry.lastIndexOf("@") + const pkg = lastAt > 0 ? entry.substring(0, lastAt) : entry + const version = lastAt > 0 ? entry.substring(lastAt + 1) : "latest" + return BunProc.install(pkg, version) +} + +async function resolveCodexLoginBin(): Promise { + const config = await Config.get() + const plugins = config.plugin ?? [] + const match = plugins.find((item) => item.includes("opencode-codex-auth-plugin")) + if (!match) return null + + const root = await resolvePluginPath(match) + const candidates = [ + path.join(root, "bin", "opencode-codex-login.js"), + path.join(root, "dist", "bin.js"), + ] + + for (const candidate of candidates) { + if (await Filesystem.exists(candidate)) return candidate + } + return null +} + +const CodexLoginCommand = cmd({ + command: "login", + describe: "log in to Codex multi-account", + async handler() { + await Instance.provide({ + directory: process.cwd(), + async fn() { + const bin = await resolveCodexLoginBin() + if (!bin) { + UI.error("opencode-codex-auth-plugin not found in config. Add it to opencode.json first.") + process.exitCode = 1 + return + } + const proc = Bun.spawn({ + cmd: [process.execPath, bin], + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }) + const exit = await proc.exited + if (exit !== 0) { + UI.error("Codex login failed") + process.exitCode = 1 + } + }, + }) + }, +}) + +export const CodexCommand = cmd({ + command: "codex", + describe: "codex auth utilities", + builder: (yargs) => yargs.command(CodexLoginCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91ef..8487aba196b6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -26,6 +26,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { CodexCommand } from "./cli/cmd/codex" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -97,6 +98,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(CodexCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || From 8ffe195f3d63f594f78b1f4ed14a3624c28c8826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Mon, 9 Feb 2026 23:06:14 +0100 Subject: [PATCH 6/6] feat: add comprehensive knowledge base documentation for various project components --- .../drafts/multi-account-codex-review.md | 66 +++ ...count-codex-rotation-quota-optimization.md | 460 ++++++++++++++++++ AGENTS.md | 153 +++--- github/AGENTS.md | 24 + packages/console/AGENTS.md | 26 + packages/desktop/AGENTS.md | 25 + packages/enterprise/AGENTS.md | 24 + packages/opencode/bin/opencode | 13 +- packages/sdk/js/AGENTS.md | 25 + packages/slack/AGENTS.md | 23 + packages/ui/AGENTS.md | 25 + packages/web/AGENTS.md | 24 + script/cleanup-local.sh | 35 ++ script/rebuild-local.sh | 32 ++ sdks/vscode/AGENTS.md | 24 + 15 files changed, 895 insertions(+), 84 deletions(-) create mode 100644 .sisyphus/drafts/multi-account-codex-review.md create mode 100644 .sisyphus/plans/multi-account-codex-rotation-quota-optimization.md create mode 100644 github/AGENTS.md create mode 100644 packages/console/AGENTS.md create mode 100644 packages/desktop/AGENTS.md create mode 100644 packages/enterprise/AGENTS.md create mode 100644 packages/sdk/js/AGENTS.md create mode 100644 packages/slack/AGENTS.md create mode 100644 packages/ui/AGENTS.md create mode 100644 packages/web/AGENTS.md create mode 100755 script/cleanup-local.sh create mode 100755 script/rebuild-local.sh create mode 100644 sdks/vscode/AGENTS.md diff --git a/.sisyphus/drafts/multi-account-codex-review.md b/.sisyphus/drafts/multi-account-codex-review.md new file mode 100644 index 000000000000..73ff18c5204c --- /dev/null +++ b/.sisyphus/drafts/multi-account-codex-review.md @@ -0,0 +1,66 @@ +# Draft: Multi-account OAuth Codex Review + +## Requirements (confirmed) + +- User wants thorough analysis/review of implemented multi-account support for OAuth Codex. +- User wants detailed review of account rotation implementation. +- User wants detailed review of quota status checks. +- User wants optimization recommendations aligned with best practices. +- User wants iterative process with thorough validation. + +## Technical Decisions + +- Start with exhaustive context gathering in parallel (explore + librarian + direct code search). +- Treat this as refactoring/optimization planning, not immediate implementation. +- Treat this as high-risk reliability review (auth persistence + request-path rotation). + +## Research Findings + +- Implementation hotspots identified: + - `packages/opencode/src/auth/index.ts` (multi-account schema, active index, rate-limit state, usage persistence) + - `packages/opencode/src/plugin/codex.ts` (OAuth flow, Codex fetch interception, 429 detection, switch/retry, usage harvesting) + - `packages/opencode/src/cli/cmd/auth.ts` (login/list/switch/usage workflows) + - `packages/opencode/src/server/routes/provider.ts` (`/codex/usage` endpoint, concurrent usage fetches) +- Current behavior observed: + - Selection path uses `getActiveCodexAccount()` for requests and `getNextAvailableCodexAccount()` on rate limit. + - Rotation currently selects first non-rate-limited account; no runtime-configurable round-robin found in code path. + - Usage persisted from both `/wham/usage` fetch and response headers. + - Default reset fallback is 5h when limit reset parse fails. +- High-risk findings (initial): + - Login still allows fallback email `"unknown"` in OAuth result handling. + - `setCodexAccount()` dedupes by `email`/`accountId`; placeholder emails can collapse distinct accounts. + - `auth list` uses non-null assertion on `rateLimit.resetAt` while schema permits missing `resetAt`. + - Persistence is read-modify-write on shared `auth.json`; concurrent updates can race. + - `/codex/usage` does parallel per-account fetch + write, which can amplify race windows. + - Device OAuth polling loop can run indefinitely without timeout/cancellation in headless flow. + - Recursive retry on 429 in `codexFetch()` can amplify storms under concurrent failures. +- Test coverage snapshot (initial): + - `packages/opencode/test/plugin/codex.test.ts` focuses on JWT/account-id extraction helpers. + - No direct tests found yet for rotation selection, fairness mode, 429 retry loops, or concurrent usage update safety. + - No direct integration test found for `/provider/codex/usage` mixed success/failure semantics. +- External best-practice anchors collected: + - Token lifecycle discipline (short-lived access, robust refresh handling, stable account IDs). + - Strict rate-limit header honoring (`Retry-After`, reset windows), per-account throttling/queues. + - Exponential backoff + jitter for 429 handling. + - Strong telemetry on quota windows/fairness. + +## Test Strategy Decision + +- **Infrastructure exists**: YES (`packages/opencode` Bun tests). +- **Automated tests**: [DECISION NEEDED] +- **Automated tests**: YES (TDD) ✅ +- **Agent-Executed QA**: ALWAYS (mandatory regardless of test choice). + +## Open Questions + +- Exact scope boundary for "fix them": planning-only vs immediate execution after plan handoff. +- Acceptable behavior changes vs strict behavior preservation. +- Required validation strategy preference (TDD/tests-after/no-unit-tests + agent QA). +- Rotation policy target: resolved -> keep first-available default, fairness opt-in. +- Quota unknown policy: resolved -> fail-open. +- Retry budget: resolved -> max retries=2 with jittered exponential backoff. + +## Scope Boundaries + +- INCLUDE: multi-account OAuth Codex, rotation flow, quota status checks, robustness/performance/maintainability risks. +- EXCLUDE: unrelated provider/account systems unless directly coupled. diff --git a/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md b/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md new file mode 100644 index 000000000000..57c3cc6beb1a --- /dev/null +++ b/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md @@ -0,0 +1,460 @@ +# Multi-account OAuth Codex Rotation + Quota Optimization + +## TL;DR + +> **Quick Summary**: Harden existing multi-account Codex OAuth by making rotation deterministic and safe under concurrency, improving quota freshness semantics, and preventing retry/account-state corruption. +> +> **Deliverables**: +> +> - Deterministic, configurable rotation state machine with safe defaults +> - Concurrency-safe auth persistence updates for multi-account state +> - Robust 429/retry behavior with bounded retries + backoff/jitter +> - Consistent quota freshness surfaces across plugin/CLI/server +> - TDD coverage for rotation/quota/concurrency edge paths +> +> **Estimated Effort**: Large +> **Parallel Execution**: YES - 3 waves +> **Critical Path**: Task 1 -> Task 2 -> Task 3 -> Task 4 -> Task 6 + +--- + +## Context + +### Original Request + +Thorough and detailed analysis/review of implemented multi-account support for OAuth Codex rotation and quota status checks; identify problems and optimize according to best practices. + +### Interview Summary + +**Key Discussions**: + +- Scope fixed to existing implementation, not greenfield rewrite. +- Strategy fixed to TDD. +- Focus fixed to reliability/correctness/perf/maintainability of rotation + quota path. + +**Research Findings**: + +- Core files: `packages/opencode/src/auth/index.ts`, `packages/opencode/src/plugin/codex.ts`, `packages/opencode/src/cli/cmd/auth.ts`, `packages/opencode/src/server/routes/provider.ts`. +- High-risk issues: shared `auth.json` race windows, recursive retry storm potential, placeholder-email identity collapse, weak tests on rotation/quota state machine. + +### Metis Review + +**Identified Gaps** (addressed in plan): + +- Missing explicit policy contracts (rotation mode, stale quota semantics, retry budget). +- Missing persistence guardrails (atomic/conflict-safe writes). +- Missing acceptance tests for concurrency and failover paths. + +--- + +## Work Objectives + +### Core Objective + +Make multi-account Codex selection/rotation/quota behavior deterministic, concurrency-safe, observable, and test-backed without broad architectural rewrite. + +### Concrete Deliverables + +- Rotation policy contract + implementation with default compatibility. +- Safe multi-writer persistence strategy for `auth.json` updates. +- Hardened retry/429 handling and account cooldown logic. +- Unified quota freshness/status contract across plugin, CLI, and provider route. +- New tests validating race, failover, and quota semantics. + +### Definition of Done + +- [ ] Targeted suites pass under TDD for auth rotation/quota behavior. +- [ ] No unbounded recursion/retry path remains in Codex request flow. +- [ ] Concurrent state updates do not lose `activeIndex`, account metadata, or usage/rate-limit fields. +- [ ] CLI list/usage and `/provider/codex/usage` show consistent freshness/error semantics. + +### Must Have + +- Preserve current external behavior by default unless policy flag explicitly changes it. +- Keep implementation scope confined to Codex multi-account path. +- Add deterministic tests for edge conditions and concurrency. + +### Must NOT Have (Guardrails) + +- No storage-backend rewrite beyond safe file-update strategy. +- No unrelated provider refactors. +- No manual-only verification steps. +- No silent account merges on placeholder identities. + +--- + +## Verification Strategy (MANDATORY) + +> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION** +> +> All verification is agent-executable. + +### Test Decision + +- **Infrastructure exists**: YES +- **Automated tests**: TDD +- **Framework**: `bun test` + +### If TDD Enabled + +Each task follows RED-GREEN-REFACTOR and includes command-level pass criteria. + +### Agent-Executed QA Scenarios (MANDATORY — ALL tasks) + +Use Bash with deterministic command assertions for module/integration behavior; include negative/failure scenarios for each task. + +--- + +## Execution Strategy + +### Parallel Execution Waves + +Wave 1 (Start Immediately): + +- Task 1 (state-machine spec + failing tests) +- Task 5 (observability contract/tests scaffolding) + +Wave 2 (After Wave 1): + +- Task 2 (persistence safety) +- Task 3 (retry/backoff/cooldown hardening) + +Wave 3 (After Wave 2): + +- Task 4 (rotation policy + identity hardening) +- Task 6 (quota freshness consistency + endpoint/CLI alignment) + +Wave 4 (After Wave 3): + +- Task 7 (integration polish + regression matrix) + +Critical Path: 1 -> 2 -> 3 -> 4 -> 6 -> 7 + +### Dependency Matrix + +| Task | Depends On | Blocks | Can Parallelize With | +| ---- | ---------- | ------- | -------------------- | +| 1 | None | 2,3,4,6 | 5 | +| 2 | 1 | 3,4,6,7 | 3 | +| 3 | 1,2 | 4,7 | 2 | +| 4 | 1,2,3 | 6,7 | None | +| 5 | None | 7 | 1 | +| 6 | 1,2,4 | 7 | None | +| 7 | 2,3,4,5,6 | None | None | + +--- + +## TODOs + +- [ ] 1. Define Rotation/Quota State Machine + RED Tests + + **What to do**: + - Write formal behavior contract for account states (active, rate-limited, stale-quota, invalid-token). + - Add failing tests for selection, rate-limit expiry, stale quota semantics, and identity handling. + + **Must NOT do**: + - No implementation changes before failing tests exist. + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: `git-master` + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 1 (with Task 5) + - **Blocks**: 2,3,4,6 + - **Blocked By**: None + + **References**: + - `packages/opencode/src/auth/index.ts` - selection and persisted state mutation methods. + - `packages/opencode/src/plugin/codex.ts` - runtime retry/429 and usage update behavior. + - `packages/opencode/test/plugin/codex.test.ts` - existing test patterns. + + **Acceptance Criteria**: + - [ ] RED tests added for rotation + quota state machine and initially fail. + - [ ] `bun test packages/opencode/test/auth/codex-rotation.test.ts` -> FAIL (expected pre-implementation). + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: RED rotation tests fail first + Tool: Bash + Preconditions: test file exists, implementation unchanged + Steps: + 1. Run: bun test packages/opencode/test/auth/codex-rotation.test.ts + 2. Assert: exit code != 0 + 3. Assert: output includes failing rotation expectation + Expected Result: test suite fails with known assertions + Evidence: terminal output capture + ``` + +- [ ] 2. Make Auth Persistence Conflict-Safe + + **What to do**: + - Implement atomic/serialized update path for shared `auth.json` mutations. + - Ensure concurrent calls do not lose account/usage/rate-limit fields. + + **Must NOT do**: + - No backend migration away from file storage in this iteration. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with Task 3) + - **Blocks**: 3,4,6,7 + - **Blocked By**: 1 + + **References**: + - `packages/opencode/src/auth/index.ts` + - `packages/opencode/src/global/index.ts` + + **Acceptance Criteria**: + - [ ] Concurrency tests pass for simultaneous mutation paths. + - [ ] `bun test packages/opencode/test/auth/codex-concurrency.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Concurrent writes preserve activeIndex and usage + Tool: Bash + Steps: + 1. Run targeted concurrency suite + 2. Assert: no lost update failures + 3. Assert: deterministic final auth state assertions pass + Expected Result: stable pass under repeated runs + Evidence: terminal output capture + ``` + +- [ ] 3. Harden 429 Retry/Backoff and Cooldown Behavior + + **What to do**: + - Replace unbounded recursive retry with bounded iterative retry budget. + - Honor reset/retry headers when available; apply jittered backoff. + + **Must NOT do**: + - No infinite retry; no stormy immediate retries. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with Task 2) + - **Blocks**: 4,7 + - **Blocked By**: 1,2 + + **References**: + - `packages/opencode/src/plugin/codex.ts` + + **Acceptance Criteria**: + - [ ] Retry budget enforced. + - [ ] Backoff/jitter tests pass for 429 sequences. + - [ ] `bun test packages/opencode/test/plugin/codex-retry.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: 429 sequence stops at retry cap + Tool: Bash + Steps: + 1. Run retry suite with mocked 429 responses + 2. Assert: retries <= configured cap + 3. Assert: failure path reports exhausted accounts safely + Expected Result: bounded retries, no recursion overflow + Evidence: test output capture + ``` + +- [ ] 4. Rotation Policy + Account Identity Hardening + + **What to do**: + - Implement policy gate for configurable rotation mode while keeping default compatibility. + - Enforce canonical identity strategy; prevent placeholder-based merges. + + **Must NOT do**: + - No default behavior flip without explicit config. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 3 + - **Blocks**: 6,7 + - **Blocked By**: 1,2,3 + + **References**: + - `packages/opencode/src/auth/index.ts` + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/cli/cmd/auth.ts` + - `packages/opencode/src/config/config.ts` + + **Acceptance Criteria**: + - [ ] Rotation mode behavior matches contract tests. + - [ ] Identity merge safety tests pass with missing/placeholder email inputs. + - [ ] `bun test packages/opencode/test/auth/codex-rotation-policy.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Placeholder email does not merge distinct accounts + Tool: Bash + Steps: + 1. Run identity safety tests + 2. Assert: separate accounts remain separate with stable IDs + Expected Result: no accidental account collapse + Evidence: test output capture + ``` + +- [ ] 5. Add Structured Telemetry for Rotation/Quota Decisions + + **What to do**: + - Add structured events/counters for selection, failover, all-accounts-limited, stale quota. + - Add tests for emitted event shape in key branches. + + **Must NOT do**: + - No noisy per-token logging flood. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 1 (with Task 1) + - **Blocks**: 7 + - **Blocked By**: None + + **References**: + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/util/log.ts` + + **Acceptance Criteria**: + - [ ] Telemetry tests verify structured payloads on failover/exhaustion branches. + - [ ] `bun test packages/opencode/test/plugin/codex-observability.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Failover emits structured event + Tool: Bash + Steps: + 1. Run observability suite + 2. Assert: event includes account id/email hash, reason, retry count + Expected Result: deterministic telemetry shape + Evidence: test output capture + ``` + +- [ ] 6. Unify Quota Freshness Contract Across Plugin/CLI/Server + + **What to do**: + - Define and enforce freshness states (`fresh|stale|unknown`) and TTL semantics. + - Align `/provider/codex/usage` and CLI rendering to avoid unsafe assumptions on missing fields. + + **Must NOT do**: + - No misleading quota status when data is stale/unknown. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 3 + - **Blocks**: 7 + - **Blocked By**: 1,2,4 + + **References**: + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/server/routes/provider.ts` + - `packages/opencode/src/cli/cmd/auth.ts` + + **Acceptance Criteria**: + - [ ] Freshness semantics test-backed end-to-end. + - [ ] `bun test packages/opencode/test/server/provider-codex-usage.test.ts` -> PASS. + - [ ] `bun test packages/opencode/test/cli/auth-codex-list-usage.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Missing resetAt handled safely in list output + Tool: Bash + Steps: + 1. Run CLI usage/list tests with missing reset fields + 2. Assert: no crash, status shown as unknown/stale contract + Expected Result: resilient rendering + Evidence: test output capture + ``` + +- [ ] 7. Final Regression Matrix + Stability Verification + + **What to do**: + - Run focused suites together and verify no regression in existing Codex behavior. + - Confirm config-compat default behavior remains preserved. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 4 + - **Blocks**: None + - **Blocked By**: 2,3,4,5,6 + + **References**: + - `packages/opencode/test/plugin/codex.test.ts` + - New test files from Tasks 1-6 + + **Acceptance Criteria**: + - [ ] All targeted suites pass. + - [ ] Existing plugin codex tests still pass. + - [ ] No flaky failures across repeat runs. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Full targeted codex reliability matrix + Tool: Bash + Steps: + 1. Run all targeted codex/auth/server/cli suites + 2. Repeat run at least twice + 3. Assert: consistent pass, no intermittent failures + Expected Result: stable regression baseline + Evidence: terminal output capture + ``` + +--- + +## Commit Strategy + +| After Task | Message | Verification | +| ---------- | ---------------------------------------------------------------------- | ---------------------------- | +| 1 | `test(codex): add failing rotation state-machine coverage` | target test file fails (RED) | +| 2-3 | `fix(codex): harden auth persistence and retry failover` | targeted suites pass | +| 4-6 | `feat(codex): enforce rotation identity and quota freshness contracts` | targeted suites pass | +| 7 | `test(codex): add regression matrix for multi-account quota flow` | matrix pass | + +--- + +## Success Criteria + +### Verification Commands + +```bash +bun test packages/opencode/test/auth/codex-rotation.test.ts +bun test packages/opencode/test/auth/codex-concurrency.test.ts +bun test packages/opencode/test/plugin/codex-retry.test.ts +bun test packages/opencode/test/plugin/codex-observability.test.ts +bun test packages/opencode/test/server/provider-codex-usage.test.ts +bun test packages/opencode/test/cli/auth-codex-list-usage.test.ts +bun test packages/opencode/test/plugin/codex.test.ts +``` + +### Final Checklist + +- [ ] Rotation logic deterministic under configured policy. +- [ ] Shared auth persistence safe under concurrent updates. +- [ ] Retry behavior bounded and header-aware. +- [ ] Quota freshness/status consistent across plugin/CLI/server. +- [ ] Regression matrix green and stable. + +--- + +## Defaults Applied + +- Preserve current default behavior (`first available`) unless explicit config changes mode. +- Keep scope constrained to Codex multi-account path and directly coupled files. + +## Decisions Needed + +- None. User selected recommended defaults: + - Rotation default: keep `first_available`, fairness mode opt-in. + - Quota unknown policy: fail-open. + - Retry budget: max retries per request = 2, jittered exponential backoff. + +--- + +## Unresolved Questions + +- none. diff --git a/AGENTS.md b/AGENTS.md index 8cfe768da39a..a3e44d75c172 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,82 +1,81 @@ -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. -- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. -- The default branch in this repo is `dev`. -- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. - -## Style Guide - -- Keep things in one function unless composable or reusable -- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context -- Avoid `try`/`catch` where possible -- Avoid using the `any` type -- Prefer single word variable names where possible -- Use Bun APIs when possible, like `Bun.file()` -- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - -### Avoid let statements - -We don't like `let` statements, especially combined with if/else statements. -Prefer `const`. - -Good: - -```ts -const foo = condition ? 1 : 2 -``` - -Bad: - -```ts -let foo - -if (condition) foo = 1 -else foo = 2 -``` - -### Avoid else statements - -Prefer early returns or using an `iife` to avoid else statements. - -Good: - -```ts -function foo() { - if (condition) return 1 - return 2 -} -``` - -Bad: - -```ts -function foo() { - if (condition) return 1 - else return 2 -} +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-02-09 +**Commit:** c5846499d +**Branch:** codex-multi-account + +## OVERVIEW + +OpenCode monorepo. Bun workspaces + Turbo. Core CLI/server in `packages/opencode`, product UIs in `packages/app` and `packages/desktop`, docs/web + integrations in sibling packages. + +## STRUCTURE + +```text +. +├── packages/ +│ ├── opencode/ # CLI + server + TUI core +│ ├── app/ # main web app (Solid + Vite) +│ ├── console/ # SaaS app/core/function/mail/resource +│ ├── desktop/ # Tauri wrapper around app/ui +│ ├── sdk/js/ # published JS SDK + generated clients +│ ├── ui/ # shared UI/components/theme assets +│ ├── web/ # docs + landing site (Astro/Starlight) +│ ├── enterprise/ # enterprise web app +│ └── slack/ # Slack integration +├── sdks/vscode/ # VS Code extension +├── github/ # GitHub Action runtime +├── infra/ # SST infra definitions +└── script/ # release/generate/version scripts ``` -### Prefer single word naming - -Try your best to find a single word name for your variables, functions, etc. -Only use multiple words if you cannot. - -Good: - -```ts -const foo = 1 -const bar = 2 -const baz = 3 -``` - -Bad: - -```ts -const fooBar = 1 -const barBaz = 2 -const bazFoo = 3 +## WHERE TO LOOK + +| Task | Location | Notes | +| --------------------------- | --------------------------------- | ----------------------------------------------------- | +| CLI commands / tool runtime | `packages/opencode/src` | deepest logic; TUI + server + tools | +| Web product behavior | `packages/app/src` | heavy hotspots: `pages/`, `context/` | +| Shared UI system | `packages/ui/src` | components/theme/icons/fonts | +| SDK regeneration | `packages/sdk/js/script/build.ts` | run after API/schema changes | +| CI/release flow | `.github/workflows/` + `script/` | `publish.yml`, `test.yml`, `version.ts`, `publish.ts` | +| Infra/deploy topology | `infra/` + `sst.config.ts` | app/console/enterprise targets | + +## CONVENTIONS + +- Default branch is `dev`. +- Use parallel tools/agents whenever calls are independent. +- Root `bun test` is intentionally blocked; run tests per package. +- Formatter baseline: `semi: false`, `printWidth: 120` in `package.json`. +- Keep code small/focused; prefer one function unless composable reuse is needed. +- Prefer `const`, early returns, minimal destructuring, no `any`. + +## ANTI-PATTERNS (THIS PROJECT) + +- Sequential tool calls when they can run in parallel. +- Restarting app/server processes in `packages/app` workflow. +- Editing generated outputs manually (`DO NOT EDIT` areas, generated SDK files). +- Adding tests that duplicate implementation logic or overuse mocks. +- Running root-level test script and treating failure as signal. + +## UNIQUE STYLES + +- Strong preference for Bun-native tooling and scripts. +- Single-word naming preferred where clarity remains good. +- Avoid `let` + branch mutation patterns; use expressions. +- Avoid `else` when early return/guard can flatten control flow. + +## COMMANDS + +```bash +bun dev +bun turbo typecheck +bun run --cwd packages/app dev +bun run --cwd packages/desktop tauri dev +./packages/sdk/js/script/build.ts +./script/generate.ts ``` -## Testing +## NOTES -You MUST avoid using `mocks` as much as possible. -Tests MUST test actual implementation, do not duplicate logic into a test. +- CI `test.yml` seeds opencode state and starts server before app e2e. +- Desktop release pipeline requires Rust/Tauri + platform signing secrets. +- Use package-local AGENTS.md files first; they override this root guidance. diff --git a/github/AGENTS.md b/github/AGENTS.md new file mode 100644 index 000000000000..2e80654aae8e --- /dev/null +++ b/github/AGENTS.md @@ -0,0 +1,24 @@ +# GITHUB ACTION KNOWLEDGE BASE + +## OVERVIEW + +GitHub Action runtime that handles `/oc` and `/opencode` comment workflows. + +## WHERE TO LOOK + +- Action runtime entry: `github/index.ts` +- Package manifest: `github/package.json` +- Usage and local mock flow: `github/README.md` +- Action metadata: `github/action.yml` + +## CONVENTIONS + +- Module entry is `index.ts` (`type: module`). +- Depends on `@actions/*`, Octokit, and `@opencode-ai/sdk`. +- Local testing uses mock env vars (`MOCK_EVENT`, `MOCK_TOKEN`, `GITHUB_RUN_ID`, model/api keys). + +## ANTI-PATTERNS + +- Don’t hardcode provider secrets/tokens. +- Don’t change event parsing without checking issue + review-comment paths. +- Don’t diverge from documented local mock workflow when debugging action logic. diff --git a/packages/console/AGENTS.md b/packages/console/AGENTS.md new file mode 100644 index 000000000000..022f455570d7 --- /dev/null +++ b/packages/console/AGENTS.md @@ -0,0 +1,26 @@ +# CONSOLE KNOWLEDGE BASE + +## OVERVIEW + +`packages/console` is the SaaS domain split into `app`, `core`, `function`, `mail`, `resource`. + +## WHERE TO LOOK + +- UI routes/pages: `packages/console/app/src/routes` +- Billing/data logic: `packages/console/core/src` +- Worker handlers: `packages/console/function/src` +- Email templates: `packages/console/mail/emails` +- Env/resource wiring: `packages/console/resource` + +## CONVENTIONS + +- `console/app` build chains `generate-sitemap.ts`, `vite build`, then `../../opencode/script/schema.ts`. +- `console/core` uses `sst shell` scripts for db/model promotion flows. +- `console/resource` has target-specific entries (`resource.node.ts`, `resource.cloudflare.ts`). +- Typecheck uses `tsgo --noEmit` in console packages. + +## ANTI-PATTERNS + +- Don’t treat `console` as one app; each subpackage has distinct runtime/deploy needs. +- Don’t bypass `sst shell` for core db/model scripts. +- Don’t place business logic inside email/template files. diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md new file mode 100644 index 000000000000..a6967e57d45d --- /dev/null +++ b/packages/desktop/AGENTS.md @@ -0,0 +1,25 @@ +# DESKTOP KNOWLEDGE BASE + +## OVERVIEW + +`packages/desktop` is Tauri v2 shell + Vite frontend wrapping shared app/ui packages. + +## WHERE TO LOOK + +- Frontend entry: `packages/desktop/src/index.tsx` +- Native backend: `packages/desktop/src-tauri/src/lib.rs` +- Tauri config: `packages/desktop/src-tauri/tauri.conf.json` +- Build preparation: `packages/desktop/scripts/prepare.ts` + +## CONVENTIONS + +- Local web-only dev: `bun run --cwd packages/desktop dev`. +- Native dev/build: `bun run --cwd packages/desktop tauri dev|build`. +- `tauri.conf.json` runs `beforeDevCommand` and `beforeBuildCommand` via Bun scripts. +- Release pipeline expects Rust toolchain and signing secrets. + +## ANTI-PATTERNS + +- Don’t treat desktop as web-only package; Rust/Tauri paths matter. +- Don’t edit generated release artifacts in workflow outputs. +- Don’t change bundle targets/icons casually; CI/release depends on them. diff --git a/packages/enterprise/AGENTS.md b/packages/enterprise/AGENTS.md new file mode 100644 index 000000000000..3e3382cfd6e2 --- /dev/null +++ b/packages/enterprise/AGENTS.md @@ -0,0 +1,24 @@ +# ENTERPRISE KNOWLEDGE BASE + +## OVERVIEW + +Enterprise-facing Solid/Vite app with API routes and Cloudflare-target build variant. + +## WHERE TO LOOK + +- App routes/pages: `packages/enterprise/src/routes` +- Core logic/services: `packages/enterprise/src/core` +- Build targets: `packages/enterprise/package.json` +- Vite/runtime config: `packages/enterprise/vite.config.ts` + +## CONVENTIONS + +- Default build is `vite build`; Cloudflare build uses `OPENCODE_DEPLOYMENT_TARGET=cloudflare`. +- Typecheck uses `tsgo --noEmit`. +- Depends on shared `@opencode-ai/ui` and `@opencode-ai/util`. + +## ANTI-PATTERNS + +- Don’t treat enterprise routes as identical to `packages/app` routes. +- Don’t bypass target env flag when building for Cloudflare. +- Don’t duplicate shared ui/util code locally. diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index 0a92b54f683a..e35cc00944d6 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -1,13 +1,12 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process" -import fs from "node:fs" -import os from "node:os" -import path from "node:path" -import { fileURLToPath } from "node:url" +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") function run(target) { - const result = spawnSync(target, process.argv.slice(2), { + const result = childProcess.spawnSync(target, process.argv.slice(2), { stdio: "inherit", }) if (result.error) { @@ -23,7 +22,7 @@ if (envPath) { run(envPath) } -const scriptPath = fs.realpathSync(fileURLToPath(import.meta.url)) +const scriptPath = fs.realpathSync(__filename) const scriptDir = path.dirname(scriptPath) const platformMap = { diff --git a/packages/sdk/js/AGENTS.md b/packages/sdk/js/AGENTS.md new file mode 100644 index 000000000000..14a18e41cff3 --- /dev/null +++ b/packages/sdk/js/AGENTS.md @@ -0,0 +1,25 @@ +# SDK KNOWLEDGE BASE + +## OVERVIEW + +Published JS SDK package; exports main/client/server plus `v2` surfaces and generated code. + +## WHERE TO LOOK + +- Public exports: `packages/sdk/js/package.json` +- Main entry: `packages/sdk/js/src/index.ts` +- V2 entrypoints: `packages/sdk/js/src/v2` +- Generated clients/types: `packages/sdk/js/src/gen`, `packages/sdk/js/src/v2/gen` +- Build/regeneration: `packages/sdk/js/script/build.ts` + +## CONVENTIONS + +- Build script is `./script/build.ts`; root regen also calls this. +- Treat `src/gen` and `src/v2/gen` as generated surfaces. +- Keep export map in `package.json` aligned with source entries. + +## ANTI-PATTERNS + +- Don’t hand-edit generated SDK files. +- Don’t add exports without updating the `exports` map. +- Don’t skip SDK rebuild after API/schema changes. diff --git a/packages/slack/AGENTS.md b/packages/slack/AGENTS.md new file mode 100644 index 000000000000..ce7f9664d4d5 --- /dev/null +++ b/packages/slack/AGENTS.md @@ -0,0 +1,23 @@ +# SLACK KNOWLEDGE BASE + +## OVERVIEW + +Slack integration package; runs Bolt app and bridges Slack threads to opencode sessions. + +## WHERE TO LOOK + +- Runtime entry: `packages/slack/src/index.ts` +- Package scripts/deps: `packages/slack/package.json` +- Setup/env docs: `packages/slack/README.md` + +## CONVENTIONS + +- Dev runner: `bun run src/index.ts`. +- Required envs: `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_APP_TOKEN`. +- Uses `@opencode-ai/sdk` for backend communication. + +## ANTI-PATTERNS + +- Don’t commit Slack secrets or `.env` credentials. +- Don’t assume stateless messages; behavior is thread/session-oriented. +- Don’t replace SDK calls with ad-hoc API wiring. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 000000000000..f0d67d8b3e8e --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,25 @@ +# UI KNOWLEDGE BASE + +## OVERVIEW + +Shared UI system: components, theme, icons, fonts, audio, and cross-app hooks/context. + +## WHERE TO LOOK + +- Components: `packages/ui/src/components` +- Theme engine: `packages/ui/src/theme` +- Shared hooks/context: `packages/ui/src/hooks`, `packages/ui/src/context` +- Assets: `packages/ui/src/assets` +- Tailwind generation: `packages/ui/script/tailwind.ts` + +## CONVENTIONS + +- Exports are path-based via `package.json` (`./*`, `./theme/*`, `./context/*`, etc.). +- Typecheck uses `tsgo --noEmit`; dev via Vite. +- Theme tokens/icons are reused across app/enterprise/desktop. + +## ANTI-PATTERNS + +- Don’t break export paths; downstream packages import via mapped subpaths. +- Don’t duplicate shared UI logic in app-specific packages. +- Don’t edit massive asset/icon sets without checking consumer impact. diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md new file mode 100644 index 000000000000..34a9436f9f87 --- /dev/null +++ b/packages/web/AGENTS.md @@ -0,0 +1,24 @@ +# WEB KNOWLEDGE BASE + +## OVERVIEW + +Astro/Starlight docs + landing site; content-driven package with shared model/session rendering. + +## WHERE TO LOOK + +- Docs content: `packages/web/src/content/docs` +- Share/render components: `packages/web/src/components/share` +- Build config/scripts: `packages/web/package.json`, `packages/web/astro.config.mjs` +- Static assets: `packages/web/src/assets`, `packages/web/public` + +## CONVENTIONS + +- Dev/build/preview use Astro scripts in package.json. +- `dev:remote` sets `VITE_API_URL=https://api.opencode.ai`. +- Keep docs structure route-friendly (`index.mdx` and nested content folders). + +## ANTI-PATTERNS + +- Don’t rely on starter-template README defaults as project truth. +- Don’t mix generated/shared runtime data into docs content files. +- Don’t change docs route layout without checking linked nav/content config. diff --git a/script/cleanup-local.sh b/script/cleanup-local.sh new file mode 100755 index 000000000000..a0c72a271605 --- /dev/null +++ b/script/cleanup-local.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +BINARY_NAME="opencode" + +echo "Cleaning up local opencode installation..." + +# Remove bun link +if [ -L "$HOME/.bun/bin/$BINARY_NAME" ] || [ -f "$HOME/.bun/bin/$BINARY_NAME" ]; then + rm -f "$HOME/.bun/bin/$BINARY_NAME" + echo "Removed: ~/.bun/bin/$BINARY_NAME" +fi + +# Remove ~/.local/bin symlink +if [ -L "$HOME/.local/bin/$BINARY_NAME" ] || [ -f "$HOME/.local/bin/$BINARY_NAME" ]; then + rm -f "$HOME/.local/bin/$BINARY_NAME" + echo "Removed: ~/.local/bin/$BINARY_NAME" +fi + +# Remove dist folder +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$REPO_DIR/packages/opencode/dist" +if [ -d "$DIST_DIR" ]; then + rm -rf "$DIST_DIR" + echo "Removed: $DIST_DIR" +fi + +echo "Cleanup complete." + +# Verify removal +if command -v opencode &> /dev/null; then + echo "Warning: 'opencode' still found at: $(which opencode)" +else + echo "No 'opencode' binary in PATH." +fi diff --git a/script/rebuild-local.sh b/script/rebuild-local.sh new file mode 100755 index 000000000000..bd15a1f388bc --- /dev/null +++ b/script/rebuild-local.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +INSTALL_DIR="${HOME}/.local/bin" +BINARY_NAME="opencode" + +cd "$REPO_DIR/packages/opencode" + +echo "Building opencode..." +bun run script/build.ts --single + +# Determine platform +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +[[ "$ARCH" == "aarch64" ]] && ARCH="arm64" +[[ "$ARCH" == "x86_64" ]] && ARCH="x64" + +DIST_NAME="opencode-${OS}-${ARCH}" +BINARY_PATH="$REPO_DIR/packages/opencode/dist/${DIST_NAME}/bin/opencode" + +# Create install dir if needed +mkdir -p "$INSTALL_DIR" + +# Remove old symlink/binary +rm -f "$INSTALL_DIR/$BINARY_NAME" + +# Create symlink +ln -sf "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" + +echo "Installed: $INSTALL_DIR/$BINARY_NAME -> $BINARY_PATH" +echo "Version: $($INSTALL_DIR/$BINARY_NAME --version)" diff --git a/sdks/vscode/AGENTS.md b/sdks/vscode/AGENTS.md new file mode 100644 index 000000000000..636141b84cd4 --- /dev/null +++ b/sdks/vscode/AGENTS.md @@ -0,0 +1,24 @@ +# VSCODE SDK KNOWLEDGE BASE + +## OVERVIEW + +VS Code extension package that launches/focuses opencode terminal sessions and injects file refs. + +## WHERE TO LOOK + +- Extension entry: `sdks/vscode/src/extension.ts` +- Packaging/build scripts: `sdks/vscode/package.json` +- Bundling: `sdks/vscode/esbuild.js` +- Dev workflow: `sdks/vscode/README.md` + +## CONVENTIONS + +- `main` entry is `dist/extension.js`. +- Build path: `check-types` + `lint` + `esbuild`. +- Extension dev should open `sdks/vscode` directly in VS Code (not repo root). + +## ANTI-PATTERNS + +- Don’t edit dist output directly. +- Don’t skip lint/type checks before packaging. +- Don’t change keybindings/commands without updating contributes metadata.