From 0d858c85db01f8ad0f24f7fb52351deb3ca39f51 Mon Sep 17 00:00:00 2001 From: Gan-Xing <599153574@qq.com> Date: Sun, 10 May 2026 21:31:30 +0000 Subject: [PATCH] park side packages, expand native api, and hide /agent --- README.md | 74 +- docs/todo/codex-gateway.md | 10 +- docs/todo/codex-native-api.md | 10 +- docs/todo/mission-control.md | 8 + docs/todo/roadmap.md | 41 +- docs/usage/weixin-slash-commands.md | 45 +- packages/codex-gateway/README.md | 7 + packages/codex-native-api/README.md | 344 +- packages/codex-native-api/package.json | 40 +- packages/codex-native-api/src/cli.ts | 90 + packages/codex-native-api/src/cli_options.ts | 105 + .../codex-native-api/src/codex_app_client.ts | 4325 +++++++++++++++++ .../codex-native-api/src/daemon_manager.ts | 1304 +++++ .../codex-native-api/src/default_provider.ts | 596 +++ packages/codex-native-api/src/index.ts | 21 +- .../codex-native-api/src/native_api_server.ts | 529 +- .../src/native_api_service.ts | 30 +- .../codex-native-api/src/native_runtime.ts | 2 + packages/codex-native-api/src/provider.ts | 6 + .../codex-native-api/src/sequenced_stderr.ts | 54 + .../test/package_exports.test.ts | 653 ++- packages/codex-native-api/tsconfig.json | 19 +- packages/mission-control/README.md | 7 + scripts/test.mjs | 2 + src/core/bridge_coordinator.ts | 60 +- src/core/command_availability.ts | 6 + src/i18n/index.ts | 2 + src/runtime/weixin_bridge_runtime.ts | 49 +- test/core/bridge_coordinator.test.ts | 75 + test/runtime/weixin_bridge_runtime.test.ts | 59 + 30 files changed, 8350 insertions(+), 223 deletions(-) create mode 100644 packages/codex-native-api/src/cli.ts create mode 100644 packages/codex-native-api/src/cli_options.ts create mode 100644 packages/codex-native-api/src/codex_app_client.ts create mode 100644 packages/codex-native-api/src/daemon_manager.ts create mode 100644 packages/codex-native-api/src/default_provider.ts create mode 100644 packages/codex-native-api/src/sequenced_stderr.ts create mode 100644 src/core/command_availability.ts diff --git a/README.md b/README.md index b728cf4..6260c59 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,20 @@ CodexBridge is a Codex-centered gateway for connecting multiple chat platforms t ## Current Direction - First delivery target: `WeChat + Codex` -- Future platforms: `Telegram`, additional chat transports -- Future Codex provider profiles: configuration-only OpenAI-compatible backends such as `MiniMax`, `DeepSeek`, `Qwen`, `OpenRouter`, `Kimi`, `Gemini`, and `iFlow` +- Package-side experiments are paused for now +- `packages/codex-gateway` is not under active development +- `packages/mission-control` is not under active development +- `packages/codex-native-api` is retained as the only package planned for possible future work, but it is also paused for now - Core rule: platforms are adapters, Codex stays the execution engine, and Codex thread state stays the source of truth ## Documents - [Core architecture](./docs/architecture/codexbridge-core-architecture.md) -- [Mission Control architecture](./docs/architecture/mission-control.md) - [Roadmap TODO](./docs/todo/roadmap.md) -- [Codex Gateway TODO](./docs/todo/codex-gateway.md) -- [Mission Control TODO](./docs/todo/mission-control.md) +- [Codex Native API TODO](./docs/todo/codex-native-api.md) +- [Codex Gateway TODO - paused](./docs/todo/codex-gateway.md) +- [Mission Control TODO - paused](./docs/todo/mission-control.md) +- [Mission Control architecture - historical reference](./docs/architecture/mission-control.md) - [WeChat slash command reference](./docs/usage/weixin-slash-commands.md) ## Repository Layout @@ -36,13 +39,14 @@ docs/ Project bootstrap is now focused on: -1. Landing the core session and binding model -2. Keeping platform and provider plugins independent -3. Making `WeChat + Codex` the first real implementation path +1. Keeping `WeChat + Codex` as the product center +2. Avoiding more backend/package expansion until the bridge direction is clearer +3. Treating `codex-gateway` and `mission-control` as paused workstreams +4. Keeping `codex-native-api` only as a retained future option, not as active work Current implemented bridge pieces: -- Core session routing with WeChat-friendly slash commands, including `/helps`, `/status`, `/usage`, `/login`, `/stop`, `/review`, `/agent`, `/plan`, `/skills`, `/plugins`, `/automation`, `/weibo`, `/new`, `/uploads`, `/as`, `/log`, `/todo`, `/remind`, `/note`, `/provider`, `/models`, `/model`, `/personality`, `/instructions`, `/fast`, `/threads`, `/search`, `/next`, `/prev`, `/open`, `/peek`, `/rename`, `/permissions`, `/allow`, `/deny`, `/reconnect`, `/retry`, `/restart`, and `/lang` +- Core session routing with WeChat-friendly slash commands, including `/helps`, `/status`, `/usage`, `/login`, `/stop`, `/review`, `/plan`, `/skills`, `/plugins`, `/automation`, `/weibo`, `/new`, `/uploads`, `/as`, `/log`, `/todo`, `/remind`, `/note`, `/provider`, `/models`, `/model`, `/personality`, `/instructions`, `/fast`, `/threads`, `/search`, `/next`, `/prev`, `/open`, `/peek`, `/rename`, `/permissions`, `/allow`, `/deny`, `/reconnect`, `/retry`, `/restart`, and `/lang` - `/open` now rebinds the current scope and immediately returns a short recent-turn preview, so users can resume an old thread with one command instead of calling `/peek` first - File-backed JSON repositories for persistent bridge state - WeChat platform skeleton for Hermes-compatible iLink config loading, QR account state reuse, inbound DM normalization, long-poll client/poller wiring, context-token persistence, text chunking, and outbound text/typing delivery @@ -50,6 +54,12 @@ Current implemented bridge pieces: - WeChat runtime wiring that feeds poll events into the shared bridge coordinator and sends responses back through the WeChat transport - OpenAI-compatible Responses adapter for non-OpenAI Chat Completions providers, including compact fallback, SSE stream translation, tool-call repair, provider/model capability rules, and gated live-provider smoke tests +Package workstream status: + +- `packages/codex-gateway`: paused +- `packages/mission-control`: paused +- `packages/codex-native-api`: retained for later only; currently paused + ## OpenAI-Compatible Provider Validation Live provider validation is opt-in so normal tests do not spend API quota. @@ -87,13 +97,6 @@ Recommended entrypoints: /review /rv /review base main -/agent 帮我检查当前项目测试并修复失败项 -/agent confirm -/agent show 1 -/agent result 1 -/agent result 1 file -/agent send 1 -/agent retry 1 /plan /pl /plan on @@ -250,44 +253,6 @@ Examples: `/plan on` enables native `plan` mode for later turns in the current bridge session. `/plan off` restores the native `default` collaboration mode. This is a mode toggle, not an approval flow. -### `/agent` and `/ag` - -Create a confirmed background Agent job for deeper multi-step work. - -Examples: - -```text -/agent 帮我研究并实现一个小功能,然后测试 -/agent confirm -/agent edit 改成只做方案,不修改代码 -/agent list -/agent show 1 -/agent result 1 -/agent result 1 file -/agent send 1 -/agent stop 1 -/agent retry 1 -/agent del 1 -``` - -The experimental workflow is hybrid but Codex-first: Codex app-server is preferred for planning, execution, and verification so an existing Codex subscription is used by default. OpenAI Agents SDK is only a fallback when an Agent API key is configured and the Codex normalization/verifier path is unavailable. Long text results can be paged with `/agent result ` or exported as a phone-friendly TXT attachment with `/agent result file`. Jobs with generated attachments keep artifact records, so `/agent send ` can resend the file if WeChat rate-limits the first delivery. If both Codex normalization and Agents SDK are unavailable, local fallback still creates a usable draft and verifier path. - -Agent planner/verifier configuration: - -```bash -# OpenAI default -OPENAI_API_KEY=... -CODEXBRIDGE_AGENT_MODEL=gpt-5.5 - -# OpenAI-compatible provider, for example MiniMax -CODEXBRIDGE_AGENT_API_KEY=... -CODEXBRIDGE_AGENT_BASE_URL=https://api.minimaxi.com/v1 -CODEXBRIDGE_AGENT_MODEL=MiniMax-M2.7 -CODEXBRIDGE_AGENT_API=chat_completions -``` - -`CODEXBRIDGE_AGENT_API_KEY` takes precedence over `OPENAI_API_KEY` for the fallback Agents SDK path. The default path still uses Codex app-server first. When `CODEXBRIDGE_AGENT_BASE_URL` or `OPENAI_BASE_URL` is set, the bridge defaults Agents SDK calls to Chat Completions compatibility mode unless `CODEXBRIDGE_AGENT_API=responses` is explicitly set. - OpenAI-compatible runtime adapter: - CodexBridge can expose non-OpenAI providers through a local Responses adapter while Codex app-server still talks to a Responses-shaped endpoint. @@ -359,7 +324,6 @@ Best-practice rule: - use `/helps` for command discovery - use `/login` and `/login list` to manage the host Codex account pool before switching accounts with `/login ` - use `/review`, `/review base `, or `/review commit ` when you want a native Codex code review without changing the current thread binding -- use `/agent ` when the task needs planning, background execution, verifier checks, and one automatic retry before delivery - use `/plan on` when you want later turns in the current session to prioritize planning first, and `/plan off` when you want to restore the default collaboration mode - use `/skills` to inspect what Codex can currently see in the active project, `/skills search ` for related matches, and `/skills show ` to understand what a skill is for before enabling or disabling it - use `/auto add ...` in natural language first; the bridge will draft a schedule, then `/auto confirm` creates the job diff --git a/docs/todo/codex-gateway.md b/docs/todo/codex-gateway.md index 746bc46..7f42a20 100644 --- a/docs/todo/codex-gateway.md +++ b/docs/todo/codex-gateway.md @@ -3,6 +3,14 @@ This document tracks the implementation backlog for `@codexbridge/codex-gateway`. +## Status + +This workstream is currently paused. + +Keep this document as a historical backlog and reference. It should not be +treated as an active implementation queue unless the product direction changes +again. + It is the execution-oriented companion to: - `docs/architecture/codexbridge-core-architecture.md` @@ -60,7 +68,7 @@ Avoid frequent edits here unless the change is truly cross-cutting: - `README.md` - `package.json` -## Current Active Focus +## Last Active Focus - [x] Stop treating OpenRouter live smoke as an active Phase 4 blocker; defer it until credentials are available again - [x] Keep new provider onboarding config-first and capability-driven instead of adding one-off provider classes diff --git a/docs/todo/codex-native-api.md b/docs/todo/codex-native-api.md index ba8a540..fc68e18 100644 --- a/docs/todo/codex-native-api.md +++ b/docs/todo/codex-native-api.md @@ -3,6 +3,14 @@ This document tracks the implementation backlog for the `track/codex-native-api` workstream. +## Status + +This workstream is retained as the only package-level backend candidate still +kept for possible future development, but it is currently paused. + +Keep this document as a preserved backlog and reference point. It should not be +treated as an active implementation queue right now. + It is the execution-oriented companion to: - `docs/architecture/codexbridge-core-architecture.md` @@ -44,7 +52,7 @@ It should **not** own: - user-facing slash-command policy unless a command is explicitly added later - commercial billing, top-up, or payment workflows -## Track Branch +## Historical Track Branch Primary long-lived branch for this workstream: diff --git a/docs/todo/mission-control.md b/docs/todo/mission-control.md index f7e685f..08b39f9 100644 --- a/docs/todo/mission-control.md +++ b/docs/todo/mission-control.md @@ -3,6 +3,14 @@ This document tracks the implementation backlog for `@codexbridge/mission-control`. +## Status + +This workstream is currently paused. + +Keep this document as a historical backlog and reference. It should not be +treated as an active implementation queue unless the product direction changes +again. + It is the execution-oriented companion to: - `docs/architecture/mission-control.md` diff --git a/docs/todo/roadmap.md b/docs/todo/roadmap.md index 4796572..1fd277e 100644 --- a/docs/todo/roadmap.md +++ b/docs/todo/roadmap.md @@ -5,18 +5,25 @@ This document is the top-level roadmap for CodexBridge. It should stay short and stable. Detailed implementation checklists belong in feature-specific TODO files instead of being expanded here. +## Current Status + +- `packages/codex-gateway`: paused +- `packages/mission-control`: paused +- `packages/codex-native-api`: retained for possible future work, but currently paused +- active package-level development is intentionally on hold until the bridge direction is narrowed again + ## Immutable Target -CodexBridge 的目标是通过微信稳定暴露 Codex 原生能力,并在桥接层扩展微信命令和个人助理工作流;`@codexbridge/codex-gateway` 的目标是让 Codex 稳定接入多模型来源。 +CodexBridge 的目标是通过微信稳定暴露 Codex 原生能力,并在桥接层扩展微信命令和个人助理工作流。 This target is stable. The route, package layout, and branch strategy may change, but every new task should be judged against whether it advances this target. -## Working Branch Model +## Historical Package Branch Model -The repository should be developed through long-lived workstream branches, not -one short-lived branch per tiny feature: +These long-lived workstream branches remain as historical references for the +paused package work: ```text main @@ -35,10 +42,9 @@ track/codex-native-api Rules: - `main` should stay mergeable and reasonably stable -- `track/codex-gateway` should primarily own Codex Gateway protocol/package work -- `track/mission-control` should primarily own Mission Control runtime/package work -- `track/codex-native-api` should primarily own Codex-native API exposure, localhost facade design, and isolated side-task routing policy -- `track/codex-native-api` should also preserve a clean extraction path toward a reusable package if the native-api boundary stabilizes +- `track/codex-gateway` is paused +- `track/mission-control` is paused +- `track/codex-native-api` is retained for possible future resumption, but it is also paused right now - low-level checklist churn should stay out of this file - avoid frequent concurrent edits to shared files such as: - `docs/todo/roadmap.md` @@ -49,17 +55,17 @@ Rules: Use these files for detailed implementation work: -- [Codex Gateway TODO](./codex-gateway.md) -- [Mission Control TODO](./mission-control.md) -- [Codex Native API TODO](./codex-native-api.md) +- [Codex Native API TODO](./codex-native-api.md) - retained for later +- [Codex Gateway TODO](./codex-gateway.md) - paused +- [Mission Control TODO](./mission-control.md) - paused Architecture references: - [Core architecture](../architecture/codexbridge-core-architecture.md) -- [Mission Control architecture](../architecture/mission-control.md) - [Codex Native API architecture](../architecture/codex-native-api.md) +- [Mission Control architecture](../architecture/mission-control.md) - historical reference -Reference sources currently tracked: +Reference sources currently tracked for later reuse: - `reference/codex-gateway` for LiteLLM, codex-proxy, open-responses, and llm-rosetta - `reference/symphony` as the orchestration reference mirror when available @@ -72,7 +78,6 @@ Reference sources currently tracked: Already landed and no longer part of the active detailed backlog: - `/review` for uncommitted changes and base-branch review -- `/agent` experimental Codex-first hybrid background jobs with draft-confirm, full-access Codex execution, verifier checks, and retry - `/plan` session-level native planning mode toggle - `/skills`, `/apps`, `/plugins`, and `/mcp` visibility and control surfaces - `/automation` draft-confirm flow and WeChat delivery-oriented scheduling @@ -118,6 +123,8 @@ affect the product as a whole, not just one package. ### P2: Codex Gateway summary +Current status: paused. + - [x] Prove end-to-end profile switching across OpenAI-native, DeepSeek, MiniMax, Qwen, and OpenRouter without changing WeChat UX - [ ] Revisit standalone launcher publication only if product direction changes; it is intentionally internal-only for now - [ ] Keep deferred OpenRouter live validation clearly separated from completed package-local protocol work @@ -128,6 +135,8 @@ Detailed checklist: ### P2: Codex Native API summary +Current status: retained for possible future work, but currently paused. + - [ ] Expose the logged-in local Codex runtime as a localhost Responses-first API without changing the main WeChat chat flow - [ ] Route isolated side tasks to Codex Native API while keeping full conversation tasks on the current Codex app-server path - [ ] Keep external provider APIs as fallback/optional paths rather than the primary route for isolated subtasks @@ -139,6 +148,8 @@ Detailed checklist: ### P2: Mission Control summary +Current status: paused. + - [x] Preserve Symphony's real core ideas: workflow-owned policy, single orchestrator authority, stable workspace identity, continuation retries after normal exit, and handoff/wait-user states - [x] Add workflow loading, workpad persistence, workspace isolation, and bounded run/verify/repair loop - [x] Keep WeChat as the control and notification entrypoint while Mission Control owns orchestration @@ -151,7 +162,7 @@ Detailed checklist: - [ ] Do not prioritize new bridge-only slash commands ahead of high-value native Codex parity work unless the native layer is unavailable - [ ] Do not add bridge-only aliases when existing commands already cover the user need well enough, such as `/open` for resume-style continuation or `/status` for cwd/session inspection -- [ ] Do not let this file become a second detailed implementation log for Codex Gateway or Mission Control +- [ ] Do not let this file become a second detailed implementation log for paused package workstreams ## Later Direction: Telegram Runtime diff --git a/docs/usage/weixin-slash-commands.md b/docs/usage/weixin-slash-commands.md index ae372ea..149546c 100644 --- a/docs/usage/weixin-slash-commands.md +++ b/docs/usage/weixin-slash-commands.md @@ -29,7 +29,7 @@ It borrows the most useful CLI help conventions while staying chat-friendly: - `/helps` shows the full command catalog - `/helps ` shows one command in detail - every slash command supports `-h`, `--help`, `-help`, and `-helps` -- every slash command also supports a short alias such as `/h`, `/st`, `/us`, `/lg`, `/sp`, `/rv`, `/ag`, `/sk`, `/n`, `/up`, `/as`, `/td`, `/rmd`, `/nt`, `/pd`, `/ms`, `/m`, `/psn`, `/ins`, `/th`, `/se`, `/nx`, `/pv`, `/o`, `/pk`, `/rn`, `/perm`, `/al`, `/dn`, `/rc`, `/rt`, and `/rs` +- every slash command also supports a short alias such as `/h`, `/st`, `/us`, `/lg`, `/sp`, `/rv`, `/sk`, `/n`, `/up`, `/as`, `/td`, `/rmd`, `/nt`, `/pd`, `/ms`, `/m`, `/psn`, `/ins`, `/th`, `/se`, `/nx`, `/pv`, `/o`, `/pk`, `/rn`, `/perm`, `/al`, `/dn`, `/rc`, `/rt`, and `/rs` - `/lang` and `/lang ` to switch reply language for this scope (higher priority than env). - thread browsing is index-first on WeChat, so `/open 2` is preferred over copying raw thread ids - before the bot reaches roughly 10 consecutive replies, the user can proactively send a single `/` to break the WeChat-side frequency limit; it is swallowed by the bridge, not forwarded to Codex, and does not create a reply @@ -48,12 +48,6 @@ It borrows the most useful CLI help conventions while staying chat-friendly: /rv /review base main /review commit HEAD~1 -/agent 帮我检查当前项目测试并修复失败项 -/agent confirm -/agent show 1 -/agent result 1 -/agent result 1 file -/agent send 1 /skills /sk /skills search 新闻 @@ -222,43 +216,6 @@ Examples: /review 重点看 Agent 状态流转相关改动的回归风险 ``` -### `/agent` and `/ag` - -Create a confirmed background Agent job for deeper multi-step work. - -- `/agent ` creates a draft instead of executing immediately -- `/agent confirm` confirms the draft and queues the background job -- `/agent edit ` refines the current draft by merging the new instruction back into the existing draft -- `/agent list` lists jobs for the current WeChat chat -- `/agent show ` shows the plan, status, attempts, and verifier result -- `/agent result ` shows the full text result in pages -- `/agent result file` exports the full text result as a phone-friendly TXT attachment -- `/agent send ` resends saved attachments for a completed job -- `/agent stop ` requests stop for the job -- `/agent retry ` queues a failed/stopped/completed job again -- `/agent rename ` updates the local job title -- `/agent del <index>` deletes the job record - -Examples: - -```text -/agent 检查当前项目测试并修复失败项 -/ag 写一份 CodexBridge Agent 接入方案 -/agent confirm -/agent edit 只做方案,不改代码 -/agent list -/agent show 1 -/agent result 1 -/agent result 1 2 -/agent result 1 file -/agent send 1 -/agent stop 1 -/agent retry 1 -/agent del 1 -``` - -Implementation note: planning, draft editing, and verification reuse the Provider currently bound to the WeChat chat first. Background execution continues on the detached task session created from that same Provider profile. Long text results are kept separately from the preview, so `/agent result <index>` can page through the full answer and `/agent result <index> file` can export it as phone-friendly TXT. Jobs with generated attachments keep artifact records, so `/agent send <index>` can resend the file if WeChat rate-limits the first delivery. - ### `/as`, `/log`, `/todo`, `/remind`, and `/note` Save personal assistant records from WeChat. diff --git a/packages/codex-gateway/README.md b/packages/codex-gateway/README.md index 99338bc..1580377 100644 --- a/packages/codex-gateway/README.md +++ b/packages/codex-gateway/README.md @@ -2,6 +2,13 @@ Internal package for the Codex Gateway protocol layer. +## Status + +Development for this package is currently paused. + +It remains in the repository as historical/internal reference material, but it +is not part of the active roadmap at this time. + Current release policy: - keep this package `private: true` diff --git a/packages/codex-native-api/README.md b/packages/codex-native-api/README.md index 3f58bfd..563ffbf 100644 --- a/packages/codex-native-api/README.md +++ b/packages/codex-native-api/README.md @@ -1,20 +1,344 @@ -# @codexbridge/codex-native-api +# codex-native-api -Internal workspace package for the Codex-only localhost API facade over the -logged-in Codex app-server runtime. +`codex-native-api` exposes the logged-in Codex runtime on the current machine as +an HTTP API. It is local-first by default, and it can also install itself as a +long-running background service on macOS, Linux, and Windows. -Current extraction status: +## Status -- first extraction shape only +This package is retained as the only package-level backend candidate still kept +in the repository, but it is not under active development right now. + +Treat the current README and package surface as a preserved reference point for +possible future work, not as an actively advancing roadmap. + +This package is for one machine running its own Codex session. It is not a +hosted multi-tenant gateway. + +## What you need + +Before this package can work on a machine, that machine needs: + +- Node.js `>= 24` +- a working local `codex` CLI on `PATH`, or `CODEX_REAL_BIN` set explicitly +- a valid local Codex login at `CODEX_HOME/auth.json` or `~/.codex/auth.json` + +If those are missing, the API server can start, but real requests will not be +usable. + +## What it does + +- starts a local HTTP API over the logged-in Codex runtime +- defaults to `127.0.0.1` +- can be explicitly exposed on `0.0.0.0` +- auto-detects local Codex auth and local `codex` CLI +- supports `/v1/responses` builtin `web_search` requests +- preserves recovered Codex tool transcript items in `response.output` when available +- can install a background daemon using the host service manager + - macOS: `launchd` + - Linux: `systemd --user` + - Windows: Scheduled Task + +## What it does not do + +- it does not bundle WeChat or Telegram transport +- it does not provide slash-command UX +- it does not turn one login into a shared hosted cloud service automatically +- it does not expose OpenAI-style function tools yet +- `/v1/chat/completions` still does not support tool declarations + +## Install + +```bash +npm install codex-native-api +``` + +You can also run it without a project install after publish: + +```bash +npx codex-native-api --port 4242 +``` + +## Quick start + +Start a local API on `127.0.0.1:4242`: + +```bash +npx codex-native-api --port 4242 +``` + +Equivalent explicit form: + +```bash +npx codex-native-api serve --port 4242 +``` + +The process prints the bound URL, resolved auth path, and access scope on +startup. + +## Verify it works + +Health check: + +```bash +curl http://127.0.0.1:4242/v1/health +``` + +Model list: + +```bash +curl http://127.0.0.1:4242/v1/models +``` + +Minimal Responses API request: + +```bash +curl http://127.0.0.1:4242/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gpt-5.5", + "input": "Say hello from codex-native-api." + }' +``` + +Builtin `web_search` request: + +```bash +curl http://127.0.0.1:4242/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gpt-5.5", + "input": "What changed in the latest Codex release?", + "tools": [ + { "type": "web_search" } + ], + "tool_choice": "web_search" + }' +``` + +Minimal Chat Completions request: + +```bash +curl http://127.0.0.1:4242/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gpt-5.5", + "messages": [ + { "role": "user", "content": "Say hello from codex-native-api." } + ] + }' +``` + +## Tool behavior + +Current tool surface is intentionally narrow: + +- `/v1/responses` supports the built-in `web_search` tool +- legacy aliases such as `web_search_preview` are normalized automatically +- `tool_choice` supports `auto`, `none`, `required`, explicit `web_search`, and + `allowed_tools` containing only `web_search` +- terminal `response.output` can include recovered `function_call`, + `function_call_output`, and final assistant message items from the Codex turn +- function tools and custom external tools are rejected for now + +When a request declares only `web_search`, the runtime is instructed to use +that builtin capability and not fall back to shell commands, file edits, MCP +tools, plugins, or image generation as substitutes. + +This means the request-side builtin tool surface is narrow, but the response +side transcript is richer than plain text-only output. + +## Public bind + +Default mode is loopback only. Public exposure must be explicit: + +```bash +npx codex-native-api --port 4242 --public --auth-token your-token +``` + +You can also use `--host 0.0.0.0`, but `--public` is the intended shortcut. + +If you expose the API publicly, understand the security model: + +- requests are executed through the logged-in Codex account on that machine +- anyone who can reach the port can use that account unless you set + `--auth-token` +- this is a machine-level trust boundary, not an account-isolated SaaS boundary + +When `--auth-token` is set, send it as: + +```bash +Authorization: Bearer your-token +``` + +## CLI reference + +### Foreground server + +```bash +npx codex-native-api [serve] [options] +``` + +Serve options: + +- `--port <number>`: bind port +- `--host <host>`: explicit bind host +- `--public`: shorthand for public bind when `--host` is not set +- `--auth-path <path>`: explicit auth file path +- `--auth-token <token>`: bearer token required for `/v1/*` +- `--cwd <path>`: default working directory for turns +- `--provider-profile <id>`: explicit provider profile id +- `--default-model <model>`: default model id + +### Background daemon + +```bash +npx codex-native-api daemon <subcommand> [options] +``` + +Daemon subcommands: + +- `install`: install and start the service for the current user +- `start`: start the installed service +- `stop`: stop the installed service +- `restart`: restart the installed service +- `status`: show service-manager status +- `logs`: print service logs +- `uninstall`: remove the installed service + +Useful daemon flags: + +- `--port <number>` +- `--host <host>` +- `--public` +- `--auth-token <token>` +- `--auth-path <path>` +- `--cwd <path>` +- `--provider-profile <id>` +- `--default-model <model>` +- `--restart-sec <seconds>`: supervisor restart delay +- `--codex-home <path>`: override `CODEX_HOME` +- `--codex-bin <path>`: override `CODEX_REAL_BIN` +- `--launch-cmd <command>`: launcher used when provider autolaunch is enabled +- `--autolaunch` +- `--no-autolaunch` +- `--dry-run`: only for `daemon install`, prints the generated service files +- `--follow`: for `daemon logs` +- `--lines <n>`: for `daemon logs` + +## Recommended daemon flows + +Local-only long-running service: + +```bash +npx codex-native-api daemon install --port 4242 +``` + +Public long-running service: + +```bash +npx codex-native-api daemon install --port 4242 --public --auth-token your-token +``` + +Inspect generated files before changing the machine: + +```bash +npx codex-native-api daemon install --port 4242 --dry-run +``` + +Read logs: + +```bash +npx codex-native-api daemon logs --follow +``` + +## Platform behavior + +### macOS + +- uses `launchd` +- installs a plist at + `~/Library/LaunchAgents/com.codexbridge.codex-native-api.plist` +- starts on user login and restarts after crashes + +### Linux + +- uses `systemd --user` +- installs a unit at `~/.config/systemd/user/codex-native-api.service` +- uses `Restart=always` +- attempts `loginctl enable-linger "$USER"` so the service can remain available + after logout when the host allows it + +### Windows + +- uses a per-user Scheduled Task +- writes configuration under `%APPDATA%\codex-native-api\` +- writes logs under `%USERPROFILE%\.codex-native-api\logs\` +- starts after user logon, not as a machine-wide system service by default + +## Generated files + +After `daemon install`, the stable edit points are: + +- macOS/Linux env file: `~/.config/codex-native-api/service.env` +- Windows env file: `%APPDATA%\codex-native-api\service.env` +- macOS/Linux logs: `~/.codex-native-api/logs/` +- Windows logs: `%USERPROFILE%\.codex-native-api\logs\` + +The env file is where you adjust bind address, port, auth token, default cwd, +and Codex path settings after installation. Restart the daemon after editing it. + +## Programmatic usage + +```ts +import { CodexNativeApiService } from 'codex-native-api'; + +const service = new CodexNativeApiService({ + port: 4242, +}); + +await service.start(); +console.log(service.baseUrl); +``` + +Default programmatic behavior: + +- one built-in `openai-native` provider profile is created automatically +- local Codex auth is resolved automatically +- local `codex` CLI is resolved automatically + +## Troubleshooting + +If `/v1/health` is `503` or requests fail: + +1. Verify the machine has a valid Codex login: + `ls ~/.codex/auth.json` +2. Verify `codex` is available: + `which codex` +3. Check daemon logs: + `npx codex-native-api daemon logs --follow` +4. If running publicly, verify the bearer token you are sending matches the one + in the service env file. + +If Linux daemon install succeeds but the service disappears after logout, check: + +```bash +loginctl show-user "$USER" -p Linger +``` + +If Windows daemon exists but does not start before login, that is expected for +the default per-user Scheduled Task model. + +## Current package shape + +- public core preview - single package, no runtime/server split yet -- internal-only release channel +- zero runtime dependencies outside Node builtins -Current public surface: +## Public surface - `CodexNativeRuntime` - `CodexNativeApiServer` - `CodexNativeApiService` - `InMemoryCodexNativeApiContinuationRegistry` - -This package intentionally does not own WeChat transport, slash-command UX, or -external provider gateway policy. +- auth helpers and provider-facing native API contract types diff --git a/packages/codex-native-api/package.json b/packages/codex-native-api/package.json index 38fa082..640ee33 100644 --- a/packages/codex-native-api/package.json +++ b/packages/codex-native-api/package.json @@ -1,9 +1,35 @@ { - "name": "@codexbridge/codex-native-api", - "version": "0.0.0", - "private": true, + "name": "codex-native-api", + "version": "0.1.0", + "private": false, "type": "module", - "description": "Localhost API facade over the logged-in Codex app-server runtime.", + "description": "Local-first API facade and cross-platform daemon wrapper over the logged-in Codex app-server runtime.", + "keywords": [ + "codex", + "localhost", + "api", + "responses", + "chat-completions", + "openai-compatible", + "daemon", + "systemd", + "launchd" + ], + "repository": { + "type": "git", + "url": "https://github.com/Gan-Xing/CodexBridge.git", + "directory": "packages/codex-native-api" + }, + "homepage": "https://github.com/Gan-Xing/CodexBridge/tree/main/packages/codex-native-api", + "bugs": { + "url": "https://github.com/Gan-Xing/CodexBridge/issues" + }, + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "codex-native-api": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -17,10 +43,16 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsx --test test/*.test.ts" }, "engines": { "node": ">=24" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" } } diff --git a/packages/codex-native-api/src/cli.ts b/packages/codex-native-api/src/cli.ts new file mode 100644 index 0000000..4768386 --- /dev/null +++ b/packages/codex-native-api/src/cli.ts @@ -0,0 +1,90 @@ +import path from 'node:path'; +import { CodexNativeApiService } from './native_api_service.js'; +import { parseServeCliArgs, normalizeServeHost, isLoopbackHost } from './cli_options.js'; +import { runDaemonCommand, runDaemonSupervisor } from './daemon_manager.js'; + +async function main(argv: string[] = process.argv.slice(2)) { + const command = String(argv[0] ?? '').trim().toLowerCase(); + if (command === 'daemon') { + await runDaemonCommand(argv.slice(1)); + return; + } + if (command === 'daemon-supervisor') { + await runDaemonSupervisor(argv.slice(1)); + return; + } + if (command === 'serve') { + await serve(argv.slice(1)); + return; + } + await serve(argv); +} + +async function serve(argv: string[]): Promise<void> { + const options = parseCliArgs(argv); + const defaultCwd = path.resolve(options.cwd ?? process.cwd()); + const host = normalizeServeHost(options); + const service = new CodexNativeApiService({ + env: process.env, + host, + port: options.port, + authPath: options.authPath, + authToken: options.authToken, + defaultCwd, + providerProfileId: options.providerProfileId, + defaultModel: options.defaultModel, + }); + + let stopped = false; + const stop = async (signal: string) => { + if (stopped) { + return; + } + stopped = true; + process.stdout.write(`stopping: ${signal}\n`); + await service.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => { void stop('SIGINT'); }); + process.on('SIGTERM', () => { void stop('SIGTERM'); }); + + const binding = await service.start(); + process.stdout.write('codex-native-api started\n'); + process.stdout.write(`base_url: ${service.baseUrl}\n`); + process.stdout.write(`default_cwd: ${defaultCwd}\n`); + process.stdout.write(`provider_profile: ${binding.providerProfileId}\n`); + process.stdout.write(`provider_kind: ${binding.providerKind}\n`); + process.stdout.write(`provider_display_name: ${binding.providerDisplayName}\n`); + process.stdout.write(`auth_path: ${binding.authPath ?? 'none'}\n`); + process.stdout.write(`access_scope: ${isLoopbackHost(host) ? 'localhost' : 'public'}\n`); + + if (!isLoopbackHost(host) && !options.authToken) { + process.stderr.write( + 'warning: public bind enabled without --auth-token; anyone who can reach this port can use your logged-in Codex runtime.\n', + ); + } + + await new Promise<void>(() => {}); +} + +const parseCliArgs = parseServeCliArgs; + +if (import.meta.url === `file://${process.argv[1]}`) { + void main().catch((error) => { + process.stderr.write(`${formatCliError(error)}\n`); + process.exit(1); + }); +} + +function formatCliError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return String(error); +} + +export { + main, + parseCliArgs, +}; diff --git a/packages/codex-native-api/src/cli_options.ts b/packages/codex-native-api/src/cli_options.ts new file mode 100644 index 0000000..d9116bd --- /dev/null +++ b/packages/codex-native-api/src/cli_options.ts @@ -0,0 +1,105 @@ +export interface ServeCliOptions { + host: string | null; + port: number | null; + authPath: string | null; + authToken: string | null; + cwd: string | null; + providerProfileId: string | null; + defaultModel: string | null; + publicBind: boolean; +} + +export function parseServeCliArgs(args: string[]): ServeCliOptions { + const options: ServeCliOptions = { + host: null, + port: null, + authPath: null, + authToken: null, + cwd: null, + providerProfileId: null, + defaultModel: null, + publicBind: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const next = args[index + 1]; + if ((arg === '--host' || arg === '-h') && next) { + options.host = next; + index += 1; + continue; + } + if (arg === '--public') { + options.publicBind = true; + continue; + } + if ((arg === '--port' || arg === '-p') && next) { + options.port = parsePort(next); + index += 1; + continue; + } + if (arg === '--auth-path' && next) { + options.authPath = next; + index += 1; + continue; + } + if (arg === '--auth-token' && next) { + options.authToken = next; + index += 1; + continue; + } + if (arg === '--cwd' && next) { + options.cwd = next; + index += 1; + continue; + } + if (arg === '--provider-profile' && next) { + options.providerProfileId = next; + index += 1; + continue; + } + if (arg === '--default-model' && next) { + options.defaultModel = next; + index += 1; + continue; + } + } + + return options; +} + +export function parsePort(value: string): number | null { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +export function parseOptionalSeconds(value: string | null | undefined): number | null { + const parsed = Number.parseFloat(String(value ?? '').trim()); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +export function parseOptionalBoolean(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') { + return value; + } + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) { + return fallback; + } + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +export function normalizeServeHost(options: Pick<ServeCliOptions, 'host' | 'publicBind'>): string | null { + return options.host ?? (options.publicBind ? '0.0.0.0' : null); +} + +export function isLoopbackHost(value: string | null): boolean { + const normalized = String(value ?? '').trim().toLowerCase(); + return normalized === '' || normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'; +} diff --git a/packages/codex-native-api/src/codex_app_client.ts b/packages/codex-native-api/src/codex_app_client.ts new file mode 100644 index 0000000..216daae --- /dev/null +++ b/packages/codex-native-api/src/codex_app_client.ts @@ -0,0 +1,4325 @@ +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { writeSequencedStderrLine } from './sequenced_stderr.js'; +import { readCodexAccountIdentity } from './auth_state.js'; +import type { + ProviderAppInfo, + ProviderApprovalRequest, + ProviderMcpServerStatus, + ProviderMcpOauthLoginResult, + ProviderPluginDetail, + ProviderPluginInstallResult, + ProviderPluginLoadError, + ProviderPluginMarketplace, + ProviderPluginsListResult, + ProviderPluginSummary, + ProviderSkillError, + ProviderSkillInfo, + ProviderPluginAppSummary, + ProviderPluginSkillSummary, + ProviderSkillsListResult, + ProviderSkillToolDependency, + ProviderUsageReport, + ProviderThreadListResult, + ProviderThreadGoal, + ProviderResponseItem, + ProviderThreadStartResult, + ProviderThreadSummary, + ProviderTurnProgress, + ProviderTurnResult, +} from './provider.js'; + +const APP_SERVER_CONNECT_TIMEOUT_MS = 20_000; + +interface CodexAppLogger { + debug?: (message: string) => void; + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +} + +interface CodexClientInfo { + name: string; + title: string; + version: string; +} + +interface CodexModelInfo { + id: string; + model: string; + displayName: string; + description: string; + isDefault: boolean; + supportedReasoningEfforts: string[]; + defaultReasoningEffort: string | null; +} + +interface CodexAppRateLimitsResponse { + rateLimits?: CodexAppRateLimitSnapshot | null; + rateLimitsByLimitId?: Record<string, CodexAppRateLimitSnapshot> | null; +} + +interface CodexAppRateLimitSnapshot { + limitId?: string | null; + limitName?: string | null; + planType?: string | null; + primary?: CodexAppRateLimitWindow | null; + secondary?: CodexAppRateLimitWindow | null; + credits?: CodexAppCreditsSnapshot | null; +} + +interface CodexAppRateLimitWindow { + usedPercent?: number | null; + windowDurationMins?: number | null; + resetsAt?: number | null; +} + +interface CodexAppCreditsSnapshot { + balance?: string | null; + hasCredits?: boolean | null; + unlimited?: boolean | null; +} + +interface CodexAppSkillToolDependency { + type?: string | null; + value?: string | null; + command?: string | null; + description?: string | null; + transport?: string | null; + url?: string | null; +} + +interface CodexAppSkillInterface { + displayName?: string | null; + defaultPrompt?: string | null; + shortDescription?: string | null; + brandColor?: string | null; +} + +interface CodexAppSkillMetadata { + name?: string | null; + description?: string | null; + enabled?: boolean | null; + path?: string | null; + scope?: string | null; + shortDescription?: string | null; + interface?: CodexAppSkillInterface | null; + dependencies?: { + tools?: CodexAppSkillToolDependency[] | null; + } | null; +} + +interface CodexAppSkillErrorInfo { + path?: string | null; + message?: string | null; +} + +interface CodexAppSkillsListEntry { + cwd?: string | null; + errors?: CodexAppSkillErrorInfo[] | null; + skills?: CodexAppSkillMetadata[] | null; +} + +interface CodexAppPluginInterface { + brandColor?: string | null; + capabilities?: string[] | null; + category?: string | null; + defaultPrompt?: string[] | null; + developerName?: string | null; + displayName?: string | null; + longDescription?: string | null; + shortDescription?: string | null; + websiteUrl?: string | null; +} + +interface CodexAppPluginSourceLocal { + type?: 'local' | string | null; + path?: string | null; +} + +interface CodexAppPluginSourceMarketplace { + type?: 'marketplace' | string | null; + marketplaceName?: string | null; +} + +type CodexAppPluginSource = CodexAppPluginSourceLocal | CodexAppPluginSourceMarketplace | null; + +interface CodexAppPluginSummary { + id?: string | null; + name?: string | null; + installed?: boolean | null; + enabled?: boolean | null; + installPolicy?: string | null; + authPolicy?: string | null; + interface?: CodexAppPluginInterface | null; + source?: CodexAppPluginSource; +} + +interface CodexAppPluginMarketplace { + name?: string | null; + path?: string | null; + interface?: { + displayName?: string | null; + } | null; + plugins?: CodexAppPluginSummary[] | null; +} + +interface CodexAppMarketplaceLoadError { + marketplacePath?: string | null; + message?: string | null; +} + +interface CodexAppPluginListResponse { + featuredPluginIds?: string[] | null; + marketplaceLoadErrors?: CodexAppMarketplaceLoadError[] | null; + marketplaces?: CodexAppPluginMarketplace[] | null; +} + +interface CodexAppPluginAppSummary { + id?: string | null; + name?: string | null; + needsAuth?: boolean | null; + description?: string | null; + installUrl?: string | null; +} + +interface CodexAppPluginSkillInterface { + displayName?: string | null; +} + +interface CodexAppPluginSkillSummary { + name?: string | null; + path?: string | null; + description?: string | null; + enabled?: boolean | null; + shortDescription?: string | null; + interface?: CodexAppPluginSkillInterface | null; +} + +interface CodexAppPluginDetail { + summary?: CodexAppPluginSummary | null; + marketplaceName?: string | null; + marketplacePath?: string | null; + description?: string | null; + apps?: CodexAppPluginAppSummary[] | null; + mcpServers?: string[] | null; + skills?: CodexAppPluginSkillSummary[] | null; +} + +interface CodexAppPluginInstallResponse { + authPolicy?: string | null; + appsNeedingAuth?: CodexAppPluginAppSummary[] | null; +} + +interface CodexAppInfo { + id?: string | null; + name?: string | null; + description?: string | null; + installUrl?: string | null; + isAccessible?: boolean | null; + isEnabled?: boolean | null; + pluginDisplayNames?: string[] | null; + appMetadata?: { + categories?: string[] | null; + developer?: string | null; + } | null; + branding?: { + developer?: string | null; + } | null; +} + +interface CodexAppMcpServerStatus { + name?: string | null; + isEnabled?: boolean | null; + authStatus?: string | null; + resourceTemplates?: unknown[] | null; + resources?: unknown[] | null; + tools?: Record<string, unknown> | null; +} + +interface CodexAppMcpOauthLoginResponse { + authorizationUrl?: string | null; +} + +interface PendingRequest { + resolve: (value: any) => void; + reject: (error: Error) => void; +} + +interface PendingApproval { + rpcId: string; + rpcResponseId: string | number; + transportKind: 'v2_command' | 'v2_file_change' | 'v2_permissions' | 'legacy_exec' | 'legacy_apply_patch'; + request: ProviderApprovalRequest; +} + +interface ApprovedExecution { + requestId: string; + kind: ProviderApprovalRequest['kind']; + threadId: string; + turnId: string | null; + itemId: string | null; + command: string | null; + approvedAt: number; + lastSignalAt: number; + lastSignalKind: string; + signalCount: number; + completedAt: number | null; + lastObservedTurnSnapshotKey: string | null; +} + +interface ProgressState { + commentaryText: string; + finalAnswerText: string; + sawAssistantActivity: boolean; + lastAssistantActivityAt: number; +} + +interface CodexAppClientOptions { + codexCliBin: string; + codexCliArgs?: string[]; + launchCommand?: string | null; + autolaunch?: boolean; + modelCatalog?: CodexModelInfo[]; + modelCatalogMode?: 'merge' | 'overlay-only'; + enabledFeatures?: string[]; + clientInfo?: CodexClientInfo; + spawnImpl?: typeof spawn; + webSocketFactory?: (url: string) => WebSocket; + platform?: NodeJS.Platform; + logger?: CodexAppLogger; + turnPollSleep?: (ms: number) => Promise<void>; + turnPollNow?: () => number; +} + +export interface CodexTextTurnInput { + type: 'text'; + text: string; + text_elements: []; +} + +export interface CodexLocalImageTurnInput { + type: 'localImage'; + path: string; +} + +export type CodexTurnInput = CodexTextTurnInput | CodexLocalImageTurnInput; + +export class CodexAppClient extends EventEmitter { + codexCliBin: string; + + codexCliArgs: string[]; + + launchCommand: string | null; + + autolaunch: boolean; + + modelCatalog: CodexModelInfo[]; + + modelCatalogMode: 'merge' | 'overlay-only'; + + enabledFeatures: string[]; + + clientInfo: CodexClientInfo; + + spawnImpl: typeof spawn; + + webSocketFactory: (url: string) => WebSocket; + + platform: NodeJS.Platform; + + logger: CodexAppLogger; + + turnPollSleep: (ms: number) => Promise<void>; + + turnPollNow: () => number; + + child: ChildProcess | null; + + socket: WebSocket | null; + + pending: Map<string, PendingRequest>; + + pendingApprovals: Map<string, PendingApproval>; + + approvedExecutions: Map<string, ApprovedExecution>; + + requestId: number; + + port: number | null; + + connected: boolean; + + startPromise: Promise<void> | null; + + childStartError: Error | null; + + childStderrTail: string[]; + + constructor({ + codexCliBin, + codexCliArgs = [], + launchCommand = null, + autolaunch = false, + modelCatalog = [], + modelCatalogMode = 'merge', + enabledFeatures = [], + clientInfo = { + name: 'codex-native-api', + title: 'Codex Native API', + version: '0.1.0', + }, + spawnImpl = spawn, + webSocketFactory = (url) => new WebSocket(url), + platform = process.platform, + logger = createNoopLogger(), + turnPollSleep = sleep, + turnPollNow = () => Date.now(), + }: CodexAppClientOptions) { + super(); + this.codexCliBin = codexCliBin; + this.codexCliArgs = normalizeStringList(codexCliArgs); + this.launchCommand = launchCommand; + this.autolaunch = autolaunch; + this.modelCatalog = modelCatalog; + this.modelCatalogMode = modelCatalogMode; + this.enabledFeatures = normalizeFeatureList(enabledFeatures); + this.clientInfo = clientInfo; + this.spawnImpl = spawnImpl; + this.webSocketFactory = webSocketFactory; + this.platform = platform; + this.logger = logger; + this.turnPollSleep = turnPollSleep; + this.turnPollNow = turnPollNow; + + this.child = null; + this.socket = null; + this.pending = new Map(); + this.pendingApprovals = new Map(); + this.approvedExecutions = new Map(); + this.requestId = 0; + this.port = null; + this.connected = false; + this.startPromise = null; + this.childStartError = null; + this.childStderrTail = []; + } + + logDebug(event: string, payload: unknown = null): void { + try { + this.logger.debug?.(`[codex-app] ${event} ${JSON.stringify(payload)}`); + } catch { + this.logger.debug?.(`[codex-app] ${event}`); + } + } + + isConnected(): boolean { + return this.connected; + } + + async start(): Promise<void> { + if (this.connected) { + return; + } + if (this.startPromise) { + await this.startPromise; + return; + } + const task = this.startServer().finally(() => { + if (this.startPromise === task) { + this.startPromise = null; + } + }); + this.startPromise = task; + await task; + } + + async stop(): Promise<void> { + this.connected = false; + this.socket?.close(); + this.socket = null; + this.childStartError = null; + this.childStderrTail = []; + const child = this.child; + if (child && child.exitCode === null) { + await terminateChildProcess(child, this.platform).catch(() => {}); + } + this.child = null; + this.pendingApprovals.clear(); + this.approvedExecutions.clear(); + this.rejectPending(new Error('Codex app client stopped')); + } + + async listThreads({ + limit = 20, + cursor = null, + searchTerm = null, + archived = false, + }: { + limit?: number; + cursor?: string | null; + searchTerm?: string | null; + archived?: boolean | null; + } = {}): Promise<ProviderThreadListResult> { + const result: any = await this.request('thread/list', { + limit, + cursor, + sortKey: 'updated_at', + searchTerm, + archived: Boolean(archived), + }, { timeoutMs: 30_000 }); + const rows = Array.isArray(result?.data) ? result.data : []; + return { + items: rows.map(mapThreadSummary), + nextCursor: typeof result?.nextCursor === 'string' ? result.nextCursor : null, + }; + } + + async readThread(threadId: string, includeTurns = false): Promise<ProviderThreadSummary | null> { + const result: any = await this.request('thread/read', { threadId, includeTurns }, { timeoutMs: 10_000 }); + return result?.thread ? mapThread(result.thread, includeTurns) : null; + } + + async archiveThread(threadId: string): Promise<void> { + await this.request('thread/archive', { threadId }, { timeoutMs: 30_000 }); + } + + async unarchiveThread(threadId: string): Promise<void> { + await this.request('thread/unarchive', { threadId }, { timeoutMs: 30_000 }); + } + + async startThread({ + cwd = null, + title = null, + model = null, + serviceTier = null, + sandboxMode = 'workspace-write', + approvalPolicy = 'on-request', + ephemeral = null, + }: { + cwd?: string | null; + title?: string | null; + model?: string | null; + serviceTier?: string | null; + sandboxMode?: string; + approvalPolicy?: string; + ephemeral?: boolean | null; + } = {}): Promise<ProviderThreadStartResult> { + const result: any = await this.request('thread/start', { + cwd, + title, + approvalPolicy, + model, + modelProvider: null, + serviceTier, + sandbox: sandboxMode, + config: null, + serviceName: null, + baseInstructions: null, + developerInstructions: null, + personality: null, + ephemeral, + experimentalRawEvents: true, + persistExtendedHistory: false, + }, { timeoutMs: 30_000 }); + return { + threadId: String(result.thread.id), + cwd: result.cwd ? String(result.cwd) : null, + title: result.thread?.name ? String(result.thread.name) : null, + }; + } + + async resumeThread({ threadId }: { threadId: string }): Promise<unknown> { + return this.request('thread/resume', { + threadId, + cwd: null, + approvalPolicy: null, + baseInstructions: null, + developerInstructions: null, + config: null, + sandbox: null, + model: null, + modelProvider: null, + personality: null, + experimentalRawEvents: true, + persistExtendedHistory: false, + }, { timeoutMs: 30_000 }); + } + + async getThreadGoal(threadId: string): Promise<ProviderThreadGoal | null> { + const result: any = await this.request('thread/goal/get', { + threadId, + }, { timeoutMs: 10_000 }); + return mapThreadGoal(result?.goal ?? null); + } + + async setThreadGoal({ + threadId, + objective = null, + status = null, + suppressAutoTurn = false, + }: { + threadId: string; + objective?: string | null; + status?: string | null; + suppressAutoTurn?: boolean; + }): Promise<ProviderThreadGoal | null> { + const autoStartedTurnPromise = suppressAutoTurn + ? this.captureNextTurnStartedForThread(threadId, 750) + : Promise.resolve(null); + const result: any = await this.request('thread/goal/set', { + threadId, + objective, + status, + }, { timeoutMs: 15_000 }); + const autoStartedTurnId = await autoStartedTurnPromise; + if (suppressAutoTurn && autoStartedTurnId) { + try { + await this.interruptTurn({ threadId, turnId: autoStartedTurnId }); + } catch (error) { + this.logDebug('thread_goal_auto_turn_interrupt_failed', { + threadId, + turnId: autoStartedTurnId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return mapThreadGoal(result?.goal ?? null); + } + + async clearThreadGoal(threadId: string): Promise<boolean> { + const result: any = await this.request('thread/goal/clear', { + threadId, + }, { timeoutMs: 15_000 }); + return result?.cleared === true; + } + + async startTurn({ + threadId, + inputText, + input = null, + cwd = null, + model = null, + effort = null, + serviceTier = null, + personality = null, + sandboxMode = 'workspace-write', + approvalPolicy = 'on-request', + collaborationMode = 'default', + developerInstructions = '', + onProgress = null, + onTurnStarted = null, + onApprovalRequest = null, + timeoutMs = 15 * 60 * 1000, + }: { + threadId: string; + inputText: string; + input?: CodexTurnInput[] | null; + cwd?: string | null; + model?: string | null; + effort?: string | null; + serviceTier?: string | null; + personality?: string | null; + sandboxMode?: string; + approvalPolicy?: string; + collaborationMode?: string; + developerInstructions?: string; + onProgress?: ((progress: ProviderTurnProgress) => Promise<void> | void) | null; + onTurnStarted?: ((meta: Record<string, unknown>) => Promise<void> | void) | null; + onApprovalRequest?: ((request: ProviderApprovalRequest) => Promise<void> | void) | null; + timeoutMs?: number; + }): Promise<ProviderTurnResult> { + this.logDebug('turn_start_requested', { + threadId, + cwd, + model, + effort, + serviceTier, + personality, + approvalPolicy, + sandboxMode, + collaborationMode, + timeoutMs, + inputCount: Array.isArray(input) ? input.length : 1, + inputSummary: summarizeTurnInput( + Array.isArray(input) && input.length > 0 + ? input + : [{ + type: 'text', + text: inputText, + text_elements: [], + }], + ), + }); + const result: any = await this.request('turn/start', { + threadId, + input: Array.isArray(input) && input.length > 0 + ? input + : [{ + type: 'text', + text: inputText, + text_elements: [], + }], + cwd, + approvalPolicy, + sandboxPolicy: mapSandboxPolicy(sandboxMode), + model, + serviceTier, + effort, + summary: null, + personality, + outputSchema: null, + collaborationMode: serializeCollaborationMode({ + collaborationMode, + model, + effort, + developerInstructions, + }), + }, { timeoutMs: 30_000 }); + const turn = result?.turn; + if (!turn?.id) { + throw new Error('Codex turn/start returned no turn id'); + } + this.logDebug('turn_start_acknowledged', { + threadId, + turnId: String(turn.id), + status: String(turn.status ?? ''), + }); + if (typeof onTurnStarted === 'function') { + await onTurnStarted({ + turnId: String(turn.id), + threadId, + }); + } + return this.waitForTurnResult({ + threadId, + turnId: String(turn.id), + onProgress, + onApprovalRequest, + timeoutMs, + }); + } + + async interruptTurn({ threadId, turnId }: { threadId: string; turnId: string }): Promise<void> { + await this.request('turn/interrupt', { threadId, turnId }, { timeoutMs: 15_000 }); + } + + getPendingApprovals({ + threadId = null, + turnId = null, + }: { + threadId?: string | null; + turnId?: string | null; + } = {}): ProviderApprovalRequest[] { + return [...this.pendingApprovals.values()] + .map((entry) => entry.request) + .filter((entry) => { + if (threadId && entry.threadId !== threadId) { + return false; + } + if (turnId && entry.turnId !== turnId) { + return false; + } + return true; + }); + } + + async respondToApproval({ + requestId, + option, + }: { + requestId: string; + option: 1 | 2 | 3; + }): Promise<void> { + const pending = this.pendingApprovals.get(String(requestId)) ?? null; + if (!pending) { + throw new Error(`Unknown approval request: ${requestId}`); + } + const result = buildApprovalResponseResult(pending, option); + const approvedExecution = createApprovedExecution(pending, option, this.turnPollNow()); + if (approvedExecution) { + this.approvedExecutions.set(approvedExecution.requestId, approvedExecution); + } + try { + this.send({ + jsonrpc: '2.0', + id: pending.rpcResponseId, + result, + }); + } catch (error) { + if (approvedExecution) { + this.approvedExecutions.delete(approvedExecution.requestId); + } + throw error; + } + this.pendingApprovals.delete(String(requestId)); + if (approvedExecution) { + this.logDebug('approval_response_sent', summarizeApprovedExecution(approvedExecution)); + } + } + + async listModels(): Promise<CodexModelInfo[]> { + const models = []; + let cursor = null; + do { + const result: any = await this.request('model/list', { + cursor, + limit: 100, + includeHidden: false, + }, { timeoutMs: 30_000 }); + const rows = Array.isArray(result?.data) ? result.data : []; + models.push(...rows.map(mapModel)); + cursor = typeof result?.nextCursor === 'string' ? result.nextCursor : null; + } while (cursor); + if (this.modelCatalogMode === 'overlay-only' && this.modelCatalog.length > 0) { + return this.modelCatalog; + } + return mergeModelCatalog(models, this.modelCatalog); + } + + async readUsage(): Promise<ProviderUsageReport | null> { + const result = await this.request('account/rateLimits/read', {}, { timeoutMs: 15_000 }); + return mapAppServerRateLimits(result); + } + + async listSkills({ + cwd = null, + forceReload = false, + }: { + cwd?: string | null; + forceReload?: boolean; + } = {}): Promise<ProviderSkillsListResult> { + const result: any = await this.request('skills/list', { + cwds: cwd ? [cwd] : [], + forceReload, + }, { timeoutMs: 30_000 }); + const rows = Array.isArray(result?.data) ? result.data : []; + const entry = rows.find((item: CodexAppSkillsListEntry) => normalizeNullableString(item?.cwd) === cwd) + ?? rows[0] + ?? null; + return { + cwd: normalizeNullableString(entry?.cwd) ?? cwd ?? null, + skills: Array.isArray(entry?.skills) ? entry.skills.map(mapSkillMetadata).filter(Boolean) : [], + errors: Array.isArray(entry?.errors) ? entry.errors.map(mapSkillErrorInfo).filter(Boolean) : [], + }; + } + + async setSkillEnabled({ + enabled, + name = null, + path = null, + }: { + enabled: boolean; + name?: string | null; + path?: string | null; + }): Promise<void> { + await this.request('skills/config/write', { + enabled, + name, + path, + }, { timeoutMs: 30_000 }); + } + + async listPlugins({ + cwd = null, + }: { + cwd?: string | null; + } = {}): Promise<ProviderPluginsListResult> { + const result: CodexAppPluginListResponse = await this.request('plugin/list', { + cwds: cwd ? [cwd] : [], + }, { timeoutMs: 30_000 }); + return { + featuredPluginIds: Array.isArray(result?.featuredPluginIds) + ? result.featuredPluginIds.map((value) => String(value ?? '').trim()).filter(Boolean) + : [], + marketplaceLoadErrors: Array.isArray(result?.marketplaceLoadErrors) + ? result.marketplaceLoadErrors.map(mapPluginLoadError).filter(Boolean) as ProviderPluginLoadError[] + : [], + marketplaces: Array.isArray(result?.marketplaces) + ? result.marketplaces.map(mapPluginMarketplace).filter(Boolean) as ProviderPluginMarketplace[] + : [], + }; + } + + async readPlugin({ + pluginName, + marketplaceName = null, + marketplacePath = null, + }: { + pluginName: string; + marketplaceName?: string | null; + marketplacePath?: string | null; + }): Promise<ProviderPluginDetail | null> { + const params: Record<string, unknown> = { + pluginName, + }; + if (marketplacePath) { + params.marketplacePath = marketplacePath; + } else if (marketplaceName) { + params.remoteMarketplaceName = marketplaceName; + } + const result: any = await this.request('plugin/read', params, { timeoutMs: 30_000 }); + return mapPluginDetail(result?.plugin ?? null, { + marketplaceName, + marketplacePath, + }); + } + + async installPlugin({ + pluginName, + marketplaceName = null, + marketplacePath = null, + }: { + pluginName: string; + marketplaceName?: string | null; + marketplacePath?: string | null; + }): Promise<ProviderPluginInstallResult> { + const params: Record<string, unknown> = { + pluginName, + }; + if (marketplacePath) { + params.marketplacePath = marketplacePath; + } else if (marketplaceName) { + params.remoteMarketplaceName = marketplaceName; + } + const result: CodexAppPluginInstallResponse = await this.request('plugin/install', params, { timeoutMs: 30_000 }); + return { + authPolicy: normalizeNullableString(result?.authPolicy), + appsNeedingAuth: Array.isArray(result?.appsNeedingAuth) + ? result.appsNeedingAuth.map(mapPluginAppSummary).filter(Boolean) as ProviderPluginAppSummary[] + : [], + }; + } + + async uninstallPlugin({ + pluginId, + }: { + pluginId: string; + }): Promise<void> { + await this.request('plugin/uninstall', { + pluginId, + }, { timeoutMs: 30_000 }); + } + + async listApps(): Promise<ProviderAppInfo[]> { + const apps = []; + let cursor = null; + do { + const result: any = await this.request('app/list', { + cursor, + limit: 100, + }, { timeoutMs: 30_000 }); + const rows = Array.isArray(result?.data) ? result.data : []; + apps.push(...rows.map(mapAppInfo).filter(Boolean)); + cursor = typeof result?.nextCursor === 'string' ? result.nextCursor : null; + } while (cursor); + return apps; + } + + async listMcpServerStatuses(): Promise<ProviderMcpServerStatus[]> { + const servers = []; + let cursor = null; + do { + const result: any = await this.request('mcpServerStatus/list', { + cursor, + limit: 100, + }, { timeoutMs: 30_000 }); + const rows = Array.isArray(result?.data) ? result.data : []; + servers.push(...rows.map(mapMcpServerStatus).filter(Boolean)); + cursor = typeof result?.nextCursor === 'string' ? result.nextCursor : null; + } while (cursor); + return servers; + } + + async setAppEnabled({ + appId, + enabled, + }: { + appId: string; + enabled: boolean; + }): Promise<void> { + await this.writeConfigValue({ + keyPath: formatConfigKeyPath(['apps', appId, 'enabled']), + value: enabled, + }); + } + + async setMcpServerEnabled({ + name, + enabled, + }: { + name: string; + enabled: boolean; + }): Promise<void> { + await this.writeConfigValue({ + keyPath: formatConfigKeyPath(['mcp_servers', name, 'enabled']), + value: enabled, + }); + } + + async startMcpServerOauthLogin({ + name, + scopes = null, + timeoutSecs = null, + }: { + name: string; + scopes?: string[] | null; + timeoutSecs?: number | null; + }): Promise<ProviderMcpOauthLoginResult> { + const result: CodexAppMcpOauthLoginResponse = await this.request('mcpServer/oauth/login', { + name, + scopes, + timeoutSecs, + }, { timeoutMs: 30_000 }); + const authorizationUrl = normalizeNullableString(result?.authorizationUrl); + if (!authorizationUrl) { + throw new Error(`mcpServer/oauth/login returned no authorization URL for ${name}`); + } + return { authorizationUrl }; + } + + async reloadMcpServers(): Promise<void> { + await this.request('config/mcpServer/reload', {}, { timeoutMs: 30_000 }); + } + + async writeConfigValue({ + keyPath, + value, + mergeStrategy = 'upsert', + filePath = null, + expectedVersion = null, + }: { + keyPath: string; + value: unknown; + mergeStrategy?: 'replace' | 'upsert'; + filePath?: string | null; + expectedVersion?: string | null; + }): Promise<void> { + await this.request('config/value/write', { + keyPath, + value, + mergeStrategy, + filePath, + expectedVersion, + }, { timeoutMs: 30_000 }); + } + + async startServer(): Promise<void> { + if (this.autolaunch && this.launchCommand?.trim()) { + const launcher = this.spawnImpl(this.launchCommand, { + shell: true, + detached: true, + stdio: 'ignore', + }); + launcher.unref?.(); + } + this.childStartError = null; + this.childStderrTail = []; + this.port = await reservePort(); + const featureArgs = this.enabledFeatures.flatMap((feature) => ['--enable', feature]); + const launchSpec = createCodexAppServerLaunchSpec({ + command: this.codexCliBin, + args: [...this.codexCliArgs, 'app-server', ...featureArgs, '--listen', `ws://127.0.0.1:${this.port}`], + platform: this.platform, + }); + try { + this.child = launchSpec.args + ? this.spawnImpl(launchSpec.command, launchSpec.args, { + stdio: ['ignore', 'pipe', 'pipe'], + ...launchSpec.options, + }) + : this.spawnImpl(launchSpec.command, { + stdio: ['ignore', 'pipe', 'pipe'], + ...launchSpec.options, + }); + } catch (error) { + throw createCodexLaunchError({ + command: launchSpec.displayCommand, + error, + platform: this.platform, + }); + } + this.logDebug('app_server_spawned', { + command: launchSpec.displayCommand, + spawnCommand: launchSpec.command, + spawnArgs: launchSpec.args, + port: this.port, + codexCliArgs: this.codexCliArgs, + enabledFeatures: this.enabledFeatures, + autolaunch: this.autolaunch, + launchCommand: this.launchCommand, + }); + this.child.stderr?.on('data', (chunk) => { + const text = String(chunk).trim(); + if (text) { + rememberCodexStderrLine(this.childStderrTail, text); + this.logger.debug?.(`[codex-app] codex.stderr ${text}`); + } + }); + this.child.on('error', (error) => { + this.childStartError = createCodexLaunchError({ + command: launchSpec.displayCommand, + error, + platform: this.platform, + }); + }); + this.child.on('exit', () => { + this.connected = false; + this.socket = null; + }); + await this.connectWebSocket(); + await this.initialize(); + } + + async connectWebSocket(): Promise<void> { + const url = `ws://127.0.0.1:${this.port}`; + const started = Date.now(); + while (Date.now() - started < APP_SERVER_CONNECT_TIMEOUT_MS) { + if (this.childStartError) { + throw this.childStartError; + } + if (this.child && this.child.exitCode !== null && !this.connected) { + throw createCodexAppServerExitedError({ + command: this.codexCliBin, + exitCode: this.child.exitCode, + stderrTail: this.childStderrTail, + }); + } + try { + await new Promise<void>((resolve, reject) => { + const ws = this.webSocketFactory(url); + const onError = (error: any) => { + ws.close(); + reject(error instanceof Error ? error : new Error(String(error?.message ?? 'WebSocket connect failed'))); + }; + ws.addEventListener('open', () => { + this.socket = ws; + this.connected = true; + ws.addEventListener('message', (message) => this.handleMessage(String(message.data))); + ws.addEventListener('close', () => { + this.connected = false; + this.socket = null; + }); + resolve(); + }, { once: true }); + ws.addEventListener('error', onError, { once: true }); + }); + return; + } catch { + await sleep(250); + } + } + if (this.childStartError) { + throw this.childStartError; + } + throw createCodexConnectTimeoutError({ + command: this.codexCliBin, + url, + stderrTail: this.childStderrTail, + }); + } + + async initialize(): Promise<void> { + await this.request('initialize', { + clientInfo: this.clientInfo, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: [ + 'codex/event/agent_reasoning_delta', + 'codex/event/reasoning_content_delta', + 'codex/event/reasoning_raw_content_delta', + 'codex/event/exec_command_output_delta', + ], + }, + }, { timeoutMs: 30_000 }); + this.send({ jsonrpc: '2.0', method: 'initialized' }); + } + + async request(method: string, params: any, { timeoutMs = 30_000 }: { timeoutMs?: number } = {}): Promise<any> { + if (!this.socket || !this.connected) { + await this.start(); + } + const id = String(++this.requestId); + const startedAt = this.turnPollNow(); + this.logDebug('rpc_request_start', { + id, + method, + timeoutMs, + params: summarizeRpcParams(method, params), + }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.pending.has(id)) { + return; + } + this.pending.delete(id); + this.logDebug('rpc_request_timeout', { + id, + method, + elapsedMs: this.turnPollNow() - startedAt, + }); + reject(new Error(`Timed out waiting for Codex JSON-RPC response to ${method}`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (result) => { + clearTimeout(timer); + this.logDebug('rpc_request_result', { + id, + method, + elapsedMs: this.turnPollNow() - startedAt, + result: summarizeRpcResult(method, result), + }); + resolve(result); + }, + reject: (error) => { + clearTimeout(timer); + this.logDebug('rpc_request_error', { + id, + method, + elapsedMs: this.turnPollNow() - startedAt, + error: error instanceof Error ? error.message : String(error), + }); + reject(error); + }, + }); + this.send({ jsonrpc: '2.0', id, method, params }); + }); + } + + send(payload: any): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('Codex app-server socket is not open'); + } + this.socket.send(JSON.stringify(payload)); + } + + handleMessage(raw: string): void { + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if ('id' in message && !('method' in message)) { + const pending = this.pending.get(String(message.id)); + if (!pending) { + return; + } + this.pending.delete(String(message.id)); + if (message.error) { + pending.reject(new Error(message.error.message || 'JSON-RPC error')); + return; + } + pending.resolve(message.result); + return; + } + + if ('method' in message) { + this.noteApprovedExecutionSignalFromNotification(message); + this.logDebug('rpc_notification', summarizeNotificationMessage(message)); + if ('id' in message && this.handleServerRequest(message)) { + return; + } + this.emit('notification', message); + } + } + + handleServerRequest(message: any): boolean { + const pendingApproval = mapPendingApproval(message); + if (!pendingApproval) { + this.emit('server_request', message); + return false; + } + this.pendingApprovals.set(pendingApproval.rpcId, pendingApproval); + this.emit('approval_request', pendingApproval.request); + return true; + } + + rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } + + captureNextTurnStartedForThread(threadId: string, timeoutMs: number): Promise<string | null> { + return new Promise((resolve) => { + let settled = false; + const finish = (turnId: string | null) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + this.off('notification', onNotification); + resolve(turnId); + }; + const onNotification = (message: any) => { + if (String(message?.method ?? '') !== 'turn/started') { + return; + } + if (extractThreadIdFromNotification(message) !== threadId) { + return; + } + finish(extractNotificationTurnId(message?.params ?? null)); + }; + const timer = setTimeout(() => finish(null), Math.max(100, timeoutMs)); + this.on('notification', onNotification); + }); + } + + getApprovedExecutions({ + threadId = null, + turnId = null, + activeOnly = false, + }: { + threadId?: string | null; + turnId?: string | null; + activeOnly?: boolean; + } = {}): ApprovedExecution[] { + return [...this.approvedExecutions.values()].filter((entry) => { + if (threadId && entry.threadId !== threadId) { + return false; + } + if (turnId && entry.turnId && entry.turnId !== turnId) { + return false; + } + if (activeOnly && entry.completedAt) { + return false; + } + return true; + }); + } + + noteApprovedExecutionSignalFromNotification(message: any): void { + const signalKind = classifyApprovedExecutionSignal(message?.method); + if (!signalKind) { + return; + } + const threadId = extractThreadIdFromNotification(message); + if (!threadId) { + return; + } + this.noteApprovedExecutionSignal({ + threadId, + turnId: extractNotificationTurnId(message?.params ?? null), + itemId: extractItemId(message?.params ?? null), + signalKind, + markCompleted: signalKind === 'item_completed' || signalKind === 'turn_completed', + }); + } + + noteApprovedExecutionSignal({ + threadId, + turnId = null, + itemId = null, + signalKind, + markCompleted = false, + }: { + threadId: string; + turnId?: string | null; + itemId?: string | null; + signalKind: string; + markCompleted?: boolean; + }): void { + const now = this.turnPollNow(); + for (const entry of this.approvedExecutions.values()) { + if (entry.completedAt) { + continue; + } + if (entry.threadId !== threadId) { + continue; + } + if (turnId && entry.turnId && entry.turnId !== turnId) { + continue; + } + if (!turnId && entry.turnId && !isThreadLevelApprovedExecutionSignal(signalKind)) { + continue; + } + const firstSignal = entry.signalCount === 0; + entry.lastSignalAt = now; + entry.lastSignalKind = signalKind; + entry.signalCount += 1; + if ( + markCompleted + && ( + !itemId + || !entry.itemId + || entry.itemId === itemId + ) + ) { + entry.completedAt = now; + } + if (firstSignal || entry.completedAt) { + this.logDebug('approval_signal', summarizeApprovedExecutionSignal(entry, signalKind)); + } + } + } + + observeApprovedExecutionTurnSnapshot({ + threadId, + turnId, + turn, + }: { + threadId: string; + turnId: string; + turn: any; + }): void { + const activeEntries = this.getApprovedExecutions({ threadId, turnId, activeOnly: true }); + if (activeEntries.length === 0 || !turn) { + return; + } + const snapshotKey = buildTurnSnapshotKey(turn); + let changed = false; + for (const entry of activeEntries) { + if (!entry.lastObservedTurnSnapshotKey) { + entry.lastObservedTurnSnapshotKey = snapshotKey; + continue; + } + if (entry.lastObservedTurnSnapshotKey !== snapshotKey) { + entry.lastObservedTurnSnapshotKey = snapshotKey; + changed = true; + } + } + if (changed) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'turn_snapshot_changed', + }); + } + } + + inspectApprovedExecutionStall({ + threadId, + turnId, + timeoutMs, + }: { + threadId: string; + turnId: string; + timeoutMs: number; + }): null | { + entry: ApprovedExecution; + idleMs: number; + idleLimitMs: number; + } { + const activeEntries = this.getApprovedExecutions({ threadId, turnId, activeOnly: true }); + if (activeEntries.length === 0) { + return null; + } + const now = this.turnPollNow(); + const idleLimitMs = computeApprovedExecutionIdleLimitMs(timeoutMs); + let stalledEntry: ApprovedExecution | null = null; + let stalledIdleMs = 0; + for (const entry of activeEntries) { + const idleMs = Math.max(0, now - Math.max(entry.lastSignalAt, entry.approvedAt)); + if (idleMs < idleLimitMs) { + continue; + } + if (!stalledEntry || idleMs > stalledIdleMs) { + stalledEntry = entry; + stalledIdleMs = idleMs; + } + } + if (!stalledEntry) { + return null; + } + return { + entry: stalledEntry, + idleMs: stalledIdleMs, + idleLimitMs, + }; + } + + clearApprovedExecutionsForTurn({ + threadId, + turnId, + }: { + threadId: string; + turnId: string; + }): void { + for (const [requestId, entry] of this.approvedExecutions.entries()) { + if (entry.threadId !== threadId) { + continue; + } + if (entry.turnId && entry.turnId !== turnId) { + continue; + } + this.approvedExecutions.delete(requestId); + } + } + + async waitForTurnResult({ + threadId, + turnId, + onProgress, + onApprovalRequest, + timeoutMs, + }: { + threadId: string; + turnId: string; + onProgress?: ((progress: ProviderTurnProgress) => Promise<void> | void) | null; + onApprovalRequest?: ((request: ProviderApprovalRequest) => Promise<void> | void) | null; + timeoutMs: number; + }): Promise<ProviderTurnResult> { + const deadline = this.turnPollNow() + timeoutMs; + let firstTerminalWithoutOutputAt = null; + let lastTurnSnapshotKey = null; + let stableTerminalReadCount = 0; + let pollCount = 0; + let includeTurnsUnsupported = false; + let includeTurnsUnsupportedAt = 0; + let pendingApprovalWaitLogged = false; + let lastPendingApprovalCount = 0; + const terminalSettleMs = computeTerminalSettleMs(timeoutMs); + const progressState: ProgressState = { + commentaryText: '', + finalAnswerText: '', + sawAssistantActivity: false, + lastAssistantActivityAt: 0, + }; + const itemOutputKinds = new Map(); + let sawTerminalNotification = false; + const onNotification = (notification) => { + if (isTerminalNotificationForThread(notification, threadId, turnId)) { + sawTerminalNotification = true; + } + const progress = extractProgressUpdate(notification, turnId, itemOutputKinds, progressState); + if (!progress) { + return; + } + if (progress.outputKind === 'final_answer') { + progressState.finalAnswerText += progress.delta; + } else { + progressState.commentaryText += progress.delta; + } + progressState.sawAssistantActivity = true; + progressState.lastAssistantActivityAt = this.turnPollNow(); + if (typeof onProgress === 'function') { + void onProgress({ + text: progress.outputKind === 'final_answer' + ? progressState.finalAnswerText + : progressState.commentaryText, + delta: progress.delta, + outputKind: progress.outputKind, + }); + } + }; + const onApprovalEvent = (request: ProviderApprovalRequest) => { + if (request.threadId !== threadId) { + return; + } + if (request.turnId && request.turnId !== turnId) { + return; + } + if (typeof onApprovalRequest === 'function') { + void onApprovalRequest(request); + } + }; + this.on('notification', onNotification); + this.on('approval_request', onApprovalEvent); + this.logDebug('turn_wait_start', { + threadId, + turnId, + timeoutMs, + deadline, + terminalSettleMs, + }); + try { + while (true) { + const pendingApprovalCount = this.getPendingApprovals({ threadId, turnId }).length; + const pastDeadline = this.turnPollNow() >= deadline; + if (pastDeadline && pendingApprovalCount === 0) { + break; + } + if (pastDeadline && pendingApprovalCount > 0) { + if (!pendingApprovalWaitLogged || pendingApprovalCount !== lastPendingApprovalCount) { + this.logDebug('turn_wait_continue', { + threadId, + turnId, + pollCount, + reason: 'pending_approval_wait', + pendingApprovalCount, + }); + } + pendingApprovalWaitLogged = true; + lastPendingApprovalCount = pendingApprovalCount; + } else { + pendingApprovalWaitLogged = false; + lastPendingApprovalCount = pendingApprovalCount; + } + pollCount += 1; + let thread = null; + try { + thread = await this.readThread(threadId, !includeTurnsUnsupported); + } catch (error) { + if (isThreadMaterializationPendingError(error)) { + this.logDebug('turn_poll_retry', { + threadId, + turnId, + pollCount, + reason: 'thread_materialization_pending', + }); + await this.turnPollSleep(1000); + continue; + } + if (isRequestTimeoutError(error)) { + this.logDebug('turn_poll_retry', { + threadId, + turnId, + pollCount, + reason: 'thread_read_timeout', + }); + await this.turnPollSleep(1000); + continue; + } + if (isIncludeTurnsUnsupportedError(error)) { + includeTurnsUnsupported = true; + includeTurnsUnsupportedAt ||= this.turnPollNow(); + this.logDebug('turn_poll_retry', { + threadId, + turnId, + pollCount, + reason: 'thread_read_include_turns_unsupported', + }); + try { + thread = await this.readThread(threadId, false); + } catch (fallbackError) { + if (isThreadMaterializationPendingError(fallbackError) || isRequestTimeoutError(fallbackError)) { + await this.turnPollSleep(250); + continue; + } + throw fallbackError; + } + } else { + throw error; + } + } + const turn = includeTurnsUnsupported + ? null + : thread?.turns?.find((entry) => entry.id === turnId) ?? null; + this.logDebug('turn_poll_snapshot', { + threadId, + turnId, + pollCount, + elapsedMs: timeoutMs - Math.max(0, deadline - this.turnPollNow()), + threadFound: Boolean(thread), + threadPath: thread?.path ?? null, + turn: summarizeTurnSnapshot(turn), + progress: summarizeProgressState(progressState), + }); + if (includeTurnsUnsupported) { + const previewText = progressState.finalAnswerText || progressState.commentaryText; + const settleAnchor = Math.max( + includeTurnsUnsupportedAt, + progressState.lastAssistantActivityAt || 0, + ); + const settleElapsedMs = settleAnchor ? this.turnPollNow() - settleAnchor : 0; + if ( + ( + !sawTerminalNotification + || !previewText + || settleElapsedMs < 500 + ) + && this.turnPollNow() + 250 < deadline + ) { + await this.turnPollSleep(250); + continue; + } + if (previewText) { + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: previewText, + outputArtifacts: [], + outputMedia: [], + outputState: sawTerminalNotification ? 'complete' : 'partial', + previewText: progressState.finalAnswerText, + finalSource: progressState.finalAnswerText ? 'progress_only' : 'commentary_only', + status: sawTerminalNotification ? 'completed' : null, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + if (sawTerminalNotification) { + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputArtifacts: [], + outputMedia: [], + outputState: 'missing', + previewText: '', + finalSource: 'none', + status: 'completed', + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + await this.turnPollSleep(250); + continue; + } + if (turn) { + this.observeApprovedExecutionTurnSnapshot({ + threadId, + turnId, + turn, + }); + } + const approvedExecutionStall = this.inspectApprovedExecutionStall({ + threadId, + turnId, + timeoutMs, + }); + if (approvedExecutionStall) { + this.logDebug('turn_wait_error', { + threadId, + turnId, + pollCount, + reason: 'approved_execution_stalled', + idleMs: approvedExecutionStall.idleMs, + idleLimitMs: approvedExecutionStall.idleLimitMs, + approval: summarizeApprovedExecution(approvedExecutionStall.entry), + }); + throw new Error(buildApprovedExecutionStallError(approvedExecutionStall)); + } + if (turn && isTurnTerminal(turn.status)) { + const outputText = extractTurnOutputText(turn); + if (outputText) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'turn_terminal', + markCompleted: true, + }); + const outputArtifacts = extractTurnOutputArtifacts(turn); + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText, + outputArtifacts, + outputMedia: normalizeLegacyImageMedia(outputArtifacts), + outputState: 'complete', + previewText: progressState.finalAnswerText, + finalSource: 'thread_items', + status: turn.status, + }; + const enrichedResult = attachSessionResponseItems(result, thread?.path ?? null); + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(enrichedResult)); + return enrichedResult; + } + const outputArtifacts = extractTurnOutputArtifacts(turn); + if (outputArtifacts.length > 0) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'turn_terminal', + markCompleted: true, + }); + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputArtifacts, + outputMedia: normalizeLegacyImageMedia(outputArtifacts), + outputState: 'complete', + previewText: progressState.finalAnswerText, + finalSource: 'thread_items_media', + status: turn.status, + }; + const enrichedResult = attachSessionResponseItems(result, thread?.path ?? null); + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(enrichedResult)); + return enrichedResult; + } + const sessionState = inspectTurnCompletionFromSessionPath(thread?.path ?? null, turnId); + const hasAssistantVisibleItems = turn.items.some((item) => isAssistantVisibleItem(item)); + const completionState = classifyTurnCompletionState(turn); + this.logDebug('turn_terminal_state', { + threadId, + turnId, + pollCount, + turn: summarizeTurnSnapshot(turn), + hasAssistantVisibleItems, + completionState, + sessionState: summarizeSessionState(thread?.path ?? null, sessionState), + progress: summarizeProgressState(progressState), + }); + if (completionState === 'interrupted') { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'turn_terminal', + markCompleted: true, + }); + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: 'interrupted', + previewText: progressState.finalAnswerText, + finalSource: progressState.finalAnswerText ? 'progress_only' : 'none', + status: turn.status, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + if (turn.error) { + this.logDebug('turn_wait_error', { + threadId, + turnId, + pollCount, + error: turn.error, + }); + throw new Error(turn.error); + } + if (sessionState.lastAgentMessage && hasAssistantVisibleItems) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'session_task_complete', + markCompleted: true, + }); + const result = buildSessionTaskCompleteResult({ + turnId, + threadId, + title: thread?.title ?? null, + status: turn.status, + previewText: progressState.finalAnswerText, + sessionState, + }); + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + const sessionTaskCompleteNeedsMaterializationWait = shouldWaitForSessionTaskMaterialization( + sessionState, + hasAssistantVisibleItems, + ); + if (shouldWaitForSettledOutputAfterTerminalTurn(turn, progressState) || sessionTaskCompleteNeedsMaterializationWait) { + const snapshotKey = buildTurnSnapshotKey(turn); + if (snapshotKey === lastTurnSnapshotKey) { + stableTerminalReadCount += 1; + } else { + lastTurnSnapshotKey = snapshotKey; + stableTerminalReadCount = 1; + } + firstTerminalWithoutOutputAt ??= this.turnPollNow(); + if ( + ( + this.turnPollNow() - firstTerminalWithoutOutputAt < terminalSettleMs + || stableTerminalReadCount < 3 + ) + && this.turnPollNow() + 1000 < deadline + ) { + this.logDebug('turn_wait_continue', { + threadId, + turnId, + pollCount, + reason: sessionTaskCompleteNeedsMaterializationWait + ? 'session_task_materialization_wait' + : 'terminal_settle_wait', + stableTerminalReadCount, + terminalElapsedMs: this.turnPollNow() - firstTerminalWithoutOutputAt, + terminalSettleMs, + }); + await this.turnPollSleep(1000); + continue; + } + } + if (sessionState.lastAgentMessage || sessionState.outputArtifacts.length > 0) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'session_task_complete', + markCompleted: true, + }); + const result = buildSessionTaskCompleteResult({ + turnId, + threadId, + title: thread?.title ?? null, + status: turn.status, + previewText: progressState.finalAnswerText, + sessionState, + }); + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + if (sessionState.hasTaskComplete) { + this.noteApprovedExecutionSignal({ + threadId, + turnId, + signalKind: 'session_task_complete', + markCompleted: true, + }); + const previewText = resolveTurnPreviewText(turn, progressState); + if (!previewText && sessionState.runtimeError) { + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: 'provider_error', + previewText: '', + finalSource: 'session_runtime_error', + status: turn.status, + errorMessage: sessionState.runtimeError, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: previewText ? 'partial' : 'missing', + previewText, + finalSource: progressState.finalAnswerText + ? 'progress_only' + : progressState.commentaryText + ? 'commentary_only' + : 'session_task_complete_empty', + status: turn.status, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + if (shouldWaitForTaskCompleteBeforeMissing(thread?.path ?? null, sessionState)) { + if (this.turnPollNow() + 1000 < deadline) { + this.logDebug('turn_wait_continue', { + threadId, + turnId, + pollCount, + reason: 'waiting_for_session_task_complete', + sessionPath: thread?.path ?? null, + }); + await this.turnPollSleep(1000); + continue; + } + const previewText = resolveTurnPreviewText(turn, progressState); + if (previewText) { + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: 'partial', + previewText, + finalSource: progressState.finalAnswerText ? 'progress_only' : 'commentary_only', + status: turn.status, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + this.logDebug('turn_wait_error', { + threadId, + turnId, + pollCount, + reason: 'task_complete_timeout_without_preview', + }); + throw new Error(`Timed out waiting for Codex turn ${turnId}`); + } + if (hasUnsettledAssistantActivity(turn, progressState)) { + if (this.turnPollNow() + 1000 < deadline) { + this.logDebug('turn_wait_continue', { + threadId, + turnId, + pollCount, + reason: 'unsettled_assistant_activity', + progress: summarizeProgressState(progressState), + }); + await this.turnPollSleep(1000); + continue; + } + const previewText = resolveTurnPreviewText(turn, progressState); + if (previewText) { + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: 'partial', + previewText, + finalSource: progressState.finalAnswerText ? 'progress_only' : 'commentary_only', + status: turn.status, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + this.logDebug('turn_wait_error', { + threadId, + turnId, + pollCount, + reason: 'assistant_activity_timeout_without_preview', + }); + throw new Error(`Timed out waiting for Codex turn ${turnId}`); + } + const previewText = resolveTurnPreviewText(turn, progressState); + const result = { + turnId, + threadId, + title: thread?.title ?? null, + outputText: '', + outputState: previewText ? 'partial' : 'missing', + previewText, + finalSource: progressState.finalAnswerText + ? 'progress_only' + : progressState.commentaryText + ? 'commentary_only' + : 'none', + status: turn.status, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + await this.turnPollSleep(1000); + } + const previewText = progressState.finalAnswerText || progressState.commentaryText; + if (previewText) { + const result = { + turnId, + threadId, + title: null, + outputText: '', + outputState: 'partial', + previewText, + finalSource: progressState.finalAnswerText ? 'progress_only' : 'commentary_only', + status: null, + }; + this.logDebug('turn_wait_return', summarizeTurnResultForDebug(result)); + return result; + } + this.logDebug('turn_wait_error', { + threadId, + turnId, + pollCount, + reason: 'overall_timeout_without_preview', + }); + throw new Error(`Timed out waiting for Codex turn ${turnId}`); + } finally { + this.clearApprovedExecutionsForTurn({ threadId, turnId }); + this.off('notification', onNotification); + this.off('approval_request', onApprovalEvent); + } + } +} + +function mapPendingApproval(message: any): PendingApproval | null { + const rpcId = String(message?.id ?? '').trim(); + const method = String(message?.method ?? '').trim(); + if (!rpcId || !method) { + return null; + } + const rpcResponseId = typeof message?.id === 'number' ? message.id : rpcId; + switch (method) { + case 'item/commandExecution/requestApproval': + return { + rpcId, + rpcResponseId, + transportKind: 'v2_command', + request: mapCommandExecutionApprovalRequest(rpcId, message.params), + }; + case 'item/fileChange/requestApproval': + return { + rpcId, + rpcResponseId, + transportKind: 'v2_file_change', + request: mapFileChangeApprovalRequest(rpcId, message.params), + }; + case 'item/permissions/requestApproval': + return { + rpcId, + rpcResponseId, + transportKind: 'v2_permissions', + request: mapPermissionsApprovalRequest(rpcId, message.params), + }; + case 'execCommandApproval': + return { + rpcId, + rpcResponseId, + transportKind: 'legacy_exec', + request: mapLegacyExecApprovalRequest(rpcId, message.params), + }; + case 'applyPatchApproval': + return { + rpcId, + rpcResponseId, + transportKind: 'legacy_apply_patch', + request: mapLegacyApplyPatchApprovalRequest(rpcId, message.params), + }; + default: + return null; + } +} + +function mapCommandExecutionApprovalRequest(requestId: string, params: any): ProviderApprovalRequest { + return { + requestId, + kind: 'command', + threadId: String(params?.threadId ?? ''), + turnId: normalizeNullableString(params?.turnId), + itemId: normalizeNullableString(params?.itemId), + reason: normalizeNullableString(params?.reason), + command: normalizeNullableString(params?.command), + cwd: normalizeNullableString(params?.cwd), + availableDecisionKeys: Array.isArray(params?.availableDecisions) + ? params.availableDecisions.map(normalizeApprovalDecisionKey).filter(Boolean) + : [], + execPolicyAmendment: Array.isArray(params?.proposedExecpolicyAmendment) + ? params.proposedExecpolicyAmendment + .map((entry: unknown) => String(entry ?? '').trim()) + .filter(Boolean) + : null, + networkPermission: normalizeBoolean(params?.additionalPermissions?.network?.enabled), + fileReadPermissions: normalizeStringList(params?.additionalPermissions?.fileSystem?.read), + fileWritePermissions: normalizeStringList(params?.additionalPermissions?.fileSystem?.write), + }; +} + +function mapFileChangeApprovalRequest(requestId: string, params: any): ProviderApprovalRequest { + return { + requestId, + kind: 'file_change', + threadId: String(params?.threadId ?? ''), + turnId: normalizeNullableString(params?.turnId), + itemId: normalizeNullableString(params?.itemId), + reason: normalizeNullableString(params?.reason), + grantRoot: normalizeNullableString(params?.grantRoot), + availableDecisionKeys: ['accept', 'acceptForSession', 'decline'], + }; +} + +function mapPermissionsApprovalRequest(requestId: string, params: any): ProviderApprovalRequest { + return { + requestId, + kind: 'permissions', + threadId: String(params?.threadId ?? ''), + turnId: normalizeNullableString(params?.turnId), + itemId: normalizeNullableString(params?.itemId), + reason: normalizeNullableString(params?.reason), + networkPermission: normalizeBoolean(params?.permissions?.network?.enabled), + fileReadPermissions: normalizeStringList(params?.permissions?.fileSystem?.read), + fileWritePermissions: normalizeStringList(params?.permissions?.fileSystem?.write), + availableDecisionKeys: ['accept', 'acceptForSession', 'decline'], + }; +} + +function mapLegacyExecApprovalRequest(requestId: string, params: any): ProviderApprovalRequest { + return { + requestId, + kind: 'command', + threadId: String(params?.conversationId ?? ''), + turnId: null, + itemId: normalizeNullableString(params?.approvalId) ?? normalizeNullableString(params?.callId), + reason: normalizeNullableString(params?.reason), + command: Array.isArray(params?.command) + ? params.command.map((entry: unknown) => String(entry ?? '').trim()).filter(Boolean).join(' ') + : null, + cwd: normalizeNullableString(params?.cwd), + availableDecisionKeys: ['accept', 'acceptForSession', 'decline'], + }; +} + +function mapLegacyApplyPatchApprovalRequest(requestId: string, params: any): ProviderApprovalRequest { + return { + requestId, + kind: 'file_change', + threadId: String(params?.conversationId ?? ''), + turnId: null, + itemId: normalizeNullableString(params?.callId), + reason: normalizeNullableString(params?.reason), + fileChanges: params?.fileChanges && typeof params.fileChanges === 'object' + ? Object.keys(params.fileChanges).filter(Boolean) + : [], + grantRoot: normalizeNullableString(params?.grantRoot), + availableDecisionKeys: ['accept', 'acceptForSession', 'decline'], + }; +} + +function buildApprovalResponseResult(pending: PendingApproval, option: 1 | 2 | 3): any { + switch (pending.transportKind) { + case 'v2_command': + return { + decision: buildV2CommandApprovalDecision(pending.request, option), + }; + case 'v2_file_change': + return { + decision: buildV2FileChangeApprovalDecision(option), + }; + case 'v2_permissions': + return buildV2PermissionsApprovalDecision(pending.request, option); + case 'legacy_exec': + case 'legacy_apply_patch': + return { + decision: buildLegacyReviewDecision(option), + }; + default: + throw new Error(`Unsupported approval transport: ${pending.transportKind}`); + } +} + +function createApprovedExecution( + pending: PendingApproval, + option: 1 | 2 | 3, + now: number, +): ApprovedExecution | null { + if (option === 3) { + return null; + } + return { + requestId: pending.rpcId, + kind: pending.request.kind, + threadId: pending.request.threadId, + turnId: pending.request.turnId, + itemId: pending.request.itemId, + command: pending.request.command ?? null, + approvedAt: now, + lastSignalAt: now, + lastSignalKind: 'approval_response_sent', + signalCount: 0, + completedAt: null, + lastObservedTurnSnapshotKey: null, + }; +} + +function buildV2CommandApprovalDecision(request: ProviderApprovalRequest, option: 1 | 2 | 3): any { + if (option === 1) { + return 'accept'; + } + if (option === 2) { + if ( + request.execPolicyAmendment + && request.execPolicyAmendment.length > 0 + && request.availableDecisionKeys?.includes('acceptWithExecpolicyAmendment') + ) { + return { + acceptWithExecpolicyAmendment: { + execpolicy_amendment: request.execPolicyAmendment, + }, + }; + } + if (request.availableDecisionKeys?.includes('acceptForSession')) { + return 'acceptForSession'; + } + throw new Error('Current approval request does not support session-wide approval'); + } + if (request.availableDecisionKeys?.includes('decline')) { + return 'decline'; + } + if (request.availableDecisionKeys?.includes('cancel')) { + return 'cancel'; + } + throw new Error('Current approval request does not support denial'); +} + +function buildV2FileChangeApprovalDecision(option: 1 | 2 | 3): string { + if (option === 1) { + return 'accept'; + } + if (option === 2) { + return 'acceptForSession'; + } + return 'decline'; +} + +function buildV2PermissionsApprovalDecision(request: ProviderApprovalRequest, option: 1 | 2 | 3) { + return { + permissions: option === 3 + ? {} + : { + ...(request.networkPermission != null ? { + network: { + enabled: request.networkPermission, + }, + } : {}), + ...(request.fileReadPermissions?.length || request.fileWritePermissions?.length ? { + fileSystem: { + read: request.fileReadPermissions ?? [], + write: request.fileWritePermissions ?? [], + }, + } : {}), + }, + scope: option === 2 ? 'session' : 'turn', + }; +} + +function buildLegacyReviewDecision(option: 1 | 2 | 3): any { + if (option === 1) { + return 'approved'; + } + if (option === 2) { + return 'approved_for_session'; + } + return 'denied'; +} + +function normalizeApprovalDecisionKey(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + if (!value || typeof value !== 'object') { + return ''; + } + const entries = Object.entries(value); + if (entries.length !== 1) { + return ''; + } + return String(entries[0]?.[0] ?? '').trim(); +} + +function classifyApprovedExecutionSignal(method: unknown): string | null { + const normalized = String(method ?? '').replace(/[^a-z]/gi, '').toLowerCase(); + switch (normalized) { + case 'itemstarted': + return 'item_started'; + case 'itemcompleted': + return 'item_completed'; + case 'threadstatuschanged': + return 'thread_status_changed'; + case 'turnstarted': + return 'turn_started'; + case 'turncompleted': + return 'turn_completed'; + case 'serverrequestresolved': + return 'server_request_resolved'; + default: + return isAgentDeltaNotificationMethod(normalized) ? 'assistant_delta' : null; + } +} + +function isThreadLevelApprovedExecutionSignal(signalKind: string): boolean { + return signalKind === 'thread_status_changed' + || signalKind === 'turn_completed' + || signalKind === 'server_request_resolved'; +} + +function summarizeApprovedExecution(entry: ApprovedExecution) { + return { + requestId: entry.requestId, + kind: entry.kind, + threadId: entry.threadId, + turnId: entry.turnId, + itemId: entry.itemId, + commandPreview: truncateDebugText(entry.command, 120), + approvedAt: entry.approvedAt, + lastSignalAt: entry.lastSignalAt, + lastSignalKind: entry.lastSignalKind, + signalCount: entry.signalCount, + completedAt: entry.completedAt, + }; +} + +function summarizeApprovedExecutionSignal(entry: ApprovedExecution, signalKind: string) { + return { + requestId: entry.requestId, + threadId: entry.threadId, + turnId: entry.turnId, + itemId: entry.itemId, + signalKind, + signalCount: entry.signalCount, + commandPreview: truncateDebugText(entry.command, 120), + completedAt: entry.completedAt, + }; +} + +function normalizeNullableString(value: unknown): string | null { + const normalized = String(value ?? '').trim(); + return normalized || null; +} + +function normalizeStringList(value: unknown): string[] { + return Array.isArray(value) + ? value.map((entry) => String(entry ?? '').trim()).filter(Boolean) + : []; +} + +function normalizeBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +function formatConfigKeyPath(segments: string[]): string { + return segments + .map((segment) => { + const value = String(segment ?? '').trim(); + if (/^[A-Za-z0-9_]+$/u.test(value)) { + return value; + } + return `"${value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"')}"`; + }) + .join('.'); +} + +function serializeCollaborationMode({ collaborationMode, model, effort, developerInstructions = '' }: any) { + if (!collaborationMode) { + return null; + } + const settings: any = { + model, + developer_instructions: developerInstructions, + }; + if (effort) { + settings.reasoning_effort = effort; + } + if (collaborationMode === 'default') { + return { + mode: 'default', + settings, + }; + } + return { + mode: collaborationMode, + settings, + }; +} + +export function createNoopLogger() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +export function createStderrLogger({ + envVar = 'CODEX_NATIVE_API_DEBUG', +}: { + envVar?: string; +} = {}) { + if (process.env[envVar] !== '1') { + return createNoopLogger(); + } + return { + debug(message: string) { + writeSequencedStderrLine(message); + }, + info(message: string) { + writeSequencedStderrLine(message); + }, + warn(message: string) { + writeSequencedStderrLine(message); + }, + error(message: string) { + writeSequencedStderrLine(message); + }, + }; +} + +function normalizeFeatureList(features: string[]): string[] { + const normalized = []; + const seen = new Set<string>(); + for (const feature of features) { + if (typeof feature !== 'string') { + continue; + } + const value = feature.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + normalized.push(value); + } + return normalized; +} + +function summarizeTurnInput(input: CodexTurnInput[]) { + return input.map((item) => { + if (item.type === 'text') { + return { + type: item.type, + textPreview: truncateDebugText(item.text, 160), + }; + } + return { + type: item.type, + path: item.path, + }; + }); +} + +function summarizeRpcParams(method: string, params: any) { + switch (method) { + case 'thread/goal/get': + case 'thread/goal/clear': + case 'thread/archive': + case 'thread/unarchive': + return { + threadId: String(params?.threadId ?? ''), + }; + case 'thread/goal/set': + return { + threadId: String(params?.threadId ?? ''), + objective: typeof params?.objective === 'string' ? params.objective : null, + status: typeof params?.status === 'string' ? params.status : null, + }; + case 'thread/read': + return { + threadId: String(params?.threadId ?? ''), + includeTurns: Boolean(params?.includeTurns), + }; + case 'thread/start': + return { + cwd: params?.cwd ?? null, + title: params?.title ?? null, + model: params?.model ?? null, + serviceTier: params?.serviceTier ?? null, + sandbox: params?.sandbox ?? null, + approvalPolicy: params?.approvalPolicy ?? null, + ephemeral: params?.ephemeral ?? null, + }; + case 'turn/start': + return { + threadId: String(params?.threadId ?? ''), + cwd: params?.cwd ?? null, + model: params?.model ?? null, + serviceTier: params?.serviceTier ?? null, + effort: params?.effort ?? null, + approvalPolicy: params?.approvalPolicy ?? null, + sandboxPolicy: params?.sandboxPolicy ?? null, + collaborationMode: params?.collaborationMode?.mode ?? null, + inputSummary: summarizeTurnInput(Array.isArray(params?.input) ? params.input : []), + }; + case 'turn/interrupt': + return { + threadId: String(params?.threadId ?? ''), + turnId: String(params?.turnId ?? ''), + }; + default: + return summarizePlainObject(params); + } +} + +function summarizeRpcResult(method: string, result: any) { + switch (method) { + case 'thread/goal/get': + case 'thread/goal/set': + return mapThreadGoal(result?.goal ?? null); + case 'thread/goal/clear': + return { + cleared: result?.cleared === true, + }; + case 'thread/archive': + return {}; + case 'thread/unarchive': + return { + threadId: String(result?.thread?.id ?? ''), + }; + case 'thread/read': + return summarizeThreadReadResult(result?.thread ?? null); + case 'thread/start': + return { + threadId: String(result?.thread?.id ?? ''), + cwd: result?.cwd ?? null, + }; + case 'turn/start': + return { + turnId: String(result?.turn?.id ?? ''), + status: String(result?.turn?.status ?? ''), + }; + default: + return summarizePlainObject(result); + } +} + +function summarizeNotificationMessage(message: any) { + return { + method: String(message?.method ?? ''), + id: 'id' in (message ?? {}) ? String(message.id ?? '') : null, + threadId: extractThreadIdFromNotification(message), + turnId: extractNotificationTurnId(message?.params ?? null), + itemId: extractItemId(message?.params ?? null), + outputKind: typeof message?.params?.item?.output_kind === 'string' + ? message.params.item.output_kind + : null, + }; +} + +function summarizeThreadReadResult(thread: any) { + if (!thread) { + return null; + } + const turns = Array.isArray(thread?.turns) ? thread.turns : []; + return { + threadId: String(thread?.id ?? ''), + title: typeof thread?.name === 'string' ? thread.name : null, + path: typeof thread?.path === 'string' ? thread.path : null, + turnCount: turns.length, + turns: turns.slice(-3).map((turn) => summarizeTurnSnapshot(turn)), + }; +} + +function mapThreadGoal(raw: any): ProviderThreadGoal | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const objective = typeof raw.objective === 'string' ? raw.objective.trim() : ''; + if (!objective) { + return null; + } + return { + threadId: String(raw.threadId ?? raw.thread_id ?? ''), + objective, + status: typeof raw.status === 'string' ? raw.status : 'active', + tokenBudget: Number.isFinite(raw.tokenBudget) ? Number(raw.tokenBudget) : null, + tokensUsed: Number.isFinite(raw.tokensUsed) ? Number(raw.tokensUsed) : null, + timeUsedSeconds: Number.isFinite(raw.timeUsedSeconds) ? Number(raw.timeUsedSeconds) : null, + createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null, + }; +} + +function summarizeTurnSnapshot(turn: any) { + if (!turn) { + return null; + } + const items = Array.isArray(turn?.items) ? turn.items : []; + return { + id: String(turn?.id ?? ''), + status: String(turn?.status ?? ''), + itemCount: items.length, + visibleItemCount: items.filter((item) => isAssistantVisibleItem(item) || isUserVisibleItem(item)).length, + outputTextPresent: Boolean(extractTurnOutputText(turn)), + outputArtifactCount: extractTurnOutputArtifacts(turn).length, + error: typeof turn?.error === 'string' ? turn.error : null, + }; +} + +function summarizeProgressState(progressState: Partial<ProgressState>) { + return { + commentaryLength: String(progressState?.commentaryText ?? '').length, + finalAnswerLength: String(progressState?.finalAnswerText ?? '').length, + sawAssistantActivity: Boolean(progressState?.sawAssistantActivity), + lastAssistantActivityAt: progressState?.lastAssistantActivityAt ?? 0, + }; +} + +interface SessionTurnCompletionState { + hasTaskComplete: boolean; + lastAgentMessage: string | null; + toolSuggestionMessage: string | null; + responseItems: ProviderResponseItem[]; + outputArtifacts: Array<{ kind?: string | null; path?: string | null }>; + runtimeError: string | null; +} + +function summarizeSessionState( + sessionPath: string | null | undefined, + sessionState: SessionTurnCompletionState, +) { + return { + sessionPath: sessionPath ?? null, + hasTaskComplete: sessionState.hasTaskComplete, + lastAgentMessagePreview: truncateDebugText(sessionState.lastAgentMessage, 160), + toolSuggestionPreview: truncateDebugText(sessionState.toolSuggestionMessage, 160), + runtimeError: truncateDebugText(sessionState.runtimeError, 160), + responseItemCount: sessionState.responseItems.length, + responseItemTypes: sessionState.responseItems + .map((item) => typeof item?.type === 'string' ? item.type : null) + .filter((value): value is string => Boolean(value)) + .slice(0, 8), + outputArtifactCount: sessionState.outputArtifacts.length, + outputArtifacts: sessionState.outputArtifacts.map((artifact) => ({ + kind: artifact.kind ?? null, + path: artifact.path ?? null, + })), + }; +} + +function summarizeTurnResultForDebug(result: ProviderTurnResult) { + return { + threadId: result.threadId ?? null, + turnId: result.turnId ?? null, + status: result.status ?? null, + outputState: result.outputState ?? null, + finalSource: result.finalSource ?? null, + errorMessage: truncateDebugText(result.errorMessage, 160), + outputTextPreview: truncateDebugText(result.outputText, 160), + previewTextPreview: truncateDebugText(result.previewText, 160), + responseItemCount: Array.isArray(result.responseItems) ? result.responseItems.length : 0, + responseItemTypes: Array.isArray(result.responseItems) + ? result.responseItems + .map((item) => typeof item?.type === 'string' ? item.type : null) + .filter((value): value is string => Boolean(value)) + .slice(0, 8) + : [], + outputArtifactCount: Array.isArray(result.outputArtifacts) ? result.outputArtifacts.length : 0, + outputArtifacts: Array.isArray(result.outputArtifacts) + ? result.outputArtifacts.map((artifact) => ({ + kind: artifact.kind ?? null, + path: artifact.path ?? null, + caption: truncateDebugText(artifact.caption, 120), + })) + : [], + }; +} + +function summarizePlainObject(value: any) { + if (!value || typeof value !== 'object') { + return value ?? null; + } + const summary: Record<string, unknown> = {}; + Object.keys(value).slice(0, 12).forEach((key) => { + const raw = value[key]; + if (raw == null || typeof raw === 'number' || typeof raw === 'boolean') { + summary[key] = raw; + return; + } + if (typeof raw === 'string') { + summary[key] = truncateDebugText(raw, 120); + return; + } + if (Array.isArray(raw)) { + summary[key] = { length: raw.length }; + return; + } + summary[key] = { keys: Object.keys(raw).slice(0, 8) }; + }); + return summary; +} + +function extractThreadIdFromNotification(message: any): string | null { + const params = message?.params ?? null; + if (typeof params?.threadId === 'string') { + return params.threadId; + } + if (typeof params?.conversationId === 'string') { + return params.conversationId; + } + if (typeof params?.item?.threadId === 'string') { + return params.item.threadId; + } + if (typeof params?.event?.threadId === 'string') { + return params.event.threadId; + } + return null; +} + +function truncateDebugText(value: unknown, limit = 240): string { + const text = String(value ?? '').replace(/\s+/gu, ' ').trim(); + if (!text) { + return ''; + } + return text.length <= limit ? text : `${text.slice(0, limit)}...`; +} + +function mapThreadSummary(raw) { + return { + threadId: String(raw.id), + title: raw.name ? String(raw.name) : null, + cwd: raw.cwd ? String(raw.cwd) : null, + updatedAt: normalizeTimestamp(raw.updatedAt), + preview: typeof raw.preview === 'string' ? raw.preview : '', + }; +} + +function mapThread(raw, includeTurns) { + return { + threadId: String(raw.id), + title: raw.name ? String(raw.name) : null, + cwd: raw.cwd ? String(raw.cwd) : null, + path: raw.path ? String(raw.path) : null, + updatedAt: normalizeTimestamp(raw.updatedAt), + preview: typeof raw.preview === 'string' ? raw.preview : '', + turns: includeTurns && Array.isArray(raw.turns) ? raw.turns.map(mapTurn) : [], + }; +} + +function normalizeTimestamp(value) { + const numeric = Number(value || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric; +} + +function mapTurn(raw) { + return { + id: String(raw?.id ?? ''), + status: extractStructuredString(raw?.status), + error: extractStructuredString(raw?.error), + items: Array.isArray(raw?.items) ? raw.items.map(mapTurnItem) : [], + }; +} + +function mapTurnItem(raw) { + return { + type: typeof raw?.type === 'string' ? raw.type : 'unknown', + role: typeof raw?.role === 'string' ? raw.role : null, + phase: typeof raw?.phase === 'string' ? raw.phase : null, + text: extractStructuredText(raw), + savedPath: extractStructuredString(raw?.savedPath), + result: extractStructuredString(raw?.result), + }; +} + +function mapModel(raw) { + return { + id: String(raw.id), + model: String(raw.model), + displayName: String(raw.displayName || raw.model), + description: String(raw.description || ''), + isDefault: Boolean(raw.isDefault), + supportedReasoningEfforts: Array.isArray(raw.supportedReasoningEfforts) + ? raw.supportedReasoningEfforts + .map((entry) => entry?.reasoningEffort) + .filter((value) => typeof value === 'string') + : [], + defaultReasoningEffort: typeof raw.defaultReasoningEffort === 'string' ? raw.defaultReasoningEffort : null, + }; +} + +function mapAppServerRateLimits(payload: CodexAppRateLimitsResponse | null | undefined): ProviderUsageReport | null { + if (!payload || typeof payload !== 'object') { + return null; + } + const report: ProviderUsageReport = { + provider: 'codex', + accountId: null, + userId: null, + email: null, + plan: null, + buckets: [], + credits: null, + }; + const snapshots: CodexAppRateLimitSnapshot[] = []; + if (payload.rateLimitsByLimitId && typeof payload.rateLimitsByLimitId === 'object') { + const keys = Object.keys(payload.rateLimitsByLimitId).sort(); + for (const key of keys) { + const snapshot = payload.rateLimitsByLimitId[key]; + if (snapshot && typeof snapshot === 'object') { + snapshots.push(snapshot); + } + } + } else if (payload.rateLimits && typeof payload.rateLimits === 'object') { + if (payload.rateLimits.limitId || payload.rateLimits.primary || payload.rateLimits.secondary || payload.rateLimits.credits) { + snapshots.push(payload.rateLimits); + } + } + + for (const snapshot of snapshots) { + if (!report.plan && typeof snapshot.planType === 'string' && snapshot.planType.trim()) { + report.plan = snapshot.planType.trim(); + } + if (!report.credits && snapshot.credits && typeof snapshot.credits === 'object') { + report.credits = { + hasCredits: Boolean(snapshot.credits.hasCredits), + unlimited: Boolean(snapshot.credits.unlimited), + balance: typeof snapshot.credits.balance === 'string' && snapshot.credits.balance.trim() + ? snapshot.credits.balance.trim() + : null, + }; + } + const windows = appServerUsageWindows(snapshot); + if (!windows.length) { + continue; + } + const limitReached = windows.some((window) => window.usedPercent >= 100); + report.buckets.push({ + name: appServerBucketName(snapshot), + allowed: !limitReached, + limitReached, + windows, + }); + } + + return report; +} + +function mapSkillToolDependency(raw: CodexAppSkillToolDependency): ProviderSkillToolDependency | null { + const type = normalizeNullableString(raw?.type); + const value = normalizeNullableString(raw?.value); + if (!type || !value) { + return null; + } + return { + type, + value, + command: normalizeNullableString(raw?.command), + description: normalizeNullableString(raw?.description), + transport: normalizeNullableString(raw?.transport), + url: normalizeNullableString(raw?.url), + }; +} + +function mapSkillMetadata(raw: CodexAppSkillMetadata): ProviderSkillInfo | null { + const name = normalizeNullableString(raw?.name); + const description = normalizeNullableString(raw?.description); + const skillPath = normalizeNullableString(raw?.path); + const scope = normalizeNullableString(raw?.scope); + if (!name || !description || !skillPath || !scope) { + return null; + } + const dependencies = Array.isArray(raw?.dependencies?.tools) + ? raw.dependencies.tools.map(mapSkillToolDependency).filter(Boolean) + : []; + return { + name, + description, + enabled: raw?.enabled !== false, + path: skillPath, + scope, + shortDescription: normalizeNullableString(raw?.interface?.shortDescription) + ?? normalizeNullableString(raw?.shortDescription), + displayName: normalizeNullableString(raw?.interface?.displayName), + defaultPrompt: normalizeNullableString(raw?.interface?.defaultPrompt), + brandColor: normalizeNullableString(raw?.interface?.brandColor), + dependencies, + }; +} + +function mapSkillErrorInfo(raw: CodexAppSkillErrorInfo): ProviderSkillError | null { + const skillPath = normalizeNullableString(raw?.path); + const message = normalizeNullableString(raw?.message); + if (!skillPath || !message) { + return null; + } + return { + path: skillPath, + message, + }; +} + +function mapPluginLoadError(raw: CodexAppMarketplaceLoadError): ProviderPluginLoadError | null { + const marketplacePath = normalizeNullableString(raw?.marketplacePath); + const message = normalizeNullableString(raw?.message); + if (!marketplacePath || !message) { + return null; + } + return { + marketplacePath, + message, + }; +} + +function mapPluginMarketplace(raw: CodexAppPluginMarketplace): ProviderPluginMarketplace | null { + const name = normalizeNullableString(raw?.name); + if (!name) { + return null; + } + return { + name, + path: normalizeNullableString(raw?.path), + displayName: normalizeNullableString(raw?.interface?.displayName), + plugins: Array.isArray(raw?.plugins) + ? raw.plugins.map((plugin) => mapPluginSummary(plugin, { + marketplaceName: name, + marketplacePath: normalizeNullableString(raw?.path), + marketplaceDisplayName: normalizeNullableString(raw?.interface?.displayName), + })).filter(Boolean) as ProviderPluginSummary[] + : [], + }; +} + +function mapPluginSummary( + raw: CodexAppPluginSummary | null | undefined, + context: { + marketplaceName?: string | null; + marketplacePath?: string | null; + marketplaceDisplayName?: string | null; + } = {}, +): ProviderPluginSummary | null { + const id = normalizeNullableString(raw?.id); + const name = normalizeNullableString(raw?.name); + if (!id || !name) { + return null; + } + const sourceType = normalizeNullableString((raw?.source as any)?.type); + const defaultPrompts = Array.isArray(raw?.interface?.defaultPrompt) + ? raw.interface.defaultPrompt.map((entry) => normalizeNullableString(entry)).filter(Boolean) as string[] + : []; + return { + id, + name, + installed: raw?.installed !== false, + enabled: raw?.enabled !== false, + installPolicy: normalizeNullableString(raw?.installPolicy) ?? 'AVAILABLE', + authPolicy: normalizeNullableString(raw?.authPolicy) ?? 'ON_USE', + marketplaceName: normalizeNullableString(context.marketplaceName) ?? 'unknown', + marketplacePath: normalizeNullableString(context.marketplacePath), + marketplaceDisplayName: normalizeNullableString(context.marketplaceDisplayName), + displayName: normalizeNullableString(raw?.interface?.displayName), + shortDescription: normalizeNullableString(raw?.interface?.shortDescription), + longDescription: normalizeNullableString(raw?.interface?.longDescription), + category: normalizeNullableString(raw?.interface?.category), + capabilities: Array.isArray(raw?.interface?.capabilities) + ? raw.interface.capabilities.map((entry) => String(entry ?? '').trim()).filter(Boolean) + : [], + developerName: normalizeNullableString(raw?.interface?.developerName), + brandColor: normalizeNullableString(raw?.interface?.brandColor), + defaultPrompts, + websiteUrl: normalizeNullableString(raw?.interface?.websiteUrl), + sourceType, + sourcePath: normalizeNullableString((raw?.source as any)?.path), + sourceRemoteMarketplaceName: normalizeNullableString((raw?.source as any)?.marketplaceName), + }; +} + +function mapPluginSkillSummary(raw: CodexAppPluginSkillSummary): ProviderPluginSkillSummary | null { + const name = normalizeNullableString(raw?.name); + const skillPath = normalizeNullableString(raw?.path); + const description = normalizeNullableString(raw?.description); + if (!name || !skillPath || !description) { + return null; + } + return { + name, + path: skillPath, + description, + enabled: raw?.enabled !== false, + shortDescription: normalizeNullableString(raw?.shortDescription), + displayName: normalizeNullableString(raw?.interface?.displayName), + }; +} + +function mapPluginAppSummary(raw: CodexAppPluginAppSummary): ProviderPluginAppSummary | null { + const id = normalizeNullableString(raw?.id); + const name = normalizeNullableString(raw?.name); + if (!id || !name) { + return null; + } + return { + id, + name, + needsAuth: Boolean(raw?.needsAuth), + description: normalizeNullableString(raw?.description), + installUrl: normalizeNullableString(raw?.installUrl), + }; +} + +function mapPluginDetail( + raw: CodexAppPluginDetail | null | undefined, + fallback: { + marketplaceName?: string | null; + marketplacePath?: string | null; + } = {}, +): ProviderPluginDetail | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const summary = mapPluginSummary(raw.summary ?? null, { + marketplaceName: normalizeNullableString(raw?.marketplaceName) ?? normalizeNullableString(fallback.marketplaceName), + marketplacePath: normalizeNullableString(raw?.marketplacePath) ?? normalizeNullableString(fallback.marketplacePath), + }); + if (!summary) { + return null; + } + return { + summary, + marketplaceName: normalizeNullableString(raw?.marketplaceName) ?? summary.marketplaceName, + marketplacePath: normalizeNullableString(raw?.marketplacePath) ?? summary.marketplacePath, + description: normalizeNullableString(raw?.description), + apps: Array.isArray(raw?.apps) ? raw.apps.map(mapPluginAppSummary).filter(Boolean) as ProviderPluginAppSummary[] : [], + mcpServers: Array.isArray(raw?.mcpServers) ? raw.mcpServers.map((entry) => String(entry ?? '').trim()).filter(Boolean) : [], + skills: Array.isArray(raw?.skills) ? raw.skills.map(mapPluginSkillSummary).filter(Boolean) as ProviderPluginSkillSummary[] : [], + }; +} + +function mapAppInfo(raw: CodexAppInfo): ProviderAppInfo | null { + const id = normalizeNullableString(raw?.id); + const name = normalizeNullableString(raw?.name); + if (!id || !name) { + return null; + } + const categories = Array.isArray(raw?.appMetadata?.categories) + ? raw.appMetadata.categories.map((entry) => normalizeNullableString(entry)).filter(Boolean) as string[] + : []; + return { + id, + name, + description: normalizeNullableString(raw?.description), + installUrl: normalizeNullableString(raw?.installUrl), + isAccessible: Boolean(raw?.isAccessible), + isEnabled: raw?.isEnabled !== false, + pluginDisplayNames: Array.isArray(raw?.pluginDisplayNames) + ? raw.pluginDisplayNames.map((entry) => String(entry ?? '').trim()).filter(Boolean) + : [], + categories, + developer: normalizeNullableString(raw?.appMetadata?.developer) + ?? normalizeNullableString(raw?.branding?.developer), + }; +} + +function mapMcpServerStatus(raw: CodexAppMcpServerStatus): ProviderMcpServerStatus | null { + const name = normalizeNullableString(raw?.name); + if (!name) { + return null; + } + return { + name, + isEnabled: raw?.isEnabled !== false, + authStatus: normalizeNullableString(raw?.authStatus) ?? 'unsupported', + toolCount: raw?.tools && typeof raw.tools === 'object' ? Object.keys(raw.tools).length : 0, + resourceCount: Array.isArray(raw?.resources) ? raw.resources.length : 0, + resourceTemplateCount: Array.isArray(raw?.resourceTemplates) ? raw.resourceTemplates.length : 0, + }; +} + +function appServerBucketName(snapshot: CodexAppRateLimitSnapshot): string { + if (typeof snapshot.limitName === 'string' && snapshot.limitName.trim()) { + return snapshot.limitName.trim(); + } + if (typeof snapshot.limitId === 'string' && snapshot.limitId.trim()) { + return snapshot.limitId.trim(); + } + return 'Rate limit'; +} + +function appServerUsageWindows(snapshot: CodexAppRateLimitSnapshot) { + const windows = [] as Array<{ + name: string; + usedPercent: number; + windowSeconds: number; + resetAfterSeconds: number; + resetAtUnix: number; + }>; + if (snapshot.primary) { + windows.push(appServerUsageWindow('Primary', snapshot.primary)); + } + if (snapshot.secondary) { + windows.push(appServerUsageWindow('Secondary', snapshot.secondary)); + } + return windows; +} + +function appServerUsageWindow(name: string, window: CodexAppRateLimitWindow) { + const rawUsedPercent = Number(window?.usedPercent ?? 0); + const usedPercent = Number.isFinite(rawUsedPercent) + ? Math.max(0, Math.min(100, Math.round(rawUsedPercent))) + : 0; + const rawWindowMinutes = Number(window?.windowDurationMins ?? 0); + const windowSeconds = Number.isFinite(rawWindowMinutes) + ? Math.max(0, Math.round(rawWindowMinutes * 60)) + : 0; + const resetAtUnix = Math.max(0, Math.floor(Number(window?.resetsAt ?? 0))); + const nowSeconds = Math.floor(Date.now() / 1000); + const resetAfterSeconds = resetAtUnix > 0 ? Math.max(0, resetAtUnix - nowSeconds) : 0; + return { + name, + usedPercent, + windowSeconds, + resetAfterSeconds, + resetAtUnix, + }; +} + +function mergeModelCatalog(baseModels, overlayModels) { + if (overlayModels.length === 0) { + return baseModels; + } + const overlayKeys = new Set(overlayModels.map((model) => model.model)); + const hasOverlayDefault = overlayModels.some((model) => model.isDefault); + const merged = overlayModels.map((overlay) => { + const base = baseModels.find((model) => model.model === overlay.model) ?? null; + return { + ...(base ?? {}), + ...overlay, + isDefault: overlay.isDefault || (!hasOverlayDefault && Boolean(base?.isDefault)), + }; + }); + for (const base of baseModels) { + if (!overlayKeys.has(base.model)) { + merged.push({ + ...base, + isDefault: hasOverlayDefault ? false : base.isDefault, + }); + } + } + return merged; +} + +function mapSandboxPolicy(mode) { + if (mode === 'read-only') { + return { type: 'readOnly' }; + } + if (mode === 'danger-full-access') { + return { type: 'dangerFullAccess' }; + } + return { type: 'workspaceWrite' }; +} + +const TERMINAL_TURN_STATUS_KEYS = new Set([ + 'completed', + 'complete', + 'succeeded', + 'success', + 'finished', + 'failed', + 'error', + 'timedout', + 'timeout', + 'interrupted', + 'cancelled', + 'canceled', + 'aborted', +]); + +function normalizeTurnStatusKey(status) { + return String(status ?? '') + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ''); +} + +function isTurnTerminal(status) { + const normalized = normalizeTurnStatusKey(status); + return Boolean(normalized) && TERMINAL_TURN_STATUS_KEYS.has(normalized); +} + +function isThreadMaterializationPendingError(error) { + const message = error instanceof Error ? error.message : String(error); + return /not materialized yet/i.test(message) + || /includeTurns is unavailable before first user message/i.test(message) + || /empty session file/i.test(message); +} + +function isIncludeTurnsUnsupportedError(error) { + const message = error instanceof Error ? error.message : String(error); + return /ephemeral threads do not support includeTurns/i.test(message); +} + +function isRequestTimeoutError(error) { + const message = error instanceof Error ? error.message : String(error); + return /Timed out waiting for Codex JSON-RPC response to /i.test(message); +} + +function isTerminalNotificationForThread( + notification: any, + threadId: string, + turnId: string, +): boolean { + if (extractThreadIdFromNotification(notification) !== threadId) { + return false; + } + const method = String(notification?.method ?? '').replace(/[^a-z]/gi, '').toLowerCase(); + if (method === 'turncompleted') { + return true; + } + if (method === 'itemcompleted') { + const notificationTurnId = extractNotificationTurnId(notification?.params ?? null); + return !notificationTurnId || notificationTurnId === turnId; + } + return false; +} + +function computeTerminalSettleMs(timeoutMs) { + const numericTimeout = Number(timeoutMs || 0); + if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) { + return 60_000; + } + return Math.min(60_000, Math.max(10_000, Math.floor(numericTimeout / 2))); +} + +function computeApprovedExecutionIdleLimitMs(timeoutMs) { + const numericTimeout = Number(timeoutMs || 0); + if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) { + return 300_000; + } + return Math.min(Math.max(180_000, Math.floor(numericTimeout / 3)), 300_000); +} + +function buildApprovedExecutionStallError({ + entry, + idleMs, +}: { + entry: ApprovedExecution; + idleMs: number; +}) { + const idleSeconds = Math.max(1, Math.round(idleMs / 1000)); + const kindLabel = entry.kind === 'command' + ? 'command' + : entry.kind === 'file_change' + ? 'file change' + : 'permission grant'; + const commandSuffix = entry.command + ? ` (${truncateDebugText(entry.command, 120)})` + : ''; + if (entry.signalCount === 0) { + return `Approval was accepted, but the approved ${kindLabel}${commandSuffix} produced no follow-up signal for ${idleSeconds} seconds. The provider may be stuck; use /retry to try again.`; + } + return `Approval was accepted, but the approved ${kindLabel}${commandSuffix} stopped making progress after ${entry.lastSignalKind} and stayed idle for ${idleSeconds} seconds. The provider may be stuck; use /retry to try again.`; +} + +const INTERRUPTED_PATTERN = /interrupt|interrupted|cancel(?:led)?|aborted?|stopped by user|用户中断|已中断/i; + +function classifyTurnCompletionState(turn) { + const haystack = `${String(turn?.status ?? '')}\n${String(turn?.error ?? '')}`.trim(); + if (!haystack) { + return 'unknown'; + } + if (INTERRUPTED_PATTERN.test(haystack)) { + return 'interrupted'; + } + return 'other'; +} + +function extractTurnOutputText(turn) { + return turn.items + .filter((item) => + isAssistantVisibleItem(item) + && classifyAgentOutput(extractAgentPhase(item), true) === 'final_answer') + .map((item) => item.text) + .filter(Boolean) + .join('\n\n') + .trim(); +} + +function extractTurnCommentaryText(turn) { + return turn.items + .filter((item) => + isAssistantVisibleItem(item) + && classifyAgentOutput(extractAgentPhase(item), true) !== 'final_answer') + .map((item) => item.text) + .filter(Boolean) + .join('\n\n') + .trim(); +} + +function resolveTurnPreviewText(turn, progressState: Partial<ProgressState> = {}) { + return progressState.finalAnswerText + || progressState.commentaryText + || extractTurnCommentaryText(turn); +} + +function extractTurnOutputArtifacts(turn) { + const seen = new Set<string>(); + return turn.items + .flatMap((item) => extractOutputArtifactFromItem(item)) + .filter((item) => { + const key = `${item.kind}:${item.path}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function normalizeLegacyImageMedia(artifacts) { + return artifacts.filter((artifact) => artifact?.kind === 'image'); +} + +function extractOutputArtifactFromItem(item) { + const savedPath = typeof item?.savedPath === 'string' ? item.savedPath.trim() : ''; + if (savedPath && fs.existsSync(savedPath)) { + return [buildArtifactFromFilePath(savedPath)]; + } + const result = typeof item?.result === 'string' ? item.result.trim() : ''; + if (result && isLocalFilePath(result) && fs.existsSync(result)) { + return [buildArtifactFromFilePath(result)]; + } + if (isRemoteImageUrl(result)) { + return [{ + kind: 'image' as const, + path: result, + displayName: path.basename(new URL(result).pathname) || null, + mimeType: inferMimeTypeFromPath(result), + sizeBytes: null, + caption: null, + source: 'provider_native' as const, + turnId: null, + }]; + } + if (String(item?.type ?? '') === 'imageGeneration') { + const inlineImage = decodeInlineImagePayload(result); + if (inlineImage) { + const outputPath = materializeInlineImage(savedPath, inlineImage); + if (outputPath) { + return [buildArtifactFromFilePath(outputPath)]; + } + } + } + return []; +} + +function buildArtifactFromFilePath(filePath) { + const normalizedPath = String(filePath ?? '').trim(); + const kind = inferArtifactKindFromPath(normalizedPath); + let sizeBytes = null; + try { + sizeBytes = fs.statSync(normalizedPath).size; + } catch { + sizeBytes = null; + } + return { + kind, + path: normalizedPath, + displayName: path.basename(normalizedPath) || null, + mimeType: inferMimeTypeFromPath(normalizedPath), + sizeBytes, + caption: null, + source: 'provider_native' as const, + turnId: null, + }; +} + +function inferArtifactKindFromPath(filePath) { + const extension = path.extname(String(filePath ?? '')).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(extension)) { + return 'image'; + } + if (['.mp4', '.mov', '.mkv', '.webm'].includes(extension)) { + return 'video'; + } + if (['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.amr'].includes(extension)) { + return 'audio'; + } + return 'file'; +} + +function inferMimeTypeFromPath(filePath) { + const extension = path.extname(String(filePath ?? '')).toLowerCase(); + return ({ + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.csv': 'text/csv', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.json': 'application/json', + '.html': 'text/html', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.tgz': 'application/gzip', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.m4a': 'audio/mp4', + })[extension] ?? null; +} + +function isLocalFilePath(value) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + return false; + } + if (/^(?:https?:)?\/\//iu.test(normalized)) { + return false; + } + if (/^data:/iu.test(normalized)) { + return false; + } + return path.isAbsolute(normalized); +} + +function extractAllAssistantVisibleText(turn) { + return turn.items + .filter((item) => isAssistantVisibleItem(item)) + .map((item) => item.text) + .filter(Boolean) + .join('\n\n') + .trim(); +} + +function isRemoteImageUrl(value) { + return /^https?:\/\/\S+/iu.test(String(value ?? '')); +} + +function decodeInlineImagePayload(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + const dataUrlMatch = raw.match(/^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\r\n]+)$/iu); + const base64 = dataUrlMatch?.[2] ?? (looksLikeBase64Image(raw) ? raw : ''); + if (!base64) { + return null; + } + try { + const buffer = Buffer.from(base64.replace(/\s+/g, ''), 'base64'); + return buffer.length > 0 ? buffer : null; + } catch { + return null; + } +} + +function looksLikeBase64Image(value) { + const normalized = String(value ?? '').replace(/\s+/g, ''); + if (!normalized || normalized.length < 64 || normalized.length % 4 !== 0) { + return false; + } + return /^[A-Za-z0-9+/=]+$/u.test(normalized); +} + +function materializeInlineImage(savedPath, buffer) { + if (savedPath) { + try { + fs.mkdirSync(path.dirname(savedPath), { recursive: true }); + fs.writeFileSync(savedPath, buffer); + return savedPath; + } catch { + return null; + } + } + try { + const fallbackPath = path.join(os.tmpdir(), `codex-native-api-inline-image-${Date.now()}.png`); + fs.writeFileSync(fallbackPath, buffer); + return fallbackPath; + } catch { + return null; + } +} + +function inspectTurnCompletionFromSessionPath(sessionPath, turnId) { + if (!sessionPath || !turnId || !fs.existsSync(sessionPath)) { + return emptySessionTurnCompletionState(); + } + try { + const lines = fs.readFileSync(sessionPath, 'utf8').split('\n'); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let entry = null; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const payload = entry?.payload ?? null; + if (entry?.type !== 'event_msg' || payload?.type !== 'task_complete') { + continue; + } + if (String(payload.turn_id ?? '') !== turnId) { + continue; + } + const responseItems = extractSessionResponseItemsForTurn(lines, index, turnId); + const lastAgentMessage = selectSessionAgentMessage( + responseItems, + extractTextCandidate(payload.last_agent_message)?.trim() || null, + ); + const toolSuggestionMessage = findSessionToolSuggestionMessageForTurn(lines, index, turnId); + const runtimeError = findSessionRuntimeErrorForTurn(lines, index, turnId); + return inspectSessionTurnArtifacts(lines, index, { + hasTaskComplete: true, + lastAgentMessage, + toolSuggestionMessage, + responseItems, + runtimeError, + }); + } + } catch { + return emptySessionTurnCompletionState(); + } + return emptySessionTurnCompletionState(); +} + +function findSessionToolSuggestionMessageForTurn(lines: string[], taskCompleteIndex: number, turnId: string): string | null { + for (let index = taskCompleteIndex - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let entry: any = null; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const payload = entry?.payload ?? null; + if (entry?.type === 'turn_context' && String(payload?.turn_id ?? '') === turnId) { + break; + } + if (entry?.type === 'event_msg' && payload?.type === 'task_started' && String(payload?.turn_id ?? '') === turnId) { + break; + } + if (entry?.type !== 'response_item') { + continue; + } + const suggestion = extractToolSuggestResponseItemText(payload); + if (suggestion) { + return suggestion; + } + } + return null; +} + +function extractToolSuggestResponseItemText(payload: any): string | null { + if (String(payload?.type ?? '') !== 'function_call' || String(payload?.name ?? '') !== 'tool_suggest') { + return null; + } + let parsedArguments: any = null; + if (typeof payload?.arguments === 'string') { + try { + parsedArguments = JSON.parse(payload.arguments); + } catch { + parsedArguments = null; + } + } else if (payload?.arguments && typeof payload.arguments === 'object') { + parsedArguments = payload.arguments; + } + const reason = extractTextCandidate(parsedArguments?.suggest_reason)?.trim() || ''; + const toolType = String(parsedArguments?.tool_type ?? '').trim().toLowerCase(); + if (!reason) { + return null; + } + const prefix = toolType === 'connector' + ? '当前缺少所需连接。' + : toolType === 'plugin' + ? '当前缺少所需插件。' + : '当前缺少所需扩展能力。'; + return `${prefix}\n${reason}\n请先完成对应的安装或认证,再重试原请求。`; +} + +function findSessionRuntimeErrorForTurn(lines: string[], taskCompleteIndex: number, turnId: string): string | null { + for (let index = taskCompleteIndex - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let entry: any = null; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const payload = entry?.payload ?? null; + if (entry?.type === 'turn_context') { + if (String(payload?.turn_id ?? '') === turnId) { + break; + } + continue; + } + if (entry?.type !== 'event_msg') { + continue; + } + const eventType = String(payload?.type ?? ''); + if (eventType === 'task_started' && String(payload?.turn_id ?? '') === turnId) { + break; + } + if (eventType === 'token_count') { + const rateLimitError = describeSessionRateLimitError(payload?.rate_limits ?? payload?.rateLimits ?? null); + if (rateLimitError) { + return rateLimitError; + } + } + const message = extractSessionErrorMessage(payload); + if (message) { + return message; + } + } + return null; +} + +function extractSessionErrorMessage(payload: any): string | null { + const eventType = String(payload?.type ?? '').toLowerCase(); + if (!/error|failed|failure/.test(eventType)) { + return null; + } + return extractTextCandidate(payload?.message) + ?? extractTextCandidate(payload?.error) + ?? extractTextCandidate(payload); +} + +function describeSessionRateLimitError(rateLimits: any): string | null { + if (!rateLimits || typeof rateLimits !== 'object') { + return null; + } + const limitId = normalizeRateLimitString(rateLimits.limit_id ?? rateLimits.limitId) ?? 'codex'; + const credits = rateLimits.credits && typeof rateLimits.credits === 'object' + ? rateLimits.credits + : null; + if (credits) { + const hasCredits = normalizeRateLimitBoolean(credits.has_credits ?? credits.hasCredits); + const unlimited = normalizeRateLimitBoolean(credits.unlimited) === true; + const balance = normalizeRateLimitString(credits.balance); + if (hasCredits === false && !unlimited) { + return `Codex subscription credits are exhausted (${limitId} balance ${balance ?? '0'}).`; + } + } + const reachedType = normalizeRateLimitString(rateLimits.rate_limit_reached_type ?? rateLimits.rateLimitReachedType); + if (reachedType) { + return `Codex usage limit reached (${limitId}: ${reachedType}).`; + } + const primaryUsed = normalizeRateLimitNumber(rateLimits.primary?.used_percent ?? rateLimits.primary?.usedPercent); + if (primaryUsed !== null && primaryUsed >= 100) { + return `Codex usage limit reached (${limitId} primary ${Math.round(primaryUsed)}%).`; + } + const secondaryUsed = normalizeRateLimitNumber(rateLimits.secondary?.used_percent ?? rateLimits.secondary?.usedPercent); + if (secondaryUsed !== null && secondaryUsed >= 100) { + return `Codex usage limit reached (${limitId} weekly ${Math.round(secondaryUsed)}%).`; + } + return null; +} + +function normalizeRateLimitString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeRateLimitBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + if (normalized === 'false') { + return false; + } + } + return null; +} + +function normalizeRateLimitNumber(value: unknown): number | null { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function inspectSessionTurnArtifacts( + lines, + taskCompleteIndex, + state: Omit<SessionTurnCompletionState, 'outputArtifacts'>, +): SessionTurnCompletionState { + const outputArtifacts = []; + const seenArtifacts = new Set<string>(); + for (let index = taskCompleteIndex - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let entry = null; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const payload = entry?.payload ?? null; + if (entry?.type === 'event_msg' && payload?.type === 'task_started') { + break; + } + if (entry?.type !== 'event_msg' || payload?.type !== 'image_generation_end') { + continue; + } + const savedPath = typeof payload?.saved_path === 'string' ? payload.saved_path.trim() : ''; + if (!savedPath || !fs.existsSync(savedPath)) { + continue; + } + const artifact = buildArtifactFromFilePath(savedPath); + const key = `${artifact.kind}:${artifact.path}`; + if (seenArtifacts.has(key)) { + continue; + } + seenArtifacts.add(key); + outputArtifacts.unshift(artifact); + } + return { + hasTaskComplete: state.hasTaskComplete, + lastAgentMessage: state.lastAgentMessage || state.toolSuggestionMessage || null, + toolSuggestionMessage: state.toolSuggestionMessage ?? null, + responseItems: state.responseItems, + runtimeError: state.runtimeError ?? null, + outputArtifacts, + }; +} + +function buildSessionTaskCompleteResult({ + turnId, + threadId, + title, + status, + previewText, + sessionState, +}) { + return { + turnId, + threadId, + title, + outputText: sessionState.lastAgentMessage ?? '', + responseItems: sessionState.responseItems, + outputArtifacts: sessionState.outputArtifacts, + outputMedia: normalizeLegacyImageMedia(sessionState.outputArtifacts), + outputState: 'complete', + previewText, + finalSource: sessionState.outputArtifacts.length > 0 + ? 'session_task_complete_media' + : 'session_task_complete', + status, + }; +} + +function shouldWaitForSessionTaskMaterialization(sessionState, hasAssistantVisibleItems) { + return sessionState.hasTaskComplete + && !hasAssistantVisibleItems + && !sessionState.lastAgentMessage + && sessionState.outputArtifacts.length === 0; +} + +function emptySessionTurnCompletionState(): SessionTurnCompletionState { + return { + hasTaskComplete: false, + lastAgentMessage: null, + toolSuggestionMessage: null, + responseItems: [], + outputArtifacts: [], + runtimeError: null, + }; +} + +function extractSessionResponseItemsForTurn( + lines: string[], + taskCompleteIndex: number, + turnId: string, +): ProviderResponseItem[] { + const responseItems: ProviderResponseItem[] = []; + for (let index = taskCompleteIndex - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let entry: any = null; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const payload = entry?.payload ?? null; + if (entry?.type === 'turn_context' && String(payload?.turn_id ?? '') === turnId) { + break; + } + if (entry?.type === 'event_msg' && payload?.type === 'task_started' && String(payload?.turn_id ?? '') === turnId) { + break; + } + if (entry?.type !== 'response_item' || !payload || typeof payload !== 'object') { + continue; + } + responseItems.unshift(cloneSessionResponseItem(payload)); + } + return responseItems; +} + +function selectSessionAgentMessage( + responseItems: ProviderResponseItem[], + fallback: string | null, +): string | null { + for (let index = responseItems.length - 1; index >= 0; index -= 1) { + const payload = responseItems[index] as Record<string, unknown>; + if (String(payload?.type ?? '') !== 'message' || String(payload?.role ?? '') !== 'assistant') { + continue; + } + const phase = String(payload?.phase ?? ''); + if (phase && phase !== 'final_answer') { + continue; + } + const text = extractTextCandidate(payload?.content)?.trim() || null; + if (text) { + return text; + } + } + return fallback; +} + +function cloneSessionResponseItem(payload: Record<string, unknown>): ProviderResponseItem { + if (typeof structuredClone === 'function') { + return structuredClone(payload); + } + return JSON.parse(JSON.stringify(payload)); +} + +function attachSessionResponseItems( + result: ProviderTurnResult, + sessionPath: string | null | undefined, +): ProviderTurnResult { + if (!result.turnId || !sessionPath) { + return result; + } + const sessionState = inspectTurnCompletionFromSessionPath(sessionPath, result.turnId); + if (sessionState.responseItems.length === 0) { + return result; + } + return { + ...result, + responseItems: sessionState.responseItems, + }; +} + +function shouldWaitForTaskCompleteBeforeMissing(sessionPath, sessionState) { + return Boolean(String(sessionPath ?? '').trim()) && !sessionState.hasTaskComplete; +} + +function shouldWaitForSettledOutputAfterTerminalTurn(turn: any, progressState: Partial<ProgressState> = {}) { + const visibleItems = turn.items.filter((item) => item.text); + if (visibleItems.length === 0) { + return true; + } + if (progressState.finalAnswerText) { + return true; + } + return visibleItems.every((item) => { + if (isUserVisibleItem(item)) { + return true; + } + if (!isAssistantVisibleItem(item)) { + return false; + } + return classifyAgentOutput(extractAgentPhase(item), true) !== 'final_answer'; + }); +} + +function hasUnsettledAssistantActivity(turn: any, progressState: Partial<ProgressState> = {}) { + if (progressState.finalAnswerText) { + return true; + } + if (progressState.commentaryText || progressState.sawAssistantActivity) { + return true; + } + return turn.items.some((item) => { + if (!isAssistantVisibleItem(item)) { + return false; + } + return classifyAgentOutput(extractAgentPhase(item), true) !== 'final_answer' && Boolean(item.text); + }); +} + + +function buildTurnSnapshotKey(turn) { + const items = Array.isArray(turn?.items) ? turn.items : []; + return JSON.stringify({ + status: turn?.status ?? '', + error: turn?.error ?? '', + items: items.map((item) => ({ + type: item?.type ?? '', + role: item?.role ?? '', + phase: item?.phase ?? '', + text: item?.text ?? '', + })), + }); +} + +function extractProgressUpdate(notification, turnId, itemOutputKinds, progressState) { + if (!notification || typeof notification.method !== 'string') { + return null; + } + const params = notification.params ?? {}; + const notificationTurnId = extractNotificationTurnId(params); + if (!notificationTurnId || notificationTurnId !== turnId) { + return null; + } + const method = notification.method; + if (method === 'item/started' || method === 'item/completed') { + const item = params?.item ?? params; + if (!isAssistantVisibleItem(item)) { + return null; + } + const itemId = extractItemId(item); + const outputKind = classifyAgentOutput(extractAgentPhase(item), method === 'item/completed'); + if (itemId) { + itemOutputKinds.set(itemId, outputKind); + } + if (method === 'item/completed' && outputKind === 'final_answer') { + const nextText = extractCompletedAgentText(params) ?? item?.text ?? null; + return buildProgressUpdate(progressState.finalAnswerText, nextText, outputKind); + } + return null; + } + if (method !== 'item/agentMessage/delta') { + if (!isAgentDeltaNotificationMethod(method)) { + return null; + } + } + const delta = extractNotificationDelta(params); + if (!delta) { + return null; + } + const itemId = extractItemId(params); + const outputKind = resolveNotificationOutputKind(params, itemId, itemOutputKinds); + const currentText = outputKind === 'final_answer' + ? progressState.finalAnswerText + : progressState.commentaryText; + return buildProgressUpdate(currentText, `${currentText}${delta}`, outputKind); +} + +function extractNotificationTurnId(params) { + const direct = typeof params?.turnId === 'string' ? params.turnId : null; + if (direct) { + return direct; + } + const nested = typeof params?.item?.turnId === 'string' ? params.item.turnId : null; + if (nested) { + return nested; + } + return typeof params?.event?.turnId === 'string' ? params.event.turnId : null; +} + +function extractNotificationDelta(params) { + if (typeof params?.delta === 'string' && params.delta) { + return params.delta; + } + if (typeof params?.text === 'string' && params.text) { + return params.text; + } + if (typeof params?.item?.delta === 'string' && params.item.delta) { + return params.item.delta; + } + return null; +} + +function extractNotificationPhase(params) { + if (typeof params?.phase === 'string') { + return params.phase; + } + if (typeof params?.item?.phase === 'string') { + return params.item.phase; + } + return null; +} + +function resolveNotificationOutputKind(params, itemId, itemOutputKinds) { + const explicit = classifyAgentOutput(extractNotificationPhase(params), false); + if (explicit === 'final_answer') { + return explicit; + } + if (itemId && itemOutputKinds.has(itemId)) { + return itemOutputKinds.get(itemId); + } + return explicit; +} + +function buildProgressUpdate(currentText, nextText, outputKind) { + const normalizedNextText = String(nextText ?? ''); + if (!normalizedNextText) { + return null; + } + const previous = String(currentText ?? ''); + const delta = normalizedNextText.startsWith(previous) + ? normalizedNextText.slice(previous.length) + : normalizedNextText; + if (!delta) { + return null; + } + return { + text: normalizedNextText, + delta, + outputKind, + }; +} + +function classifyAgentOutput(phase, completed) { + if (!phase) { + return completed ? 'final_answer' : 'commentary'; + } + const normalized = phase.replace(/[^a-z]/gi, '').toLowerCase(); + if ( + normalized === 'final' + || normalized === 'answer' + || normalized === 'response' + || normalized === 'finalanswer' + || normalized === 'finalresponse' + ) { + return 'final_answer'; + } + return 'commentary'; +} + +function normalizeEventItemType(item) { + return String(item?.type ?? '').replace(/[^a-z]/gi, '').toLowerCase(); +} + +function normalizeEventItemRole(item) { + return String(item?.role ?? '').replace(/[^a-z]/gi, '').toLowerCase(); +} + +function isAssistantVisibleItem(item) { + const itemType = normalizeEventItemType(item); + if (itemType === 'agentmessage' || itemType === 'assistantmessage') { + return true; + } + return itemType === 'message' && normalizeEventItemRole(item) === 'assistant'; +} + +function isUserVisibleItem(item) { + const itemType = normalizeEventItemType(item); + if (itemType.includes('user')) { + return true; + } + return itemType === 'message' && normalizeEventItemRole(item) === 'user'; +} + +function isAgentDeltaNotificationMethod(method) { + const normalized = String(method ?? '').replace(/[^a-z]/gi, '').toLowerCase(); + return normalized === 'itemagentmessagedelta' + || normalized === 'itemassistantmessagedelta' + || normalized === 'itemmessagedelta'; +} + +function extractItemId(value) { + const candidates = [value?.itemId, value?.item_id, value?.id, value?.item?.id]; + for (const candidate of candidates) { + if (candidate !== null && candidate !== undefined && String(candidate).trim()) { + return String(candidate); + } + } + return null; +} + +function extractAgentPhase(value) { + const candidates = [value?.phase, value?.item?.phase]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate; + } + } + return null; +} + +function extractCompletedAgentText(params) { + if (typeof params?.text === 'string' && params.text) { + return params.text; + } + if (typeof params?.item?.text === 'string' && params.item.text) { + return params.item.text; + } + return null; +} + +function extractStructuredText(value) { + const directText = extractTextCandidate(value?.text) + ?? extractTextCandidate(value?.content) + ?? extractTextCandidate(value?.message) + ?? extractTextCandidate(value?.value); + return directText ?? extractTextCandidate(value); +} + +function extractStructuredString(value) { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (!value || typeof value !== 'object') { + return null; + } + return extractTextCandidate(value) ?? extractTextCandidate(value?.message) ?? extractTextCandidate(value?.error); +} + +function extractTextCandidate(value) { + if (typeof value === 'string') { + return value; + } + if (!value || typeof value !== 'object') { + return null; + } + for (const key of ['text', 'delta', 'content', 'value', 'message']) { + if (typeof value[key] === 'string') { + return value[key]; + } + } + for (const key of ['parts', 'segments', 'content']) { + const candidate = value[key]; + if (!Array.isArray(candidate)) { + continue; + } + const text = candidate + .map((entry) => extractTextCandidate(entry)) + .filter((entry) => typeof entry === 'string') + .join(''); + if (text) { + return text; + } + } + return null; +} + +function rememberCodexStderrLine(stderrTail: string[], text: string): void { + stderrTail.push(text); + while (stderrTail.length > 10) { + stderrTail.shift(); + } +} + +function createCodexAppServerLaunchSpec({ + command, + args, + platform, +}: { + command: string; + args: string[]; + platform: NodeJS.Platform; +}): { + command: string; + args?: string[] | null; + options?: Record<string, unknown>; + displayCommand: string; +} { + if (platform === 'win32' && /\.(cmd|bat)$/iu.test(command)) { + return { + command: buildWindowsShellCommandLine([command, ...args]), + args: null, + options: { + shell: true, + windowsHide: true, + }, + displayCommand: command, + }; + } + return { + command, + args, + displayCommand: command, + }; +} + +function createCodexLaunchError({ + command, + error, + platform, +}: { + command: string; + error: unknown; + platform: NodeJS.Platform; +}): Error { + const code = typeof error === 'object' && error && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : ''; + const message = error instanceof Error ? error.message : String(error ?? 'Unknown error'); + if (code === 'ENOENT' || /spawn .* ENOENT/i.test(message)) { + const windowsHint = platform === 'win32' + ? ' Ensure the Codex CLI is installed and reachable on PATH, or set CODEX_REAL_BIN to the full path of codex.exe or codex.cmd.' + : ' Ensure the Codex CLI is installed and reachable on PATH.'; + return new Error(`Failed to launch Codex app-server with "${command}": command not found.${windowsHint}`); + } + return new Error(`Failed to launch Codex app-server with "${command}": ${message}`); +} + +function createCodexAppServerExitedError({ + command, + exitCode, + stderrTail, +}: { + command: string; + exitCode: number; + stderrTail: string[]; +}): Error { + const detail = stderrTail.length > 0 + ? ` Last stderr: ${stderrTail.join(' | ')}` + : ''; + return new Error(`Codex app-server exited before opening its WebSocket (command: "${command}", exit code: ${exitCode}).${detail}`); +} + +function createCodexConnectTimeoutError({ + command, + url, + stderrTail, +}: { + command: string; + url: string; + stderrTail: string[]; +}): Error { + const detail = stderrTail.length > 0 + ? ` Last stderr: ${stderrTail.join(' | ')}` + : ''; + return new Error(`Timed out connecting to ${url} after launching "${command}".${detail}`); +} + +function buildWindowsShellCommandLine(parts: string[]): string { + return parts.map(quoteWindowsShellArgument).join(' '); +} + +function quoteWindowsShellArgument(value: string): string { + const normalized = String(value ?? ''); + if (!normalized) { + return '""'; + } + if (!/[\s"]/u.test(normalized)) { + return normalized; + } + return `"${normalized.replace(/"/g, '""')}"`; +} + +async function reservePort(): Promise<number> { + return new Promise<number>((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to reserve TCP port')); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function sleep(ms: number): Promise<void> { + return new Promise<void>((resolve) => { + setTimeout(resolve, ms); + }); +} + +function waitForChildExit(child: ChildProcess | null, timeoutMs: number): Promise<void> { + return new Promise<void>((resolve, reject) => { + if (!child || child.exitCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Timed out waiting for Codex child process to exit')); + }, timeoutMs); + const onExit = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + clearTimeout(timer); + child.off('exit', onExit); + }; + child.on('exit', onExit); + }); +} + +async function terminateChildProcess(child: ChildProcess, platform: NodeJS.Platform): Promise<void> { + if (platform === 'win32' && typeof child.pid === 'number') { + await terminateWindowsProcessTree(child.pid); + return; + } + child.kill('SIGTERM'); + await waitForChildExit(child, 5000).catch(() => { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + return waitForChildExit(child, 2000).catch(() => {}); + }); +} + +function terminateWindowsProcessTree(pid: number): Promise<void> { + return new Promise<void>((resolve) => { + const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + windowsHide: true, + stdio: 'ignore', + }); + killer.on('error', () => { + resolve(); + }); + killer.on('exit', () => { + resolve(); + }); + }); +} + +export { readCodexAccountIdentity }; diff --git a/packages/codex-native-api/src/daemon_manager.ts b/packages/codex-native-api/src/daemon_manager.ts new file mode 100644 index 0000000..321351e --- /dev/null +++ b/packages/codex-native-api/src/daemon_manager.ts @@ -0,0 +1,1304 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + isLoopbackHost, + normalizeServeHost, + parseOptionalBoolean, + parseOptionalSeconds, + parsePort, + parseServeCliArgs, + type ServeCliOptions, +} from './cli_options.js'; + +export interface CodexNativeApiDaemonLayout { + platform: NodeJS.Platform; + homeDir: string; + configDir: string; + stateDir: string; + logDir: string; + envFile: string; + stdoutLog: string; + stderrLog: string; + launchdLabel: string | null; + launchdPlistPath: string | null; + systemdServiceName: string | null; + systemdUnitPath: string | null; + windowsTaskName: string | null; +} + +export interface CodexNativeApiDaemonInstallPlan { + layout: CodexNativeApiDaemonLayout; + serviceEnv: Record<string, string>; + serviceEnvFileContent: string; + launchSpec: SelfLaunchSpec; + supervisorArgs: string[]; + generatedAuthToken: string | null; + artifactPath: string | null; + artifactContent: string | null; +} + +interface SelfLaunchSpec { + command: string; + args: string[]; + workingDirectory: string; +} + +interface DaemonCommandOptions { + subcommand: 'install' | 'start' | 'stop' | 'restart' | 'status' | 'logs' | 'uninstall'; + serveOptions: ServeCliOptions; + dryRun: boolean; + follow: boolean; + lines: number; + restartSec: number | null; + codexHome: string | null; + codexRealBin: string | null; + launchCommand: string | null; + autolaunch: boolean | null; +} + +interface SupervisorOptions { + envFile: string | null; + homeDir: string | null; + stdoutLog: string | null; + stderrLog: string | null; + once: boolean; +} + +const DAEMON_CONFIG_DIR_NAME = 'codex-native-api'; +const DAEMON_STATE_DIR_NAME = '.codex-native-api'; +const DAEMON_SERVICE_ENV_NAME = 'service.env'; +const DAEMON_STDOUT_LOG_NAME = 'codex-native-api.out.log'; +const DAEMON_STDERR_LOG_NAME = 'codex-native-api.err.log'; +const LAUNCHD_LABEL = 'com.codexbridge.codex-native-api'; +const SYSTEMD_SERVICE_NAME = 'codex-native-api.service'; +const WINDOWS_TASK_NAME = 'CodexNativeApi'; +const MANAGED_ENV_KEYS = [ + 'CODEX_NATIVE_API_HOST', + 'CODEX_NATIVE_API_PORT', + 'CODEX_NATIVE_API_PUBLIC', + 'CODEX_NATIVE_API_AUTH_PATH', + 'CODEX_NATIVE_API_AUTH_TOKEN', + 'CODEX_NATIVE_API_DEFAULT_CWD', + 'CODEX_NATIVE_API_PROVIDER_PROFILE', + 'CODEX_NATIVE_API_DEFAULT_MODEL', + 'CODEX_NATIVE_API_RESTART_SEC', + 'CODEX_HOME', + 'CODEX_REAL_BIN', + 'CODEX_APP_LAUNCH_CMD', + 'CODEX_APP_AUTOLAUNCH', +]; + +export async function runDaemonCommand(argv: string[]): Promise<void> { + const options = parseDaemonCommandArgs(argv); + if (options.subcommand === 'install') { + const plan = await buildDaemonInstallPlan(options); + if (options.dryRun) { + printInstallPlan(plan); + return; + } + await installDaemon(plan); + printInstallSuccess(plan); + return; + } + + if (options.dryRun) { + throw new Error('--dry-run is only supported for daemon install.'); + } + + switch (options.subcommand) { + case 'start': + await startDaemon(); + return; + case 'stop': + await stopDaemon(); + return; + case 'restart': + await restartDaemon(); + return; + case 'status': + await statusDaemon(); + return; + case 'logs': + await logsDaemon({ follow: options.follow, lines: options.lines }); + return; + case 'uninstall': + await uninstallDaemon(); + return; + default: + throw new Error(`Unsupported daemon subcommand: ${String(options.subcommand)}`); + } +} + +export async function runDaemonSupervisor(argv: string[]): Promise<void> { + const options = parseSupervisorArgs(argv); + const layout = resolveDaemonLayout(process.env, { + homeDir: options.homeDir ? path.resolve(options.homeDir) : null, + }); + const envFile = path.resolve(options.envFile ?? layout.envFile); + const stdoutLog = path.resolve(options.stdoutLog ?? layout.stdoutLog); + const stderrLog = path.resolve(options.stderrLog ?? layout.stderrLog); + + await loadEnvFileIntoProcessEnv(envFile); + process.env.HOME ||= layout.homeDir; + process.env.USERPROFILE ||= layout.homeDir; + if (process.platform === 'win32') { + process.env.APPDATA ||= path.join(layout.homeDir, 'AppData', 'Roaming'); + process.env.LOCALAPPDATA ||= path.join(layout.homeDir, 'AppData', 'Local'); + } + + await fsp.mkdir(path.dirname(stdoutLog), { recursive: true }); + await fsp.mkdir(path.dirname(stderrLog), { recursive: true }); + + let child: ReturnType<typeof spawn> | null = null; + let stopping = false; + process.on('SIGINT', () => stop('SIGINT')); + process.on('SIGTERM', () => stop('SIGTERM')); + + do { + const code = await runSupervisorChild({ + stdoutLog, + stderrLog, + onChild: (nextChild) => { + child = nextChild; + }, + }); + child = null; + if (stopping || options.once) { + process.exitCode = typeof code === 'number' ? code : 0; + break; + } + const restartMs = Math.max( + 0, + Number.parseFloat(process.env.CODEX_NATIVE_API_RESTART_SEC ?? '2') * 1000, + ); + await sleep(restartMs); + } while (!stopping); + + function stop(signal: string) { + if (stopping) { + return; + } + stopping = true; + writeLogLine(stderrLog, 'stderr', `[codex-native-api-daemon] stopping on ${signal}`); + if (child && !child.killed) { + child.kill(signal as NodeJS.Signals); + } + } +} + +export async function buildDaemonInstallPlan( + options: Pick< + DaemonCommandOptions, + 'serveOptions' | 'restartSec' | 'codexHome' | 'codexRealBin' | 'launchCommand' | 'autolaunch' + >, + { + platform = process.platform, + env = process.env, + currentWorkingDirectory = process.cwd(), + entryPath = process.argv[1] ?? fileURLToPath(import.meta.url), + nodeBin = process.execPath, + }: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + currentWorkingDirectory?: string; + entryPath?: string; + nodeBin?: string; + } = {}, +): Promise<CodexNativeApiDaemonInstallPlan> { + const layout = resolveDaemonLayout(env, { platform }); + const existingEnv = await readEnvFileRecord(layout.envFile); + const launchSpec = resolveSelfLaunchSpec({ + entryPath, + nodeBin, + }); + + const host = normalizeServeHost(options.serveOptions) + ?? (normalizeString(existingEnv.CODEX_NATIVE_API_HOST) || '127.0.0.1'); + const publicBind = !isLoopbackHost(host); + const authTokenFromExisting = normalizeString(existingEnv.CODEX_NATIVE_API_AUTH_TOKEN); + const requestedAuthToken = normalizeString(options.serveOptions.authToken); + const generatedAuthToken = publicBind && !requestedAuthToken && !authTokenFromExisting + ? crypto.randomBytes(24).toString('hex') + : null; + + const serviceEnv = { + ...existingEnv, + CODEX_NATIVE_API_HOST: host, + CODEX_NATIVE_API_PORT: String( + options.serveOptions.port + ?? parsePort(existingEnv.CODEX_NATIVE_API_PORT ?? '') + ?? 4242, + ), + CODEX_NATIVE_API_PUBLIC: publicBind ? '1' : '0', + CODEX_NATIVE_API_AUTH_PATH: + normalizeString(options.serveOptions.authPath) + || normalizeString(existingEnv.CODEX_NATIVE_API_AUTH_PATH), + CODEX_NATIVE_API_AUTH_TOKEN: + requestedAuthToken + || authTokenFromExisting + || generatedAuthToken + || '', + CODEX_NATIVE_API_DEFAULT_CWD: + normalizeString(options.serveOptions.cwd) + || normalizeString(existingEnv.CODEX_NATIVE_API_DEFAULT_CWD) + || currentWorkingDirectory, + CODEX_NATIVE_API_PROVIDER_PROFILE: + normalizeString(options.serveOptions.providerProfileId) + || normalizeString(existingEnv.CODEX_NATIVE_API_PROVIDER_PROFILE), + CODEX_NATIVE_API_DEFAULT_MODEL: + normalizeString(options.serveOptions.defaultModel) + || normalizeString(existingEnv.CODEX_NATIVE_API_DEFAULT_MODEL) + || normalizeString(env.CODEX_DEFAULT_MODEL), + CODEX_NATIVE_API_RESTART_SEC: String( + options.restartSec + ?? parseOptionalSeconds(existingEnv.CODEX_NATIVE_API_RESTART_SEC) + ?? 2, + ), + CODEX_HOME: + normalizeString(options.codexHome) + || normalizeString(existingEnv.CODEX_HOME) + || normalizeString(env.CODEX_HOME) + || path.join(layout.homeDir, '.codex'), + CODEX_REAL_BIN: + normalizeString(options.codexRealBin) + || normalizeString(existingEnv.CODEX_REAL_BIN) + || normalizeString(env.CODEX_REAL_BIN) + || (findCommandOnPath(platform, env.PATH, ['codex', 'codex.exe', 'codex.cmd', 'codex.bat']) ?? ''), + CODEX_APP_LAUNCH_CMD: + normalizeString(options.launchCommand) + || normalizeString(existingEnv.CODEX_APP_LAUNCH_CMD) + || normalizeString(env.CODEX_APP_LAUNCH_CMD), + CODEX_APP_AUTOLAUNCH: String( + options.autolaunch + ?? parseOptionalBoolean(existingEnv.CODEX_APP_AUTOLAUNCH, parseOptionalBoolean(env.CODEX_APP_AUTOLAUNCH, false)), + ), + }; + + const supervisorArgs = [ + ...launchSpec.args, + 'daemon-supervisor', + '--home-dir', layout.homeDir, + '--env-file', layout.envFile, + '--stdout-log', layout.stdoutLog, + '--stderr-log', layout.stderrLog, + ]; + const serviceEnvFileContent = renderServiceEnvFile(serviceEnv); + + let artifactPath: string | null = null; + let artifactContent: string | null = null; + if (platform === 'darwin') { + artifactPath = layout.launchdPlistPath; + artifactContent = renderLaunchdPlist({ + label: layout.launchdLabel!, + supervisorCommand: launchSpec.command, + supervisorArgs, + workingDirectory: launchSpec.workingDirectory, + stdoutLog: layout.stdoutLog, + stderrLog: layout.stderrLog, + pathEnv: buildServicePathEnv(platform, env.PATH, launchSpec.command), + homeDir: layout.homeDir, + }); + } else if (platform === 'linux') { + artifactPath = layout.systemdUnitPath; + artifactContent = renderSystemdUnit({ + description: 'Codex Native API', + envFile: layout.envFile, + launchSpec, + supervisorArgs, + pathEnv: buildServicePathEnv(platform, env.PATH, launchSpec.command), + homeDir: layout.homeDir, + userName: resolveUserName(env), + logName: env.LOGNAME || resolveUserName(env), + }); + } + + return { + layout, + serviceEnv, + serviceEnvFileContent, + launchSpec, + supervisorArgs, + generatedAuthToken, + artifactPath, + artifactContent, + }; +} + +export function resolveDaemonLayout( + env: NodeJS.ProcessEnv = process.env, + { + platform = process.platform, + homeDir = null, + }: { + platform?: NodeJS.Platform; + homeDir?: string | null; + } = {}, +): CodexNativeApiDaemonLayout { + if (platform === 'win32') { + const winPath = path.win32; + const resolvedHomeDir = winPath.resolve(homeDir || resolveHomeDir(env, platform)); + const appData = winPath.resolve(env.APPDATA || winPath.join(resolvedHomeDir, 'AppData', 'Roaming')); + const configDir = winPath.join(appData, DAEMON_CONFIG_DIR_NAME); + const stateDir = winPath.join(resolvedHomeDir, DAEMON_STATE_DIR_NAME); + const logDir = winPath.join(stateDir, 'logs'); + return { + platform, + homeDir: resolvedHomeDir, + configDir, + stateDir, + logDir, + envFile: winPath.join(configDir, DAEMON_SERVICE_ENV_NAME), + stdoutLog: winPath.join(logDir, DAEMON_STDOUT_LOG_NAME), + stderrLog: winPath.join(logDir, DAEMON_STDERR_LOG_NAME), + launchdLabel: null, + launchdPlistPath: null, + systemdServiceName: null, + systemdUnitPath: null, + windowsTaskName: WINDOWS_TASK_NAME, + }; + } + + const resolvedHomeDir = path.resolve(homeDir || resolveHomeDir(env, platform)); + const configRoot = path.resolve(env.XDG_CONFIG_HOME || path.join(resolvedHomeDir, '.config')); + const configDir = path.join(configRoot, DAEMON_CONFIG_DIR_NAME); + const stateDir = path.join(resolvedHomeDir, DAEMON_STATE_DIR_NAME); + const logDir = path.join(stateDir, 'logs'); + if (platform === 'darwin') { + return { + platform, + homeDir: resolvedHomeDir, + configDir, + stateDir, + logDir, + envFile: path.join(configDir, DAEMON_SERVICE_ENV_NAME), + stdoutLog: path.join(logDir, DAEMON_STDOUT_LOG_NAME), + stderrLog: path.join(logDir, DAEMON_STDERR_LOG_NAME), + launchdLabel: LAUNCHD_LABEL, + launchdPlistPath: path.join(resolvedHomeDir, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`), + systemdServiceName: null, + systemdUnitPath: null, + windowsTaskName: null, + }; + } + + return { + platform, + homeDir: resolvedHomeDir, + configDir, + stateDir, + logDir, + envFile: path.join(configDir, DAEMON_SERVICE_ENV_NAME), + stdoutLog: path.join(logDir, DAEMON_STDOUT_LOG_NAME), + stderrLog: path.join(logDir, DAEMON_STDERR_LOG_NAME), + launchdLabel: null, + launchdPlistPath: null, + systemdServiceName: SYSTEMD_SERVICE_NAME, + systemdUnitPath: path.join(configRoot, 'systemd', 'user', SYSTEMD_SERVICE_NAME), + windowsTaskName: null, + }; +} + +export function renderServiceEnvFile(record: Record<string, string>): string { + const normalized = new Map<string, string>(); + Object.entries(record).forEach(([key, value]) => { + const normalizedKey = normalizeString(key); + if (!normalizedKey) { + return; + } + normalized.set(normalizedKey, String(value ?? '')); + }); + + const lines: string[] = [ + '# Generated by codex-native-api daemon install', + '# Safe to edit after install.', + '', + '# Managed Codex Native API service values', + ]; + MANAGED_ENV_KEYS.forEach((key) => { + lines.push(`${key}=${normalized.get(key) ?? ''}`); + normalized.delete(key); + }); + + if (normalized.size > 0) { + lines.push('', '# Additional environment overrides'); + Array.from(normalized.keys()).sort().forEach((key) => { + lines.push(`${key}=${normalized.get(key) ?? ''}`); + }); + } + + return `${lines.join('\n')}\n`; +} + +export function renderSystemdUnit({ + description, + envFile, + launchSpec, + supervisorArgs, + pathEnv, + homeDir, + userName, + logName, +}: { + description: string; + envFile: string; + launchSpec: SelfLaunchSpec; + supervisorArgs: string[]; + pathEnv: string; + homeDir: string; + userName: string; + logName: string; +}): string { + const execStart = quoteSystemdExecStart(launchSpec.command, supervisorArgs); + return [ + '[Unit]', + `Description=${description}`, + 'After=network-online.target', + 'Wants=network-online.target', + '', + '[Service]', + 'Type=simple', + `WorkingDirectory=${launchSpec.workingDirectory}`, + `Environment=HOME=${homeDir}`, + `Environment=USER=${userName}`, + `Environment=LOGNAME=${logName}`, + `Environment=PATH=${pathEnv}`, + `EnvironmentFile=${envFile}`, + `ExecStart=${execStart}`, + 'Restart=always', + 'RestartSec=2', + 'KillMode=process', + '', + '[Install]', + 'WantedBy=default.target', + '', + ].join('\n'); +} + +export function renderLaunchdPlist({ + label, + supervisorCommand, + supervisorArgs, + workingDirectory, + stdoutLog, + stderrLog, + pathEnv, + homeDir, +}: { + label: string; + supervisorCommand: string; + supervisorArgs: string[]; + workingDirectory: string; + stdoutLog: string; + stderrLog: string; + pathEnv: string; + homeDir: string; +}): string { + const allArgs = [supervisorCommand, ...supervisorArgs]; + return [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', + '<plist version="1.0">', + '<dict>', + ` <key>Label</key><string>${escapeXml(label)}</string>`, + ' <key>ProgramArguments</key>', + ' <array>', + ...allArgs.map((value) => ` <string>${escapeXml(value)}</string>`), + ' </array>', + ` <key>WorkingDirectory</key><string>${escapeXml(workingDirectory)}</string>`, + ' <key>RunAtLoad</key><true/>', + ' <key>KeepAlive</key><true/>', + ` <key>StandardOutPath</key><string>${escapeXml(stdoutLog)}</string>`, + ` <key>StandardErrorPath</key><string>${escapeXml(stderrLog)}</string>`, + ' <key>EnvironmentVariables</key>', + ' <dict>', + ` <key>HOME</key><string>${escapeXml(homeDir)}</string>`, + ` <key>PATH</key><string>${escapeXml(pathEnv)}</string>`, + ' </dict>', + '</dict>', + '</plist>', + '', + ].join('\n'); +} + +export function buildWindowsInstallScript(plan: CodexNativeApiDaemonInstallPlan): string { + const launchArgs = buildWindowsCommandArgumentString(plan.supervisorArgs); + const taskName = plan.layout.windowsTaskName ?? WINDOWS_TASK_NAME; + return [ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(taskName)}`, + `$NodeBin = ${toPowerShellString(plan.launchSpec.command)}`, + `$Arguments = ${toPowerShellString(launchArgs)}`, + '$CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name', + '$Action = New-ScheduledTaskAction -Execute $NodeBin -Argument $Arguments', + '$Trigger = New-ScheduledTaskTrigger -AtLogOn -User $CurrentIdentity', + '$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Days 3650) -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable', + '$Principal = New-ScheduledTaskPrincipal -UserId $CurrentIdentity -LogonType Interactive -RunLevel Highest', + 'Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null', + 'Start-ScheduledTask -TaskName $TaskName', + `Write-Host ${toPowerShellString(`Installed scheduled task: ${taskName}`)}`, + ].join('\n'); +} + +async function installDaemon(plan: CodexNativeApiDaemonInstallPlan): Promise<void> { + await ensureDaemonDirectories(plan.layout); + await fsp.writeFile(plan.layout.envFile, plan.serviceEnvFileContent, 'utf8'); + + switch (plan.layout.platform) { + case 'darwin': + await fsp.writeFile(plan.layout.launchdPlistPath!, plan.artifactContent ?? '', 'utf8'); + await runCommand('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}`, plan.layout.launchdPlistPath!], { allowFailure: true }); + await runCommand('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, plan.layout.launchdPlistPath!]); + await runCommand('launchctl', ['enable', `gui/${process.getuid?.() ?? 0}/${plan.layout.launchdLabel!}`]); + await runCommand('launchctl', ['kickstart', '-k', `gui/${process.getuid?.() ?? 0}/${plan.layout.launchdLabel!}`]); + return; + case 'linux': + await fsp.mkdir(path.dirname(plan.layout.systemdUnitPath!), { recursive: true }); + await fsp.writeFile(plan.layout.systemdUnitPath!, plan.artifactContent ?? '', 'utf8'); + await runCommand('systemctl', ['--user', 'daemon-reload']); + await runCommand('systemctl', ['--user', 'enable', '--now', plan.layout.systemdServiceName!]); + if (await commandExists('loginctl')) { + await runCommand('loginctl', ['enable-linger', resolveUserName(process.env)], { allowFailure: true }); + } + return; + case 'win32': + await runPowerShellScript(buildWindowsInstallScript(plan)); + return; + default: + throw new Error(`Unsupported daemon platform: ${plan.layout.platform}`); + } +} + +async function startDaemon(): Promise<void> { + const layout = resolveDaemonLayout(); + switch (layout.platform) { + case 'darwin': + assertFileExists(layout.launchdPlistPath, 'launchd plist'); + await runCommand('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, layout.launchdPlistPath!], { allowFailure: true }); + await runCommand('launchctl', ['enable', `gui/${process.getuid?.() ?? 0}/${layout.launchdLabel!}`]); + await runCommand('launchctl', ['kickstart', '-k', `gui/${process.getuid?.() ?? 0}/${layout.launchdLabel!}`]); + return; + case 'linux': + assertFileExists(layout.systemdUnitPath, 'systemd user unit'); + await runCommand('systemctl', ['--user', 'start', layout.systemdServiceName!]); + return; + case 'win32': + await runPowerShellScript([ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(layout.windowsTaskName ?? WINDOWS_TASK_NAME)}`, + 'Start-ScheduledTask -TaskName $TaskName', + ].join('\n')); + return; + default: + throw new Error(`Unsupported daemon platform: ${layout.platform}`); + } +} + +async function stopDaemon(): Promise<void> { + const layout = resolveDaemonLayout(); + switch (layout.platform) { + case 'darwin': + assertFileExists(layout.launchdPlistPath, 'launchd plist'); + await runCommand('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}`, layout.launchdPlistPath!], { allowFailure: true }); + return; + case 'linux': + await runCommand('systemctl', ['--user', 'stop', layout.systemdServiceName!]); + return; + case 'win32': + await runPowerShellScript([ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(layout.windowsTaskName ?? WINDOWS_TASK_NAME)}`, + 'Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue', + ].join('\n')); + return; + default: + throw new Error(`Unsupported daemon platform: ${layout.platform}`); + } +} + +async function restartDaemon(): Promise<void> { + const layout = resolveDaemonLayout(); + switch (layout.platform) { + case 'darwin': + assertFileExists(layout.launchdPlistPath, 'launchd plist'); + await runCommand('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}`, layout.launchdPlistPath!], { allowFailure: true }); + await runCommand('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, layout.launchdPlistPath!]); + await runCommand('launchctl', ['enable', `gui/${process.getuid?.() ?? 0}/${layout.launchdLabel!}`]); + await runCommand('launchctl', ['kickstart', '-k', `gui/${process.getuid?.() ?? 0}/${layout.launchdLabel!}`]); + return; + case 'linux': + await runCommand('systemctl', ['--user', 'restart', layout.systemdServiceName!]); + return; + case 'win32': + await runPowerShellScript([ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(layout.windowsTaskName ?? WINDOWS_TASK_NAME)}`, + 'Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue', + 'Start-ScheduledTask -TaskName $TaskName', + ].join('\n')); + return; + default: + throw new Error(`Unsupported daemon platform: ${layout.platform}`); + } +} + +async function statusDaemon(): Promise<void> { + const layout = resolveDaemonLayout(); + switch (layout.platform) { + case 'darwin': + await runCommand('launchctl', ['print', `gui/${process.getuid?.() ?? 0}/${layout.launchdLabel!}`]); + return; + case 'linux': + await runCommand('systemctl', ['--user', 'status', layout.systemdServiceName!, '--no-pager']); + return; + case 'win32': + await runPowerShellScript([ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(layout.windowsTaskName ?? WINDOWS_TASK_NAME)}`, + '$Task = Get-ScheduledTask -TaskName $TaskName', + '$Info = Get-ScheduledTaskInfo -TaskName $TaskName', + '$Task | Select-Object TaskName, State | Format-List | Out-String | Write-Host', + '$Info | Select-Object LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns | Format-List | Out-String | Write-Host', + ].join('\n')); + return; + default: + throw new Error(`Unsupported daemon platform: ${layout.platform}`); + } +} + +async function logsDaemon({ + follow, + lines, +}: { + follow: boolean; + lines: number; +}): Promise<void> { + const layout = resolveDaemonLayout(); + if (layout.platform === 'linux') { + const args = ['--user', '-u', layout.systemdServiceName!, '-n', String(lines)]; + if (follow) { + args.push('-f'); + } + await runCommand('journalctl', args); + return; + } + + await printFileTail(layout.stdoutLog, lines, process.stdout, `== ${layout.stdoutLog} ==\n`); + await printFileTail(layout.stderrLog, lines, process.stderr, `== ${layout.stderrLog} ==\n`); + if (!follow) { + return; + } + await followLogFiles([layout.stdoutLog, layout.stderrLog]); +} + +async function uninstallDaemon(): Promise<void> { + const layout = resolveDaemonLayout(); + switch (layout.platform) { + case 'darwin': + await runCommand('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}`, layout.launchdPlistPath!], { allowFailure: true }); + await removeIfExists(layout.launchdPlistPath); + return; + case 'linux': + await runCommand('systemctl', ['--user', 'disable', '--now', layout.systemdServiceName!], { allowFailure: true }); + await removeIfExists(layout.systemdUnitPath); + await runCommand('systemctl', ['--user', 'daemon-reload'], { allowFailure: true }); + return; + case 'win32': + await runPowerShellScript([ + '$ErrorActionPreference = "Stop"', + `$TaskName = ${toPowerShellString(layout.windowsTaskName ?? WINDOWS_TASK_NAME)}`, + 'Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue', + ].join('\n')); + return; + default: + throw new Error(`Unsupported daemon platform: ${layout.platform}`); + } +} + +async function runSupervisorChild({ + stdoutLog, + stderrLog, + onChild, +}: { + stdoutLog: string; + stderrLog: string; + onChild: (child: ReturnType<typeof spawn>) => void; +}): Promise<number | null> { + const launchSpec = resolveSelfLaunchSpec(); + const serveArgs = [ + ...launchSpec.args, + 'serve', + ...buildServeArgsFromEnv(process.env), + ]; + writeLogLine(stdoutLog, 'stdout', `[codex-native-api-daemon] starting: ${launchSpec.command} ${serveArgs.join(' ')}`); + const child = spawn(launchSpec.command, serveArgs, { + cwd: launchSpec.workingDirectory, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + onChild(child); + child.stdout.on('data', (chunk) => writeLogChunk(stdoutLog, 'stdout', chunk)); + child.stderr.on('data', (chunk) => writeLogChunk(stderrLog, 'stderr', chunk)); + + return await new Promise((resolve) => { + child.once('error', (error) => { + writeLogLine(stderrLog, 'stderr', `[codex-native-api-daemon] child spawn failed: ${error instanceof Error ? error.stack || error.message : String(error)}`); + resolve(1); + }); + child.once('exit', (code, signal) => { + writeLogLine(stderrLog, 'stderr', `[codex-native-api-daemon] child exited code=${code ?? ''} signal=${signal ?? ''}`); + resolve(code); + }); + }); +} + +function resolveSelfLaunchSpec({ + entryPath = process.argv[1] ?? fileURLToPath(import.meta.url), + nodeBin = process.execPath, +}: { + entryPath?: string; + nodeBin?: string; +} = {}): SelfLaunchSpec { + const resolvedEntryPath = path.resolve(entryPath); + const packageRoot = path.resolve(path.dirname(resolvedEntryPath), '..'); + if (resolvedEntryPath.endsWith('.ts')) { + return { + command: nodeBin, + args: ['--import', 'tsx', resolvedEntryPath], + workingDirectory: packageRoot, + }; + } + return { + command: nodeBin, + args: [resolvedEntryPath], + workingDirectory: packageRoot, + }; +} + +function buildServeArgsFromEnv(env: NodeJS.ProcessEnv): string[] { + const args: string[] = []; + const host = normalizeString(env.CODEX_NATIVE_API_HOST); + const publicBind = parseOptionalBoolean(env.CODEX_NATIVE_API_PUBLIC, false); + if (host) { + args.push('--host', host); + } else if (publicBind) { + args.push('--public'); + } + const port = parsePort(env.CODEX_NATIVE_API_PORT ?? ''); + if (port !== null) { + args.push('--port', String(port)); + } + const authPath = normalizeString(env.CODEX_NATIVE_API_AUTH_PATH); + if (authPath) { + args.push('--auth-path', authPath); + } + const authToken = normalizeString(env.CODEX_NATIVE_API_AUTH_TOKEN); + if (authToken) { + args.push('--auth-token', authToken); + } + const cwd = normalizeString(env.CODEX_NATIVE_API_DEFAULT_CWD); + if (cwd) { + args.push('--cwd', cwd); + } + const providerProfileId = normalizeString(env.CODEX_NATIVE_API_PROVIDER_PROFILE); + if (providerProfileId) { + args.push('--provider-profile', providerProfileId); + } + const defaultModel = normalizeString(env.CODEX_NATIVE_API_DEFAULT_MODEL); + if (defaultModel) { + args.push('--default-model', defaultModel); + } + return args; +} + +function parseDaemonCommandArgs(argv: string[]): DaemonCommandOptions { + const subcommand = normalizeDaemonSubcommand(argv[0]); + if (!subcommand) { + throw new Error('Daemon command requires one of: install, start, stop, restart, status, logs, uninstall.'); + } + const args = argv.slice(1); + const serveOptions = parseServeCliArgs(args); + let dryRun = false; + let follow = false; + let lines = 80; + let restartSec: number | null = null; + let codexHome: string | null = null; + let codexRealBin: string | null = null; + let launchCommand: string | null = null; + let autolaunch: boolean | null = null; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const next = args[index + 1]; + if (arg === '--dry-run') { + dryRun = true; + continue; + } + if (arg === '--follow' || arg === '-f') { + follow = true; + continue; + } + if ((arg === '--lines' || arg === '-n') && next) { + const parsed = Number.parseInt(next, 10); + lines = Number.isFinite(parsed) && parsed > 0 ? parsed : 80; + index += 1; + continue; + } + if (arg === '--restart-sec' && next) { + restartSec = parseOptionalSeconds(next); + index += 1; + continue; + } + if (arg === '--codex-home' && next) { + codexHome = next; + index += 1; + continue; + } + if (arg === '--codex-bin' && next) { + codexRealBin = next; + index += 1; + continue; + } + if (arg === '--launch-cmd' && next) { + launchCommand = next; + index += 1; + continue; + } + if (arg === '--autolaunch') { + autolaunch = true; + continue; + } + if (arg === '--no-autolaunch') { + autolaunch = false; + continue; + } + } + + return { + subcommand, + serveOptions, + dryRun, + follow, + lines, + restartSec, + codexHome, + codexRealBin, + launchCommand, + autolaunch, + }; +} + +function parseSupervisorArgs(argv: string[]): SupervisorOptions { + const options: SupervisorOptions = { + envFile: null, + homeDir: null, + stdoutLog: null, + stderrLog: null, + once: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === '--once') { + options.once = true; + continue; + } + if (arg === '--env-file' && next) { + options.envFile = next; + index += 1; + continue; + } + if (arg === '--home-dir' && next) { + options.homeDir = next; + index += 1; + continue; + } + if (arg === '--stdout-log' && next) { + options.stdoutLog = next; + index += 1; + continue; + } + if (arg === '--stderr-log' && next) { + options.stderrLog = next; + index += 1; + continue; + } + } + return options; +} + +function normalizeDaemonSubcommand(value: string | undefined): DaemonCommandOptions['subcommand'] | null { + const normalized = normalizeString(value).toLowerCase(); + if ( + normalized === 'install' + || normalized === 'start' + || normalized === 'stop' + || normalized === 'restart' + || normalized === 'status' + || normalized === 'logs' + || normalized === 'uninstall' + ) { + return normalized; + } + return null; +} + +async function ensureDaemonDirectories(layout: CodexNativeApiDaemonLayout): Promise<void> { + await fsp.mkdir(layout.configDir, { recursive: true }); + await fsp.mkdir(layout.logDir, { recursive: true }); + if (layout.platform === 'darwin' && layout.launchdPlistPath) { + await fsp.mkdir(path.dirname(layout.launchdPlistPath), { recursive: true }); + } + if (layout.platform === 'linux' && layout.systemdUnitPath) { + await fsp.mkdir(path.dirname(layout.systemdUnitPath), { recursive: true }); + } +} + +async function readEnvFileRecord(filePath: string): Promise<Record<string, string>> { + try { + const content = await fsp.readFile(filePath, 'utf8'); + return parseEnvText(content); + } catch { + return {}; + } +} + +async function loadEnvFileIntoProcessEnv(filePath: string): Promise<void> { + const record = await readEnvFileRecord(filePath); + Object.entries(record).forEach(([key, value]) => { + process.env[key] = value; + }); +} + +function parseEnvText(content: string): Record<string, string> { + const record: Record<string, string> = {}; + content.split(/\r?\n/u).forEach((rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + return; + } + const separatorIndex = line.indexOf('='); + if (separatorIndex <= 0) { + return; + } + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) { + return; + } + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + record[key] = value; + }); + return record; +} + +async function runCommand( + command: string, + args: string[], + { + allowFailure = false, + }: { + allowFailure?: boolean; + } = {}, +): Promise<void> { + await new Promise<void>((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + windowsHide: true, + }); + child.once('error', (error) => { + if (allowFailure) { + resolve(); + return; + } + reject(error); + }); + child.once('exit', (code) => { + if (!allowFailure && code !== 0) { + reject(new Error(`Command failed (${command} ${args.join(' ')}): exit ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function runPowerShellScript(script: string): Promise<void> { + const powershell = process.platform === 'win32' ? 'powershell.exe' : 'powershell'; + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + await runCommand(powershell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]); +} + +async function commandExists(command: string): Promise<boolean> { + return await new Promise<boolean>((resolve) => { + const child = spawn(command, ['--version'], { + stdio: 'ignore', + windowsHide: true, + }); + child.once('error', () => resolve(false)); + child.once('exit', (code) => resolve(code === 0)); + }); +} + +async function printFileTail( + filePath: string, + lines: number, + stream: NodeJS.WriteStream, + header: string, +): Promise<void> { + stream.write(header); + try { + const content = await fsp.readFile(filePath, 'utf8'); + const tail = content.split(/\r?\n/u).slice(-lines).join('\n').trim(); + if (tail) { + stream.write(`${tail}\n`); + } + } catch { + stream.write('(no log yet)\n'); + } +} + +async function followLogFiles(filePaths: string[]): Promise<void> { + const positions = new Map<string, number>(); + for (const filePath of filePaths) { + try { + const stats = await fsp.stat(filePath); + positions.set(filePath, stats.size); + } catch { + positions.set(filePath, 0); + } + } + await new Promise<void>(() => { + const timer = setInterval(async () => { + for (const filePath of filePaths) { + try { + const stats = await fsp.stat(filePath); + const previousSize = positions.get(filePath) ?? 0; + if (stats.size <= previousSize) { + continue; + } + const handle = await fsp.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(stats.size - previousSize); + await handle.read(buffer, 0, buffer.length, previousSize); + process.stdout.write(buffer.toString('utf8')); + positions.set(filePath, stats.size); + } finally { + await handle.close(); + } + } catch { + // Ignore transient read errors while following logs. + } + } + }, 1000); + process.on('SIGINT', () => clearInterval(timer)); + process.on('SIGTERM', () => clearInterval(timer)); + }); +} + +function buildServicePathEnv(platform: NodeJS.Platform, currentPath: string | undefined, nodeBin: string): string { + const parts = new Set<string>(); + const nodeDir = path.dirname(nodeBin); + if (nodeDir) { + parts.add(nodeDir); + } + String(currentPath ?? '').split(path.delimiter).forEach((entry) => { + const normalized = normalizeString(entry); + if (normalized) { + parts.add(normalized); + } + }); + if (platform === 'darwin') { + ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].forEach((entry) => parts.add(entry)); + } else if (platform === 'linux') { + ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'].forEach((entry) => parts.add(entry)); + } + return Array.from(parts).join(path.delimiter); +} + +function quoteSystemdExecStart(command: string, args: string[]): string { + return [command, ...args].map(quoteSystemdArg).join(' '); +} + +function quoteSystemdArg(value: string): string { + if (!/[\s"\\]/u.test(value)) { + return value; + } + return `"${value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"')}"`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/gu, '&') + .replace(/</gu, '<') + .replace(/>/gu, '>') + .replace(/"/gu, '"') + .replace(/'/gu, '''); +} + +function buildWindowsCommandArgumentString(args: string[]): string { + return args.map(quoteWindowsArgument).join(' '); +} + +function quoteWindowsArgument(value: string): string { + if (!/[\s"]/u.test(value)) { + return value; + } + let escaped = '"'; + let slashCount = 0; + for (const character of value) { + if (character === '\\') { + slashCount += 1; + continue; + } + if (character === '"') { + escaped += '\\'.repeat((slashCount * 2) + 1); + escaped += '"'; + slashCount = 0; + continue; + } + if (slashCount > 0) { + escaped += '\\'.repeat(slashCount); + slashCount = 0; + } + escaped += character; + } + if (slashCount > 0) { + escaped += '\\'.repeat(slashCount * 2); + } + escaped += '"'; + return escaped; +} + +function toPowerShellString(value: string): string { + return `'${String(value ?? '').replace(/'/gu, "''")}'`; +} + +function resolveHomeDir(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string { + const requested = normalizeString(platform === 'win32' ? (env.USERPROFILE || env.HOME) : (env.HOME || env.USERPROFILE)); + return requested || os.homedir(); +} + +function resolveUserName(env: NodeJS.ProcessEnv): string { + return normalizeString(env.USER) || normalizeString(env.LOGNAME) || os.userInfo().username; +} + +function findCommandOnPath( + platform: NodeJS.Platform, + currentPath: string | undefined, + candidates: string[], +): string | null { + const pathEntries = String(currentPath ?? '').split(path.delimiter).filter((entry) => entry.trim()); + for (const entry of pathEntries) { + for (const candidate of candidates) { + const resolved = path.join(entry, candidate); + if (fs.existsSync(resolved)) { + return resolved; + } + } + if (platform !== 'win32') { + continue; + } + const normalizedCandidates = candidates.flatMap((candidate) => ( + candidate.endsWith('.exe') || candidate.endsWith('.cmd') || candidate.endsWith('.bat') + ? [candidate] + : [`${candidate}.exe`, `${candidate}.cmd`, `${candidate}.bat`] + )); + for (const candidate of normalizedCandidates) { + const resolved = path.join(entry, candidate); + if (fs.existsSync(resolved)) { + return resolved; + } + } + } + return null; +} + +async function removeIfExists(filePath: string | null): Promise<void> { + if (!filePath) { + return; + } + try { + await fsp.unlink(filePath); + } catch { + // Ignore missing files during uninstall. + } +} + +function assertFileExists(filePath: string | null, label: string): void { + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`${label} is not installed: ${filePath ?? 'unknown path'}`); + } +} + +function printInstallPlan(plan: CodexNativeApiDaemonInstallPlan): void { + process.stdout.write(`platform: ${plan.layout.platform}\n`); + process.stdout.write(`env_file: ${plan.layout.envFile}\n`); + if (plan.artifactPath) { + process.stdout.write(`service_artifact: ${plan.artifactPath}\n`); + } + if (plan.generatedAuthToken) { + process.stdout.write(`generated_auth_token: ${plan.generatedAuthToken}\n`); + } + process.stdout.write('\n'); + process.stdout.write(plan.serviceEnvFileContent); + if (plan.artifactContent) { + process.stdout.write('\n'); + process.stdout.write(plan.artifactContent); + if (!plan.artifactContent.endsWith('\n')) { + process.stdout.write('\n'); + } + } + if (plan.layout.platform === 'win32') { + process.stdout.write('\n'); + process.stdout.write(`${buildWindowsInstallScript(plan)}\n`); + } +} + +function printInstallSuccess(plan: CodexNativeApiDaemonInstallPlan): void { + process.stdout.write(`daemon installed for ${plan.layout.platform}\n`); + process.stdout.write(`env_file: ${plan.layout.envFile}\n`); + if (plan.artifactPath) { + process.stdout.write(`service_artifact: ${plan.artifactPath}\n`); + } + process.stdout.write(`stdout_log: ${plan.layout.stdoutLog}\n`); + process.stdout.write(`stderr_log: ${plan.layout.stderrLog}\n`); + if (plan.generatedAuthToken) { + process.stdout.write(`auth_token: ${plan.generatedAuthToken}\n`); + } +} + +function writeLogChunk(filePath: string, streamName: 'stdout' | 'stderr', chunk: unknown): void { + const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + if (streamName === 'stderr') { + process.stderr.write(text); + } else { + process.stdout.write(text); + } + fs.appendFile(filePath, text, () => {}); +} + +function writeLogLine(filePath: string, streamName: 'stdout' | 'stderr', line: string): void { + writeLogChunk(filePath, streamName, `${new Date().toISOString()} ${line}\n`); +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/codex-native-api/src/default_provider.ts b/packages/codex-native-api/src/default_provider.ts new file mode 100644 index 0000000..bc96f7a --- /dev/null +++ b/packages/codex-native-api/src/default_provider.ts @@ -0,0 +1,596 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { CodexAppClient, createStderrLogger, type CodexTurnInput } from './codex_app_client.js'; +import { readCodexAccountIdentity } from './auth_state.js'; +import type { + ProviderApprovalRequest, + ProviderModelInfo, + ProviderPluginContract, + ProviderProfile, + ProviderThreadListResult, + ProviderThreadSummary, + ProviderThreadStartResult, + ProviderTurnAttachment, + ProviderTurnEvent, + ProviderTurnProgress, + ProviderTurnResult, + ProviderTurnSession, + ProviderTurnSessionSettings, +} from './provider.js'; + +export interface DefaultCodexProviderProfileConfig extends Record<string, unknown> { + cliBin: string; + launchCommand?: string | null; + autolaunch?: boolean; + codexCliArgs?: string[]; + modelCatalog?: unknown[]; + modelCatalogMode?: 'merge' | 'overlay-only'; + defaultModel?: string | null; +} + +export type DefaultCodexProviderProfile = ProviderProfile & { + config: DefaultCodexProviderProfileConfig; +}; + +export interface ProviderProfileRepositoryLike { + get(id: string): ProviderProfile | null | undefined; + list(): ProviderProfile[]; +} + +export interface ProviderRegistryLike { + getProvider<T extends ProviderPluginContract>(providerKind: string): T; +} + +export interface DefaultCodexNativeProviderBootstrap { + providerProfiles: ProviderProfileRepositoryLike; + providerRegistry: ProviderRegistryLike; + defaultProviderProfileId: string; +} + +interface DefaultCodexProviderPluginOptions { + clientFactory?: (profile: DefaultCodexProviderProfile) => CodexAppClient; +} + +const DEFAULT_PROVIDER_PROFILE_ID = 'openai-default'; +const DEFAULT_PROVIDER_KIND = 'openai-native'; +const DEFAULT_PROVIDER_DISPLAY_NAME = 'Codex OpenAI'; + +const DEFAULT_NATIVE_API_DEVELOPER_INSTRUCTIONS = [ + 'codex-native-api runtime constraints:', + '- This turn is running through a localhost API facade over the logged-in Codex runtime.', + '- codex-native-api owns request and continuation lifecycle for this API session.', + '- Do not assume any chat-platform wrapper, slash-command UX, or external delivery layer exists.', +].join('\n'); + +function joinDeveloperInstructions(...blocks: Array<string | null | undefined>): string { + return blocks + .map((block) => normalizeOptionalString(block)) + .filter(Boolean) + .join('\n\n') + .trim(); +} + +export class InMemoryProviderProfileRepository implements ProviderProfileRepositoryLike { + constructor(private readonly profiles: ProviderProfile[]) {} + + get(id: string): ProviderProfile | null { + return this.profiles.find((profile) => profile.id === id) ?? null; + } + + list(): ProviderProfile[] { + return [...this.profiles]; + } +} + +export class SingleProviderRegistry implements ProviderRegistryLike { + constructor(private readonly providerPlugin: ProviderPluginContract) {} + + getProvider<T extends ProviderPluginContract>(providerKind: string): T { + if (providerKind !== this.providerPlugin.kind) { + throw new Error(`Unknown provider kind: ${providerKind}`); + } + return this.providerPlugin as T; + } +} + +export class DefaultCodexNativeProviderPlugin implements ProviderPluginContract { + kind = DEFAULT_PROVIDER_KIND; + + displayName = DEFAULT_PROVIDER_DISPLAY_NAME; + + private readonly clientFactory: (profile: DefaultCodexProviderProfile) => CodexAppClient; + + private readonly clients = new Map<string, CodexAppClient>(); + + constructor({ + clientFactory = (profile) => new CodexAppClient({ + codexCliBin: profile.config.cliBin, + codexCliArgs: profile.config.codexCliArgs ?? [], + launchCommand: profile.config.launchCommand ?? null, + autolaunch: profile.config.autolaunch ?? false, + modelCatalog: Array.isArray(profile.config.modelCatalog) ? profile.config.modelCatalog as any[] : [], + modelCatalogMode: profile.config.modelCatalogMode ?? 'merge', + logger: createStderrLogger(), + }), + }: DefaultCodexProviderPluginOptions = {}) { + this.clientFactory = clientFactory; + } + + async startThread({ + providerProfile, + cwd = null, + title = null, + ephemeral = null, + }: { + providerProfile: ProviderProfile; + cwd?: string | null; + title?: string | null; + ephemeral?: boolean | null; + metadata?: Record<string, unknown>; + }): Promise<ProviderThreadStartResult> { + const client = await this.ensureClient(providerProfile); + const modelInfo = await this.resolveModelInfo(providerProfile, client, null); + return client.startThread({ + cwd, + title, + model: modelInfo?.model ?? null, + ephemeral, + }); + } + + async readThread({ + providerProfile, + threadId, + includeTurns = false, + }: { + providerProfile: ProviderProfile; + threadId: string; + includeTurns?: boolean; + }): Promise<ProviderThreadSummary | null> { + const client = await this.ensureClient(providerProfile); + return client.readThread(threadId, includeTurns); + } + + async listThreads({ + providerProfile, + limit = 20, + cursor = null, + searchTerm = null, + archived = false, + }: { + providerProfile: ProviderProfile; + limit?: number; + cursor?: string | null; + searchTerm?: string | null; + archived?: boolean | null; + }): Promise<ProviderThreadListResult> { + const client = await this.ensureClient(providerProfile); + return client.listThreads({ limit, cursor, searchTerm, archived: Boolean(archived) }); + } + + async startTurn({ + providerProfile, + bridgeSession, + sessionSettings, + event, + inputText, + developerInstructions = null, + onProgress = null, + onTurnStarted = null, + onApprovalRequest = null, + }: { + providerProfile: ProviderProfile; + bridgeSession: ProviderTurnSession; + sessionSettings: ProviderTurnSessionSettings | null; + event: ProviderTurnEvent; + inputText: string; + developerInstructions?: string | null; + onProgress?: ((progress: ProviderTurnProgress) => Promise<void> | void) | null; + onTurnStarted?: ((meta: Record<string, unknown>) => Promise<void> | void) | null; + onApprovalRequest?: ((request: ProviderApprovalRequest) => Promise<void> | void) | null; + }): Promise<ProviderTurnResult> { + const client = await this.ensureClient(providerProfile); + const modelInfo = await this.resolveModelInfo( + providerProfile, + client, + sessionSettings?.model ?? null, + ); + const turnInput = buildCodexTurnInput(event, inputText); + return client.startTurn({ + threadId: bridgeSession.codexThreadId, + inputText: turnInput[0]?.type === 'text' ? turnInput[0].text : inputText, + input: turnInput, + cwd: bridgeSession.cwd ?? event.cwd ?? null, + model: modelInfo?.model ?? null, + effort: normalizeOptionalString(sessionSettings?.reasoningEffort ?? null), + serviceTier: normalizeCodexServiceTier(sessionSettings?.serviceTier ?? null), + personality: normalizeCodexPersonality(sessionSettings?.personality ?? null), + approvalPolicy: sessionSettings?.approvalPolicy ?? 'on-request', + sandboxMode: sessionSettings?.sandboxMode ?? 'workspace-write', + collaborationMode: normalizeCodexCollaborationMode(sessionSettings?.collaborationMode ?? null), + developerInstructions: joinDeveloperInstructions( + DEFAULT_NATIVE_API_DEVELOPER_INSTRUCTIONS, + developerInstructions, + ), + onProgress, + onTurnStarted, + onApprovalRequest, + }); + } + + async reconnectProfile({ + providerProfile, + }: { + providerProfile: ProviderProfile; + }): Promise<Record<string, unknown>> { + const previousClient = this.clients.get(providerProfile.id) ?? null; + if (previousClient) { + this.clients.delete(providerProfile.id); + await previousClient.stop(); + } + const client = this.clientFactory(providerProfile as DefaultCodexProviderProfile); + this.clients.set(providerProfile.id, client); + await client.start(); + return { + connected: client.isConnected(), + accountIdentity: readCodexAccountIdentity(), + }; + } + + async listModels({ + providerProfile, + }: { + providerProfile: ProviderProfile; + }): Promise<ProviderModelInfo[]> { + const client = await this.ensureClient(providerProfile); + return client.listModels(); + } + + async stop(): Promise<void> { + const clients = [...this.clients.values()]; + this.clients.clear(); + await Promise.allSettled(clients.map((client) => client.stop())); + } + + private async ensureClient(providerProfile: ProviderProfile): Promise<CodexAppClient> { + let client = this.clients.get(providerProfile.id) ?? null; + if (!client) { + client = this.clientFactory(providerProfile as DefaultCodexProviderProfile); + this.clients.set(providerProfile.id, client); + } + await client.start(); + return client; + } + + private async resolveModelInfo( + providerProfile: ProviderProfile, + client: CodexAppClient, + requestedModel: string | null, + ): Promise<ProviderModelInfo | null> { + if (requestedModel) { + return { + id: requestedModel, + model: requestedModel, + displayName: requestedModel, + description: '', + isDefault: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + }; + } + const config = providerProfile.config as DefaultCodexProviderProfileConfig; + if (config.defaultModel) { + return { + id: config.defaultModel, + model: config.defaultModel, + displayName: config.defaultModel, + description: '', + isDefault: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + }; + } + const models = await client.listModels(); + return models.find((model) => model.isDefault) ?? models[0] ?? null; + } +} + +export function createDefaultCodexNativeProviderBootstrap( + env: NodeJS.ProcessEnv = process.env, + { + platform = process.platform, + cwd = process.cwd(), + }: { + platform?: NodeJS.Platform; + cwd?: string; + } = {}, +): DefaultCodexNativeProviderBootstrap { + const profile = loadDefaultCodexNativeProviderProfile(env, { platform, cwd }); + const providerPlugin = new DefaultCodexNativeProviderPlugin(); + return { + providerProfiles: new InMemoryProviderProfileRepository([profile]), + providerRegistry: new SingleProviderRegistry(providerPlugin), + defaultProviderProfileId: profile.id, + }; +} + +export function loadDefaultCodexNativeProviderProfile( + env: NodeJS.ProcessEnv = process.env, + { + platform = process.platform, + cwd = process.cwd(), + }: { + platform?: NodeJS.Platform; + cwd?: string; + } = {}, +): DefaultCodexProviderProfile { + const codexCliBin = resolveConfiguredCommand(normalizeOptionalString(env.CODEX_REAL_BIN), { + platform, + env, + cwd, + }) ?? resolveCommand('codex', { + platform, + env, + cwd, + }) ?? 'codex'; + const now = Date.now(); + return { + id: DEFAULT_PROVIDER_PROFILE_ID, + providerKind: DEFAULT_PROVIDER_KIND, + displayName: DEFAULT_PROVIDER_DISPLAY_NAME, + config: { + cliBin: codexCliBin, + launchCommand: normalizeOptionalString(env.CODEX_APP_LAUNCH_CMD), + autolaunch: parseBoolean(env.CODEX_APP_AUTOLAUNCH, false), + codexCliArgs: parseCommandArgs(env.CODEX_CLI_ARGS), + modelCatalog: [], + modelCatalogMode: 'merge', + defaultModel: normalizeOptionalString(env.CODEX_DEFAULT_MODEL), + }, + createdAt: now, + updatedAt: now, + }; +} + +function buildCodexTurnInput(event: ProviderTurnEvent, inputText: string): CodexTurnInput[] { + const attachments = Array.isArray(event.attachments) ? event.attachments : []; + const normalizedInputText = String(inputText ?? '').trim(); + if (attachments.length === 0) { + return [{ + type: 'text', + text: normalizedInputText, + text_elements: [], + }]; + } + + const textPrompt = buildAttachmentPrompt(normalizedInputText, attachments); + const input: CodexTurnInput[] = [{ + type: 'text', + text: textPrompt, + text_elements: [], + }]; + for (const attachment of attachments) { + if (attachment.kind !== 'image') { + continue; + } + input.push({ + type: 'localImage', + path: attachment.localPath, + }); + } + return input; +} + +function buildAttachmentPrompt(userText: string, attachments: readonly ProviderTurnAttachment[]): string { + const normalizedText = String(userText ?? '').trim(); + const lines: string[] = []; + if (normalizedText) { + lines.push(normalizedText, ''); + } else { + lines.push('User sent attachments without additional text.', ''); + } + lines.push('Attachments:'); + attachments.forEach((attachment, index) => { + lines.push(`${index + 1}. ${describeAttachment(attachment)}`); + lines.push(` path: ${attachment.localPath}`); + if (attachment.fileName) { + lines.push(` filename: ${attachment.fileName}`); + } + if (attachment.mimeType) { + lines.push(` mime: ${attachment.mimeType}`); + } + if (typeof attachment.durationSeconds === 'number' && Number.isFinite(attachment.durationSeconds)) { + lines.push(` duration_seconds: ${attachment.durationSeconds}`); + } + if (attachment.transcriptText) { + lines.push(` transcript_hint: ${attachment.transcriptText}`); + } + if (attachment.kind === 'image') { + lines.push(' attached_as: localImage'); + } + }); + lines.push('', 'Use the local file paths above when you inspect these attachments.'); + return lines.join('\n'); +} + +function describeAttachment(attachment: ProviderTurnAttachment): string { + switch (attachment.kind) { + case 'image': + return 'image'; + case 'voice': + return 'voice message'; + case 'file': + return 'file'; + case 'video': + return 'video'; + default: + return 'attachment'; + } +} + +function normalizeOptionalString(value: unknown): string | null { + const normalized = typeof value === 'string' ? value.trim() : ''; + return normalized || null; +} + +function parseBoolean(value: unknown, fallback: boolean): boolean { + if (value === undefined) { + return fallback; + } + return String(value).trim() !== 'false' && String(value).trim() !== '0'; +} + +function parseCommandArgs(value: unknown): string[] { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return []; + } + return normalized.split(/\s+/u).filter(Boolean); +} + +function normalizeCodexPersonality(value: unknown): 'friendly' | 'pragmatic' | 'none' | null { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'friendly' || normalized === 'pragmatic' || normalized === 'none') { + return normalized; + } + return null; +} + +function normalizeCodexServiceTier(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ?? null; +} + +function normalizeCodexCollaborationMode(value: string | null | undefined): 'default' | 'plan' { + return normalizeOptionalString(value) === 'plan' ? 'plan' : 'default'; +} + +function resolveConfiguredCommand( + configuredCommand: string | null, + options: { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + cwd: string; + }, +): string | null { + const normalized = normalizeOptionalString(configuredCommand); + if (!normalized) { + return null; + } + return resolveExplicitCommandPath(normalized, options); +} + +function resolveCommand( + command: string, + { + platform = process.platform, + env = process.env, + cwd = process.cwd(), + }: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + cwd?: string; + } = {}, +): string | null { + const normalizedCommand = normalizeOptionalString(command); + if (!normalizedCommand) { + return null; + } + const explicit = resolveExplicitCommandPath(normalizedCommand, { + platform, + env, + cwd, + }); + if (explicit) { + return explicit; + } + if (hasPathSeparator(normalizedCommand)) { + return null; + } + const pathEntries = splitPathEntries(env.PATH ?? ''); + const suffixes = resolveCommandSuffixes(platform, env, normalizedCommand); + for (const entry of pathEntries) { + for (const suffix of suffixes) { + const candidate = path.join(entry, `${normalizedCommand}${suffix}`); + if (isCommandFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function resolveExplicitCommandPath( + command: string, + { + platform, + env, + cwd, + }: { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + cwd: string; + }, +): string | null { + const expandedHome = command.startsWith('~') + ? path.join(env.HOME ?? '', command.slice(1)) + : command; + const resolved = path.isAbsolute(expandedHome) + ? expandedHome + : path.resolve(cwd, expandedHome); + if (isCommandFile(resolved)) { + return resolved; + } + if (platform === 'win32' && !path.extname(resolved)) { + for (const extension of resolveWindowsExecutableExtensions(env)) { + const candidate = `${resolved}${extension}`; + if (isCommandFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function splitPathEntries(pathValue: string): string[] { + return pathValue + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function resolveCommandSuffixes( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + command: string, +): string[] { + if (platform !== 'win32') { + return ['']; + } + if (path.extname(command)) { + return ['']; + } + return resolveWindowsExecutableExtensions(env); +} + +function resolveWindowsExecutableExtensions(env: NodeJS.ProcessEnv): string[] { + const raw = normalizeOptionalString(env.PATHEXT) + ?? '.COM;.EXE;.BAT;.CMD'; + return raw + .split(';') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); +} + +function hasPathSeparator(command: string): boolean { + return command.includes('/') || command.includes('\\'); +} + +function isCommandFile(candidate: string): boolean { + try { + const stat = fs.statSync(candidate); + return stat.isFile(); + } catch { + return false; + } +} diff --git a/packages/codex-native-api/src/index.ts b/packages/codex-native-api/src/index.ts index fd0a0ed..3729afd 100644 --- a/packages/codex-native-api/src/index.ts +++ b/packages/codex-native-api/src/index.ts @@ -1,8 +1,8 @@ -export const CODEX_NATIVE_API_PACKAGE_NAME = '@codexbridge/codex-native-api' as const; +export const CODEX_NATIVE_API_PACKAGE_NAME = 'codex-native-api' as const; -export const CODEX_NATIVE_API_PACKAGE_PHASE = 'phase-5-first-extraction' as const; +export const CODEX_NATIVE_API_PACKAGE_PHASE = 'public-core-preview' as const; -export const CODEX_NATIVE_API_RELEASE_CHANNEL = 'internal-only' as const; +export const CODEX_NATIVE_API_RELEASE_CHANNEL = 'public-preview' as const; export const CODEX_NATIVE_API_OWNS = [ 'logged-in-codex-localhost-api', @@ -13,6 +13,7 @@ export const CODEX_NATIVE_API_OWNS = [ 'continuation-registry', 'native-api-side-task-routing', 'localhost-auth-and-health', + 'cross-platform-daemon-service-manager', ] as const; export const CODEX_NATIVE_API_DOES_NOT_OWN = [ @@ -46,6 +47,20 @@ export type { CodexTokenIdentity, WriteCodexAuthOptions, } from './auth_state.js'; +export { + DefaultCodexNativeProviderPlugin, + InMemoryProviderProfileRepository, + SingleProviderRegistry, + createDefaultCodexNativeProviderBootstrap, + loadDefaultCodexNativeProviderProfile, +} from './default_provider.js'; +export type { + DefaultCodexNativeProviderBootstrap, + DefaultCodexProviderProfile, + DefaultCodexProviderProfileConfig, + ProviderProfileRepositoryLike, + ProviderRegistryLike, +} from './default_provider.js'; export { InMemoryCodexNativeApiContinuationRegistry } from './native_api_continuation_registry.js'; export type { CodexNativeApiContinuationEntry, diff --git a/packages/codex-native-api/src/native_api_server.ts b/packages/codex-native-api/src/native_api_server.ts index f3bcefe..762d3d7 100644 --- a/packages/codex-native-api/src/native_api_server.ts +++ b/packages/codex-native-api/src/native_api_server.ts @@ -17,6 +17,7 @@ import type { ProviderModelInfo, ProviderPluginContract, ProviderProfile, + ProviderResponseItem, ProviderTurnProgress, } from './provider.js'; @@ -103,6 +104,8 @@ export class CodexNativeApiServer { private readonly host: string; + private readonly localhostOnly: boolean; + private readonly requestedPort: number; private readonly authToken: string | null; @@ -152,6 +155,7 @@ export class CodexNativeApiServer { this.runtime = runtime; this.resolveRuntimeContext = resolveRuntimeContext; this.host = normalizeString(host) || DEFAULT_HOST; + this.localhostOnly = isLoopbackHost(this.host); this.requestedPort = Number.isFinite(port) ? Number(port) : 0; this.authToken = normalizeString(authToken) || null; this.defaultModel = normalizeString(defaultModel) || null; @@ -318,10 +322,11 @@ export class CodexNativeApiServer { writeJson(response, ready ? 200 : 503, { object: 'health.check', status: ready ? 'ok' : (readiness.runtimeReachable ? 'degraded' : 'unavailable'), - localhost_only: true, + localhost_only: this.localhostOnly, native_api: buildNativeApiObservability({ routePath: '/v1/health', providerProfile: context.providerProfile, + localhostOnly: this.localhostOnly, }), route_capabilities: { models: { @@ -332,6 +337,10 @@ export class CodexNativeApiServer { continuation: true, stream: true, compact: false, + builtin_tools: { + web_search: true, + }, + function_tools: false, }, chat_completions: { create: true, @@ -369,10 +378,11 @@ export class CodexNativeApiServer { data: inspected.models.map((model) => serializeModel(model, context.providerProfile)), models: inspected.models.map((model) => serializeModel(model, context.providerProfile)), meta: { - localhost_only: true, + localhost_only: this.localhostOnly, native_api: buildNativeApiObservability({ routePath: '/v1/models', providerProfile: context.providerProfile, + localhostOnly: this.localhostOnly, }), continuation_registry: serializeContinuationRegistryDescriptor(this.continuationRegistry.describe()), native_runtime: buildRuntimeMetadata({ @@ -496,7 +506,14 @@ export class CodexNativeApiServer { const outputText = normalizeString(execution.result.outputText); const previewText = normalizeString(execution.result.previewText); const effectiveText = outputText || previewText; - if (!effectiveText) { + const transcriptOutput = normalizeProviderResponseItemsToResponsesOutput(execution.result.responseItems); + const hasCompletedOutput = Boolean(outputText) || transcriptOutput.length > 0; + const responseOutput = appendFallbackAssistantResponseOutput( + transcriptOutput, + effectiveText, + hasCompletedOutput ? 'completed' : 'incomplete', + ); + if (responseOutput.length === 0) { writeJson(response, 502, { error: { message: normalizeString(execution.result.errorMessage) || 'Codex native runtime returned no response text.', @@ -560,6 +577,17 @@ export class CodexNativeApiServer { return; } const requestBody = body as JsonRecord; + const toolPreparation = prepareResponsesBuiltinTooling(requestBody); + if (toolPreparation.error) { + writeJson(response, 400, { + error: { + message: toolPreparation.error.message, + type: 'invalid_request_error', + code: toolPreparation.error.code, + }, + }); + return; + } const stream = Boolean(requestBody.stream); const previousResponseId = normalizeString(requestBody.previous_response_id) || null; const prompt = buildPromptFromResponsesRequest(requestBody); @@ -668,6 +696,7 @@ export class CodexNativeApiServer { effectiveCwd, reasoningEffort, serviceTier, + developerInstructions: toolPreparation.developerInstructions, }); return; } @@ -688,12 +717,20 @@ export class CodexNativeApiServer { effectiveCwd, reasoningEffort, serviceTier, + developerInstructions: toolPreparation.developerInstructions, requestUser: normalizeNullableString(requestBody.user), }); const outputText = normalizeString(execution.result.outputText); const previewText = normalizeString(execution.result.previewText); const effectiveText = outputText || previewText; - if (!effectiveText) { + const transcriptOutput = normalizeProviderResponseItemsToResponsesOutput(execution.result.responseItems); + const hasCompletedOutput = Boolean(outputText) || transcriptOutput.length > 0; + const responseOutput = appendFallbackAssistantResponseOutput( + transcriptOutput, + effectiveText, + hasCompletedOutput ? 'completed' : 'incomplete', + ); + if (responseOutput.length === 0) { writeJson(response, 502, { error: { message: normalizeString(execution.result.errorMessage) || 'Codex native runtime returned no response text.', @@ -730,9 +767,9 @@ export class CodexNativeApiServer { responseId, createdAt, responseModel: effectiveModel, - status: outputText ? 'completed' : 'incomplete', - outputText: effectiveText, - incompleteDetails: outputText ? null : { + status: hasCompletedOutput ? 'completed' : 'incomplete', + output: responseOutput, + incompleteDetails: hasCompletedOutput ? null : { reason: 'native_runtime_partial', }, nativeApi: buildNativeApiObservability({ @@ -782,6 +819,7 @@ export class CodexNativeApiServer { effectiveCwd, reasoningEffort, serviceTier, + developerInstructions = null, requestUser = null, onProgress = null, onTurnStarted = null, @@ -801,6 +839,7 @@ export class CodexNativeApiServer { effectiveCwd: string | null; reasoningEffort: string | null; serviceTier: string | null; + developerInstructions?: string | null; requestUser?: string | null; onProgress?: ((progress: ProviderTurnProgress) => Promise<void> | void) | null; onTurnStarted?: ((meta: CodexNativeRuntimeTurnStartedMeta) => Promise<void> | void) | null; @@ -818,6 +857,7 @@ export class CodexNativeApiServer { onTurnStarted, prepareTurn: (session) => ({ inputText: prompt, + developerInstructions, locale, metadata: { source: 'codex-native-api', @@ -857,6 +897,7 @@ export class CodexNativeApiServer { onTurnStarted, prepareTurn: (session) => ({ inputText: prompt, + developerInstructions, locale, metadata: { source: 'codex-native-api', @@ -897,6 +938,7 @@ export class CodexNativeApiServer { effectiveCwd, reasoningEffort, serviceTier, + developerInstructions = null, }: { response: ServerResponse; request: JsonRecord; @@ -917,6 +959,7 @@ export class CodexNativeApiServer { effectiveCwd: string | null; reasoningEffort: string | null; serviceTier: string | null; + developerInstructions?: string | null; }): Promise<void> { const initialNativeRuntime = buildRuntimeMetadata({ providerProfile: context.providerProfile, @@ -975,6 +1018,7 @@ export class CodexNativeApiServer { effectiveCwd, reasoningEffort, serviceTier, + developerInstructions, requestUser: normalizeNullableString(request.user), onTurnStarted: (meta) => { latestTurnMeta = { @@ -990,6 +1034,13 @@ export class CodexNativeApiServer { const outputText = rawString(execution.result.outputText); const previewText = rawString(execution.result.previewText); const effectiveText = outputText || previewText; + const transcriptOutput = normalizeProviderResponseItemsToResponsesOutput(execution.result.responseItems); + const hasCompletedOutput = Boolean(outputText) || transcriptOutput.length > 0; + const responseOutput = appendFallbackAssistantResponseOutput( + transcriptOutput, + effectiveText, + hasCompletedOutput ? 'completed' : 'incomplete', + ); const nativeRuntime = buildRuntimeMetadata({ providerProfile: context.providerProfile, readiness, @@ -1002,7 +1053,7 @@ export class CodexNativeApiServer { turnId: execution.result.turnId ?? latestTurnMeta.turnId, bridgeSessionId: execution.session.id, }; - if (!effectiveText) { + if (responseOutput.length === 0) { flushEvents(failResponsesStreamState(streamState, { message: normalizeString(execution.result.errorMessage) || 'Codex native runtime returned no response text.', type: 'native_runtime_error', @@ -1019,7 +1070,9 @@ export class CodexNativeApiServer { finishSse(response); return; } - flushEvents(syncResponsesStreamMessageToTerminalText(streamState, effectiveText)); + if (effectiveText) { + flushEvents(syncResponsesStreamMessageToTerminalText(streamState, effectiveText)); + } if (previousResponseId) { this.continuationRegistry.touch(previousResponseId); } @@ -1037,8 +1090,9 @@ export class CodexNativeApiServer { lastUsedAt: startedAt, }); flushEvents(finishResponsesStreamState(streamState, { - status: outputText ? 'completed' : 'incomplete', - incompleteDetails: outputText ? null : { + status: hasCompletedOutput ? 'completed' : 'incomplete', + output: responseOutput, + incompleteDetails: hasCompletedOutput ? null : { reason: 'native_runtime_partial', }, nativeApi: buildNativeApiObservability({ @@ -1381,6 +1435,247 @@ function buildPromptFromResponsesRequest(request: JsonRecord): string { return sections.join('\n\n').trim(); } +function prepareResponsesBuiltinTooling(request: JsonRecord): { + developerInstructions: string | null; + error: { + message: string; + code: string; + } | null; +} { + const toolDeclarations = normalizeArray(request.tools); + const normalizedTools: JsonRecord[] = []; + for (const tool of toolDeclarations) { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + return { + developerInstructions: null, + error: { + message: 'The first native /v1/responses slice only supports JSON-object builtin tool declarations.', + code: 'unsupported_responses_tooling', + }, + }; + } + const normalizedType = normalizeResponsesBuiltinToolType((tool as JsonRecord).type); + if (normalizedType !== 'web_search') { + return { + developerInstructions: null, + error: { + message: 'The first native /v1/responses slice currently supports only the built-in web_search tool. Function tools and custom tools are not wired yet.', + code: 'unsupported_responses_tooling', + }, + }; + } + normalizedTools.push({ + ...(tool as JsonRecord), + type: normalizedType, + }); + } + if (request.tools !== undefined) { + request.tools = normalizedTools; + } + + let toolPolicy: 'default' | 'toolless' | 'web_search_optional' | 'web_search_required' = 'default'; + const declaredBuiltinTools = normalizedTools.map((tool) => normalizeString(tool.type)).filter(Boolean); + if (declaredBuiltinTools.includes('web_search')) { + toolPolicy = 'web_search_optional'; + } + + const toolChoice = request.tool_choice; + if (toolChoice !== undefined && toolChoice !== null) { + if (typeof toolChoice === 'string') { + const normalizedChoice = normalizeString(toolChoice); + const normalizedBuiltinChoice = normalizeResponsesBuiltinToolType(normalizedChoice); + if (normalizedChoice === 'auto') { + request.tool_choice = 'auto'; + } else if (normalizedChoice === 'none') { + request.tool_choice = 'none'; + toolPolicy = 'toolless'; + } else if (normalizedChoice === 'required') { + if (!declaredBuiltinTools.includes('web_search')) { + return { + developerInstructions: null, + error: { + message: 'tool_choice=\"required\" currently requires declaring the built-in web_search tool in tools.', + code: 'unsupported_responses_tooling', + }, + }; + } + request.tool_choice = 'required'; + toolPolicy = 'web_search_required'; + } else if (normalizedBuiltinChoice === 'web_search') { + request.tool_choice = 'web_search'; + ensureBuiltinWebSearchToolDeclaration(request); + toolPolicy = 'web_search_required'; + } else { + return { + developerInstructions: null, + error: { + message: 'The first native /v1/responses slice currently supports tool_choice values of auto, none, required, or explicit web_search only.', + code: 'unsupported_responses_tooling', + }, + }; + } + } else if (typeof toolChoice === 'object' && !Array.isArray(toolChoice)) { + const rawType = normalizeString((toolChoice as JsonRecord).type); + const normalizedBuiltinChoice = normalizeResponsesBuiltinToolType(rawType); + if (!rawType || rawType === 'auto') { + request.tool_choice = 'auto'; + } else if (rawType === 'none') { + request.tool_choice = 'none'; + toolPolicy = 'toolless'; + } else if (rawType === 'required') { + if (!declaredBuiltinTools.includes('web_search')) { + return { + developerInstructions: null, + error: { + message: 'tool_choice.type=\"required\" currently requires declaring the built-in web_search tool in tools.', + code: 'unsupported_responses_tooling', + }, + }; + } + request.tool_choice = 'required'; + toolPolicy = 'web_search_required'; + } else if (normalizedBuiltinChoice === 'web_search') { + request.tool_choice = { + ...(toolChoice as JsonRecord), + type: 'web_search', + }; + ensureBuiltinWebSearchToolDeclaration(request); + toolPolicy = 'web_search_required'; + } else if (rawType === 'allowed_tools') { + const normalizedAllowedTools: JsonRecord[] = []; + for (const allowedTool of normalizeArray((toolChoice as JsonRecord).tools)) { + if (!allowedTool || typeof allowedTool !== 'object' || Array.isArray(allowedTool)) { + return { + developerInstructions: null, + error: { + message: 'tool_choice.allowed_tools entries must be JSON objects.', + code: 'unsupported_responses_tooling', + }, + }; + } + const normalizedAllowedType = normalizeResponsesBuiltinToolType((allowedTool as JsonRecord).type); + if (normalizedAllowedType !== 'web_search') { + return { + developerInstructions: null, + error: { + message: 'The first native /v1/responses slice currently supports only built-in web_search entries inside tool_choice.allowed_tools.', + code: 'unsupported_responses_tooling', + }, + }; + } + normalizedAllowedTools.push({ + ...(allowedTool as JsonRecord), + type: normalizedAllowedType, + }); + } + if (normalizedAllowedTools.length === 0) { + return { + developerInstructions: null, + error: { + message: 'tool_choice.allowed_tools must include at least one supported built-in tool.', + code: 'unsupported_responses_tooling', + }, + }; + } + request.tool_choice = { + ...(toolChoice as JsonRecord), + type: 'allowed_tools', + tools: normalizedAllowedTools, + }; + ensureBuiltinWebSearchToolDeclaration(request); + toolPolicy = 'web_search_optional'; + } else { + return { + developerInstructions: null, + error: { + message: 'The first native /v1/responses slice currently supports only builtin web_search tool_choice objects.', + code: 'unsupported_responses_tooling', + }, + }; + } + } else { + return { + developerInstructions: null, + error: { + message: 'tool_choice must be a string or JSON object.', + code: 'unsupported_responses_tooling', + }, + }; + } + } + + return { + developerInstructions: buildResponsesBuiltinToolDeveloperInstructions(toolPolicy), + error: null, + }; +} + +function buildResponsesBuiltinToolDeveloperInstructions( + toolPolicy: 'default' | 'toolless' | 'web_search_optional' | 'web_search_required', +): string | null { + switch (toolPolicy) { + case 'toolless': + return [ + 'codex-native-api tool policy for this /v1/responses request:', + '- The client explicitly disabled tool use for this turn.', + '- Do not use shell commands, file edits, MCP tools, plugins, web search, or image generation.', + '- Answer only from the supplied conversation context and the model\'s own reasoning.', + ].join('\n'); + case 'web_search_optional': + return [ + 'codex-native-api tool policy for this /v1/responses request:', + '- The only supported built-in tool for this turn is web_search.', + '- You may use the built-in web_search capability when fresh or external information would materially improve the answer.', + '- Do not substitute shell commands, file edits, MCP tools, plugins, or image generation for web_search.', + '- Return a normal assistant answer after any needed search.', + ].join('\n'); + case 'web_search_required': + return [ + 'codex-native-api tool policy for this /v1/responses request:', + '- The client explicitly selected the built-in web_search tool.', + '- You must use the built-in web_search capability before the final answer.', + '- Do not substitute shell commands, file edits, MCP tools, plugins, or image generation for web_search.', + '- Return a normal assistant answer after the search.', + ].join('\n'); + default: + return null; + } +} + +function ensureBuiltinWebSearchToolDeclaration(request: JsonRecord): void { + const existingTools = normalizeArray(request.tools); + if (existingTools.some((tool) => normalizeResponsesBuiltinToolType(tool?.type) === 'web_search')) { + request.tools = existingTools.map((tool) => { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + return tool; + } + const normalizedType = normalizeResponsesBuiltinToolType((tool as JsonRecord).type); + return normalizedType + ? { + ...(tool as JsonRecord), + type: normalizedType, + } + : tool; + }); + return; + } + request.tools = [ + ...existingTools, + { type: 'web_search' }, + ]; +} + +function normalizeResponsesBuiltinToolType(type: unknown): string { + switch (normalizeString(type)) { + case 'web_search': + case 'web_search_preview': + case 'web_search_preview_2025_03_11': + return 'web_search'; + default: + return ''; + } +} + function detectUnsupportedChatCompletionsFeature( request: JsonRecord, ): { @@ -1620,6 +1915,205 @@ function renderChatCompletionsMessageContent(content: unknown): string { return renderResponsesContent(normalized); } +function normalizeProviderResponseItemsToResponsesOutput(responseItems: unknown): JsonRecord[] { + return normalizeArray(responseItems) + .map((item) => normalizeProviderResponseItem(item)) + .filter((item): item is JsonRecord => Boolean(item)); +} + +function normalizeProviderResponseItem(item: unknown): JsonRecord | null { + const candidate = normalizeRecord(item); + if (!candidate) { + return null; + } + const type = normalizeString(candidate.type); + switch (type) { + case 'message': + return normalizeProviderResponseMessageItem(candidate); + case 'reasoning': + return { + id: normalizeNullableString(candidate.id) || `rs_${crypto.randomUUID()}`, + type: 'reasoning', + status: 'completed', + summary: Array.isArray(candidate.summary) ? cloneJson(candidate.summary) : [], + }; + case 'function_call': + if (normalizeString(candidate.name) === 'tool_suggest') { + return null; + } + return normalizeProviderFunctionCallItem(candidate); + case 'custom_tool_call': + return normalizeProviderCustomToolCallItem(candidate); + case 'function_call_output': + case 'custom_tool_call_output': + return normalizeProviderToolOutputItem(candidate, type); + default: + return null; + } +} + +function normalizeProviderResponseMessageItem(candidate: JsonRecord): JsonRecord | null { + if (normalizeString(candidate.role) !== 'assistant') { + return null; + } + const phase = normalizeString(candidate.phase); + if (phase && phase !== 'final_answer') { + return null; + } + const content = normalizeProviderResponseMessageContent(candidate.content); + if (content.length === 0) { + return null; + } + return { + id: normalizeNullableString(candidate.id) || `msg_${crypto.randomUUID()}`, + type: 'message', + status: 'completed', + role: 'assistant', + content, + }; +} + +function normalizeProviderResponseMessageContent(content: unknown): JsonRecord[] { + return normalizeArray(content) + .map((part) => normalizeProviderResponseMessagePart(part)) + .filter((part): part is JsonRecord => Boolean(part)); +} + +function normalizeProviderResponseMessagePart(part: unknown): JsonRecord | null { + const candidate = normalizeRecord(part); + if (!candidate) { + const text = normalizeString(part); + return text + ? { + type: 'output_text', + text, + annotations: [], + } + : null; + } + const type = normalizeString(candidate.type); + if (type === 'output_text' || type === 'text') { + const text = normalizeString(candidate.text); + if (!text) { + return null; + } + return { + type: 'output_text', + text, + annotations: Array.isArray(candidate.annotations) ? cloneJson(candidate.annotations) : [], + }; + } + const text = normalizeString(candidate.text); + if (!text) { + return null; + } + return { + type: 'output_text', + text, + annotations: [], + }; +} + +function normalizeProviderFunctionCallItem(candidate: JsonRecord): JsonRecord | null { + const callId = normalizeNullableString(candidate.call_id); + const name = normalizeNullableString(candidate.name); + if (!callId || !name) { + return null; + } + return { + id: normalizeNullableString(candidate.id) || `fc_${crypto.randomUUID()}`, + type: 'function_call', + call_id: callId, + name, + arguments: normalizeProviderToolArguments(candidate.arguments), + status: 'completed', + }; +} + +function normalizeProviderCustomToolCallItem(candidate: JsonRecord): JsonRecord | null { + const callId = normalizeNullableString(candidate.call_id); + const name = normalizeNullableString(candidate.name); + if (!callId || !name) { + return null; + } + return omitUndefined({ + id: normalizeNullableString(candidate.id) || `ctc_${crypto.randomUUID()}`, + type: 'custom_tool_call', + call_id: callId, + name, + input: normalizeNullableString(candidate.input), + arguments: normalizeProviderToolArguments(candidate.arguments), + status: 'completed', + }); +} + +function normalizeProviderToolOutputItem(candidate: JsonRecord, type: string): JsonRecord | null { + const callId = normalizeNullableString(candidate.call_id); + const output = normalizeProviderToolOutput(candidate.output); + if (!callId || output === null) { + return null; + } + return { + id: normalizeNullableString(candidate.id) || `tool_out_${crypto.randomUUID()}`, + type, + call_id: callId, + output, + status: 'completed', + }; +} + +function normalizeProviderToolArguments(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (value == null) { + return '{}'; + } + try { + return JSON.stringify(value); + } catch { + return '{}'; + } +} + +function normalizeProviderToolOutput(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + if (value == null) { + return null; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendFallbackAssistantResponseOutput( + output: JsonRecord[], + fallbackText: string | null, + status: 'completed' | 'incomplete', +): JsonRecord[] { + if (!fallbackText || output.some((item) => item.type === 'message' && item.role === 'assistant')) { + return output; + } + return [ + ...output, + { + id: `msg_${crypto.randomUUID()}`, + type: 'message', + status, + role: 'assistant', + content: [{ + type: 'output_text', + text: fallbackText, + annotations: [], + }], + }, + ]; +} + function buildResponsesObject({ request, responseId, @@ -2122,11 +2616,13 @@ function finishResponsesStreamState( state: ResponsesStreamState, { status, + output = null, incompleteDetails = null, nativeApi = null, nativeRuntime, }: { status: string; + output?: JsonRecord[] | null; incompleteDetails?: JsonRecord | null; nativeApi?: JsonRecord | null; nativeRuntime: JsonRecord; @@ -2147,7 +2643,7 @@ function finishResponsesStreamState( createdAt: state.createdAt, responseModel: state.responseModel, status, - output: cloneJson(state.output), + output: Array.isArray(output) ? output : cloneJson(state.output), incompleteDetails, nativeApi: nativeApi ?? state.initialNativeApi, nativeRuntime, @@ -2306,6 +2802,7 @@ function withResponsesStreamSequence(state: ResponsesStreamState, payload: JsonR function buildNativeApiObservability({ routePath, providerProfile, + localhostOnly = true, responseId = null, chatCompletionId = null, previousResponseId = null, @@ -2316,6 +2813,7 @@ function buildNativeApiObservability({ }: { routePath: string; providerProfile: ProviderProfile; + localhostOnly?: boolean; responseId?: string | null; chatCompletionId?: string | null; previousResponseId?: string | null; @@ -2327,7 +2825,7 @@ function buildNativeApiObservability({ const resumed = Boolean(previousResponseId || continuationEntry); return omitUndefined({ route_path: normalizeString(routePath) || '/v1/responses', - localhost_only: true, + localhost_only: localhostOnly, request_target: { provider_profile_id: providerProfile.id, provider_kind: providerProfile.providerKind, @@ -2430,6 +2928,11 @@ function deriveRequestTitle(prefix: string, prompt: string): string { return `${prefix}: ${preview}`; } +function isLoopbackHost(value: string): boolean { + const normalized = normalizeString(value).toLowerCase(); + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'; +} + function firstNonEmptyLine(value: string): string { return normalizeString(value.split('\n').find((line) => normalizeString(line)) ?? ''); } diff --git a/packages/codex-native-api/src/native_api_service.ts b/packages/codex-native-api/src/native_api_service.ts index 4892f39..628e174 100644 --- a/packages/codex-native-api/src/native_api_service.ts +++ b/packages/codex-native-api/src/native_api_service.ts @@ -2,25 +2,22 @@ import { CodexNativeApiServer, type CodexNativeApiServerOptions, } from './native_api_server.js'; +import { resolveCodexAuthPath } from './auth_state.js'; +import { + createDefaultCodexNativeProviderBootstrap, + type ProviderProfileRepositoryLike, + type ProviderRegistryLike, +} from './default_provider.js'; import { CodexNativeRuntime } from './native_runtime.js'; import type { ProviderPluginContract, ProviderProfile, } from './provider.js'; -interface ProviderProfileRepositoryLike { - get(id: string): ProviderProfile | null | undefined; - list(): ProviderProfile[]; -} - -interface ProviderRegistryLike { - getProvider<T extends ProviderPluginContract>(providerKind: string): T; -} - export interface CodexNativeApiServiceOptions { runtime?: CodexNativeRuntime; - providerProfiles: ProviderProfileRepositoryLike; - providerRegistry: ProviderRegistryLike; + providerProfiles?: ProviderProfileRepositoryLike; + providerRegistry?: ProviderRegistryLike; defaultProviderProfileId?: string | null; providerProfileId?: string | null; authPath?: string | null; @@ -82,9 +79,12 @@ export class CodexNativeApiService { now, createResponseId, }: CodexNativeApiServiceOptions) { - this.providerProfiles = providerProfiles; - this.providerRegistry = providerRegistry; - this.defaultProviderProfileId = normalizeString(defaultProviderProfileId) || null; + const bootstrap = createDefaultCodexNativeProviderBootstrap(env); + this.providerProfiles = providerProfiles ?? bootstrap.providerProfiles; + this.providerRegistry = providerRegistry ?? bootstrap.providerRegistry; + this.defaultProviderProfileId = normalizeString(defaultProviderProfileId) + || normalizeString(bootstrap.defaultProviderProfileId) + || null; this.requestedProviderProfileId = normalizeString(providerProfileId) || null; this.authPath = normalizeString(authPath) || null; this.env = env; @@ -125,7 +125,7 @@ export class CodexNativeApiService { providerProfileId: providerProfile.id, providerKind: providerProfile.providerKind, providerDisplayName: providerProfile.displayName, - authPath: this.authPath, + authPath: this.authPath ?? resolveCodexAuthPath(this.env), }; } diff --git a/packages/codex-native-api/src/native_runtime.ts b/packages/codex-native-api/src/native_runtime.ts index 9e30e89..ca1349a 100644 --- a/packages/codex-native-api/src/native_runtime.ts +++ b/packages/codex-native-api/src/native_runtime.ts @@ -27,6 +27,7 @@ export interface CodexNativeRuntimeReadiness { export interface CodexNativeRuntimeTurnPreparation { event: CodexNativeInboundEvent; inputText: string; + developerInstructions?: string | null; collaborationMode?: CodexNativeSessionSettings['collaborationMode']; personality?: CodexNativeSessionSettings['personality']; accessPreset?: CodexNativeSessionSettings['accessPreset']; @@ -361,6 +362,7 @@ export class CodexNativeRuntime { sessionSettings, event: request.event, inputText: request.inputText, + developerInstructions: request.developerInstructions ?? null, onProgress, onTurnStarted: typeof onTurnStarted === 'function' ? async (meta) => { diff --git a/packages/codex-native-api/src/provider.ts b/packages/codex-native-api/src/provider.ts index 7bc6bb6..afc4923 100644 --- a/packages/codex-native-api/src/provider.ts +++ b/packages/codex-native-api/src/provider.ts @@ -347,6 +347,10 @@ export interface ProviderTurnProgress { outputKind: string; } +export interface ProviderResponseItem { + [key: string]: unknown; +} + export type OutputArtifactKind = 'image' | 'file' | 'video' | 'audio'; export interface OutputArtifact { @@ -388,6 +392,7 @@ export interface ProviderTurnResult { threadId?: string | null; title?: string | null; status?: string | null; + responseItems?: ProviderResponseItem[]; outputArtifacts?: OutputArtifact[]; outputMedia?: Array<{ kind: 'image'; @@ -469,6 +474,7 @@ export interface ProviderPluginContract { sessionSettings: ProviderTurnSessionSettings | null; event: ProviderTurnEvent; inputText: string; + developerInstructions?: string | null; onProgress?: ((progress: ProviderTurnProgress) => Promise<void> | void) | null; onTurnStarted?: ((meta: Record<string, unknown>) => Promise<void> | void) | null; onApprovalRequest?: ((request: ProviderApprovalRequest) => Promise<void> | void) | null; diff --git a/packages/codex-native-api/src/sequenced_stderr.ts b/packages/codex-native-api/src/sequenced_stderr.ts new file mode 100644 index 0000000..d28b8a1 --- /dev/null +++ b/packages/codex-native-api/src/sequenced_stderr.ts @@ -0,0 +1,54 @@ +let globalLogSequence = 0; + +type SequencedStderrOptions = { + envVar?: string | null; +}; + +function shouldWrite(envVar: string | null | undefined): boolean { + return !envVar || process.env[envVar] === '1'; +} + +function nextSequence(): number { + globalLogSequence += 1; + return globalLogSequence; +} + +export function writeSequencedStderrLine( + message: string, + { + envVar = null, + }: SequencedStderrOptions = {}, +): void { + if (!shouldWrite(envVar)) { + return; + } + const normalized = String(message ?? '').trim(); + const lines = normalized + .split(/\r?\n/gu) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) { + return; + } + for (const line of lines) { + process.stderr.write(`[${nextSequence()}] ${line}\n`); + } +} + +export function writeSequencedDebugLog( + tag: string, + event: string, + payload: unknown, + { + envVar = 'CODEXBRIDGE_DEBUG_WEIXIN', + }: SequencedStderrOptions = {}, +): void { + if (!shouldWrite(envVar)) { + return; + } + try { + writeSequencedStderrLine(`[${tag}] ${event} ${JSON.stringify(payload)}`); + } catch { + writeSequencedStderrLine(`[${tag}] ${event}`); + } +} diff --git a/packages/codex-native-api/test/package_exports.test.ts b/packages/codex-native-api/test/package_exports.test.ts index f9a185b..1799d98 100644 --- a/packages/codex-native-api/test/package_exports.test.ts +++ b/packages/codex-native-api/test/package_exports.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { @@ -11,12 +12,34 @@ import { CodexNativeApiServer, CodexNativeRuntime, InMemoryCodexNativeApiContinuationRegistry, + loadDefaultCodexNativeProviderProfile, } from '../src/index.js'; +import { parseCliArgs } from '../src/cli.js'; +import { + buildDaemonInstallPlan, + buildWindowsInstallScript, + resolveDaemonLayout, +} from '../src/daemon_manager.js'; + +function parseSsePayloads(raw: string): any[] { + return raw + .split('\n\n') + .map((block) => block.trim()) + .filter(Boolean) + .map((block) => { + const dataLine = block + .split('\n') + .find((line) => line.startsWith('data: ')); + return dataLine ? dataLine.slice(6) : null; + }) + .filter((line): line is string => Boolean(line) && line !== '[DONE]') + .map((line) => JSON.parse(line)); +} test('package exports the first extraction metadata', () => { - assert.equal(CODEX_NATIVE_API_PACKAGE_NAME, '@codexbridge/codex-native-api'); - assert.equal(CODEX_NATIVE_API_PACKAGE_PHASE, 'phase-5-first-extraction'); - assert.equal(CODEX_NATIVE_API_RELEASE_CHANNEL, 'internal-only'); + assert.equal(CODEX_NATIVE_API_PACKAGE_NAME, 'codex-native-api'); + assert.equal(CODEX_NATIVE_API_PACKAGE_PHASE, 'public-core-preview'); + assert.equal(CODEX_NATIVE_API_RELEASE_CHANNEL, 'public-preview'); }); test('package exports the core localhost runtime surface', () => { @@ -27,22 +50,644 @@ test('package exports the core localhost runtime surface', () => { assert.equal(typeof CodexNativeApiService, 'function'); }); +test('service can bootstrap the default Codex provider and auth path automatically', () => { + const profile = loadDefaultCodexNativeProviderProfile({ + CODEX_HOME: '/tmp/codex-native-api-home', + PATH: process.env.PATH ?? '', + }); + assert.equal(profile.id, 'openai-default'); + assert.equal(profile.providerKind, 'openai-native'); + assert.equal(profile.displayName, 'Codex OpenAI'); + + const service = new CodexNativeApiService({ + env: { + CODEX_HOME: '/tmp/codex-native-api-home', + PATH: process.env.PATH ?? '', + }, + }); + assert.deepEqual(service.describeBinding(), { + providerProfileId: 'openai-default', + providerKind: 'openai-native', + providerDisplayName: 'Codex OpenAI', + authPath: '/tmp/codex-native-api-home/auth.json', + }); +}); + test('package metadata and root entrypoint keep a stable public boundary', () => { const packageJsonPath = path.resolve(import.meta.dirname, '../package.json'); const indexPath = path.resolve(import.meta.dirname, '../src/index.ts'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; private?: boolean; + main?: string; + types?: string; + bin?: Record<string, string>; exports?: Record<string, { types?: string; default?: string } | string>; files?: string[]; + devDependencies?: Record<string, string>; }; const source = fs.readFileSync(indexPath, 'utf8'); - assert.equal(packageJson.private, true); + assert.equal(packageJson.name, 'codex-native-api'); + assert.equal(packageJson.private, false); + assert.equal(packageJson.main, './dist/index.js'); + assert.equal(packageJson.types, './dist/index.d.ts'); + assert.equal(packageJson.bin?.['codex-native-api'], './dist/cli.js'); assert.deepEqual(Object.keys(packageJson.exports ?? {}).sort(), ['.', './package.json']); assert.equal((packageJson.exports?.['.'] as { types?: string })?.types, './dist/index.d.ts'); assert.equal((packageJson.exports?.['.'] as { default?: string })?.default, './dist/index.js'); assert.deepEqual(packageJson.files, ['dist', 'README.md']); + assert.equal(typeof packageJson.devDependencies?.typescript, 'string'); + assert.equal(typeof packageJson.devDependencies?.tsx, 'string'); + assert.equal(typeof packageJson.devDependencies?.['@types/node'], 'string'); assert.equal(source.includes('export * from'), false); assert.match(source, /export \{\s*[\s\S]*CodexNativeRuntime/); assert.match(source, /export type \{\s*[\s\S]*ProviderPluginContract/); }); + +test('package cli keeps a minimal standalone startup surface', () => { + assert.deepEqual(parseCliArgs([ + '--port', '4242', + '--public', + '--cwd', '/tmp/work', + '--default-model', 'gpt-5.5', + ]), { + host: null, + port: 4242, + authPath: null, + authToken: null, + cwd: '/tmp/work', + providerProfileId: null, + defaultModel: 'gpt-5.5', + publicBind: true, + }); +}); + +test('server health metadata reports public exposure when bound to 0.0.0.0', async () => { + const providerProfile = { + id: 'openai-default', + providerKind: 'openai-native', + displayName: 'Codex OpenAI', + config: {}, + createdAt: 0, + updatedAt: 0, + }; + const providerPlugin = { + kind: 'openai-native', + displayName: 'Codex OpenAI', + async startThread() { + throw new Error('unused in health check'); + }, + async readThread() { + return null; + }, + async listThreads() { + return { items: [], nextCursor: null }; + }, + async startTurn() { + throw new Error('unused in health check'); + }, + async listModels() { + return [{ + id: 'gpt-5.5', + model: 'gpt-5.5', + displayName: 'GPT-5.5', + description: '', + isDefault: true, + supportedReasoningEfforts: ['medium'], + defaultReasoningEffort: 'medium', + }]; + }, + }; + const runtime = new CodexNativeRuntime({ + readAccountIdentity: () => ({ + accountId: 'acct_test', + email: 'test@example.com', + name: 'Test User', + plan: 'plus', + authMode: 'chatgpt', + }), + }); + const server = new CodexNativeApiServer({ + runtime, + host: '0.0.0.0', + port: 0, + resolveRuntimeContext: () => ({ + providerProfile, + providerPlugin, + authPathOrOptions: {}, + }), + }); + + await server.start(); + try { + const port = new URL(server.baseUrl).port; + const response = await fetch(`http://127.0.0.1:${port}/v1/health`); + assert.equal(response.status, 200); + const payload = await response.json() as { + localhost_only?: boolean; + native_api?: { localhost_only?: boolean }; + }; + assert.equal(payload.localhost_only, false); + assert.equal(payload.native_api?.localhost_only, false); + } finally { + await server.stop(); + } +}); + +test('responses requests normalize builtin web_search tools and pass a constrained tool policy into the runtime', async () => { + const calls: Array<{ kind: string; payload: any }> = []; + const runtime = new CodexNativeRuntime({ + now: () => 777_000, + createSessionId: () => 'session-native-tools-1', + readAccountIdentity: () => ({ + accountId: 'acct_test', + email: 'test@example.com', + name: 'Test User', + plan: 'plus', + authMode: 'chatgpt', + }), + }); + const providerProfile = { + id: 'openai-default', + providerKind: 'openai-native', + displayName: 'Codex OpenAI', + config: {}, + createdAt: 0, + updatedAt: 0, + }; + const providerPlugin = { + kind: 'openai-native', + displayName: 'Codex OpenAI', + async startThread(params: any) { + calls.push({ kind: 'startThread', payload: params }); + return { + threadId: 'thread-native-tools-1', + cwd: params.cwd, + title: params.title, + }; + }, + async readThread() { + return null; + }, + async listThreads() { + return { items: [], nextCursor: null }; + }, + async startTurn(params: any) { + calls.push({ kind: 'startTurn', payload: params }); + return { + outputText: 'searched answer', + previewText: '', + threadId: params.bridgeSession.codexThreadId, + turnId: 'turn-native-tools-1', + }; + }, + async listModels() { + calls.push({ kind: 'listModels', payload: null }); + return [{ + id: 'gpt-5.5', + model: 'gpt-5.5', + displayName: 'GPT-5.5', + description: '', + isDefault: true, + supportedReasoningEfforts: ['medium'], + defaultReasoningEffort: 'medium', + }]; + }, + }; + const server = new CodexNativeApiServer({ + runtime, + defaultLocale: 'en-US', + resolveRuntimeContext: () => ({ + providerProfile, + providerPlugin, + authPathOrOptions: {}, + }), + createResponseId: () => 'resp_native_tools_1', + }); + + await server.start(); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5', + input: 'Find current Codex news.', + tools: [{ + type: 'web_search_preview_2025_03_11', + }], + tool_choice: { + type: 'allowed_tools', + tools: [{ + type: 'web_search_preview', + }], + }, + }), + }); + const body = await response.json() as any; + + assert.equal(response.status, 200); + assert.equal(body.tools[0].type, 'web_search'); + assert.equal(body.tool_choice.type, 'allowed_tools'); + assert.equal(body.tool_choice.tools[0].type, 'web_search'); + assert.match( + calls[2]?.payload.developerInstructions, + /only supported built-in tool for this turn is web_search/i, + ); + assert.match( + calls[2]?.payload.developerInstructions, + /Do not substitute shell commands, file edits, MCP tools, plugins, or image generation for web_search\./, + ); + assert.equal(calls[2]?.payload.inputText, 'Find current Codex news.'); + } finally { + await server.stop(); + } +}); + +test('responses rejects function tools until external tool calling is wired', async () => { + const server = new CodexNativeApiServer({ + resolveRuntimeContext: () => { + throw new Error('resolver should not run'); + }, + }); + + await server.start(); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input: 'Call a custom function.', + tools: [{ + type: 'function', + function: { + name: 'lookup', + parameters: { type: 'object' }, + }, + }], + }), + }); + const body = await response.json() as any; + + assert.equal(response.status, 400); + assert.equal(body.error.code, 'unsupported_responses_tooling'); + assert.match(body.error.message, /only the built-in web_search tool/i); + } finally { + await server.stop(); + } +}); + +test('responses output preserves provider tool transcript items and filters commentary-only assistant messages', async () => { + const runtime = new CodexNativeRuntime({ + now: () => 888_000, + createSessionId: () => 'session-native-transcript-1', + readAccountIdentity: () => ({ + accountId: 'acct_test', + email: 'test@example.com', + name: 'Test User', + plan: 'plus', + authMode: 'chatgpt', + }), + }); + const providerProfile = { + id: 'openai-default', + providerKind: 'openai-native', + displayName: 'Codex OpenAI', + config: {}, + createdAt: 0, + updatedAt: 0, + }; + const providerPlugin = { + kind: 'openai-native', + displayName: 'Codex OpenAI', + async startThread(params: any) { + return { + threadId: 'thread-native-transcript-1', + cwd: params.cwd, + title: params.title, + }; + }, + async readThread() { + return null; + }, + async listThreads() { + return { items: [], nextCursor: null }; + }, + async startTurn(params: any) { + return { + outputText: '', + previewText: '', + threadId: params.bridgeSession.codexThreadId, + turnId: 'turn-native-transcript-1', + responseItems: [{ + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'hidden commentary' }], + }, { + type: 'function_call', + call_id: 'call_web_1', + name: 'web_search', + arguments: '{"query":"codex native api"}', + }, { + type: 'function_call_output', + call_id: 'call_web_1', + output: '{"hits":1}', + }, { + type: 'message', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'final tool-backed answer' }], + }], + }; + }, + async listModels() { + return [{ + id: 'gpt-5.5', + model: 'gpt-5.5', + displayName: 'GPT-5.5', + description: '', + isDefault: true, + supportedReasoningEfforts: ['medium'], + defaultReasoningEffort: 'medium', + }]; + }, + }; + const server = new CodexNativeApiServer({ + runtime, + defaultLocale: 'en-US', + resolveRuntimeContext: () => ({ + providerProfile, + providerPlugin, + authPathOrOptions: {}, + }), + createResponseId: () => 'resp_native_transcript_1', + }); + + await server.start(); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5', + input: 'Search for Codex native API status.', + }), + }); + const body = await response.json() as any; + + assert.equal(response.status, 200); + assert.equal(body.status, 'completed'); + assert.deepEqual(body.output.map((item: any) => item.type), [ + 'function_call', + 'function_call_output', + 'message', + ]); + assert.equal(body.output[0].call_id, 'call_web_1'); + assert.equal(body.output[0].name, 'web_search'); + assert.equal(body.output[1].output, '{"hits":1}'); + assert.equal(body.output[2].content[0].text, 'final tool-backed answer'); + } finally { + await server.stop(); + } +}); + +test('streaming responses completed event includes recovered provider tool transcript output', async () => { + const runtime = new CodexNativeRuntime({ + now: () => 889_000, + createSessionId: () => 'session-native-stream-transcript-1', + readAccountIdentity: () => ({ + accountId: 'acct_test', + email: 'test@example.com', + name: 'Test User', + plan: 'plus', + authMode: 'chatgpt', + }), + }); + const providerProfile = { + id: 'openai-default', + providerKind: 'openai-native', + displayName: 'Codex OpenAI', + config: {}, + createdAt: 0, + updatedAt: 0, + }; + const providerPlugin = { + kind: 'openai-native', + displayName: 'Codex OpenAI', + async startThread(params: any) { + return { + threadId: 'thread-native-stream-transcript-1', + cwd: params.cwd, + title: params.title, + }; + }, + async readThread() { + return null; + }, + async listThreads() { + return { items: [], nextCursor: null }; + }, + async startTurn(params: any) { + return { + outputText: '', + previewText: '', + threadId: params.bridgeSession.codexThreadId, + turnId: 'turn-native-stream-transcript-1', + responseItems: [{ + type: 'function_call', + call_id: 'call_web_stream_1', + name: 'web_search', + arguments: '{"query":"codex bridge"}', + }, { + type: 'message', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'streamed final answer' }], + }], + }; + }, + async listModels() { + return [{ + id: 'gpt-5.5', + model: 'gpt-5.5', + displayName: 'GPT-5.5', + description: '', + isDefault: true, + supportedReasoningEfforts: ['medium'], + defaultReasoningEffort: 'medium', + }]; + }, + }; + const server = new CodexNativeApiServer({ + runtime, + defaultLocale: 'en-US', + resolveRuntimeContext: () => ({ + providerProfile, + providerPlugin, + authPathOrOptions: {}, + }), + createResponseId: () => 'resp_native_stream_transcript_1', + }); + + await server.start(); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5', + input: 'Stream Codex bridge status.', + stream: true, + }), + }); + const raw = await response.text(); + const events = parseSsePayloads(raw); + const completed = events.find((event) => event.type === 'response.completed'); + + assert.equal(response.status, 200); + assert.ok(completed); + assert.deepEqual(completed.response.output.map((item: any) => item.type), [ + 'function_call', + 'message', + ]); + assert.equal(completed.response.output[0].call_id, 'call_web_stream_1'); + assert.equal(completed.response.output[1].content[0].text, 'streamed final answer'); + } finally { + await server.stop(); + } +}); + +test('daemon layout resolves platform-specific service paths', () => { + const darwin = resolveDaemonLayout({ + HOME: '/tmp/darwin-home', + }, { + platform: 'darwin', + }); + assert.equal(darwin.envFile, '/tmp/darwin-home/.config/codex-native-api/service.env'); + assert.equal(darwin.launchdPlistPath, '/tmp/darwin-home/Library/LaunchAgents/com.codexbridge.codex-native-api.plist'); + + const linux = resolveDaemonLayout({ + HOME: '/tmp/linux-home', + }, { + platform: 'linux', + }); + assert.equal(linux.systemdUnitPath, '/tmp/linux-home/.config/systemd/user/codex-native-api.service'); + + const win32 = resolveDaemonLayout({ + USERPROFILE: 'C:\\Users\\GanXing', + APPDATA: 'C:\\Users\\GanXing\\AppData\\Roaming', + }, { + platform: 'win32', + }); + assert.equal(win32.envFile, 'C:\\Users\\GanXing\\AppData\\Roaming\\codex-native-api\\service.env'); + assert.equal(win32.windowsTaskName, 'CodexNativeApi'); +}); + +test('daemon install plans render launchd, systemd, and windows service artifacts', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-native-api-daemon-')); + + const darwinPlan = await buildDaemonInstallPlan({ + serveOptions: { + host: null, + port: 4242, + authPath: null, + authToken: null, + cwd: '/tmp/codex-work', + providerProfileId: null, + defaultModel: null, + publicBind: true, + }, + restartSec: 3, + codexHome: null, + codexRealBin: '/usr/local/bin/codex', + launchCommand: null, + autolaunch: false, + }, { + platform: 'darwin', + env: { + HOME: path.join(tempRoot, 'darwin-home'), + PATH: '/usr/local/bin:/usr/bin:/bin', + }, + currentWorkingDirectory: '/tmp/codex-work', + entryPath: '/opt/codex-native-api/dist/cli.js', + nodeBin: '/usr/local/bin/node', + }); + + assert.equal(darwinPlan.layout.launchdLabel, 'com.codexbridge.codex-native-api'); + assert.ok(darwinPlan.generatedAuthToken); + assert.match(darwinPlan.serviceEnvFileContent, /CODEX_NATIVE_API_PUBLIC=1/); + assert.match(darwinPlan.serviceEnvFileContent, /CODEX_NATIVE_API_PORT=4242/); + assert.match(darwinPlan.artifactContent ?? '', /daemon-supervisor/); + assert.match(darwinPlan.artifactContent ?? '', /KeepAlive/); + + const linuxPlan = await buildDaemonInstallPlan({ + serveOptions: { + host: null, + port: 4243, + authPath: null, + authToken: 'secret-token', + cwd: '/srv/codex', + providerProfileId: 'openai-default', + defaultModel: 'gpt-5.5', + publicBind: false, + }, + restartSec: 5, + codexHome: '/srv/.codex', + codexRealBin: '/usr/bin/codex', + launchCommand: 'codex-app', + autolaunch: true, + }, { + platform: 'linux', + env: { + HOME: path.join(tempRoot, 'linux-home'), + USER: 'ganxing', + LOGNAME: 'ganxing', + PATH: '/usr/local/bin:/usr/bin:/bin', + }, + currentWorkingDirectory: '/srv/codex', + entryPath: '/opt/codex-native-api/dist/cli.js', + nodeBin: '/usr/bin/node', + }); + + assert.match(linuxPlan.serviceEnvFileContent, /CODEX_NATIVE_API_PORT=4243/); + assert.match(linuxPlan.serviceEnvFileContent, /CODEX_APP_AUTOLAUNCH=true/); + assert.match(linuxPlan.artifactContent ?? '', /Restart=always/); + assert.match(linuxPlan.artifactContent ?? '', /EnvironmentFile=/); + assert.match(linuxPlan.artifactContent ?? '', /daemon-supervisor/); + + const windowsPlan = await buildDaemonInstallPlan({ + serveOptions: { + host: null, + port: 4244, + authPath: null, + authToken: null, + cwd: 'C:\\Work', + providerProfileId: null, + defaultModel: null, + publicBind: false, + }, + restartSec: 2, + codexHome: 'C:\\Users\\GanXing\\.codex', + codexRealBin: 'C:\\Program Files\\nodejs\\codex.cmd', + launchCommand: null, + autolaunch: false, + }, { + platform: 'win32', + env: { + USERPROFILE: 'C:\\Users\\GanXing', + APPDATA: 'C:\\Users\\GanXing\\AppData\\Roaming', + PATH: 'C:\\Windows\\System32;C:\\Program Files\\nodejs', + }, + currentWorkingDirectory: 'C:\\Work', + entryPath: 'C:\\pkg\\codex-native-api\\dist\\cli.js', + nodeBin: 'C:\\Program Files\\nodejs\\node.exe', + }); + + const windowsScript = buildWindowsInstallScript(windowsPlan); + assert.match(windowsPlan.serviceEnvFileContent, /CODEX_NATIVE_API_PORT=4244/); + assert.match(windowsScript, /Register-ScheduledTask/); + assert.match(windowsScript, /RestartCount 999/); + assert.match(windowsScript, /daemon-supervisor/); +}); diff --git a/packages/codex-native-api/tsconfig.json b/packages/codex-native-api/tsconfig.json index 32ddb08..0bd0668 100644 --- a/packages/codex-native-api/tsconfig.json +++ b/packages/codex-native-api/tsconfig.json @@ -1,18 +1,29 @@ { - "extends": "../../tsconfig.json", "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", "allowJs": false, + "rootDir": "src", + "outDir": "dist", + "strict": false, + "noEmitOnError": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "noEmit": false, - "outDir": "dist", - "rootDir": "src", - "strict": true + "types": ["node"] }, "include": [ "src/**/*.ts" ], "exclude": [ + "dist", + "node_modules", "test/**/*.ts" ] } diff --git a/packages/mission-control/README.md b/packages/mission-control/README.md index c462930..d632175 100644 --- a/packages/mission-control/README.md +++ b/packages/mission-control/README.md @@ -3,6 +3,13 @@ Mission Control runtime package, currently developed inside the CodexBridge repository. +## Status + +Development for this package is currently paused. + +It remains in the repository as historical/internal reference material, but it +is not part of the active roadmap at this time. + Immutable target: > `@codexbridge/mission-control` provides a durable, goal-driven runtime that diff --git a/scripts/test.mjs b/scripts/test.mjs index d388af0..1517d61 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -5,10 +5,12 @@ import { spawnSync } from 'node:child_process'; const distTestDir = path.join(process.cwd(), 'dist', 'test'); fs.rmSync(distTestDir, { recursive: true, force: true }); +const AGENT_COMMAND_ENV_FLAG = 'CODEXBRIDGE_ENABLE_AGENT_COMMAND'; const LIVE_AGENT_TEST_ENV_FLAG = 'CODEXBRIDGE_TEST_ALLOW_LIVE_AGENT'; const LIVE_OPENAI_COMPATIBLE_TEST_ENV_FLAG = 'CODEXBRIDGE_TEST_LIVE_OPENAI_COMPATIBLE'; loadOptionalEnvFile(process.env.CODEXBRIDGE_TEST_ENV_FILE); const isolatedEnv = { ...process.env }; +isolatedEnv[AGENT_COMMAND_ENV_FLAG] ??= '1'; const allowLiveAgent = isolatedEnv[LIVE_AGENT_TEST_ENV_FLAG] === '1'; const allowLiveOpenAICompatible = isolatedEnv[LIVE_OPENAI_COMPATIBLE_TEST_ENV_FLAG] === '1'; diff --git a/src/core/bridge_coordinator.ts b/src/core/bridge_coordinator.ts index b3bcf87..8da181c 100644 --- a/src/core/bridge_coordinator.ts +++ b/src/core/bridge_coordinator.ts @@ -5,6 +5,7 @@ import crypto from 'node:crypto'; import { AsyncLocalStorage } from 'node:async_hooks'; import { execFileSync } from 'node:child_process'; import { formatPlatformScopeKey } from './contracts.js'; +import { isAgentCommandEnabled } from './command_availability.js'; import { parseSlashCommand } from './command_parser.js'; import { NotFoundError } from './errors.js'; import { @@ -1067,6 +1068,9 @@ export class BridgeCoordinator { } renderAgentMissionNotification(job: AgentJob, notification: MissionHostNotification): string | null { + if (!isAgentCommandEnabled()) { + return null; + } const cycleResult = notification?.cycleResult ?? null; const loopSnapshot = notification?.loopSnapshot ?? null; if (!shouldRenderAgentMissionNotification(cycleResult, loopSnapshot)) { @@ -1306,6 +1310,12 @@ export class BridgeCoordinator { case 'review': return this.handleReviewCommand(event, command.args, options); case 'agent': + if (!isAgentCommandEnabled()) { + return messageResponse([ + this.t('coordinator.command.unsupported', { name: command.name }), + this.t('coordinator.command.useHelps'), + ], this.buildScopedSessionMeta(event)); + } return this.handleAgentCommand(event, command.args); case 'skills': return this.handleSkillsCommand(event, command.args); @@ -5881,7 +5891,10 @@ export class BridgeCoordinator { return this.renderReviewClarifyResponse(event, commandResult.question, commandResult.candidates); } else { return messageResponse([ - commandResult.reason || this.t('coordinator.review.empty'), + sanitizeReviewCommandReason( + commandResult.reason || this.t('coordinator.review.empty'), + this.currentI18n, + ), ], this.buildScopedSessionMeta(event)); } } else { @@ -12329,6 +12342,8 @@ function buildReviewCommandSkillPrompt({ 'commit', 'custom', ], + canEscalateToBackgroundExecution: isAgentCommandEnabled(), + backgroundExecutionCommand: isAgentCommandEnabled() ? '/agent' : null, customOptions: [ 'instructions', 'focus', @@ -12347,6 +12362,7 @@ function buildReviewCommandSkillPrompt({ '', `Please read and follow this command skill file: ${REVIEW_COMMAND_SKILL_PATH}`, 'Use it to interpret the /review command request below.', + 'If backgroundExecutionCommand is null, do not recommend /agent or any hidden command. Keep reject reasons generic.', 'Return exactly one JSON object that matches the skill contract.', 'Do not use Markdown. Do not explain. Do not execute the review yourself.', '', @@ -15086,6 +15102,14 @@ function shouldRenderAgentMissionNotification( return cycleResult.status === 'continue' && cycleResult.stage.startsWith('verifier.'); } +function sanitizeReviewCommandReason(reason: string, i18n: Translator): string { + const normalized = compactWhitespace(reason); + if (isAgentCommandEnabled() || !/\/ag(?:ent)?\b/iu.test(normalized)) { + return normalized; + } + return i18n.t('coordinator.review.backgroundExecutionUnavailable'); +} + function parseAutomationAddSpec(text: string) { const input = String(text ?? '').trim(); const match = input.match(/^\/\S+\s+add\s+(.+)$/iu); @@ -17438,38 +17462,6 @@ function getCommandHelpSpecs(i18n: Translator) { i18n.t('coordinator.help.note.review'), ], }), - agent: freezeCommandHelp({ - name: 'agent', - aliases: ['ag'], - summary: i18n.t('coordinator.help.summary.agent'), - usage: [ - '/agent <任务>', - '/agent confirm', - '/agent edit <修改提示>', - '/agent list', - '/agent show <序号>', - '/agent result <序号> [页码]', - '/agent result <序号> file', - '/agent send <序号>', - '/agent stop <序号>', - '/agent retry <序号>', - '/agent rename <序号> <新标题>', - '/agent del <序号>', - '/agent -h', - ], - examples: [ - '/agent 检查当前项目测试并修复失败项', - '/agent confirm', - '/agent show 1', - '/agent result 1', - '/agent result 1 file', - '/agent send 1', - '/agent retry 1', - ], - notes: [ - i18n.t('coordinator.help.note.agent'), - ], - }), skills: freezeCommandHelp({ name: 'skills', aliases: ['sk'], @@ -18257,7 +18249,6 @@ const COMMAND_HELP_ORDER = Object.freeze([ 'login', 'stop', 'review', - 'agent', 'skills', 'plugins', 'apps', @@ -18308,7 +18299,6 @@ const COMMAND_ALIAS_DEFINITIONS = Object.freeze({ login: ['lg'], stop: ['sp'], review: ['rv'], - agent: ['ag'], skills: ['sk'], plugins: ['pg'], apps: ['ap'], diff --git a/src/core/command_availability.ts b/src/core/command_availability.ts new file mode 100644 index 0000000..875cc80 --- /dev/null +++ b/src/core/command_availability.ts @@ -0,0 +1,6 @@ +const ENABLED_FLAG_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export function isAgentCommandEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const rawValue = String(env.CODEXBRIDGE_ENABLE_AGENT_COMMAND ?? '').trim().toLowerCase(); + return ENABLED_FLAG_VALUES.has(rawValue); +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 56730e0..480535f 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -212,6 +212,7 @@ const CATALOGS: Record<SupportedLocale, MessageCatalog> = { 'coordinator.review.noCwd': '当前没有可用的工作目录。先发送普通消息建立会话,或使用 /new 指定 cwd。', 'coordinator.review.failed': '代码审查失败:{error}', 'coordinator.review.empty': '代码审查已完成,但没有返回可见内容。', + 'coordinator.review.backgroundExecutionUnavailable': '这是执行或修复请求,不是只读审查。当前后台执行命令已隐藏,请直接用普通消息描述你要完成的目标。', 'coordinator.review.started': '正在运行代码审查:{target}。', 'coordinator.review.heartbeat': '代码审查仍在进行:{target}。', 'coordinator.review.target.uncommitted': '代码审查 | 未提交改动', @@ -1362,6 +1363,7 @@ const CATALOGS: Record<SupportedLocale, MessageCatalog> = { 'coordinator.review.noCwd': 'There is no working directory yet. Send a normal message first, or use /new with a cwd.', 'coordinator.review.failed': 'Code review failed: {error}', 'coordinator.review.empty': 'The code review finished, but returned no visible output.', + 'coordinator.review.backgroundExecutionUnavailable': 'This is an execution or repair request, not a read-only review. The background execution command is currently hidden, so describe the goal in a normal message instead.', 'coordinator.review.started': 'Starting code review: {target}.', 'coordinator.review.heartbeat': 'Code review is still running: {target}.', 'coordinator.review.target.uncommitted': 'Code review | Uncommitted changes', diff --git a/src/runtime/weixin_bridge_runtime.ts b/src/runtime/weixin_bridge_runtime.ts index 3f6ac2a..c1716a5 100644 --- a/src/runtime/weixin_bridge_runtime.ts +++ b/src/runtime/weixin_bridge_runtime.ts @@ -1,4 +1,5 @@ import { parseSlashCommand } from '../core/command_parser.js'; +import { isAgentCommandEnabled } from '../core/command_availability.js'; import { writeSequencedDebugLog } from '../core/sequenced_stderr.js'; import { WeixinPoller } from '../platforms/weixin/poller.js'; import { createI18n, type Translator } from '../i18n/index.js'; @@ -1317,27 +1318,28 @@ export class WeixinBridgeRuntime { this.trackBackgroundTask(task); } } - const agentJobs = typeof this.agentJobs?.claimSupervisableJobs === 'function' - ? this.agentJobs.claimSupervisableJobs('weixin') - : this.agentJobs?.claimQueuedJobs?.('weixin') ?? []; - if (!Array.isArray(agentJobs)) { - return; - } - for (const job of agentJobs) { - const jobId = typeof job?.id === 'string' ? job.id : ''; - if (jobId && this.scheduledAgentJobIds.has(jobId)) { - continue; - } - if (jobId) { - this.scheduledAgentJobIds.add(jobId); - } - const task = this.runAgentJob(job) - .finally(() => { + if (isAgentCommandEnabled()) { + const agentJobs = typeof this.agentJobs?.claimSupervisableJobs === 'function' + ? this.agentJobs.claimSupervisableJobs('weixin') + : this.agentJobs?.claimQueuedJobs?.('weixin') ?? []; + if (Array.isArray(agentJobs)) { + for (const job of agentJobs) { + const jobId = typeof job?.id === 'string' ? job.id : ''; + if (jobId && this.scheduledAgentJobIds.has(jobId)) { + continue; + } if (jobId) { - this.scheduledAgentJobIds.delete(jobId); + this.scheduledAgentJobIds.add(jobId); } - }); - this.trackBackgroundTask(task); + const task = this.runAgentJob(job) + .finally(() => { + if (jobId) { + this.scheduledAgentJobIds.delete(jobId); + } + }); + this.trackBackgroundTask(task); + } + } } const reminders = this.assistantRecords?.claimDueReminders?.('weixin') ?? []; if (Array.isArray(reminders)) { @@ -1365,6 +1367,12 @@ export class WeixinBridgeRuntime { } async runAgentJob(job: any): Promise<RuntimeResponse> { + if (!isAgentCommandEnabled()) { + return { + type: 'message', + messages: [], + }; + } const scopeId = String(job?.externalScopeId ?? ''); if (!scopeId || await this.isScopeBusyForAgent(job)) { if (job?.id && typeof this.agentJobs?.claimSupervisableJobs !== 'function') { @@ -1471,6 +1479,9 @@ export class WeixinBridgeRuntime { job: any, notification: MissionHostNotification, ): Promise<void> { + if (!isAgentCommandEnabled()) { + return; + } const content = typeof this.bridgeCoordinator.renderAgentMissionNotification === 'function' ? await this.bridgeCoordinator.renderAgentMissionNotification(job, notification) : null; diff --git a/test/core/bridge_coordinator.test.ts b/test/core/bridge_coordinator.test.ts index c20c422..60f98d8 100644 --- a/test/core/bridge_coordinator.test.ts +++ b/test/core/bridge_coordinator.test.ts @@ -25,6 +25,29 @@ function normalizeCommandSkillInput(value: unknown) { return String(value ?? '').replace(/\\/g, '/'); } +async function withEnvOverride<T>( + key: string, + value: string | null, + callback: () => Promise<T> | T, +): Promise<T> { + const hadOwnValue = Object.prototype.hasOwnProperty.call(process.env, key); + const previousValue = process.env[key]; + if (value === null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + try { + return await callback(); + } finally { + if (hadOwnValue && previousValue !== undefined) { + process.env[key] = previousValue; + } else { + delete process.env[key]; + } + } +} + function buildDefaultAgentSkillOutput(inputText: unknown): string | null { const normalized = normalizeCommandSkillInput(inputText); if (!normalized.includes('"command": "agent"')) { @@ -2299,6 +2322,7 @@ test('/helps lists all supported slash commands and help entrypoints', async () assert.match(text, /\/login \(\/lg\) 管理本机 Codex 登录账号/); assert.match(text, /\/stop \(\/sp\) 请求中断当前正在执行的回复/); assert.match(text, /\/review \(\/rv\) 对当前工作区改动运行原生 Codex 代码审查/); + assert.doesNotMatch(text, /\/agent/); assert.match(text, /\/uploads \(\/up, \/ul\) 开启上传暂存模式/); assert.match(text, /\/as \(\/assistant\) 助理记录统一入口/); assert.match(text, /\/todo \(\/td\) 指定为待办类型的助理入口/); @@ -2340,6 +2364,7 @@ test('/helps renders English help text when locale is set to en', async () => { assert.match(text, /\/usage \(\/us\) Show the current Codex account plus 5-hour and weekly remaining usage/); assert.match(text, /\/login \(\/lg\) Manage the host Codex login account/); assert.match(text, /\/review \(\/rv\) Run a native Codex code review for the current workspace changes/); + assert.doesNotMatch(text, /\/agent/); assert.match(text, /\/uploads \(\/up, \/ul\) Enter upload staging mode/); assert.match(text, /\/as \(\/assistant\) Unified assistant record entry/); assert.match(text, /\/todo \(\/td\) Typed assistant entry for todo records/); @@ -2377,6 +2402,56 @@ test('/login starts a pending Codex device login flow', async () => { assert.equal(codexAuthManager.startCalls.length, 1); }); +test('/agent stays hidden and unavailable when the command flag is disabled', async () => { + await withEnvOverride('CODEXBRIDGE_ENABLE_AGENT_COMMAND', null, async () => { + const { runtime, openai } = makeRuntime({ defaultCwd: '/tmp/openai-default' }); + const originalStartTurn = openai.startTurn.bind(openai); + openai.startTurn = async (params: any) => { + const parserInput = normalizeCommandSkillInput(params?.inputText); + if (parserInput.includes('docs/command-skills/review.md') && parserInput.includes('"command": "review"')) { + return { + outputText: JSON.stringify({ + schemaVersion: 'codexbridge.review-command-skill.v1', + ok: false, + action: 'reject', + confidence: 0.98, + requiresConfirmation: false, + reason: '这是执行或修复请求,不是只读审查。应该使用 /agent。', + }), + }; + } + return originalStartTurn(params); + }; + + const helps = await runtime.services.bridgeCoordinator.handleInboundEvent({ + platform: 'weixin', + externalScopeId: 'wx-user-agent-hidden-1', + text: '/helps', + }); + assert.doesNotMatch(helps.messages[0]?.text ?? '', /\/agent/); + + const result = await runtime.services.bridgeCoordinator.handleInboundEvent({ + platform: 'weixin', + externalScopeId: 'wx-user-agent-hidden-1', + text: '/agent list', + }); + + assert.equal(result.messages[0]?.text ?? '', '不支持的命令:/agent'); + assert.equal(result.messages[1]?.text ?? '', '用 /helps 查看可用命令。'); + + const review = await runtime.services.bridgeCoordinator.handleInboundEvent({ + platform: 'weixin', + externalScopeId: 'wx-user-agent-hidden-1', + text: '/review 顺手把发现的问题也修了', + }); + + assert.equal( + review.messages[0]?.text ?? '', + '这是执行或修复请求,不是只读审查。当前后台执行命令已隐藏,请直接用普通消息描述你要完成的目标。', + ); + }); +}); + test('/login returns a friendly message when the OpenAI device endpoint is blocked', async () => { const codexAuthManager = makeFakeCodexAuthManager({ startError: new Error('Device login request failed: <!DOCTYPE html><title>Just a moment...'), diff --git a/test/runtime/weixin_bridge_runtime.test.ts b/test/runtime/weixin_bridge_runtime.test.ts index c7890b9..89652a3 100644 --- a/test/runtime/weixin_bridge_runtime.test.ts +++ b/test/runtime/weixin_bridge_runtime.test.ts @@ -3,6 +3,29 @@ import test from 'node:test'; import { WeixinBridgeRuntime } from '../../src/runtime/weixin_bridge_runtime.js'; import { createI18n } from '../../src/i18n/index.js'; +async function withEnvOverride( + key: string, + value: string | null, + callback: () => Promise | T, +): Promise { + const hadOwnValue = Object.prototype.hasOwnProperty.call(process.env, key); + const previousValue = process.env[key]; + if (value === null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + try { + return await callback(); + } finally { + if (hadOwnValue && previousValue !== undefined) { + process.env[key] = previousValue; + } else { + delete process.env[key]; + } + } +} + interface RuntimeHarnessOptions { coordinator: any; automationJobs?: any; @@ -803,6 +826,42 @@ test('WeixinBridgeRuntime prefers supervision-backed agent scheduling and does n await runtime.waitForIdle(); }); +test('WeixinBridgeRuntime skips agent supervision when the command is disabled', async () => { + await withEnvOverride('CODEXBRIDGE_ENABLE_AGENT_COMMAND', null, async () => { + let claimed = false; + let dispatched = false; + const runtime = makeRuntime({ + agentJobs: { + claimSupervisableJobs() { + claimed = true; + return [{ + id: 'agent-disabled-1', + platform: 'weixin', + externalScopeId: 'wxid_agent_disabled', + title: 'Hidden agent job', + }]; + }, + }, + sendText: async () => {}, + coordinator: { + async reconcileActiveTurn() { + return null; + }, + async runAgentJob() { + dispatched = true; + return completeResponse('should not run'); + }, + }, + }); + + await runtime.runAutomationSweep(); + await runtime.waitForIdle(); + + assert.equal(claimed, false); + assert.equal(dispatched, false); + }); +}); + test('WeixinBridgeRuntime proactively delivers package-backed agent loop notifications per host policy without duplicating terminal replies', async () => { const sent: Array<{ externalScopeId: string; content: string }> = []; const job = {