From ec3d7debea79d9ba5216b5f7ce4c4fe1530baaf3 Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Tue, 13 Oct 2026 06:41:46 +0200 Subject: [PATCH] Restore Codex model catalogue in Windows Office service --- README.md | 2 +- docs/agents.md | 2 +- src/server/models.ts | 30 ++++++++++++++++++++++++++++-- tests/models.test.ts | 18 +++++++++++++++++- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fac9441a5..6a62d8cf4 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ In about two minutes, `up`: `--project` is optional: it clones that repo as the first floor. Leave it out and pick projects in the elevator. -**Signing in the agents.** `--claude-token` uses your Claude subscription; `--anthropic-api-key ` uses an API key instead. Leave both out and run `/login` in the first worker's terminal. Codex and OpenCode aren't installed by the script: `deploy/aws.sh ssh` and install them yourself. +**Signing in the agents.** `--claude-token` uses your Claude subscription; `--anthropic-api-key ` uses an API key instead. Leave both out and run `/login` in the first worker's terminal. Codex and OpenCode aren't installed by the script: `deploy/aws.sh ssh` and install them yourself. On Windows, the Codex model picker also works when Codex was installed through npm and the Office runs as a background service. **GitHub.** Your local `gh auth token` is copied to the machine so the office can clone private repos, show the boards and push PRs. Anyone in the office can use it, so pass `--github-token ` or `--no-github-token` to limit that. diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..c6577857f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -66,7 +66,7 @@ The Cursor CLI reads hooks from `~/.cursor/hooks.json` and from `.cursor/hooks.j The entries (`sessionStart`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `postToolUseFailure` and `stop`) report to a loopback endpoint with a per-worker token, and pass on only the chat id, your prompt and the tool's name: never the assistant's messages, tool input or output. A subagent's events are ignored. They answer Cursor with no decision of their own. Cursor has no hook for a permission prompt, so a worker waiting on one shows as *working*, with the tool it wants to run, until you answer it in its terminal. A worker on a machine where Cursor isn't signed in is flagged as needing you, and its terminal is where you log in. The **Model** field takes a Cursor model id, with suggestions from `cursor-agent models` when the CLI is signed in; leave it empty to use your Cursor settings. Cursor has no separate effort flag: a parameterized model takes it in brackets, as in `claude-opus-4-8[effort=high]`. Usage and cost stay in Cursor's terminal and your Cursor account; the office doesn't meter them, and the daily budget stays Claude-only. Written against Cursor CLI 2026.09.08. -Codex uses its interactive CLI with `--no-alt-screen`, preserves native sandbox and approval settings, and resumes through `codex resume `. Set it as the default with `--agent codex`. The **Model** field lists the models Codex's own picker shows (from `codex debug models`) and **Effort** the levels the picked model supports; they go on its command line as `--model` and `-c model_reasoning_effort=…`, when it's resumed too (Codex resumes on its current settings, not the session's), and a worker's own model replaces a `--model` in `--agent-args`. Leave either on **Default** to use your Codex settings; `/model` inside its terminal still changes both. The office supplies command hooks through per-process config overrides, without editing your Codex configuration. On first use, open the terminal, complete any login/setup, and review the generated Office commands in `/hooks`. Hooks need your native trust approval before session/status tracking works; the office never bypasses that review. Workers that do not report startup are marked as needing input. Codex tasks use local summaries and do not launch Claude for task naming. Its token metrics come from the root rollout identified by the trusted hook, under `CODEX_HOME/sessions` or `CODEX_HOME/archived_sessions` (default `~/.codex`). The reader checks the session identity, bounds file reads, and sends only normalized counters to the browser. Snapshots update after hooks and on a 10-second poll, survive restarts, and reset for a new session. Cache and reasoning are counted once. Child-session usage is excluded; USD cost and API-call counts are shown as unavailable. The rollout format is version-dependent (verified against 0.154.0); missing or unsupported records remain unavailable instead of being shown as zero. Reads inspect at most a 1 MiB header and the latest 4 MiB of a rollout; if no newer counter is found in that tail, the last known snapshot remains visible. +Codex uses its interactive CLI with `--no-alt-screen`, preserves native sandbox and approval settings, and resumes through `codex resume `. Set it as the default with `--agent codex`. The **Model** field lists the models Codex's own picker shows (from `codex debug models`) and **Effort** the levels the picked model supports; they go on its command line as `--model` and `-c model_reasoning_effort=…`, when it's resumed too (Codex resumes on its current settings, not the session's), and a worker's own model replaces a `--model` in `--agent-args`. On Windows, an npm-installed Codex is queried through its Node entry point, so the model list also loads when the Office background service has no Codex desktop binary on its search path. Leave either on **Default** to use your Codex settings; `/model` inside its terminal still changes both. The office supplies command hooks through per-process config overrides, without editing your Codex configuration. On first use, open the terminal, complete any login/setup, and review the generated Office commands in `/hooks`. Hooks need your native trust approval before session/status tracking works; the office never bypasses that review. Workers that do not report startup are marked as needing input. Codex tasks use local summaries and do not launch Claude for task naming. Its token metrics come from the root rollout identified by the trusted hook, under `CODEX_HOME/sessions` or `CODEX_HOME/archived_sessions` (default `~/.codex`). The reader checks the session identity, bounds file reads, and sends only normalized counters to the browser. Snapshots update after hooks and on a 10-second poll, survive restarts, and reset for a new session. Cache and reasoning are counted once. Child-session usage is excluded; USD cost and API-call counts are shown as unavailable. The rollout format is version-dependent (verified against 0.154.0); missing or unsupported records remain unavailable instead of being shown as zero. Reads inspect at most a 1 MiB header and the latest 4 MiB of a rollout; if no newer counter is found in that tail, the last known snapshot remains visible. OpenCode metrics come from assistant-message token/cost records exposed by its plugin SDK. Updated messages replace prior values so streaming updates do not add the same call twice; existing root and child sessions are loaded on resume. History that is still loading or unavailable is labeled as partial. These snapshots update usage only, so a report cannot dismiss a permission request. The daily budget and historical ledger remain Claude-only. diff --git a/src/server/models.ts b/src/server/models.ts index 5cd396021..f5ba19264 100644 --- a/src/server/models.ts +++ b/src/server/models.ts @@ -2,7 +2,7 @@ // reasoning efforts it takes, where the CLI says. Each is asked without a shell, and only what // looks like a model gets through. import { spawn } from 'node:child_process'; -import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { AGENT_EFFORTS, isValidCodexModel, isValidCursorModel, isValidGrokModel, isValidOpenCodeModel, type AgentProvider, type ModelOption } from '../shared/providers.js'; @@ -133,9 +133,35 @@ export async function fetchGrokModels(command: string, cwd: string, runner: Mode * Run `codex debug models` without a shell: Codex's catalogue as JSON, of which the office takes * the models its own picker lists, what each is called and the reasoning efforts it supports. */ +export function resolveCodexModelCommand( + command: string, + platform = process.platform, + searchPath = process.env.PATH ?? '', + exists: (file: string) => boolean = existsSync, +): { file: string; args: string[] } { + if (platform !== 'win32' || !/^codex(?:\.(?:exe|cmd))?$/i.test(path.win32.basename(command))) + return { file: command, args: [] }; + + const dirs = path.win32.isAbsolute(command) + ? [path.win32.dirname(command)] + : searchPath.split(path.win32.delimiter).map((dir) => dir.replace(/^"|"$/g, '')).filter(Boolean); + for (const dir of dirs) { + const executable = path.win32.join(dir, 'codex.exe'); + if (exists(executable)) return { file: executable, args: [] }; + + // npm installs a .cmd shim, which Node cannot spawn with shell:false on Windows. Run its + // adjacent JavaScript entry point with this process's Node instead of opening a shell. + const shim = path.win32.join(dir, 'codex.cmd'); + const script = path.win32.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); + if (exists(shim) && exists(script)) return { file: process.execPath, args: [script] }; + } + return { file: command, args: [] }; +} + export async function fetchCodexModels(command: string, cwd: string, runner: ModelCommandRunner = runModelCommand): Promise { try { - const result = await runner(command, ['debug', 'models'], { cwd, timeout: MODEL_COMMAND_TIMEOUT_MS, maxBuffer: MODEL_DETAILS_MAX_BUFFER }); + const invocation = runner === runModelCommand ? resolveCodexModelCommand(command) : { file: command, args: [] }; + const result = await runner(invocation.file, [...invocation.args, 'debug', 'models'], { cwd, timeout: MODEL_COMMAND_TIMEOUT_MS, maxBuffer: MODEL_DETAILS_MAX_BUFFER }); const listed: unknown = JSON.parse(result.stdout)?.models; if (!Array.isArray(listed)) throw new Error('no models'); const models: ModelOption[] = []; diff --git a/tests/models.test.ts b/tests/models.test.ts index 02dac48d1..c863d77db 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { isValidGrokModel, isValidMuseModel, isValidOpenCodeModel } from '../src/shared/providers.js'; -import { MODEL_LISTERS, createModelCatalogue, fetchCodexModels, fetchCursorModels, fetchGrokModels, fetchOpenCodeModels, type ModelCommandRunner } from '../src/server/models.js'; +import { MODEL_LISTERS, createModelCatalogue, fetchCodexModels, fetchCursorModels, fetchGrokModels, fetchOpenCodeModels, resolveCodexModelCommand, type ModelCommandRunner } from '../src/server/models.js'; import { AGENT_PROVIDERS, PROVIDER_META } from '../src/shared/providers.js'; test('OpenCode model ids require provider/model and reject whitespace or control characters', () => { @@ -94,6 +94,22 @@ test('Codex catalogue errors do not expose command output', async () => { await assert.rejects(fetchCodexModels('codex', '/project', async () => ({ stdout: '{"models":"none"}', stderr: '' })), /unavailable/i); }); +test('Codex model lookup runs an npm-installed Windows CLI without a shell', () => { + const npm = String.raw`C:\Users\test\AppData\Roaming\npm`; + const script = String.raw`C:\Users\test\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js`; + const files = new Set([String.raw`C:\Users\test\AppData\Roaming\npm\codex.cmd`, script]); + const exists = (file: string) => files.has(file); + assert.deepEqual(resolveCodexModelCommand('codex', 'win32', `C:\\Tools;${npm}`, exists), { file: process.execPath, args: [script] }); + assert.deepEqual(resolveCodexModelCommand(String.raw`C:\Users\test\AppData\Roaming\npm\codex.cmd`, 'win32', '', exists), { file: process.execPath, args: [script] }); + assert.deepEqual(resolveCodexModelCommand('codex', 'linux', '', exists), { file: 'codex', args: [] }); +}); + +test('Codex model lookup prefers a native Windows executable', () => { + const executable = String.raw`C:\Tools\codex.exe`; + const exists = (file: string) => file === executable; + assert.deepEqual(resolveCodexModelCommand('codex', 'win32', String.raw`C:\Tools`, exists), { file: executable, args: [] }); +}); + test('every provider that says its CLI lists its models has a lister, and only those', () => { const listed = AGENT_PROVIDERS.filter((p) => PROVIDER_META[p].models?.catalog); assert.deepEqual(Object.keys(MODEL_LISTERS).sort(), [...listed].sort());