diff --git a/CLAUDE.md b/CLAUDE.md index 98f5ee7..394526c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ codex-collab health - Communicates with Codex via `codex app-server` JSON-RPC protocol over stdio - Threads stored in `~/.codex-collab/threads.json` as short ID → full ID mapping - Logs stored in `~/.codex-collab/logs/` per thread +- User defaults stored in `~/.codex-collab/config.json` (model, reasoning, sandbox, approval, timeout) - Approval requests use file-based IPC in `~/.codex-collab/approvals/` - Short IDs are 8-char hex, support prefix resolution - Bun is the TypeScript runtime — never use npm/yarn/pnpm for running diff --git a/README.md b/README.md index 1446f6a..b5ccb28 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ codex-collab run --resume "now check error handling" --content-only | Flag | Description | |------|-------------| | `-d, --dir ` | Working directory | -| `-m, --model ` | Model name | -| `-r, --reasoning ` | low, medium, high, xhigh (default: xhigh) | +| `-m, --model ` | Model name (default: auto — latest available) | +| `-r, --reasoning ` | low, medium, high, xhigh (default: auto — highest for model) | | `-s, --sandbox ` | read-only, workspace-write, danger-full-access (default: workspace-write; review always uses read-only) | | `--mode ` | Review mode: pr, uncommitted, commit, custom | | `--ref ` | Commit ref for `--mode commit` | @@ -118,6 +118,39 @@ codex-collab run --resume "now check error handling" --content-only +## Defaults & Configuration + +By default, codex-collab auto-selects the **latest model** (preferring `-codex` variants) and the **highest reasoning effort** supported by that model. No configuration needed — it stays current as new models are released. + +To override defaults persistently, use `codex-collab config`: + +```bash +# Show current config +codex-collab config + +# Set a preferred model +codex-collab config model gpt-5.3-codex + +# Set default reasoning effort +codex-collab config reasoning high + +# Unset a key (return to auto-detection) +codex-collab config model --unset + +# Unset all keys +codex-collab config --unset +``` + +Available keys: `model`, `reasoning`, `sandbox`, `approval`, `timeout` + +CLI flags always take precedence over config, and config takes precedence over auto-detection: + +``` +CLI flag > config file > auto-detected +``` + +Config is stored in `~/.codex-collab/config.json`. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md) code of conduct. diff --git a/README.zh-CN.md b/README.zh-CN.md index 609a0b9..72b9a6a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,8 +105,8 @@ codex-collab run --resume "现在检查错误处理" --content-only | 参数 | 说明 | |------|------| | `-d, --dir ` | 工作目录 | -| `-m, --model ` | 模型名称 | -| `-r, --reasoning ` | low, medium, high, xhigh(默认: xhigh) | +| `-m, --model ` | 模型名称(默认: 自动选择最新可用模型) | +| `-r, --reasoning ` | low, medium, high, xhigh(默认: 自动选择模型支持的最高级别) | | `-s, --sandbox ` | read-only, workspace-write, danger-full-access(默认: workspace-write;review 始终使用 read-only) | | `--mode ` | 审查模式: pr, uncommitted, commit, custom | | `--ref ` | 指定 commit 哈希(配合 `--mode commit`) | @@ -118,6 +118,26 @@ codex-collab run --resume "现在检查错误处理" --content-only +## 默认值与配置 + +默认情况下,codex-collab 自动选择**最新模型**(优先选择 `-codex` 变体)及该模型支持的**最高推理级别**。无需配置——新模型发布后自动更新。 + +使用 `codex-collab config` 持久化覆盖默认值: + +```bash +codex-collab config # 查看当前配置 +codex-collab config model gpt-5.3-codex # 设置默认模型 +codex-collab config reasoning high # 设置默认推理级别 +codex-collab config model --unset # 取消单个设置(恢复自动检测) +codex-collab config --unset # 取消所有设置 +``` + +可配置项: `model`、`reasoning`、`sandbox`、`approval`、`timeout` + +优先级: `CLI 参数 > 配置文件 > 自动检测` + +配置存储于 `~/.codex-collab/config.json`。 + ## 参与贡献 欢迎贡献!开发环境搭建及贡献流程详见 [CONTRIBUTING.md](CONTRIBUTING.md)。本项目遵循 [Contributor Covenant](CODE_OF_CONDUCT.md) 行为准则。 diff --git a/SKILL.md b/SKILL.md index 72987f2..f6f5df3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -106,7 +106,7 @@ codex-collab progress Progress lines stream in real-time during execution: ``` -[codex] Thread a1b2c3d4 started (gpt-5.3-codex, workspace-write) +[codex] Thread a1b2c3d4 started (gpt-5.4, workspace-write) [codex] Turn started [codex] Running: npm test [codex] Edited: src/auth.ts (update) @@ -177,6 +177,10 @@ codex-collab clean # Delete old logs and stale mappings ### Utility ```bash +codex-collab config # Show persistent defaults +codex-collab config model gpt-5.3-codex # Set default model +codex-collab config model --unset # Unset a key (return to auto) +codex-collab config --unset # Unset all keys (return to auto) codex-collab models # List available models codex-collab approve # Approve a pending request codex-collab decline # Decline a pending request @@ -187,8 +191,8 @@ codex-collab health # Check prerequisites | Flag | Description | |------|-------------| -| `-m, --model ` | Model name (default: gpt-5.3-codex) | -| `-r, --reasoning ` | Reasoning effort: low, medium, high, xhigh (default: xhigh) | +| `-m, --model ` | Model name (default: auto — latest available) | +| `-r, --reasoning ` | Reasoning effort: low, medium, high, xhigh (default: auto — highest for model) | | `-s, --sandbox ` | Sandbox: read-only, workspace-write, danger-full-access (default: workspace-write; review always uses read-only) | | `-d, --dir ` | Working directory (default: cwd) | | `--resume ` | Resume existing thread (run and review) | diff --git a/src/cli.ts b/src/cli.ts index 53081db..18d7de3 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { loadThreadMapping, removeThread, saveThreadMapping, + updateThreadMeta, updateThreadStatus, withThreadLock, } from "./threads"; @@ -38,12 +39,98 @@ import { import { resolve, join } from "path"; import type { ReviewTarget, - Thread, ThreadStartResponse, Model, TurnResult, } from "./types"; +// --------------------------------------------------------------------------- +// User config — persistent defaults from ~/.codex-collab/config.json +// --------------------------------------------------------------------------- + +/** Fields users can set in ~/.codex-collab/config.json. */ +interface UserConfig { + model?: string; + reasoning?: string; + sandbox?: string; + approval?: string; + timeout?: number; +} + +function loadUserConfig(): UserConfig { + try { + const parsed = JSON.parse(readFileSync(config.configFile, "utf-8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error(`[codex] Warning: config file is not a JSON object — ignoring: ${config.configFile}`); + return {}; + } + return parsed as UserConfig; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; + if (e instanceof SyntaxError) { + console.error(`[codex] Warning: invalid JSON in ${config.configFile} — ignoring config`); + } else { + console.error(`[codex] Warning: could not read config: ${e instanceof Error ? e.message : String(e)}`); + } + return {}; + } +} + +function saveUserConfig(cfg: UserConfig): void { + try { + writeFileSync(config.configFile, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); + } catch (e) { + die(`Could not save config to ${config.configFile}: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** Apply user config to parsed options — only for fields not set via CLI flags. + * Config values are added to `configured` (not `explicit`) so they suppress + * auto-detection but are NOT forwarded as overrides on thread resume. */ +function applyUserConfig(options: Options): void { + const cfg = loadUserConfig(); + + if (!options.explicit.has("model") && typeof cfg.model === "string") { + if (/[^a-zA-Z0-9._\-\/:]/.test(cfg.model)) { + console.error(`[codex] Warning: ignoring invalid model in config: ${cfg.model}`); + } else { + options.model = cfg.model; + options.configured.add("model"); + } + } + if (!options.explicit.has("reasoning") && typeof cfg.reasoning === "string") { + if (config.reasoningEfforts.includes(cfg.reasoning as any)) { + options.reasoning = cfg.reasoning as ReasoningEffort; + options.configured.add("reasoning"); + } else { + console.error(`[codex] Warning: ignoring invalid reasoning in config: ${cfg.reasoning}`); + } + } + if (!options.explicit.has("sandbox") && typeof cfg.sandbox === "string") { + if (config.sandboxModes.includes(cfg.sandbox as any)) { + options.sandbox = cfg.sandbox as SandboxMode; + options.configured.add("sandbox"); + } else { + console.error(`[codex] Warning: ignoring invalid sandbox in config: ${cfg.sandbox}`); + } + } + if (!options.explicit.has("approval") && typeof cfg.approval === "string") { + if (config.approvalPolicies.includes(cfg.approval as any)) { + options.approval = cfg.approval as ApprovalPolicy; + options.configured.add("approval"); + } else { + console.error(`[codex] Warning: ignoring invalid approval in config: ${cfg.approval}`); + } + } + if (!options.explicit.has("timeout") && cfg.timeout !== undefined) { + if (typeof cfg.timeout === "number" && Number.isFinite(cfg.timeout) && cfg.timeout > 0) { + options.timeout = cfg.timeout; + } else { + console.error(`[codex] Warning: ignoring invalid timeout in config: ${cfg.timeout}`); + } + } +} + // --------------------------------------------------------------------------- // Signal handlers — clean up spawned app-server and update thread status // --------------------------------------------------------------------------- @@ -99,8 +186,8 @@ interface ParsedArgs { } interface Options { - reasoning: ReasoningEffort; - model: string; + reasoning: ReasoningEffort | undefined; + model: string | undefined; sandbox: SandboxMode; approval: ApprovalPolicy; dir: string; @@ -112,14 +199,16 @@ interface Options { reviewRef: string | null; base: string; resumeId: string | null; - /** Flags explicitly provided on the command line. */ + /** Flags explicitly provided on the command line (forwarded on resume). */ explicit: Set; + /** Flags set by user config file (suppress auto-detection but NOT forwarded on resume). */ + configured: Set; } function parseArgs(args: string[]): ParsedArgs { const options: Options = { - reasoning: config.defaultReasoningEffort, - model: config.model, + reasoning: undefined, + model: undefined, sandbox: config.defaultSandbox, approval: config.defaultApprovalPolicy, dir: process.cwd(), @@ -132,6 +221,7 @@ function parseArgs(args: string[]): ParsedArgs { base: "main", resumeId: null, explicit: new Set(), + configured: new Set(), }; const positional: string[] = []; @@ -222,6 +312,7 @@ function parseArgs(args: string[]): ParsedArgs { process.exit(1); } options.timeout = val; + options.explicit.add("timeout"); } else if (arg === "--limit") { if (i + 1 >= args.length) { console.error("Error: --limit requires a value"); @@ -265,6 +356,8 @@ function parseArgs(args: string[]): ParsedArgs { options.resumeId = args[++i]; } else if (arg === "--all") { options.limit = Infinity; + } else if (arg === "--unset") { + options.explicit.add("unset"); } else if (arg.startsWith("-")) { console.error(`Error: Unknown option: ${arg}`); console.error("Run codex-collab --help for usage"); @@ -343,6 +436,73 @@ function createDispatcher(shortId: string, opts: Options): EventDispatcher { ); } +/** Pick the best model by following the upgrade chain from the server default, + * then preferring a -codex variant if one exists at the latest generation. */ +function pickBestModel(models: Model[]): string | undefined { + const byId = new Map(models.map(m => [m.id, m])); + + // Start from the server's default model + let current = models.find(m => m.isDefault); + if (!current) return undefined; + + // Follow the upgrade chain to the latest generation + const visited = new Set(); + while (current.upgrade && !visited.has(current.id)) { + visited.add(current.id); + const next = byId.get(current.upgrade); + if (!next) break; // upgrade target not in the list + current = next; + } + + // Prefer -codex variant if available at this generation + if (!current.id.endsWith("-codex")) { + const codexVariant = byId.get(current.id + "-codex"); + if (codexVariant && codexVariant.upgrade === null) return codexVariant.id; + } + + return current.id; +} + +/** Pick the highest reasoning effort a model supports. */ +function pickHighestEffort(supported: Array<{ reasoningEffort: string }>): ReasoningEffort | undefined { + const available = new Set(supported.map(s => s.reasoningEffort)); + for (let i = config.reasoningEfforts.length - 1; i >= 0; i--) { + if (available.has(config.reasoningEfforts[i])) return config.reasoningEfforts[i]; + } + return undefined; +} + +/** Auto-resolve model and/or reasoning effort when not set by CLI or config. */ +async function resolveDefaults(client: AppServerClient, opts: Options): Promise { + const isSet = (key: string) => opts.explicit.has(key) || opts.configured.has(key); + const needModel = !isSet("model"); + const needReasoning = !isSet("reasoning"); + if (!needModel && !needReasoning) return; + + let models: Model[]; + try { + models = await fetchAllPages(client, "model/list", { includeHidden: true }); + } catch (e) { + console.error(`[codex] Warning: could not fetch model list (${e instanceof Error ? e.message : String(e)}). Model and reasoning will be determined by the server.`); + return; + } + if (models.length === 0) { + console.error(`[codex] Warning: server returned no models. Model and reasoning will be determined by the server.`); + return; + } + + if (needModel) { + opts.model = pickBestModel(models); + } + + if (needReasoning) { + const modelData = models.find(m => m.id === opts.model); + if (modelData?.supportedReasoningEfforts?.length) { + opts.reasoning = pickHighestEffort(modelData.supportedReasoningEfforts); + } + } +} + /** Try to archive a thread on the server. Returns status string. */ async function tryArchive(client: AppServerClient, threadId: string): Promise<"archived" | "already_done" | "failed"> { try { @@ -386,7 +546,10 @@ function resolveReviewTarget(positional: string[], opts: Options): ReviewTarget /** Per-turn parameter overrides: all values for new threads, explicit-only for resume. */ function turnOverrides(opts: Options) { if (!opts.resumeId) { - return { cwd: opts.dir, model: opts.model, effort: opts.reasoning, approvalPolicy: opts.approval }; + const o: Record = { cwd: opts.dir, approvalPolicy: opts.approval }; + if (opts.model) o.model = opts.model; + if (opts.reasoning) o.effort = opts.reasoning; + return o; } const o: Record = {}; if (opts.explicit.has("dir")) o.cwd = opts.dir; @@ -477,6 +640,7 @@ async function startOrResumeThread( client: AppServerClient, opts: Options, extraStartParams?: Record, + preview?: string, ): Promise<{ threadId: string; shortId: string; effective: ThreadStartResponse }> { if (opts.resumeId) { const threadId = resolveThreadId(config.threadsFile, opts.resumeId); @@ -493,11 +657,16 @@ async function startOrResumeThread( // Forced overrides from caller (e.g., review forces sandbox to read-only) if (extraStartParams) Object.assign(resumeParams, extraStartParams); const effective = await client.request("thread/resume", resumeParams); + // Refresh stored metadata so `jobs` stays accurate after resume + updateThreadMeta(config.threadsFile, threadId, { + model: effective.model, + ...(opts.explicit.has("dir") ? { cwd: opts.dir } : {}), + ...(preview ? { preview } : {}), + }); return { threadId, shortId, effective }; } const startParams: Record = { - model: opts.model, cwd: opts.dir, approvalPolicy: opts.approval, sandbox: opts.sandbox, @@ -505,14 +674,16 @@ async function startOrResumeThread( persistExtendedHistory: false, ...extraStartParams, }; + if (opts.model) startParams.model = opts.model; const effective = await client.request( "thread/start", startParams, ); const threadId = effective.thread.id; registerThread(config.threadsFile, threadId, { - model: opts.model, + model: effective.model, cwd: opts.dir, + preview, }); const shortId = findShortId(config.threadsFile, threadId); if (!shortId) die(`Internal error: thread ${threadId.slice(0, 12)}... registered but not found in mapping`); @@ -546,7 +717,9 @@ async function cmdRun(positional: string[], opts: Options) { const prompt = positional.join(" "); const exitCode = await withClient(async (client) => { - const { threadId, shortId, effective } = await startOrResumeThread(client, opts); + await resolveDefaults(client, opts); + + const { threadId, shortId, effective } = await startOrResumeThread(client, opts, undefined, prompt); if (opts.contentOnly) { console.error(`[codex] Running (thread ${shortId})...`); @@ -598,8 +771,17 @@ async function cmdReview(positional: string[], opts: Options) { const target = resolveReviewTarget(positional, opts); const exitCode = await withClient(async (client) => { + await resolveDefaults(client, opts); + + let reviewPreview: string; + switch (target.type) { + case "custom": reviewPreview = target.instructions; break; + case "baseBranch": reviewPreview = `Review PR (base: ${target.branch})`; break; + case "uncommittedChanges": reviewPreview = "Review uncommitted changes"; break; + case "commit": reviewPreview = `Review commit ${target.sha}`; break; + } const { threadId, shortId, effective } = await startOrResumeThread( - client, opts, { sandbox: "read-only" }, + client, opts, { sandbox: "read-only" }, reviewPreview, ); if (opts.contentOnly) { @@ -664,62 +846,52 @@ async function fetchAllPages( async function cmdJobs(opts: Options) { const mapping = loadThreadMapping(config.threadsFile); - const reverseMap = new Map(); - for (const [shortId, entry] of Object.entries(mapping)) { - reverseMap.set(entry.threadId, shortId); - } - // Fetch all pages from server — we filter to locally-known threads, - // so a single page could miss local threads buried behind non-local ones. - const allThreads = await withClient((client) => - fetchAllPages(client, "thread/list", { sortKey: "updated_at" }), - ); - - // Only show threads created by this CLI (those with a local short ID mapping) - let localThreads = allThreads.filter((t) => reverseMap.has(t.id)); + // Build entries sorted by updatedAt (most recent first), falling back to createdAt + let entries = Object.entries(mapping) + .map(([shortId, entry]) => ({ shortId, ...entry })) + .sort((a, b) => { + const ta = new Date(a.updatedAt ?? a.createdAt).getTime(); + const tb = new Date(b.updatedAt ?? b.createdAt).getTime(); + return tb - ta; + }); // Detect stale "running" status: if the owning process is dead, mark as interrupted. - for (const t of localThreads) { - const sid = reverseMap.get(t.id)!; - const entry = mapping[sid]; - if (entry?.lastStatus === "running" && !isProcessAlive(sid)) { - updateThreadStatus(config.threadsFile, t.id, "interrupted"); - entry.lastStatus = "interrupted"; - removePidFile(sid); + for (const e of entries) { + if (e.lastStatus === "running" && !isProcessAlive(e.shortId)) { + updateThreadStatus(config.threadsFile, e.threadId, "interrupted"); + e.lastStatus = "interrupted"; + removePidFile(e.shortId); } } - if (opts.limit !== Infinity) localThreads = localThreads.slice(0, opts.limit); + if (opts.limit !== Infinity) entries = entries.slice(0, opts.limit); if (opts.json) { - const enriched = localThreads.map((t) => { - const sid = reverseMap.get(t.id)!; - const entry = mapping[sid]; - return { - shortId: sid, - threadId: t.id, - status: entry.lastStatus ?? "unknown", - modelProvider: t.modelProvider, - cwd: t.cwd, - preview: t.preview || null, - createdAt: new Date(t.createdAt * 1000).toISOString(), - updatedAt: new Date(t.updatedAt * 1000).toISOString(), - }; - }); + const enriched = entries.map(e => ({ + shortId: e.shortId, + threadId: e.threadId, + status: e.lastStatus ?? "unknown", + model: e.model ?? null, + cwd: e.cwd ?? null, + preview: e.preview ?? null, + createdAt: e.createdAt, + updatedAt: e.updatedAt ?? e.createdAt, + })); console.log(JSON.stringify(enriched, null, 2)); } else { - if (localThreads.length === 0) { + if (entries.length === 0) { console.log("No threads found."); return; } - for (const t of localThreads) { - const sid = reverseMap.get(t.id)!; - const entry = mapping[sid]; - const status = entry.lastStatus ?? "idle"; - const age = formatAge(t.updatedAt); - const preview = t.preview ? ` ${t.preview.slice(0, 50)}` : ""; + for (const e of entries) { + const status = e.lastStatus ?? "idle"; + const ts = new Date(e.updatedAt ?? e.createdAt).getTime() / 1000; + const age = formatAge(ts); + const model = e.model ? ` (${e.model})` : ""; + const preview = e.preview ? ` ${e.preview.slice(0, 50)}` : ""; console.log( - ` ${sid} ${status.padEnd(12)} ${age.padEnd(8)} ${t.cwd}${preview}`, + ` ${e.shortId} ${status.padEnd(12)} ${age.padEnd(8)} ${e.cwd ?? ""}${model}${preview}`, ); } } @@ -852,7 +1024,7 @@ async function cmdProgress(positional: string[]) { async function cmdModels() { const allModels = await withClient((client) => - fetchAllPages(client, "model/list"), + fetchAllPages(client, "model/list", { includeHidden: true }), ); for (const m of allModels) { @@ -1040,6 +1212,76 @@ async function cmdDelete(positional: string[]) { } } +async function cmdConfig(positional: string[], opts: Options) { + const VALID_KEYS: Record boolean; hint: string }> = { + model: { validate: v => !/[^a-zA-Z0-9._\-\/:]/.test(v), hint: "model name (e.g. gpt-5.4, gpt-5.3-codex)" }, + reasoning: { validate: v => (config.reasoningEfforts as readonly string[]).includes(v), hint: config.reasoningEfforts.join(", ") }, + sandbox: { validate: v => (config.sandboxModes as readonly string[]).includes(v), hint: config.sandboxModes.join(", ") }, + approval: { validate: v => (config.approvalPolicies as readonly string[]).includes(v), hint: config.approvalPolicies.join(", ") }, + timeout: { validate: v => { const n = Number(v); return Number.isFinite(n) && n > 0; }, hint: "seconds (e.g. 1200)" }, + }; + + const cfg = loadUserConfig(); + + // No args → show current config, or --unset to clear all + if (positional.length === 0) { + if (opts.explicit.has("unset")) { + saveUserConfig({}); + console.log("All config values cleared. Using auto-detected defaults."); + return; + } + if (Object.keys(cfg).length === 0) { + console.log("No user config set. Using auto-detected defaults."); + console.log(`\nConfig file: ${config.configFile}`); + console.log(`\nAvailable keys: ${Object.keys(VALID_KEYS).join(", ")}`); + console.log("Set a value: codex-collab config "); + console.log("Unset a value: codex-collab config --unset"); + } else { + for (const [k, v] of Object.entries(cfg)) { + console.log(` ${k}: ${v}`); + } + console.log(`\nConfig file: ${config.configFile}`); + } + return; + } + + const key = positional[0]; + if (!Object.hasOwn(VALID_KEYS, key)) { + die(`Unknown config key: ${key}\nValid keys: ${Object.keys(VALID_KEYS).join(", ")}`); + } + + // Unset + if (opts.explicit.has("unset")) { + delete (cfg as Record)[key]; + saveUserConfig(cfg); + console.log(`Unset ${key} (will use auto-detected default)`); + return; + } + + // Key only → show value + if (positional.length === 1) { + const val = (cfg as Record)[key]; + if (val !== undefined) { + console.log(`${key}: ${val}`); + } else { + console.log(`${key}: (not set — auto-detected)`); + } + return; + } + + const value = positional[1]; + + // Validate and set + const spec = VALID_KEYS[key]; + if (!spec.validate(value)) { + die(`Invalid value for ${key}: ${value}\nValid: ${spec.hint}`); + } + + (cfg as Record)[key] = key === "timeout" ? Number(value) : value; + saveUserConfig(cfg); + console.log(`Set ${key}: ${value}`); +} + async function cmdHealth() { const findCmd = process.platform === "win32" ? "where" : "which"; const which = Bun.spawnSync([findCmd, "codex"]); @@ -1080,6 +1322,7 @@ Commands: kill Stop a running thread output Read full log for thread progress Show recent activity for thread + config [key] [value] Show or set persistent defaults models List available models approve Approve a pending request decline Decline a pending request @@ -1088,8 +1331,8 @@ Commands: health Check prerequisites Options: - -m, --model Model name (default: ${config.model}) - -r, --reasoning Reasoning: ${config.reasoningEfforts.join(", ")} (default: ${config.defaultReasoningEffort}) + -m, --model Model name (default: auto — latest available) + -r, --reasoning Reasoning: ${config.reasoningEfforts.join(", ")} (default: auto — highest available) -s, --sandbox Sandbox: ${config.sandboxModes.join(", ")} (default: ${config.defaultSandbox}) -d, --dir Working directory (default: cwd) @@ -1138,7 +1381,7 @@ async function main() { // Keep in sync with the switch below. const knownCommands = new Set([ "run", "review", "jobs", "kill", "output", "progress", - "models", "approve", "decline", "clean", "delete", "health", + "config", "models", "approve", "decline", "clean", "delete", "health", ]); if (!knownCommands.has(command)) { console.error(`Error: Unknown command: ${command}`); @@ -1152,6 +1395,11 @@ async function main() { ensureDataDirs(); } + // Apply user config for commands that use options + if (command === "run" || command === "review") { + applyUserConfig(options); + } + switch (command) { case "run": return cmdRun(positional, options); @@ -1165,6 +1413,8 @@ async function main() { return cmdOutput(positional, options); case "progress": return cmdProgress(positional); + case "config": + return cmdConfig(positional, options); case "models": return cmdModels(); case "approve": diff --git a/src/config.ts b/src/config.ts index 896ecba..d500c0c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,12 +11,8 @@ function getHome(): string { } export const config = { - // Default model - model: "gpt-5.3-codex", - // Reasoning effort levels reasoningEfforts: ["low", "medium", "high", "xhigh"] as const, - defaultReasoningEffort: "xhigh" as const, // Sandbox modes sandboxModes: ["read-only", "workspace-write", "danger-full-access"] as const, @@ -38,6 +34,7 @@ export const config = { get approvalsDir() { return join(this.dataDir, "approvals"); }, get killSignalsDir() { return join(this.dataDir, "kill-signals"); }, get pidsDir() { return join(this.dataDir, "pids"); }, + get configFile() { return join(this.dataDir, "config.json"); }, // Display jobsListLimit: 20, diff --git a/src/threads.ts b/src/threads.ts index a7e93ba..eeec9bc 100644 --- a/src/threads.ts +++ b/src/threads.ts @@ -126,7 +126,7 @@ export function saveThreadMapping(threadsFile: string, mapping: ThreadMapping): export function registerThread( threadsFile: string, threadId: string, - meta?: { model?: string; cwd?: string }, + meta?: { model?: string; cwd?: string; preview?: string }, ): ThreadMapping { validateId(threadId); // ensure safe for use as filename (kill signals, etc.) return withThreadLock(threadsFile, () => { @@ -138,6 +138,7 @@ export function registerThread( createdAt: new Date().toISOString(), model: meta?.model, cwd: meta?.cwd, + preview: meta?.preview, }; saveThreadMapping(threadsFile, mapping); return mapping; @@ -194,6 +195,27 @@ export function updateThreadStatus( }); } +export function updateThreadMeta( + threadsFile: string, + threadId: string, + meta: { model?: string; cwd?: string; preview?: string }, +): void { + withThreadLock(threadsFile, () => { + const mapping = loadThreadMapping(threadsFile); + for (const entry of Object.values(mapping)) { + if (entry.threadId === threadId) { + if (meta.model !== undefined) entry.model = meta.model; + if (meta.cwd !== undefined) entry.cwd = meta.cwd; + if (meta.preview !== undefined) entry.preview = meta.preview; + entry.updatedAt = new Date().toISOString(); + saveThreadMapping(threadsFile, mapping); + return; + } + } + console.error(`[codex] Warning: cannot update metadata for unknown thread ${threadId.slice(0, 12)}...`); + }); +} + export function removeThread(threadsFile: string, shortId: string): void { withThreadLock(threadsFile, () => { const mapping = loadThreadMapping(threadsFile); diff --git a/src/types.ts b/src/types.ts index d89d17d..bdd5f79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -444,6 +444,7 @@ export interface ThreadMappingEntry { createdAt: string; model?: string; cwd?: string; + preview?: string; lastStatus?: "running" | "completed" | "failed" | "interrupted"; updatedAt?: string; }