# Codex++ 拓展开发指南 Codex++ 把注入到 Codex 页面里的 UI 能力开放给用户脚本(拓展)调用。入口只有一个全局对象: ```js window.codexPlus ``` 本文档描述**当前已经可用**的接口。接口版本:`apiVersion = 1`(适配层 `1.2.0`)。 - Claude Desktop 的 Code 本地会话使用独立的 `claudeDesktop` 声明接入按钮、后台工具和会话事件,见 [Claude 适配指南](docs/claude-desktop-extensions.md)。它不注入 `window.codexPlus`,不改变本文的 Codex 接口。 - 想了解设计取舍与已知限制的来龙去脉,见 [docs/specs/2026-09-30-ui-extension-api-design.md](docs/specs/2026-09-30-ui-extension-api-design.md) - 类型声明:起 `types/codex-plus-extensions.d.ts`,在脚本开头加 `/// ` 即可获得编辑器补全 --- ## 目录 - [快速上手](#快速上手) - [脚本放在哪、怎么生效](#脚本放在哪怎么生效) - [生命周期契约(最重要的一节)](#生命周期契约最重要的一节) - [拓展实例与拓展包:codexPlus.extension](#拓展实例与拓展包codexplusextension) - [在 Codex 对话里造拓展](#在-codex-对话里造拓展) - [发布到社区拓展](#发布到社区拓展) - [精选与官方拓展](#精选与官方拓展) - [API 参考](#api-参考) - [后端调用与路由白名单](#后端调用与路由白名单) - [类名与属性契约](#类名与属性契约) - [样式](#样式) - [限制](#限制) - [排查](#排查) - [尚未开放的接口](#尚未开放的接口) --- ## 快速上手 ```js // 1. 会话行的「更多操作」菜单里加一项 window.codexPlus.registerRowAction({ label: "导出 Markdown", icon: "⇩", onActivate: ({ session_id }) => { window.codexPlus.call("/export-markdown", { session_id }) .then(() => window.codexPlus.toast("导出完成", { type: "success" })) .catch((error) => window.codexPlus.toast(`导出失败:${error.message}`, { type: "error" })); }, }); // 2. 图标栏加一个入口,点开是一个整页视图 window.codexPlus.registerPage( { title: "我的面板", render: ({ container }) => { // 不要缓存 container:每次打开页面都会重新调用 render container.innerHTML = `
你好
`; }, }, { navLabel: "我的面板", icon: "P" }, ); // 3. Codex++ 菜单的「主页」面板里加一个开关 let enabled = false; window.codexPlus.registerMenuItem({ label: "我的开关", description: "开启后做某件事", toggleValue: () => enabled, onChange: (next) => { enabled = next; window.codexPlus.toast(`已${next ? "开启" : "关闭"}`, { type: "info" }); }, }); ``` --- ## 脚本放在哪、怎么生效 | 平台 | 目录 | | --- | --- | | macOS / Linux | `~/.config/Codex++/user_scripts/` | | Windows | `%APPDATA%\Codex++\user_scripts\` | - 加载 `.js` 文件和带 `codexpp.json` 的子目录(拓展包,见下文),按名称小写排序执行 - 每个脚本 / 拓展包在独立 IIFE 里运行,互不污染 - 改完脚本后在管理页点「热重载拓展」即可生效,无需重启 Codex 脚本标识(`codexPlus.script.key`)形如 `user:my-script.js`,由来源目录与文件名拼成;拓展包是 `user:<目录名>`。 --- ## 生命周期契约(最重要的一节) **注册表持久,DOM 瞬态。** Codex++ 的 UI 宿主会被反复重建: | 宿主 | 何时重建 | | --- | --- | | 整页 overlay | 每次打开都先清空再重建 | | 会话行按钮组 | 内部版本号变化时整组重建 | | Codex++ 菜单 | 每次打开都重新构建 | | 浮层面板 | 设置开关变化时 runtime 重启 | 所以要遵守三条: 1. **不要缓存 DOM 引用**。`render({ container })` 每次打开都重新调用,你应该每次都从零构建 `container` 的内容。 2. **不要假设你 `appendChild` 的节点还在**。宿主重建后它会被清掉,但**注册表里的数据仍在**,下一次渲染会自动重新带上它。你不需要为此做任何事。 3. **需要持有定时器 / 全局监听时**,用 `codexPlus.onCleanup` 注册清理,或在下次渲染时先把自己上一次的定时器清掉。 ```js let timer = null; window.codexPlus.registerPage({ title: "轮询面板", render: ({ container }) => { container.innerHTML = `
`; const output = container.querySelector("[data-output]"); clearInterval(timer); // 先停掉上一次的 timer = setInterval(() => { output.textContent = new Date().toLocaleTimeString(); }, 1000); }, }); window.codexPlus.onCleanup(() => clearInterval(timer)); ``` **每个 `register*` 都返回一个 dispose 函数**,调用即注销该项并从所有宿主移除。注册的寿命不一定等于脚本的寿命——不需要时可以提前注销。 --- ## 拓展实例与拓展包:codexPlus.extension 新写的拓展推荐用 `codexPlus.extension`。它创建一个拓展实例 `ctx`,**ctx 上的每次注册都随实例卸载按逆序自动撤销**, 不需要自己维护 dispose 列表,热重载时也不会残留旧实例。 早期版本叫 `codexPlus.plugin`,仍然可用,等同于 `codexPlus.extension`。 ```js codexPlus.extension((ctx) => { ctx.registerPage({ title: "时钟", render: ({ container }) => { container.textContent = new Date().toLocaleTimeString(); } }); ctx.setInterval(() => ctx.emit("clock/tick", Date.now()), 1000); // 卸载时自动清理 ctx.listen(window, "focus", () => ctx.toast("欢迎回来")); // 卸载时自动 removeEventListener ctx.effect(() => { // 任意副作用:返回撤销函数 const observer = new MutationObserver(() => {}); observer.observe(document.body, { childList: true }); return () => observer.disconnect(); }); }); ``` 要点: - **在脚本同步初始化期间调用**。此时内核会替你登记热重载清理;`await` 之后创建的实例只能自己 `ctx.dispose()`。 - `apply` 同步抛错时,已做的注册先回滚再把错误抛出(脚本标为加载失败);异步 `apply` reject 时同样回滚并上报。 - 回调、事件处理器、中间件抛错都记进本脚本的失败通道,不会影响其他拓展。 | ctx 成员 | 说明 | | --- | --- | | `effect(fn)` | 立即执行 `fn`,它返回的函数在卸载时调用;返回值可提前撤销。`fn` 是 async 时等它给出撤销函数,实例已卸载就拿到时立刻撤销 | | `registerRowAction / registerNavEntry / registerMenuItem / registerPage` | 同 `codexPlus.register*`,归属与撤销自动处理 | | `on(event, handler)` / `emit(event, ...args)` | 拓展间事件。内核广播 `extension/apply`(`apply` 含异步部分完成后)、`extension/dispose`(参数 `{ id, scriptKey }`)。`on("ready")` / `on("dispose")` 只属于本实例:`ready` 在 `apply` 完成后调用(已完成时立即调用),`dispose` 在卸载时调用 | | `intercept(name, handler, { order })` / `waterfall(name, value)` | 中间件链:`handler(value, next)` 调 `next(v)` 交给下一个,不调即短路;出错的中间件被跳过;没有中间件时原样返回 | | `listen(target, type, handler, options)` | 自动撤销的 `addEventListener` | | `setTimeout / setInterval` | 自动清理的定时器 | | `onScan(handler, { phase })` | 跟着 Codex++ 的页面扫描跑:Codex 界面变了(会话切换、侧栏 / 输入框重建……)时调用,按帧合并、页面隐藏时不跑。`phase` 为 `deferred`(默认,完整扫描)或 `light`(每次变化都跑,handler 要便宜)。适合「Codex 重建了某块界面,把自己的东西补回去」,不必自己开 MutationObserver。插进页面的节点必须带 `data-codex-plus-ext` 属性,否则会触发自喂扫描循环。返回的撤销函数带 `run()`:立即跑一次 | | `call(route, payload, options)` | 拓展包只能调清单里声明的路由;单文件脚本沿用全局白名单 | | `toast / fail / dispose` | 提示、上报失败、主动卸载 | | `registerComposerAction(def)` | 输入区(发送按钮那一行)的图标按钮,`onActivate({ editor, insertText, close })` | | `registerCommand({ name, description, run })` | 斜杠命令:输入框里 `/name 参数` 回车时调用 `run(args, { editor, insertText, setText, toast })`,输入框随后清空;输入 `/` 时在输入框上方提示匹配的拓展命令,Tab 补全。只拦截已注册的拓展命令,同名时先注册的生效 | | `registerMessageAction({ label, icon, roles, visible, onActivate })` | 每条对话消息操作行(复制、编辑旁)的图标按钮,`onActivate({ message, close })`;`message` 见下 | | `registerMessageDecoration({ roles, visible, render })` | 消息正文下方的一块内容:`render({ message, container })`,消息文字变化(流式输出)时清空重画,留空不占位 | | `registerSidePanel({ title, icon, width, render })` | 右侧面板:顶栏右侧出现开关,打开时主区域让出宽度;`render({ container, close })` 每次打开调用,可返回清理函数;返回值带 `open() / close() / toggle()` | | `asset(path)` | 包内 `assets/` 资源的 data URL(图片、音频、字体),找不到返回空串 | | `provide(name, value) / get(name) / inject(deps, apply)` | 拓展间服务,见「拓展间服务」 | | `locale.t / register / bind / resolve / current` | 多语言,见「多语言」 | | `storage.get / set / remove / keys` | 按拓展 id 隔离的本地存储,值按 JSON 存取 | | `config` | 生效配置的只读快照(清单 `config` 声明,见「拓展配置」) | | `id / scriptKey / manifest / disposed` | 只读信息 | ### 界面插槽 ```js codexPlus.extension((ctx) => { ctx.registerSlot({ slot: "composer.above", render: ({ container }) => { container.textContent = "提示:今天的额度还剩 80%"; } }); const clock = ctx.registerSlot({ slot: "titlebar", render: ({ container }) => { const button = document.createElement("button"); button.textContent = new Date().toLocaleTimeString(); container.appendChild(button); return () => button.remove(); // 撤下或重画前调用 } }); ctx.setInterval(() => clock.refresh(), 60_000); }); ``` - 位置:`composer.above`(输入框上方)、`thread.top` / `thread.bottom`(对话区顶部 / 底部)、`sidebar.top` / `sidebar.bottom` (左侧栏列表顶部 / 底部)、`titlebar`(标题栏右侧按钮组)、`turn.after`(每一轮对话末尾,render 多收到 `turnKey`)、 `sessionRow.prefix` / `sessionRow.suffix`(侧栏每个会话标题前 / 后,多收到 `sessionId`、`title`)、 `overlay`(铺满窗口的浮层,默认不挡点击,放进去的元素可以点)、`activity.after`(对话里每个活动条目——命令、 工具 / 子 agent 调用、出错——的末尾,多收到 `kind`(`command` / `toolCall` / `fileChange` / `reasoning` / `error` / `other`)、`itemIds`、`text`;条目收在 Codex 的「用时 Ns」里,用户展开后才出现;推理只在 Codex 的显示模式会显示推理时才有条目)、 `composer.left` / `composer.right`(输入框底栏左侧按钮组末尾 / 右侧按钮组开头,和「添加内容」「发送」同一行)、 `composer.below`(输入框下方)、`home.above`(新对话首页输入框上方)、`header.title`(顶栏会话标题后面)、 `approval`(审批卡片底部,多收到 `kind`)、`sidebar.section`(侧栏「项目」「最近」等分组标题右侧,多收到 `section`)、 `projectRow.suffix`(侧栏项目名后面,多收到 `projectId`、`label`)、`composer.model`(输入框底栏的模型 / 推理强度按钮后面)、 `settings.nav`(Codex 设置页左侧导航末尾,多收到 `sampleClass`:原生导航项的类名,可借来对齐样式)。 `activity.after` 的 `kind` 还可能是 `plan`(计划条目)。 未知位置会报错。 - `render({ container, slot, ... })` 在容器新建时调用;Codex 重建 DOM(切换会话、侧栏刷新)后会重新调用,不要缓存节点。 返回的函数在该项被撤下或重画前调用。`container` 留空则不占位。返回的句柄带 `refresh()` 强制重画。 - 同一位置多个拓展按 `order` 排列(越小越靠前)。拓展卸载、热重载时自动撤下。 ### 消息插槽与侧边面板 ```js codexPlus.extension((ctx) => { // 助手消息上加「统计字数」按钮,并在正文下方显示字数 ctx.registerMessageAction({ label: "统计字数", icon: "#", roles: ["assistant"], onActivate: ({ message }) => ctx.toast(`${message.text.length} 字`) }); ctx.registerMessageDecoration({ roles: ["assistant"], render: ({ message, container }) => { if (message.text.length > 2000) container.textContent = `长回复:${message.text.length} 字`; } }); // 右侧面板 const panel = ctx.registerSidePanel({ title: "便签", icon: "✎", render: ({ container }) => { container.textContent = ctx.storage.get("note", "(空)"); } }); ctx.registerCommand({ name: "note", description: "打开便签", run: () => panel.open() }); }); ``` - `message` 是 `{ role, id, turnId, conversationId, text, element }`:`role` 为 `user` / `assistant`;`text` 是正文纯文本, 流式输出时是已输出的部分;`element` 只读用。消息节点会随滚动被 Codex 重建,Codex++ 负责每次补上按钮和装饰, 拓展不要缓存这些节点。 - 只有存在消息插槽注册时,Codex++ 才观察对话区的变化(去抖 300ms);没有注册时零开销。 - 侧边面板同一时间只开一个,挂在 Codex 的 React 树外面;拓展卸载、热重载时自动关闭。 ### Codex 会话事件与请求中间件 拓展可以订阅 Codex app-server 推给页面的通知,也可以改写页面发往 app-server 的请求: ```js codexPlus.extension((ctx) => { // 轮次结束时提示一下 ctx.on("codex/turn/completed", (params) => ctx.toast(`本轮结束:${params?.turn?.status ?? ""}`)); // 每次发起轮次时,在用户输入前加一段固定说明 ctx.intercept("codex/request", (value, next) => { if (value.method !== "turn/start" || !Array.isArray(value.params?.input)) return next(value); const input = [{ type: "text", text: "(请用中文回答)" }, ...value.params.input]; return next({ ...value, params: { ...value.params, input } }); }); }); ``` - 已转发的事件:`codex/thread/started`、`codex/turn/started`、`codex/turn/completed`、`codex/item/started`、 `codex/item/completed`、`codex/item/agentMessage/delta`、`codex/error`,以及用量与等待用户的 `codex/thread/tokenUsage/updated`、 `codex/item/tool/requestUserInput`、`codex/item/commandExecution/requestApproval`、`codex/item/fileChange/requestApproval`、 `codex/item/permissions/requestApproval`、`codex/mcpServer/elicitation/request`、`codex/serverRequest/resolved`、 `codex/item/tool/userInputAnswered`、`codex/item/approval/responded`;处理器参数是通知的原样 `params` 和 `{ method }`。 **新版 Codex 的通知不再经过页面,这些事件收不到**;要跟踪会话进展请用下面「会话历史」里的 `session/event` / `agent/status`。 - `codex/request` 的值是 `{ method, params }`,只能改 `params`,改 `method` 无效。没有任何中间件时请求原样发出。 新版 Codex 的请求经页面与宿主进程之间的 RPC 发出,Codex++ 在这一层拦截,中间件可以返回 Promise, 超过 5 秒或出错时原样发出;旧版 Codex 的消息分发路径是同步的,那里返回 Promise 视为出错并原样放行。 - 一条用户消息会同时备好 `turn/start` 和 `turn/steer` 两份候选(会话空闲时发前者,正在回复时发后者), 中间件会各收到一次,要改写用户输入就两种都处理。新会话的首条消息按 `turn/start` 交给中间件, 它的 `params` 还带着建会话用的字段(`cwd`、`workspaceRoots` 等),没有 `threadId`。 - 要在请求**前后**都做事(拿到 app-server 的返回值)用 `codex/exchange`:值是 `{ method, params, hostId, send }`, 中间件换掉 `send` 包住原请求,链尾调用最终的 `send()`。内置拓展「侧聊保存」就挂在这里(临时旁支分叉时记下主历史): ```js ctx.intercept("codex/exchange", (exchange, next) => next({ ...exchange, send: async () => { const result = await exchange.send(); if (exchange.method === "thread/fork") ctx.toast(`已分叉:${result?.thread?.id ?? ""}`); return result; }, })); ``` 它挂在 app-server 客户端的 sendRequest 补丁上,覆盖范围与该补丁一致;`send` 抛错会原样抛给 Codex,包装时自己兜住。 - 这几项都直接面向 Codex 的内部协议,字段随 Codex 版本可能变化,拓展要容错。 ### 步骤准入与模型请求 `agent/pre-step` 和 `agent/request` 用 `ctx.on` 注册,处理器是 `(params, next)`: 先 `await next()` 拿到下游的决定,再返回改过的版本。 ```js codexPlus.extension((ctx) => { // 每条用户消息后面补一条上下文 ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => { const decision = await next(); if (decision.kind === "reject" || signal.aborted) return decision; const note = { role: "user", content: [{ type: "text", text: `[now: ${new Date().toISOString()}]` }] }; return { ...decision, messages: [...decision.messages, note] }; }); // 换模型或推理强度 ctx.on("agent/request", async (params, next) => { const config = await next(); return { ...config, reasoningEffort: "high" }; }); }); ``` - `agent/pre-step`:`params` 是 `{ agent, turn, step, messages, signal, conversationId }`,`messages` 是本轮要进入的用户消息 (`{ role: "user", id, source: { kind: "user" }, content: [{ type: "text", text }, …] }`,图片、提及等非文本条目原样在 `content` 里)。 返回 `{ kind: "enter", messages }` 改写本轮输入(可以增删消息、改文字),或 `{ kind: "reject", reason? }` 不发这一轮, Codex 会显示发送失败,Codex++ 用 `reason` 弹提示。 - `agent/request`:`params` 是 `{ agent, turn, step, signal, conversationId }`,`next()` 返回 `{ provider, model, reasoningEffort }`。`model` 是 Codex 里的完整模型 id(如 `jojocodemax/gpt-6.1-sol`),`provider` 是斜杠前的部分; 改 `model` 或 `reasoningEffort` 会写回本轮的协作模式设置,只改 `provider` 时按「供应商/模型」拼回去。 - 限制:Codex 只在用户发消息时经过这里,一轮里的后续步骤(工具调用之后)不会再触发,`step` 恒为 1,`turn` 拿不到(`undefined`); `agent` 只是 `{ id, session: { id } }` 描述,没有 `steer` / `inject` 等方法;`maxTokens`、`startsRequestSeries` 不生效。 处理器抛错时跳过它、按 `next()` 的结果继续,不会挡住 Codex 发消息;全链超过 5 秒按原样发出,`signal` 随之中止。 - 同一条用户消息只跑一次这两条链,结果套用到它的 `turn/start` 与 `turn/steer` 两份候选上,之后才轮到 `codex/request`。 ### 拓展配置 拓展包可以在清单里声明配置项,Codex++ 管理器(拓展列表里的「设置」)和 Codex「拓展」页面详情的「设置」页签都会自动生成设置表单: ```json { "id": "pomodoro", "config": { "minutes": { "type": "number", "title": "专注时长(分钟)", "description": "每个番茄钟的长度", "default": 25 }, "sound": { "type": "boolean", "title": "结束时提示音", "default": true }, "theme": { "type": "select", "title": "配色", "options": [{ "value": "red", "label": "番茄红" }, "green"] }, "token": { "type": "string", "title": "API 令牌", "secret": true } } } ``` - 类型:`string`、`text`(多行)、`number`(可加 `min` / `max`)、`boolean`、`select`(`options` 为值或 `{ value, label }`)、 `array`(`items` 为元素类型,只能是简单类型,最多 200 项)、`object`(`properties` 为子字段,最多嵌套 3 层);每层最多 32 项。没给 `default` 时取空串 / 0(有 `min` 取 `min`)/ false / 第一个选项 / 空数组;对象的默认值由子字段默认值组成, 清单里的 `default` 只覆盖其中的键。`secret` 只影响界面(密码框),值仍以明文存在本机 `user_scripts.json` 的 `configs` 里。 - `title` / `description` 可以是字符串,也可以是 `{ "zh": "…", "en": "…" }`,管理器按界面语言显示。 - 本机文件路径:`string` 加 `"format": "file"`,管理器表单在输入框旁给「选择文件」按钮(系统对话框);`"extensions": ["png", "jpg"]` 限定可选的扩展名(不带点,最多 20 个,只能和 `format: "file"` 一起用)。值仍是普通字符串,页面拿不到本机文件,要读内容 得经 Codex++ 的路由或常驻后台(背景图拓展就是这样,见 `assets/extensions/image-overlay/`)。 - 嵌套示例:`"proxy": { "type": "object", "properties": { "host": { "type": "string" }, "port": { "type": "number", "min": 1, "max": 65535 } } }`, `"words": { "type": "array", "items": { "type": "string" } }`。保存时对象可以只给部分键(其余取默认),未声明的键会被拒绝。 - 读取:页面代码用 `ctx.config`;钩子、网关中间件、工具脚本用第二个参数的 `context.config`。都是生效配置 (默认值叠加已保存且类型正确的值)的只读快照。 - 修改:管理器表单保存后自动热重载;对话造拓展可用 `codexpp_extension_configure` 改值。只接受声明过的键,类型不符即报错。 - 自己画设置界面:表单不够用时(图库、试听、预览……),清单写 `"settings": { "page": "settings.html" }`,管理器里「设置」 打开这个页面(和[管理器面板](#管理器面板)同一套沙箱与 `codexPlusManager` 接口,拓展停用时也能打开);Codex 里 「拓展」页面选中这个拓展时,详情的「设置」页签显示 `ctx.registerSettingsPanel({ render({ container, config, saveConfig }) })` 画的面板 (拓展停用、面板还没登记时退回按清单生成的通用表单), `saveConfig(values)` 只存本拓展声明过的项、回生效配置,可返回清理函数。两边想用同一份界面,就把界面写成一个函数, 页面脚本里调一次、设置页里内联一份(官方的 Agent 桌宠拓展就是这样,见 `assets/extensions/agent-pet/settings.js`)。 - 不重载地接住新配置:`ctx.onConfigUpdate((next, prev) => { 应用 next; return true; })`。管理器保存后先问页面,这个拓展的 每个实例都返回 `true` 时 `ctx.config` 原地换成新值,不再热重载;有一个实例没接(没注册或返回别的)就照常整体热重载。 - 多实例:清单写 `"instances": [{ "id": "work", "name": "工作", "config": { "label": "工作区" } }, { "id": "home" }]`, 页面脚本按实例各应用一次,`ctx.config` 是生效配置叠上该实例的 `config`,`ctx.instance` 为 `{ id, name }`, `ctx.id` 变成 `<拓展 id>#<实例 id>`,注册项的 `id` 与 `ctx.storage` 也按实例分开。实例最多 16 个,`config` 只能写清单 `config` 里声明过的键。钩子、网关、后台不分实例。 ### 给模型加工具 拓展包可以声明给 Codex 模型调用的工具,由已接入的 Codex++ 拓展 MCP(`codex_plus_extensions`)对外提供: ```json { "id": "text-tools", "name": "文本工具", "tools": [{ "name": "word_count", "description": "统计一段文字的字数", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }, "script": "tools/word-count.mjs", "timeout": 30 }] } ``` ```js // tools/word-count.mjs:默认导出 (args, { extension, tool }) => 结果 export default ({ text }) => `共 ${[...text].length} 个字符`; ``` - 对外工具名为 `<拓展 id>__<工具名>`(合计不超过 64 个字符);`inputSchema` 必须是 `type: "object"` 的 JSON Schema,省略时为空对象。 - 返回字符串作为文本;返回 `{ content: [...] }` 按 MCP 内容块原样返回;其它值转为 JSON 文本;抛错时以错误返回给模型。 `timeout` 1~300 秒,默认 30。每次调用起一个 Node 进程。 - 前提是在「拓展」页的「对话造拓展」里接入过 Codex。拓展写入、启停、重载后服务会通知 Codex 刷新工具列表; 已经开着的对话可能仍用旧列表,新开对话一定生效。 ### Codex 钩子 拓展包可以在清单里声明 Codex 钩子,在工具执行前后、提交提示词时、本轮结束前插手 Codex 的 agent 循环: ```json { "id": "rm-guard", "name": "危险命令拦截", "hooks": { "PreToolUse": [{ "matcher": "^Bash$", "script": "hooks/guard.mjs", "timeout": 30 }] } } ``` ```js // hooks/guard.mjs:默认导出 (payload, { event, extension }) => Codex 钩子输出 | null export default (payload) => /rm\s+-rf\s+\//.test(payload.tool_input?.command ?? "") ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "禁止 rm -rf /" } } : null; ``` - 可用事件:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PreCompact`、 `PostCompact`、`SubagentStart`、`SubagentStop`、`Stop`、`PreModelRequest`、`PostModelRequest` (`SessionEnd` / `Interrupt` 超时只有 1~3 秒,不开放)。 - `PreModelRequest` / `PostModelRequest`:agent 每次请求模型前 / 后各触发一次,**官方直连也触发**,适合按步观察 (计步、计时、记用量、按步提醒)。payload 有 `session_id`、`turn_id`、`model`、`transcript_path`、`cwd`; `PostModelRequest` 另有 `status`、`error`、`token_usage`。不带消息内容,要看内容用 `context.sessionQuery` 按 `session_id` 读。**只能观察**:Codex 不采用它们的输出,拦不下这一步,也加不了上下文;要改请求请用网关中间件 (需经 Codex++ 代理)。`matcher` 按模型名匹配。它们挡在每一步前面,脚本要快。 - `payload` 与输出都按 Codex 钩子协议;多个拓展的输出由 Codex++ 合并:任一 `deny` 即拒绝、`allow` 只采用第一个 `updatedInput`、`decision: "block"` 与 `continue: false` 任一即生效、`additionalContext` 与 `systemMessage` 按拓展顺序拼接。 - 运行方式:Codex++ 只往 `~/.codex/config.toml` 写**固定的分发条目**(每个用到的事件一条,命令 `CodexPlusPlus --codex-plus-hook <事件>`,超时 120 秒,状态文字「Codex++ 拓展钩子」)。Codex 要求钩子经用户 信任后才运行,所以第一次用到某个事件时,需要在 Codex 的 Hooks 设置里信任这条条目一次;之后拓展增删、启停都不用 重新信任,由 Codex++ 的拓展开关决定哪些钩子在跑。拓展启停、删除、热重载以及对话造拓展写入后都会自动同步条目, 没有拓展再用到的事件会移除。 - 每次触发只起一个 Node 进程(优先用 Codex App 自带的 Node,或 `CODEX_PLUS_NODE` 指定),按 `matcher` 过滤后并发 运行命中的脚本;单个脚本出错或超时只记诊断日志,不影响其它拓展,也不挡 Codex。 - 钩子脚本以你的用户权限运行、没有沙箱。信任分发条目即信任了 Codex++ 拓展开关里启用的全部钩子。 ### 网关请求中间件 拓展包可以在 Codex 的模型请求发往上游前改写它: ```json { "id": "zh-reply", "name": "中文回复", "gateway": { "request": [{ "script": "gateway/request.mjs", "timeout": 5 }] } } ``` ```js // gateway/request.mjs:默认导出 (body, { path, extension }) => 新 body | null export default (body) => ({ ...body, instructions: `${body.instructions ?? ""}\n\n请始终用简体中文回答。` }); ``` - `body` 是 OpenAI Responses 请求体的副本;返回新对象即替换,返回 `null` 不改。多个拓展按拓展 key 顺序串联, 前一个的输出是后一个的输入。 - 按步触发:agent 每走一步(每次工具调用之后)都会向模型发一次请求,都经过这里。第二个参数里的 `step` 是 `{ index, toolResults, userText, firstStep }`(本轮第几步、已有几个工具结果、本轮用户输入、是否第一步)。 返回 `{ reject: "原因" }` 即不发这一步,Codex 收到带原因的错误,后面的中间件不再跑(与页面侧 `agent/pre-step` 的 reject 相同)。`timeout` 1~10 秒(默认 5),整条链最多 15 秒。 - 加请求头:往第二个参数的 `headers` 对象里写(`context.headers["x-trace"] = "1"`),发往上游时附上。 鉴权、Cookie、Host、内容长度 / 类型这类头由 Codex++ 管,写了也会被忽略。失败重试时沿用同一组头。 - 只对经 Codex++ 本地代理 / 网关发出的请求生效(中转、聚合、网关模式);官方直连不经过 Codex++,不受影响。 - 只有存在启用中的网关中间件时才会为每个请求起一个 Node 进程;任何中间件出错或超时都会保留原请求并记诊断日志。 - 中间件能看到完整的请求内容(含对话),以你的用户权限运行。只启用你信任的拓展。 #### 段、顺序与关掉内置环节 Codex++ 自己在发请求前也会做几件事(本地压缩、第三方推理折叠、exec 工具适配、按供应商路由与协议转换)。 拓展的中间件可以排在这些内置环节之前或之后,也可以关掉其中可替换的环节自己实现: ```json { "gateway": { "request": [ { "script": "gateway/early.mjs", "order": -10 }, { "script": "gateway/upstream.mjs", "stage": "upstream" } ], "replaces": ["builtin:reasoning-collapse"] } } ``` - `stage`:`request`(默认)在内置环节之前改原始 Responses 请求;`upstream` 在内置环节之后改真正发给上游的请求体, 第二个参数多给 `stage: "upstream"`、`wireApi`(`responses` / `chat` / `anthropic`…)、`relay: { id, name }`、`model`、 `attempt`(换候选重发时每次都跑,请求体按候选不同)。`upstream` 段同样可以 `{ reject }`、加请求头。 - `stage: "route"`:中转轮换按规则选出候选供应商之后调用,收到 `{ model, conversationId, candidates: [{ id, name, protocol }] }`, 返回重排 / 删减后的同形对象或 `{ order: ["供应商 id", …] }`,按这个顺序尝试(认不得的 id 忽略,一个都不认得时照原顺序)。 网关模式的候选(供应商 + Key)同样经过这里,`candidates` 每项是 `{ id: "<供应商>#<序号>", provider, name, model }`, 上下文多给 `agent`。只有一个候选时不调用;聚合模型、显式模型路由不经过这里。 - `order`:同一段里越小越先,相同时按拓展 key 顺序。 - `replaces`:关掉的内置环节,可选 `builtin:local-compaction`(本地压缩)、`builtin:reasoning-collapse`(第三方推理折叠)、 `builtin:exec-adapt`(exec 工具适配)、`builtin:relay-rotation`(轮换失败后自动换下一个供应商;关掉后只用选中的那一个, 配合 route 段自己决定顺序)、`builtin:gateway-failover`(网关模式里一个候选失败后换下一个;关掉后只试排第一的那个)。 本地代理(中转与网关两种模式都经过)里还能关: - `builtin:auto-retry`:「请求避震」。关掉后上游错误原样交回,不再退避重发;要不要重试交给 `gateway.error`。 - `builtin:foreign-reasoning`:别家签发的加密推理内容的剥离与拒收后重发。关掉后加密项原样发出,被拒也不重发。 - `builtin:unlisted-model-mapping`:目录外模型名映射到默认模型。关掉后模型名原样转发,映射在 request 段自己做。 网关模式里还能关: - `builtin:key-cooldown`:429 / 额度耗尽后让 Key 冷却。关掉后不再冷却,已在冷却的 Key 也照常当候选(排在可用的后面)。 - `builtin:key-breaker`:供应商连续 5xx / 连接失败后熔断。关掉后不再计入熔断,已熔断的供应商也照常当候选。 - `builtin:local-rate-limit`:Key 的本地 RPM / 并发限速。关掉后不再按名额排队或跳过。 - `builtin:title-model`:起名请求改发到绑定里设的起名模型。关掉后起名请求照原模型路由。 关掉冷却 / 熔断 / 限速后,换不换下一个候选仍按失败分类定(配合 `builtin:gateway-failover` 一起关就只试第一个); 要避开哪些 Key、供应商由拓展在 route 段自己筛。拓展启用时生效,停用后恢复。 两种模式都还能关: - `builtin:request-conversion`:把 Responses 请求转成 Chat Completions / Anthropic Messages。关掉后 upstream 段拿到的请求体 仍是 Responses 形状(上下文 `requestConversion: "extension"`,`wireApi` / `relay.protocol` 是上游协议,`endpoint` 是按上游协议选好的地址), 由拓展转成上游要的格式。响应方向仍由 Codex++ 按上游协议转回 Responses,响应流中间件拿到的照旧是 Responses 事件。 - `builtin:auth-headers`:按供应商注入 `Authorization` / `x-api-key` / `anthropic-version`。关掉后 Codex++ 只带供应商的自定义头, upstream 段里**只有声明了这一项的拓展**拿到 `context.credential = { apiKey, protocol }`(这个候选的 Key,网关模式里是轮换选中的那个), 它往 `context.headers` 里写的鉴权头也才被采用;别的拓展看不到 Key,写的鉴权头照旧被丢掉。官方登录成员的 OAuth 凭据、 转给 Codex++ 本机网关那一跳的令牌仍由 Codex++ 带;网关模式的 Anthropic 原生直通改走协议代理链路(直通不经过 upstream 段)。 - `builtin:official-shaping`:官方登录 / ChatGPT 后端的请求整理(请求体改成 Codex CLI 的样子、UA 与版本头、ChatGPT API 账号的 请求整理、官方请求自动补 web_search 声明)。关掉后由拓展在 upstream 段整理(上下文 `official: true`);会话头与官方凭据仍由 Codex++ 处理。 - `builtin:request-log`:请求日志与用量统计。关掉后经代理的请求不再写请求日志、不计入用量,「请求状态条」也看不到进行中的请求; 要记就在响应流中间件里自己记(`response.completed` 事件带 usage)。 仍由 Codex++ 做、不开放的只有:响应方向的协议转换(响应流中间件排在它之后,拿到的是转好的 Responses 事件,要改回复在那里改), 以及官方登录凭据本身(OAuth token 不交给拓展)。 ### 网关响应流中间件 拓展包可以改写模型回复流回 Codex 的事件,写法是「包住整条流」的中间件: ```json { "id": "redactor", "name": "回复脱敏", "gateway": { "response": [{ "script": "gateway/response.mjs", "timeout": 5 }] } } ``` ```js // gateway/response.mjs:默认导出 (context, next) => 异步可迭代对象 export default async function* (context, next) { for await (const event of next()) { if (event.type === "response.output_text.delta") yield { ...event, delta: event.delta.replaceAll("secret", "***") }; else yield event; } } ``` - 事件是 Codex 实际收到的 Responses SSE 事件对象(带 `type`)。可以改写、丢弃、插入事件,也可以不调用 `next()` 自己给出整条流;`next()` 只能调用一次。改了文字增量时,`response.output_item.done` / `response.completed` 里的完整文本要一并改。 - `context`:`{ path, extension, config, request }`,`request` 是请求摘要(`model`、`instructions`、`reasoning`、 `tools` 工具名、`inputItems`、`lastUserText`、`sessionId` 会话 id),不含完整对话。 - 多个拓展按拓展 key 顺序嵌套,先声明的在最外层。所有响应中间件跑在一个常驻 Node 工作进程里,每个事件一次往返。 - 任一中间件出错、超时(`timeout` 1~10 秒,按单个事件计)或工作进程退出,这次回复剩下的部分原样放行。 - 只对经 Codex++ 本地代理 / 网关的流式回复生效;官方直连、远程压缩回复不经过这里。 ### 网关失败重试 ```json { "gateway": { "error": [{ "script": "gateway/error.mjs", "timeout": 5 }] } } ``` ```js // gateway/error.mjs:默认导出 (error, { path, extension, config }) => { retry: true, delayMs } | { kind: "retry" } | null export default (error) => (error.status === 429 && error.attempt < 2 ? { retry: true, delayMs: error.json?.retryAfterMs ?? 2000 } : null); ``` - 返回 `{ kind: "fallback", model: "其它模型" }` 即换这个模型重发(降级),同样计入 3 次上限。 - 被拓展请求中间件拒绝的那一步不会进入这里。 - 上游返回错误状态时调用,`error` 为 `{ status, body, json, path, attempt }`(`body` 最多 64 KiB)。多个拓展按顺序问, 第一个要求重试的生效;同一请求最多重发 3 次,间隔最多 10 秒。不重试时 Codex 照常收到原错误。 - 只对经 Codex++ 代理 / 网关的请求生效;没有拓展声明时不读响应体,零开销。流式回复中途断开不在此列。 ### 工具管道 对应「工具执行前 / 执行 / 执行后」三段: ```json { "toolPipeline": { "preExecute": [{ "matcher": "^Bash$", "script": "tools/pre.mjs", "timeout": 30 }], "execute": [{ "matcher": "^Bash$", "script": "tools/exec.mjs", "timeout": 60 }], "postExecute": [{ "matcher": "^exec_command$", "script": "tools/post.mjs", "timeout": 5 }] } } ``` ```js // tools/pre.mjs:改参数或拦截 export default (input, { name, callId }) => input.command.includes("rm -rf") ? { deny: "不允许" } : null; // tools/exec.mjs:返回结果即代为执行,原工具不跑;返回 undefined 交还原工具 export default (input) => (input.command === "date" ? new Date().toISOString() : undefined); // tools/post.mjs:改写模型看到的工具结果 export default (output) => (typeof output === "string" ? output.replace(/sk-\w+/g, "sk-***") : undefined); ``` - `preExecute` / `execute` 在 Codex 的 PreToolUse 钩子里运行(`matcher` 匹配钩子里的工具名,如 `Bash`),上下文 `{ name, callId, cwd, payload, config, sessionQuery, service }`;用到时 Codex 配置里会写入「Codex++ 拓展钩子」分发条目, 需要在 Codex 的 Hooks 设置里信任一次。 - `execute` 返回结果后,原工具调用以「拒绝 + 结果」结束;经 Codex++ 代理时,下一次请求里网关把这条工具输出整个换成 纯结果,模型看到的就是执行结果。不经代理时模型从拒绝理由里读到结果。 - `postExecute` 在网关请求侧改写 `function_call_output` / `custom_tool_call_output`(`matcher` 匹配模型侧工具名,如 `exec_command`、`apply_patch`、`mcp__服务__工具`),上下文 `{ name, input, callId, config }`。按「拓展 + 脚本 + 调用 + 输出」缓存,同一条结果只改一次。只对经 Codex++ 代理的请求生效。 - Codex 界面里显示的仍是原工具结果;改写只影响模型看到的内容。 ### 会话历史(只读) 内置服务 `sessionQuery` 只读查询 Codex 的会话,页面、常驻后台、模型工具三处都能用: ```js codexPlus.extension({ inject: ["sessionQuery"], async apply(ctx) { const [latest] = await ctx.sessionQuery.listSessions({ limit: 1 }); const turns = await ctx.sessionQuery.listEvents(latest.id, { types: ["user/message"], fromEnd: true, limit: 5 }); const page = await ctx.sessionQuery.searchSessions({ query: "bug", limit: 20 }); // page.nextCursor } }); // 常驻后台:export default ({ sessionQuery }) => ({ ... });模型工具:export default (args, { sessionQuery }) => ... ``` | 方法 | 返回 | | --- | --- | | `listSessions({ archived?, limit?, offset? })` | 会话数组,新到旧 | | `filterSessions({ id?, cwd?, archived?, createdAfter?, createdBefore?, limit? })` | 会话数组 | | `readSession(id)` | `{ session, events }` 完整事件日志 | | `readTitle(id)` | 标题 | | `listEvents(id, { types?, offset?, limit?, fromEnd? })` | 轻量事件(长文本截到 200 字) | | `filterEvents(id, { types?, text?, limit? })` | 匹配的事件(文本不分大小写) | | `readEvent({ sessionId, seq, before?, after? })` | 以 seq 为中心的事件窗口 | | `searchSessions({ query, limit?, cursor? })` | `{ items, nextCursor }`,按标题、首条消息、目录匹配 | | `searchEvents({ query, sessionId?, types?, archived?, limit?, cursor? })` | `{ items: [{ sessionId, event }], nextCursor }`;不给 sessionId 时按新到旧扫全部会话,找够一页即停 | | `readEventsSince({ sessionId, seq, limit? })` | seq 及之后的完整事件(增量读) | | `project(id, { init, apply })` | 把事件日志折叠成状态:`apply(state, event)` 返回新状态 | | `observeSession(id, handler, { intervalMs?, fromSeq? })` | 订阅新事件(默认从当前末尾开始,每 2 秒增量读),返回撤销函数;页面与后台可用 | - 会话:`{ id, title, cwd, model, createdAt, updatedAt, archived, preview, gitBranch }`。 - 事件:`{ type, seq, time, data }`。`type` 有 `turn/start`、`user/message`、`assistant/message`(`data.phase`)、 `assistant/reasoning`(只有摘要)、`tool/call`(`name`、`callId`、`input`)、`tool/result`(`callId`、`output`)、 `turn/complete`(`durationMs`、`lastMessage`)、`turn/aborted`(`reason`)、`token/usage`(`total`、`last`、`contextWindow`)、 `session/compacted`。 Codex 注入的环境上下文、开发者消息、加密推理不返回;单条文本超过 2 万字截断。 - 全程只读:数据库只读打开,rollout 文件只读。 #### 全部会话的事件流 ```js codexPlus.extension((ctx) => { ctx.on("session/event", (event, { sessionId }) => { if (event.type === "tool/call") console.log(sessionId, event.data.name); }); ctx.on("agent/status", ({ sessionId, status, turn }) => { /* status: running | idle | aborted */ }); }); // 常驻后台:sessionQuery.observeAll((event, { sessionId }) => …, { onStatus }) ``` - 有人监听时才开始轮询(页面可见时 1.5 秒一次,后台 2 秒),只看最近更新的 20 个会话;监听前已有的事件不重放, 之后新建的会话从头发。监听全部撤下后自动停。页面里也可以用 `sessionQuery.observeAll(handler)`。 ### 会话写入(分叉、追加、发起一轮) 内置服务 `sessions` 经 Codex 自己的 app-server 连接写会话: ```js codexPlus.extension({ inject: ["sessions", "sessionQuery"], async apply(ctx) { const [latest] = await ctx.sessionQuery.listSessions({ limit: 1 }); const copy = await ctx.sessions.fork(latest.id); // 分叉出一个新会话 await ctx.sessions.append(copy.id, "(补充背景:本项目使用 pnpm)"); // 追加一条用户消息 await ctx.sessions.append(copy.id, [{ role: "assistant", text: "收到" }]); const turn = await ctx.sessions.startTurn(copy.id, "请总结一下上面的对话"); // 让 agent 跑一轮 // await ctx.sessions.interrupt(copy.id, turn.id); } }); ``` | 方法 | 说明 | | --- | --- | | `list({ limit?, cursor?, archived? })` | Codex 的会话列表(`thread/list` 的原样结果) | | `create(input?, options?)` | 新建会话;给了 `input` 时紧接着发起第一轮,返回 `{ thread, turn }` | | `fork(id, options?)` | 分叉,返回新会话(含 `id`、`forkedFromId`) | | `rename(id, name)` | 改会话标题 | | `append(id, items)` | 追加条目:字符串当作用户消息,`{ role, text }` 按角色转成消息,带 `type` 的对象按 Responses 条目原样写入 | | `startTurn(id, input, options?)` | 以 `input`(文字或 `[{ type: "text", text }]`)发起一轮,返回 `{ id, status, … }` | | `steer(id, input, turnId?)` | 会话正在跑时往当前这一轮插话 | | `interrupt(id, turnId)` | 中断一轮 | | `compact(id)` | 压缩会话上下文 | - 只开放上面几个方法(没有删除)。页面里 `inject: ["sessions"]`;常驻后台、模型工具、钩子的上下文里也有 `sessions`, 由 Codex++ 在 Codex 页面里执行,所以 Codex 需要开着。 - `startTurn` 会真的让模型回复(消耗额度),只在用户要求时用。读会话用 `sessionQuery`。 ### 模型供应商 拓展可以声明模型供应商,并入 Codex++ 网关的供应商池(网关模式下 Codex 的模型列表里出现 `/<模型>`): ```json { "id": "my-relay", "config": { "apiKey": { "type": "string", "title": "API Key", "secret": true } }, "providers": [{ "id": "myrelay", "name": "我的中转", "protocol": "chat", "baseUrl": "https://relay.example.com/v1", "apiKey": { "config": "apiKey" }, "models": ["gpt-x", "gpt-y"] }] } ``` - `protocol`:`responses`(默认)/ `chat` / `anthropic`。`apiKey` 写字面值,或 `{ "config": "键" }` 引用拓展的 string 配置项 (用户在拓展设置里填,Key 不写进代码)。`models` 至少一个;每个拓展最多 8 个供应商。 - `"discoverModels": true`:从上游 `/models` 拉模型列表(10 分钟刷新一次),与 `models` 合并;这时 `models` 可以为空。 - 只在构建 Agent 模型资料与网关路由时并入,不写进 `gateway.json`;与用户已有的同 id 供应商以用户的为准, 写了 `"override": true` 时顶替用户的那个(位置不变)。拓展停用即消失、被顶替的恢复。 - 要用代码实现一个供应商(用代码写供应商适配器):在常驻后台里起一个本机 HTTP 服务实现 Chat / Responses 接口, `baseUrl` 指向 `http://127.0.0.1:<端口>`。 ### 拓展调模型 页面里 `ctx.llm`,常驻后台、模型工具、钩子的上下文里 `llm`,经 Codex++ 协议代理用当前的供应商与模型路由发一次请求: ```js const { text, model, usage } = await ctx.llm.complete({ input: "把下面这段话改成一句话摘要:……", // 字符串,或 [{ role: "user" | "assistant" | "developer", content }] instructions: "只输出摘要", // 可选 model: "gpt-5-mini", // 可选,默认 Codex 配置里的 model reasoningEffort: "low", // 可选;还有 maxOutputTokens、temperature }); const { models, default: current } = await ctx.llm.listModels(); // Codex 当前模型目录里的模型 ``` - 这次请求不经过任何拓展的网关中间件(免得自己改写自己);上游是 Responses 还是 Chat Completions、流式还是整段都会整理成 `text`。 - 会消耗额度,单次最长 120 秒。 ### 技能、子 agent、MCP 服务器与全局指令 这几类是 Codex 自己的能力,拓展启用时由 Codex++ 写进 Codex 的配置目录,停用时撤掉(只动自己写过的,不覆盖你已有的同名条目): ```json { "id": "review-kit", "agents": [{ "name": "reviewer", "description": "代码审查,改完代码后派它", "instructions": "prompts/reviewer.md", "model": "gpt-5", "reasoningEffort": "high" }], "mcpServers": { "docs": { "command": "node", "args": ["${extensionDir}/mcp/server.mjs"], "env": { "API_KEY": "${config.apiKey}" } }, "search": { "url": "https://example.com/mcp" } }, "instructions": "rules.md", "config": { "apiKey": { "type": "string", "secret": true } } } ``` - `skills`:包内含 `SKILL.md` 的目录列表;省略时自动收 `skills/*/SKILL.md`。装进 `~/.codex/skills/<拓展 id>-<目录名>`。 - `agents`:写成 Codex 的 `[agents.<拓展 id>-<名字>]` 角色;`instructions`(文字或包内 .md)、`model`、`reasoningEffort` 放进 Codex++ 生成的角色配置文件。`nicknames` 可给角色起几个昵称。 - `mcpServers`:本地进程(`command` / `args` / `env` / `cwd`)或 HTTP(`url` / `headers`),二选一; 写成 `[mcp_servers.<拓展 id>-<名字>]`。字符串里的 `${extensionDir}` 换成包目录,`${config.键}` 换成拓展配置值。 - `instructions`:文字或包内 .md,合并进 `~/.codex/AGENTS.md` 里 Codex++ 托管的一段——官方直连也生效,适合改系统提示词。 - 改动在新开的 Codex 对话里生效。 ### 提示词与资源 ```json { "prompts": [{ "name": "review", "description": "审查一个文件", "arguments": [{ "name": "file", "required": true }], "template": "prompts/review.md" }], "resources": [{ "name": "团队规范", "file": "docs/style.md" }] } ``` - 经 Codex++ 拓展 MCP(`codex_plus_extensions`)提供:提示词对外名 `<拓展 id>__<名字>`,模板里的 `{{参数}}` 换成调用时的值; 资源地址 `codexpp://<拓展 id>/<包内路径>`,单个文件最多 1 MiB。 ### HTTP 路由与定时任务 ```json { "backend": { "script": "backend/main.mjs", "http": true }, "schedules": [{ "method": "digest", "at": "09:00" }, { "method": "poll", "everyMinutes": 30 }] } ``` ```js // backend/main.mjs export default ({ sessions }) => ({ // POST http://127.0.0.1:/ext/<拓展 id>/hook http(request) { // { method, path, query, headers, body, json? } if (request.path === "/hook") return { status: 200, json: { ok: true } }; return { status: 404, body: "not found" }; }, async digest() { /* 每天 9 点 */ }, async poll() { /* 每 30 分钟 */ }, }); ``` - `backend.http: true` 才开放 `/ext/<拓展 id>/…`;返回字符串当纯文本,返回 `{ status?, headers?, body?, json? }` 按字段组装。 带 `Origin` 的请求(网页发来的)一律拒绝。 - `schedules`:`everyMinutes`(1~10080)或 `at: "HH:MM"`(本机时区)二选一,最多 8 条,需要同时声明 `backend`。 Codex++ 每分钟检查一次,到点调用后台方法(参数 `{ scheduledAt, schedule }`);上次运行时间记在拓展数据目录,重启不补跑也不重复。 Codex++ 没在运行时不会触发。 ### 输入触发、快捷键、主题与提问 ```js codexPlus.extension((ctx) => { // 输入框里打 # 时弹候选,选中后把「#查询词」换成 insert(@ $ / 是 Codex 自己的,不能用) ctx.registerInputTrigger({ trigger: "#", items: async (query) => [{ label: "#123 登录失败", description: "issue", insert: "#123 " }] }); // 快捷键:Mod 在 macOS 是 ⌘,其余是 Ctrl;必须带修饰键(或 F1~F12) ctx.registerShortcut({ key: "Mod+Shift+K", run: () => ctx.toast("按下了") }); // 主题:同一时间只生效一个,用户的选择记在本机 ctx.registerTheme({ id: "sepia", name: "复古", variables: { "--color-token-main-surface-primary": "#f4ecd8" }, css: "body{letter-spacing:.01em}" }); ctx.themes.apply("sepia"); // 也可以 inject: ["themes"] 后 list() / current / apply(null) // 问用户:返回 { value, text },取消为 null const answer = await ctx.ask({ title: "要继续吗?", options: [{ value: "no", label: "算了" }, { value: "yes", label: "继续" }] }); const named = await ctx.ask({ title: "起个名字", input: { placeholder: "名字" } }); }); // 常驻后台、模型工具、钩子:ui.ask({ … }) / ui.toast("文字"),在 Codex 页面里弹(最长等 10 分钟) ``` - `@`(提及)、`$`(技能)、`/`(命令)是 Codex 自己的触发符,拓展注册会报错。 - 输入触发在光标前是「触发符 + 查询词」(触发符前是开头或空白、查询词里没有空白)时弹出,↑↓ 选、回车 / Tab 确认、Esc 关。 - 主题切换时发 `theme/change` 事件;拓展装上 / 卸下后合并发一次 `extensions/changed`(`{ extensions: [{ id, scriptKey, version }] }`)。 - 每次从 Codex++ 读到设置(启动时、之后每次心跳)发 `settings/loaded`(`{ reason: "startup" | "heartbeat", changedKeys? }`)。 心跳时 `changedKeys` 是这次值变了的设置字段名(只有名字没有值,空数组表示没变),管理器里改的设置靠它识别;启动时不带。 - 当前供应商换了发 `relayProfile/switched`(`{ previous, profile }`);决定模型目录的字段变了(模型列表、每模型窗口 / 自动压缩 / 元数据、聚合成员、换供应商)发 `modelCatalog/changed`(`{ profile }`),新目录用 `/model-catalog/public` 取。 `profile` 形状同 `/settings/public` 的 `activeProfile`。页面里改的立即发,管理器里改的经心跳(约 5 秒)发;第一次读到设置时不发。 - 在 Codex 页面里改了一项 Codex++ 设置后发 `settings/changed`(`{ key, value, persisted }`):`key` 是改动的设置字段 (存在 Codex++ 后端的用后端字段名,如 `codexAppCapacityAutoRetry`),先按新值发一次 `persisted: false`(这时读设置已是新值), 写进后端后再发一次 `persisted: true`。管理器里改的设置经心跳的 `settings/loaded` 过来。 跟着设置走的内置拓展都靠订阅这两个事件跟上设置变化,Codex++ 读写设置的地方不点名任何功能。 ### 内置拓展与顶替 Codex++ 自带的功能全部是内置拓展:管理器「Codex → 内置拓展」页与 Codex 里的 Codex++ 设置页按同一份清单 (`crates/codex-plus-core/assets/enhancement-settings.json`)列出它们,每项一个开关。页面里跑的内置功能各是一个 内置拓展实例(`scriptKey` 为 `builtin:`),和第三方拓展走同一套生命周期,在 `extensions/changed` 里也能看到。 - **每项只有一个开关**。有对应设置项的(会话删除、粘贴修复、梦皮、语音听写……),开关就是那个设置项,默认值也跟着它; 没有设置项的(模型分组、多开身份提示、会话标题中间件)启停记在用户脚本配置的 `scripts["builtin:"]` 里。 子选项(删除不再确认、建议直接发送、重试间隔、对话宽度……)作为所属内置拓展的参数,开关打开后在它下面显示。 - 「启用内置拓展」总开关关闭时暂停全部内置拓展;「独立模式」里的补丁不受它控制。 - 页面里跑的内置拓展每个都只是一个 `apply(ctx)`,只用公开的 ctx 接口:多开身份提示挂在 `overlay` 插槽给的容器里, 侧聊保存挂在 `codex/exchange` 中间件上, 粘贴修复的监听经 `ctx.listen` 挂上;Codex 重建界面后要补回去的(语音听写、轮次回退、梦皮、会话 ID 角标、 滚动位置恢复、对话宽度、已删会话的行过滤……)经 `ctx.onScan` 登记扫描步骤;跟着设置走的订阅 `settings/loaded`、`settings/changed`。 它们的共同写法是「装上时对齐一次、卸下时再对齐一次(这时功能读到的已是停用,自己撤下)」,第三方顶替时照抄即可。 Codex++ 的扫描循环与读写设置的地方不再点名功能,内置拓展卸下时它的监听、中间件、扫描步骤、定时器都跟着撤掉; 同页重复注入时先卸下上一份注入装上的实例。会话删除、Markdown 导出也是经 `registerRowAction` 注册的会话行操作 (删除是 `placement: "inline"`,导出带 `surfaces: ["sidebar", "archived"]`),内核只是按注册项画按钮组的宿主; 语音听写的快捷键监听在内置拓展装上时才开始,没装着时什么也不跑。 「改 Codex 原生界面」的能力(原生菜单、原生重试按钮、原生会话管理器)刻意不开放给拓展(见「尚未开放的接口」)。 - 拓展包在清单里声明 `replaces` 即可顶替内置功能,它启用时内置的让位、停用时内置的回来: ```json { "id": "my-groups", "renderer": "renderer.js", "replaces": ["model-picker-groups"] } ``` 已经改成官方拓展包的原内置拓展(`usage-statusline`、`proxy-request-status`)仍可写进 `replaces`: 你的拓展启用时对应的官方拓展包让位(管理器里显示停用、`replacedBy` 是你的拓展),停用时它回来。 在 Codex++ 后台生效的内置拓展(原生 Fast 强制解锁、原生输入预测、模型白名单解锁、强制中文界面、第三方推理折叠、 纯 API 禁用 Work 视图、原生浏览器请求标识兼容、历史轮次补全、桌宠跟随鼠标)同样能顶替:被顶替时 Codex++ 后台把它们的设置项 按「关」处理(多数要重启 Codex 才完全生效),顶替它们的拓展用下面这些接口写出等价功能: | 内置拓展 | 等价写法 | | --- | --- | | 原生 Fast 解锁、原生输入预测、强制中文、模型白名单解锁 | `preload`(在 Codex 自己的脚本之前执行,配 `reloadOnLaunch: true`),见「页面最早期脚本与启动前后」 | | 纯 API 禁用 Work 视图、原生浏览器请求标识兼容 | `launch.before` / `launch.after`(启动 Codex 之前 / 退出之后跑 Node 脚本)、`launch.args` | | 第三方推理折叠 | 网关响应流中间件 | | 历史轮次补全 | 订阅公开事件 `codexpp/session-index-repaired`(顶替后,会话索引修复结束时发,`{ aborted, checkedAtMs }`) | | 桌宠跟随鼠标 | 路由 `/system/cursor-position`(系统鼠标位置)+ `/cdp/evaluate` 往 `avatar-overlay` 页面推坐标 | 只有「官方额度解锁」不能顶替:它只在关闭增强时生效,那时拓展根本不加载。 - **官方拓展包**:随 Codex++ 一起发布、来源为 `bundled` 的拓展包(脚本 key `bundled:<目录名>`),嵌在程序里,第一次用到时写到 `<配置目录>/bundled_extensions/` 下,之后和别的拓展包一样扫描、启停、热重载,可以停用、不能删除。目前有「Agent 桌宠」 (`bundled:agent-pet`,源码在 `assets/extensions/agent-pet/`):原来的内置桌宠与大肥鱼整体改成了它,页面代码只用公开接口, 数据服务是白名单路由 `/agent-pet/catalog`、`/agent-pet/image`、`/agent-pet/sound`、`/agent-pet/import-sound`、`/whale/media`、 `/whale/full`、`/whale/history`、`/whale/session`——这些路由只在有启用中的拓展在清单里声明了它们时才干活,供应商 Key 只在 Codex++ 里用。 它的设置界面也在包里(`settings.js`),Codex「拓展」详情页和管理器「设置」用的是同一份。可以照着它写自己的桌宠。 另有「打字特效」(`bundled:typing-effects`)与「背景图」(`bundled:image-overlay`),源码在 `assets/extensions/` 下: 打字特效是纯页面功能,不用任何路由;背景图的图片经白名单路由 `/image-overlay/image` 读,这条路由只读背景图拓展自己配置里的 那张本机图片、不接受页面传入的路径,同样只在有启用中的拓展声明了它时才干活。原来的打字特效、背景图设置在第一次用到时换算成 它们的配置,启停照旧。 「用量状态栏」(`bundled:usage-statusline`)与「请求状态条」(`bundled:proxy-request-status`)也在 `assets/extensions/` 下: 分别经白名单路由 `/thread-usage-live`、`/proxy-status` 取数,挂在 `composer.below`、`composer.above` 插槽里,并提供 `codexpp.usage`、`codexpp.proxyStatus` 服务。原来的「用量状态栏」「请求状态条」开关在第一次用到时换算成它们的启停(默认都开)。 - `codexPlus.builtins()` 返回 `[{ id, name, active, disabled, replacedBy }]`:`active` 表示内置拓展实例在用(没停用、没被顶替); 有设置项的内置拓展,功能本身是否打开还看那个设置项。「多开身份提示」完全用公开接口(`overlay` 插槽)写成, 可以当作写法参考;「会话标题中间件」是 `ctx.intercept("session/title", (value, next) => …)` 链:会话首轮结束后以 `{ sessionId, title, firstMessage, lastMessage }` 跑一遍,返回值的 `title` 与原标题不同就改名。 ### 页面最早期脚本与启动前后 有些功能必须赶在 Codex 自己的脚本之前、或在 Codex 启动之前做(改写 Codex 的模块、改启动参数、改 Codex 的本地状态文件): ```json { "id": "my-fast", "preload": { "scripts": ["preload/fast.js"], "reloadOnLaunch": true }, "launch": { "args": ["--lang=zh-CN"], "before": "launch/before.mjs", "after": "launch/after.mjs", "timeout": 10 }, "replaces": ["native-fast-unlock"] } ``` - `preload`:经 CDP 的「新文档脚本」登记,Codex 页面每次新建文档时在 Codex 自己的脚本之前执行(同 Codex++ 的原生模块补丁)。 脚本里能读 `codexPlusPreload`(`{ id, key, config }`),这时 `codexPlus` 与拓展内核还不在,只能用浏览器原生接口; 各拓展分别 try/catch,抛错记进 `window.__codexPlusPreloadErrors`。`reloadOnLaunch: true` 时启动 Codex 后整页重载一次, 脚本才赶得上第一份文档。改了 preload 要重启 Codex 才生效。单个脚本最大 512 KiB。 - `launch.args`:追加到 Codex 的启动参数(用户在管理器里填的同名参数优先)。只能是 `--名字` 或 `--名字=值`,最多 16 个; 调试端口、用户数据目录、远程调试来源由 Codex++ 管,不能设。 - `launch.before` / `launch.after`:启动 Codex 之前 / Codex 退出之后各跑一次(Node,默认导出函数),拿到 `{ event, extension, codexHome, stateDir, appDir, platform, config }`,不含任何凭据。超时 `timeout` 秒(1~30,默认 10); 出错、超时只记诊断日志,不挡 Codex 启动。 - 都只在用户脚本总开关与增强都开着时生效;「下次启动跳过拓展」开着时也跳过。 ### 悬浮球页签、工具调用视图、设置页分区 ```js codexPlus.extension((ctx) => { // 右下角悬浮球面板里加一个页签(悬浮球的「下一步建议」或「回答大纲」至少开着一个时可见) ctx.registerFloatingTab({ title: "待办", icon: "✓", render: ({ container, close }) => { container.textContent = "今天还有 3 件事"; } }); // 对话里的工具调用 / 命令条目末尾挂一块内容;按工具名(字符串、正则或正则字符串)或条目类型过滤 ctx.registerToolView({ tools: ["exec_command", /^mcp__/], render: ({ container, call }) => { // call:{ kind, itemIds, text, sessionId, callId, name, input, output },工具名、参数、结果来自会话日志 container.textContent = `${call.name} 输出 ${String(call.output ?? "").length} 字`; } }); ctx.registerToolView({ kinds: ["plan"], render: ({ container }) => { container.textContent = "计划条目"; } }); // Codex 设置页左侧导航末尾加一项,点开是自己画的页面 ctx.registerSettingsSection({ title: "我的拓展", icon: "⚙", render: ({ container }) => { container.textContent = "设置内容"; } }); }); ``` - 三者都随拓展实例卸载撤下。工具调用条目收在 Codex 的「用时 Ns」里,用户展开后才出现;只按 `kinds` 过滤时不读会话日志。 ### 按事件找写法 同一类能力在 Codex++ 里按层落在不同地方:页面里的 `ctx.on` 只能观察和改写页面经手的东西,工具执行、模型请求要在 Node 侧 (清单的 `toolPipeline` / `hooks` / `gateway`)插手。 | 想做的事 | 写法 | | --- | --- | | 改写或拒绝本轮用户输入、换模型 / 推理强度 | 页面 `ctx.on("agent/pre-step" / "agent/request", (params, next) => …)`(见上一节) | | 观察全部会话的事件、轮次开始 / 结束 | 页面 `ctx.on("session/event" / "agent/status", …)` | | 工具结果写进会话、新会话出现、一轮被中止时得到通知 | 页面 `ctx.on("tools/result" / "agent/created" / "agent/error", …)`:`tools/result` 收到 `({ callId, name, arguments, agent }, { isError, value, content })`,`agent` 只是 `{ id, session: { id } }` 描述 | | 工具执行前改参数 / 拦截、代为执行、执行后改结果 | 清单 `toolPipeline` 的 preExecute / execute / postExecute | | 本轮要停下时要求继续、会话开始时补上下文、子 agent 起止 | 清单 `hooks`:Stop / SessionStart / SubagentStart / SubagentStop | | 改系统提示词、改写模型回复流 | 清单 `gateway.request` / `gateway.response`;只改提示词也可以用 `instructions` | | 上游出错时重试或降级 | 清单 `gateway.error` | 已经按 dsh 插件写法写好的插件(`apply(ctx)` 里 `ctx.on("tools/pre-execute", …)`、`ctx.tools.register(…)`、`ctx.commands.register(…)` 等)不用改写:在管理器「拓展 → dsh 插件」用本地目录导入,或者放进拓展包里、在清单里用 `dsh` 指向它: ```json { "id": "my-kit", "renderer": "renderer.js", "dsh": "dsh-plugin" } ``` `dsh-plugin/` 是包内一个带 `package.json` 的目录(单个插件或 bundle 都行)。对话造拓展的写入工具同样认 `dsh` 与 `instances`。拓展启用时 Codex++ 把它作为 dsh 插件导入 (id 为 `ext-<拓展 id>`)并生成配套拓展包,拓展停用时一起停用、删除时一起移除;插件目录或拓展版本变了会重新分析。 包里的 `Service` 子类插件会实例化并提供给同包插件 `inject`(支持行上的 `isolate` 与 `ctx.isolate`);插件之间的自定义事件 (`ctx.emit` / `serial` / `waterfall` …)跨包也能收到;包导出的 `./typert` 会自动登记,前端半边用 `ctx.remote..()` 调; `ctx.webServer.registerUpgrade` 注册的 WebSocket 经 `/ext/dsh-<插件>/<路径>` 可用(Host 必须是本机回环地址,网页只接受同源)。 支持情况见 `docs/plans/2026-10-30-dsh-plugin-import-design.md`(第 14 节是最新一期)。 ### 拓展间服务 ```js // 提供方 codexPlus.extension((ctx) => { ctx.provide("metrics", { record: (name) => console.log("metric", name) }); }); // 使用方:声明 inject,依赖齐全才应用 codexPlus.extension({ inject: ["metrics"], apply(ctx) { ctx.metrics.record("opened"); } }); ``` - `ctx.provide(name, value)`:服务名是全局扁平的命名空间,重名抛错;随提供方卸载撤销。 - `inject`:写在 `codexPlus.extension({ inject, apply })` 里(或第二个参数 `{ inject }`)。依赖没到时挂起、不报错; 提供方卸载或被替换时依赖方先卸载、回到挂起,提供方回来后自动重新应用。此时 `codexPlus.extension` 返回的是 生命周期句柄 `{ id, state, ctx, dispose() }`,`state` 为 `pending` / `active` / `failed` / `disposed`。 - 可选依赖:`inject` 用对象写法 `{ metrics: { required: false } }`,缺了照样应用,`ctx.metrics` 为 `undefined`; 提供方出现、消失或被替换时同样重新应用。也可以不写进 `inject`,用到时 `ctx.get(name)`(不存在为 `undefined`)。 - `ctx.inject(deps, apply)`:子作用域,只让依赖某服务的那部分挂起 / 重建。 - 内置服务:`sessionQuery`、`locale`;官方拓展包的数据也是服务:`codexpp.usage`(用量状态栏提供,`cached(threadId?)` 取它算好的速度、缓存命中、上下文占用)、 `codexpp.proxyStatus`(请求状态条提供,`requests()` 最近的代理请求、`fetch()` 立即问一次)。这两个服务随提供它的拓展包启停, 要自己画界面又不想依赖它们,可以直接调白名单路由 `/thread-usage-live`、`/proxy-status`。`codexPlus.services()` 列出当前已提供的服务与提供方。 #### 后台提供的服务(跨进程) 常驻后台也能提供服务,页面、其它后台、模型工具、钩子都按名字调用,调用都落到同一个后台进程: ```json { "backend": { "script": "backend/main.mjs", "provides": ["counter"] } } ``` ```js // backend/main.mjs export default ({ provide }) => { let total = 0; provide("counter", { add: (n) => (total += n), total: () => total }); return {}; }; // 页面:codexPlus.extension({ inject: ["counter"], async apply(ctx) { await ctx.counter.add(1); } }) // 后台 / 模型工具 / 钩子:await service("counter").add(1) (service 在默认导出的参数或第二个参数里) ``` - 服务名要在 `backend.provides` 里声明(字母开头,字母、数字、`_ . -`),后台代码里用 `provide(name, impl)` 实现; 同名时按拓展 key 顺序先声明的生效。调用参数和返回值要能 JSON 序列化,方法都是异步的。 - 页面:注入包把后台服务清单交给页面,登记成可 `inject` 的服务;页面里已有同名服务时以页面的为准。 - 拓展 MCP(模型工具)与钩子进程不托管后台,它们的调用经 Codex 页面桥接转给托管后台的 Codex++ 进程, 所以 Codex 需要开着。 ### 多语言 ``` my-ext/ ├── codexpp.json ├── locale/en.json { "meta": { "title": "Word Count", "description": "..." }, "count": "{n} words" } └── locale/zh.json { "meta": { "title": "字数统计", "description": "..." }, "count": "{n} 字" } ``` ```js codexPlus.extension((ctx) => { ctx.toast(ctx.locale.t("count", { n: 42 })); // 包内词条,自动登记在拓展 id 命名空间 ctx.locale.register("menu", { zh: { open: "打开" }, en: { open: "Open" } }); const t = ctx.locale.bind("menu"); ctx.on("locale/change", ({ locale }) => ctx.toast(`${locale}: ${t("open")}`)); }); ``` - 语言文件名就是语言 id(`en`、`zh`、`zh-TW`),每个最大 256 KiB;`meta.title` / `meta.description` 是本地化的 名称与描述,管理器按界面语言显示,缺了就用清单里的 `name` / `description`。 - 查找顺序:当前语言(如 `zh-CN`)→ 主语言(`zh`)→ `en`,先在指定命名空间、再在 `common`,都没有返回 key; 占位符写 `{name}`,键可以用点号取嵌套词条(`meta.title`)。 - `ctx.locale.current` 是 Codex 页面的语言;`ctx.locale.resolve({ en, zh })` 解析多语言文本对象。 - 也可以 `inject: ["locale"]` 用全局的 `locale` 服务(`register` 返回撤销函数,需要自己撤销)。 ### 常驻后台 页面脚本拿不到本地文件、进程和长时间运行的计时器。拓展包可以声明一个常驻后台,由页面经 `ctx.backend` 调用: ```json { "id": "notes", "name": "便签", "renderer": "main.js", "backend": { "script": "backend/main.mjs" } } ``` ```js // backend/main.mjs:默认导出 (ctx) => 方法表;ctx = { extension, config, dataDir, emit, log, sessionQuery, provide, service } import fs from "node:fs"; export default ({ emit }) => ({ read: () => (fs.existsSync("note.txt") ? fs.readFileSync("note.txt", "utf8") : ""), save: (text) => { fs.writeFileSync("note.txt", String(text)); emit("saved", { length: String(text).length }); return true; }, }); ``` ```js // main.js(页面) codexPlus.extension((ctx) => { ctx.backend.on("saved", ({ length }) => ctx.toast(`已保存 ${length} 字`)); ctx.registerCommand({ name: "note", description: "读便签", run: async () => ctx.toast(await ctx.backend.call("read")) }); }); ``` - 第一次 `call` 时用 Node 启动(优先 Codex App 自带的 Node,或 `CODEX_PLUS_NODE`),之后常驻在 Codex++ 启动器里; 页面加载、拓展热重载 / 启停时全部停止,下次调用重新启动。内存状态不持久,要持久就写文件。 - 工作目录是 `~/.config/Codex++/extension-data/<拓展 id>/`(即 `ctx.dataDir`)。 - 与后台之间是行式 JSON:方法只收一个 JSON 参数、返回可序列化的值,抛错即 reject。默认超时 20 秒,最多 24 秒。 - `emit(event, payload)` 进有界队列(每个后台保留最近 200 条),页面 `ctx.backend.on` 以长轮询取走;`on` 不会启动后台。 - 后台以你的用户权限运行、没有沙箱。只启用你信任的拓展。 ### 管理器面板 拓展可以在 Codex++ 管理器里有自己的一块界面(统计、批量操作、比设置表单更复杂的配置)。清单里声明 `manager`: ```json { "id": "notes", "name": "便签", "backend": { "script": "backend/main.mjs" }, "manager": { "page": "manager.html", "title": "便签统计" } } ``` 管理器「本地拓展」里这一行的「…」菜单会出现「打开面板」,面板就展开在这一行下面。页面里有 `window.codexPlusManager`: ```html

共 … 条

``` | `codexPlusManager` 成员 | 说明 | | --- | --- | | `config.get()` / `config.set(values)` / `config.onChange(fn)` | 读写本拓展的配置(只能写清单 `config` 里声明过的项,类型要对);保存后和设置表单一样热更新到 Codex 页面 | | `backend.call(method, params)` | 调本拓展的常驻后台(`backend.script` 导出的方法),超时 30 秒 | | `asset(path)` | 本拓展 `assets/` 里资源的 data URL | | `toast(text)` | 在管理器里弹一条提示 | | `theme()` / `onTheme(fn)` | 管理器当前是 `dark` 还是 `light`;`` 也会跟着设好 | | `data(name, params)` | 读清单 `manager.data` 里声明过的只读管理器数据(见下) | | `navigate(target)` | 跳到管理器的另一个页面 | | `call(route, payload)` | 调本拓展清单 `permissions.routes` 声明过、且开放给拓展页面的 Codex++ 数据路由(目前是 `/agent-pet/*`) | - 页面放在**沙箱 iframe** 里,允许脚本和表单交互:拿不到管理器页面与它的接口,不能弹窗、不能跳转顶层页面。 - CSP 限制外部资源请求和表单外发(`default-src 'none'`、`form-action 'none'`):脚本、样式写在页面里,图片等资源用 `asset()` 取, 要访问网络或本机文件就放进常驻后台,经 `backend.call` 调。CSP 不阻止 iframe 自身导航,不能当作完全断网的安全边界;只安装信任作者的拓展。 - `page` 必须是包内的 .html 文件,最大 1 MiB;`title` 不填用拓展名。拓展停用时面板打不开。 - 也可以只声明 `manager`(不带页面脚本):纯管理器工具类的拓展。 管理器本身也「万物皆拓展」:拓展页面可以进侧栏,也可以顶替管理器的内置页面。 ```json { "manager": { "page": "page.html", "title": "用量看板", "nav": "management", "icon": "bar-chart", "replaces": ["gatewayUsage"], "data": ["requestLogs", "gatewayUsage"] } } ``` - `nav`:侧栏入口所在分组 `gateway` / `management`(`true` 同它)/ `tools`,点开是整页的拓展页面,也进 ⌘K 面板。 `icon` 可选 `app-window`、`bar-chart`、`list`、`server`、`store`、`palette`、`network`、`book`、`waypoints`、`plug`、`split`、 `messages`、`paw`、`git-branch`、`wrench`、`settings`、`code`、`layers`,其余用默认图标。 - `replaces`:顶替的管理器页面(路由 id:`hub`、`gatewayProviders`、`gateway`、`gatewayRouting`、`gatewayUsage`、`gatewaySettings`、 `requestLog`、`overview`、`relay`、`multiLaunch`、`relayEnvironment`、`claude`、`sessions`、`context`、`skills`、`weixin`、`dreamSkin`、 `pluginMarket`、`recommendations`、`agentCache`、`agentCollaboration`、`computerUse`)。打开这些页面时显示拓展页面, 上方有「查看内置页面」随时切回。「万物皆拓展」「内置拓展」「设置」三页是停用拓展的入口,不能顶替。 - `data`:页面经 `codexPlusManager.data(name, params)` 能读的只读数据,要先在这里声明:`requestFlow`(最近的请求,`{ limit }`)、 `requestLogs`(请求日志,`{ limit, sinceMs, agent, relayId, model }`)、`gatewayUsage`(用量统计,`{ range }`)、 `localSessions`(本机会话,`{ offset, limit }`)。都不含 Key。 - `codexPlusManager.navigate(target)`:跳到管理器别的页面(路由 id,或 `ext:<拓展 key>` 打开另一个拓展页面)。 #### 公共只读数据 除上述四类外,`manager.data` 还可以声明以下七类。调用方式相同;新增数据返回下表对象(没有 `status` / `message` 包装),读取失败时 Promise reject,旧四类的参数与返回结构不变。 | 数据名 | 返回与范围 | | --- | --- | | `gatewayProviders` | `{ providers }`:网关供应商的 `id/name/enabled/protocol/models/modelWindows/contextWindow/keyRpm/keyConcurrency/price/modelPrices`;不是直连供应商列表 | | `gatewayRouting` | `{ groups, autoGroups }`:组的 `id/name/mode/sticky/members[{model,weight}]/hideMembers/pick`;自动组额外给 `hidden/customized` | | `gatewayHealth` | `{ scope: "managerProcess", providers, speeds, agents }`:供应商熔断状态、剩余冷却时间、连续错误数;模型首字耗时样本;Agent 请求/改道/失败计数及进行中的供应商 id。不返回 Key id、错误原文、完整请求轨迹 | | `gatewayQuota` | `{ scope: "managerProcess", cachedOnly: true, providers: [{ id, name, quota }] }`:已有额度缓存,`quota` 为 `{ windows: [{ windowMinutes, usedPercent, resetsAtMs }], updatedAtMs }`;无缓存时为 `null`,不是零额度 | | `installedSkills` | `{ skills: [{ id, name, enabled, bundled, managed, agents, contentHash }] }`:本地已装技能;`enabled` 对应 Codex,其他 Agent 看 `agents`;不读远端目录,版本以内容哈希表示 | | `installedPlugins` | `{ plugins: [{ id, name, version, source, enabled }] }`:仅 Codex++ 插件市场托管的本地安装记录,不代表全部 Codex 插件;`source` 仅 `public/full`,`id` 沿用原生 `name@market` 标识 | | `agentCollaboration` | `{ enabled, agents, tasks, total }`:Agent 的 `id/name/enabled/model/maxConcurrentTasks`;任务的 `id/agentId/status/createdAtMs/updatedAtMs/cancelRequested`。参数 `{ limit }` 默认 50、范围 1–200,按更新时间倒序、同时间按 id 升序 | ```json { "manager": { "page": "panel.html", "data": ["gatewayProviders", "gatewayHealth", "gatewayQuota"] } } ``` ```js const { providers } = await codexPlusManager.data("gatewayProviders"); const { providers: quotas } = await codexPlusManager.data("gatewayQuota"); // 额度是缓存:quota === null 表示暂未采集,不能显示成余额为零。 ``` - 新增数据在宿主侧重新检查拓展总开关、当前启停状态、有效清单及 `manager.data` 声明;停用后已打开的页面也不能继续读取。 - 数据按明确字段构造,不透传内部配置、供应商地址、代理地址、Key(包括打码 Key)、磁盘路径、任务提示词/答案/进度/错误。 - `gatewayHealth`、`gatewayQuota` 只读**当前管理器进程**已有缓存;网关在别的进程处理请求时可能为空或不是最新,不保证跨进程汇总。 - 不主动探测/刷新上游、不安装下载、不启动执行器。账户余额当前只有主动联网刷新入口,暂不开放;`gatewayQuota` 是额度窗口,不是账户余额。 - `agentCollaboration` 只读主数据库已落盘快照,不刷新租约、不创建数据库/schema/WAL sidecar,也不读取尚未 checkpoint 的 WAL;状态可能落后于执行中的任务。 - 这些是 manager iframe 的数据能力,不扩展 renderer 的 `codexPlus.call` 白名单,也不开放设置写入、删除、安装或任意 Tauri 命令。类型定义见 `types/codex-plus-extensions.d.ts`。 ### 资源文件 拓展包里的图片、音频、字体放在 `assets/` 下,页面代码用 `ctx.asset(path)` 取 data URL: ```js codexPlus.extension((ctx) => { const audio = new Audio(ctx.asset("assets/ding.mp3")); ctx.registerComposerAction({ label: "叮", icon: "🔔", onActivate: () => audio.play() }); }); ``` - 支持 png / jpg / gif / webp / svg / ico / avif / mp3 / wav / ogg / m4a / woff / woff2 / ttf / otf。单个最大 1 MiB, 一个包合计最大 4 MiB,超出的不下发(管理器里能看到 `skippedAssets`)。资源随拓展下发给页面,越小越好。 - 页面里不能用 `file://` 加载本机文件。对话造拓展时,agent 用写入工具 `files[].sourcePath` 把本机文件按字节复制进包。 ### 拓展包 一个拓展可以是一个目录:放在 `user_scripts/` 下,根部有 `codexpp.json`。 ``` user_scripts/ clock/ codexpp.json renderer.js ``` ```json { "id": "clock", "name": "时钟", "version": "0.1.0", "description": "图标栏里的时钟页面", "engines": { "codexpp": 1 }, "renderer": ["renderer.js"], "permissions": { "routes": ["/diagnostics/log"] } } ``` | 字段 | 说明 | | --- | --- | | `id` | 必填,只能包含字母、数字、`-`、`_` | | `name` / `version` / `description` | 管理页展示用 | | `icon` | 图标:包内 `assets/` 下的图片(png / jpg / gif / webp / svg / ico / avif,如 `"assets/icon.svg"`),或 https 地址。显示在 Codex「拓展」页面的侧栏、列表和详情头部;找不到或不是图片时用默认图标 | | `engines.codexpp` | 整数:需要的 `apiVersion`;字符串:对 Codex++ 版本的 semver 范围(如 `"^2.1"`、`">=2.1.0 <3"`、`"2.x"`、`"1.0 - 2.5"`、`"^1 \|\| ^2"`)。不满足时不加载并在管理页显示原因 | | `renderer` | 字符串或数组,必须是包内相对路径;按顺序拼进同一个作用域执行。只声明 `hooks` 的拓展可以省略 | | `hooks` | Codex 钩子声明,见上文「Codex 钩子」 | | `gateway` | 网关请求中间件声明,见上文「网关请求中间件」 | | `tools` | 给模型的工具声明,见上文「给模型加工具」 | | `config` | 配置项声明,见上文「拓展配置」 | | `permissions.routes` | `ctx.call` 能调的路由,需在[路由白名单](#后端调用与路由白名单)内。这是声明约束而不是安全边界 | 包根目录放一个 `README.md`(也认 `README.zh-CN.md` / `README.zh.md`,中文版优先,不分大小写),Codex「拓展」页面的详情 就会在「细节」页签里显示它,形态对齐 VS Code 的扩展详情:「细节」是 README,「设置」是设置面板或通用表单,「功能」列出清单里 接管的能力(工具、钩子、网关、后台、Skills……),右侧栏是标识符、版本、来源与链接。README 支持常用 Markdown(标题、列表、 任务列表、引用、代码块、表格、链接、图片),原始 HTML 当文字显示;链接只放行 http(s) / mailto / 页内锚点,图片只放行 https 与包内 `assets/` 下的文件(写相对路径,如 `![截图](assets/shot.png)`)。最多读前 512 KB。 清单不合法(JSON 错误、入口越出包目录、版本不兼容等)时包不注入,管理页显示「加载失败」和原因。 未知字段会被忽略,后续版本会在清单里增加更多能力面。拓展包是文件夹,管理页不提供删除按钮,请在目录里手动移除。 整体设计见 [docs/plans/2026-10-30-codexpp-extension-system-design.md](docs/plans/2026-10-30-codexpp-extension-system-design.md)。 ### 在 Codex 对话里造拓展 不想自己写代码时,可以让 Codex 帮你写:在管理器「拓展」页的「对话造拓展」一栏点「接入 Codex」,然后新开一个 Codex 对话, 直接说需求,例如「帮我做个拓展:图标栏加一个番茄钟」。说「插件」也行,agent 知道这里指的是 Codex++ 拓展。 接入后 Codex 多了一个本机 MCP 服务 `codex_plus_extensions`(由 Codex++ 主程序以 `--codex-plus-extension-mcp` 运行),提供: | 工具 | 作用 | | --- | --- | | `codexpp_extension_guide` | 创作指南(`assets/extension-creator/guide.md`)、API 版本与可用路由 | | `codexpp_extension_list` / `codexpp_extension_read` | 列出拓展与加载状态、读回某个拓展的源码 | | `codexpp_extension_inspect` | 只读查看正在运行的 Codex 页面:关键区域概览,或按文字 / 选择器查元素,返回稳定属性与建议选择器 | | `codexpp_extension_screenshot` | 截取 Codex 页面的某个元素或整个窗口,写完拓展后确认效果 | | `codexpp_extension_write` | 写入 `user_scripts//`(清单由参数生成),立刻热重载并返回加载状态与报错 | | `codexpp_extension_set_enabled` / `codexpp_extension_reload` | 启停、手动热重载 | 边界: - 只写用户目录下的拓展包,同名目录必须是同 id 的拓展包才会覆盖;路径只能在包内。 - 大小:agent 逐字写的文本(`content`)单个 256 KiB、合计 1 MiB——这是模型一次工具调用能可靠写出的量,不是安全边界; 本机已有的文件用 `sourcePath` 复制:图片、音频、字体单个 1 MiB、合计 4 MiB(以 data URL 随页面下发), 打包产物等文本单个 4 MiB、合计 8 MiB(随页面注入)。用了第三方库的界面让 agent 先用 esbuild 打包成单文件再复制进包。 - 不提供删除,不要的拓展由 agent 停用,文件由你手动删除。 - 只做页内热重载。已加载的旧脚本里有没登记清理函数的,热重载会刷新整个 Codex 页面、可能打断当前对话, 这时工具不自动重载,会让你在管理器里手动点「热重载」。 - 写入属于 MCP 工具调用,是否每次确认由 Codex 自己的工具审批设置决定。 ### 发布到社区拓展 管理器「拓展」页的「社区拓展」分栏会列出 GitHub 上打了 `codexpp-extension` topic 的公开仓库,所有 Codex++ 用户都能看到并安装。 **一键发布**:在「本地拓展」里点拓展包那一行的「发布」。需要本机装了 GitHub CLI 并登录(`gh auth login`),git 配好 `user.name` / `user.email`;Codex++ 不读、不存 token,推送时只在那一条命令上借用 `gh auth git-credential`。 发布前会检查清单(名称、描述、`x.y.z` 版本号)、密钥(`.env`、`*.pem`、`auth.json` 等文件名,`sk-…`、`ghp_…`、私钥等内容, `secret` 配置项的默认值)、软链与体积,有问题时不能发布;版本号已在远端存在时可以点「补丁号 +1」。发布会: 建公开仓库 `codexpp-`(可改名)或取回已有仓库 → 用拓展包整体替换仓库内容并写入 `repository` → 没有 README 时按清单生成、 按选择加 LICENSE → 提交并打 `v` → 推送 → 建 release 并上传 `-v.zip` → 加 topic。每一步的输出都显示在 发布框里,失败时停在那一步,可以改好后重试。 也可以手动发布: 1. 仓库**根目录**放拓展包(`codexpp.json` 和它引用的文件),推到公开仓库。一个仓库只放一个拓展包。 2. 仓库设置 → Topics 加上 `codexpp-extension`。最长 10 分钟后出现在社区栏(列表缓存周期),用户点「刷新」可立即看到。 3. 发布版本:打 `v` 形式的 tag(如 `v1.2.0`)并建 release,再把包根目录打成 `-v.zip` (压缩包根目录就是包根目录)作为附件上传到这个 release。 安装时按「最新 release → 最新 `v*` tag → 默认分支」选版本,并锁定到对应的提交: - release 里有 `-.zip` 时下载这个附件,并按 GitHub 记录的 sha256 校验; - 否则按提交从 GitHub 下载仓库压缩包; - 一个 tag 都没有时,用户装的是默认分支当时的提交,界面会提示「作者未发布版本」。 清单里可以加几个只用于展示的字段,加载器会忽略它们:`author`、`homepage`、`icon`(仓库内相对路径,或 GitHub 上的 https 地址)、 `license`(GitHub 认不出 `LICENSE` 文件时用)、`keywords`。 社区栏标着「未审核」:用户安装前会看到这个版本声明的全部能力(后台进程、钩子、网关中间件、模型工具、路由、页面脚本), 安装后默认不启用。解包限制为 2000 个文件、10 MiB,拒绝软链和越界路径;清单的 `id` 必须与默认分支上的一致。 本机已有同 id 的拓展时需要用户确认替换,旧目录移到 `extension_backups/`。 ### 精选与官方拓展 管理器「拓展 → 精选」分栏来自 Codex++ 维护的精选清单(`BigPizzaV3/CodexPlusPlusScriptMarket` 的 `index.json`), 每一条都经过审核。清单里除了老的单文件脚本,还可以放拓展包: ```json { "scripts": [ { "kind": "package", "id": "word-count", "name": "字数统计", "description": "…", "author": "alice", "repo": "alice/codexpp-word-count", "tag": "v1.2.0", "sha": "<40 位提交 sha>", "official": false } ], "blocked": ["mallory/codexpp-miner"] } ``` - **锁定提交**:安装只按 `sha` 从 GitHub 下载这一个提交,不看作者后来发的 release 或 tag。作者更新后要重新审核、改清单里的 `sha`,用户那边才会看到「更新」。`tag` 只用于展示。拓展包条目也可以单独放在顶层的 `packages` 数组里。 - **官方**:条目写了 `"official": true`,**并且**仓库属于 Codex++ 官方账号(代码里的 `OFFICIAL_OWNERS`,目前是 `BigPizzaV3`)才显示「官方」徽章并排在最前;只满足其一的按精选显示。拓展自己的清单里写什么都不算数。 - **下架**:`blocked` 里的仓库在社区栏不显示、不能安装;本机已经装了的会在精选栏顶部提示停用(不会自动删除)。 - **与社区栏的关系**:进了精选清单的仓库不再出现在社区栏。社区栏里和精选 / 官方拓展同 id 的条目、以及非官方仓库自称「官方」 的条目会标「可疑」并写明原因;本机装着官方或精选版本时,社区里同 id 的拓展不能替换它。 - 想申请精选:向市场仓库提 PR 加一条 `kind: "package"` 条目,写明要审核的 tag 与对应 sha;也可以直接联系维护者。 - **维护者在管理器里审核**:本机 gh 账号对市场仓库有写权限时,社区卡片上有「审核」,精选卡片在作者发了新版本时有 「审核更新」。审核框下载并暂存仓库当前的最新版本(就是将要锁定的那个提交),列出文件、能力、密钥扫描,更新时附上与 当前锁定提交的差异;点「通过」后管理器用 gh 往 `index.json` 的 `packages` 段写一条(同仓库则替换),作为市场仓库的 一个提交,提交说明写明仓库与 sha。只改 `packages` 段和 `updated_at`,其余内容原样保留;疑似密钥或软链没处理前不能收录。 --- ## API 参考 ### `codexPlus.version` / `codexPlus.apiVersion` - `version`:适配层版本,字符串 - `apiVersion`:接口契约版本,数字,当前为 `1`。契约发生破坏性变化时会增加 ```js if (window.codexPlus.apiVersion < 1) { window.codexPlus.toast("请升级 Codex++ 以使用本拓展", { type: "warn" }); } ``` ### `codexPlus.script` `{ key: string, manifest }`,当前脚本的标识与清单(拓展包才有,单文件脚本为 `null`)。用于诊断与日志。是 getter,脚本初始化结束后再读也能拿到。 ### `codexPlus.toast(message, options?)` 右下角提示。 | 选项 | 说明 | | --- | --- | | `type` | `info` / `success` / `warn` / `error`,决定边框配色;`info` 与不传等价,用默认外观 | - 最多同时显示 **3 条**,超出时挤掉最旧的一条(先入先出),剩余几条会自动重排位置 - 单条默认 10 秒后自动消失 - 返回值:`{ ok: boolean, value?: () => void }`。`value` 是「立即关闭这一条」的函数 ### `codexPlus.call(route, payload?, options?)` 调用 Codex++ 后端,返回 Promise,**失败时 reject**。 ```js try { const result = await window.codexPlus.call("/diagnostics/log", { event: "hello" }); } catch (error) { console.error(error.message); // 可读的失败原因 } ``` | 选项 | 说明 | | --- | --- | | `timeout` | 毫秒,默认 26000 | 错误归一化说明:底层桥接有两种错误风格(路由层返回 `{status:"failed"}`,面板层返回 `{error}`),这里统一转成 Promise reject,所以你只需 `try/catch`。 **超时只放弃等待,不会取消服务端任务**——桥接协议没有取消通道。另外页面刷新或桥接重连会中断在途请求。 ### `codexPlus.registerRowAction(definition, options?)` 在会话行上加一项操作:默认进「更多操作」菜单,也可以画成行上常驻的图标按钮,或者同时出现在归档页的行上。 内置的「删除」(行内按钮)与「导出」(菜单项 + 归档页)就是这样注册的。 | 字段 | 必填 | 说明 | | --- | --- | --- | | `label` | 是 | 菜单项文字;行内按钮的可访问标签与悬停提示;归档页按钮的文字 | | `onActivate` | 二选一 | `(context) => void`,见下 | | `render` | 二选一 | `({ container, row, session_id, title, close }) => void \| cleanup`:自己往菜单里画一项(如 React 组件),菜单拆掉时调用返回的清理函数(只用于菜单项) | | `icon` | 否 | 单个字形或 SVG 字符串;不传用 `◇` | | `placement` | 否 | `"menu"`(默认)或 `"inline"`:画成行上常驻的图标按钮,套 Codex 原生「置顶 / 归档」按钮的外观 | | `surfaces` | 否 | 出现在哪:`["sidebar"]`(默认,侧栏会话行)、加上 `"archived"` 也画在归档页行的「取消归档」旁(文字按钮) | | `className` | 否 | 给行内按钮 / 归档页按钮加的类名(样式与识别用) | | `onMount` | 否 | `({ button, row, session_id }) => void`:行内按钮建好、套原生外观之前调用,可设属性。按钮会随行重建,不要在这里挂监听 | `onActivate` 收到 `{ row, session_id, title, archived, resolve, close, button?, event? }`:`row` 是该行的 DOM 元素, `archived` 表示是不是归档页的行;归档页的行未必读得到会话 id,`await resolve()` 会按标题问 Codex++ 找到 `{ session_id, title }`。 `close()` 关闭菜单。行内按钮被 React 摘掉时整组按指纹重建。 ### `codexPlus.currentSessionId()` 当前打开的 Codex 会话 id;首页、新对话还没有 id 时为空串。拓展实例上同名 `ctx.currentSessionId()`;斜杠命令的 `run(args, context)` 里也有 `context.sessionId`。 ### `codexPlus.currentSession()` 当前打开的会话 `{ id, title, hostId, remote }`:`hostId` 是会话所在主机(本机为 `"local"`),`remote` 为真时是远程 / SSH 会话 (本机会话日志里没有它的用量)。 ### `codexPlus.registerNavEntry(definition, options?)` 在 Codex 图标栏加一个入口。 | 字段 | 必填 | 说明 | | --- | --- | --- | | `label` | 是 | 可访问标签(也是 `aria-label`) | | `onActivate` | 是 | 点击时调用 | | `icon` | 否 | 单个字形或 SVG 字符串 | > 一般不用直接用它——`registerPage` 会自动配一个入口。手写 `registerNavEntry` 适合「入口只做一件事、不需要独立页面」的场景。 ### `codexPlus.registerPage(definition, options?)` 注册一个整页视图,**并自动配一个图标栏入口**。 | 字段 | 必填 | 说明 | | --- | --- | --- | | `title` | 是 | 页面标题 | | `render` | 是 | `({ container, close, script }) => void`,**每次打开都调用** | | `icon` | 否 | 入口图标 | | 选项 | 说明 | | --- | --- | | `navLabel` | 图标栏入口的标签,默认取 `title` | | `order` | 多个入口之间的排序 | 页面外壳复用内置页面的骨架,所以自动获得:缩放跟随、深浅色主题、图标栏选中态同步、原生选中态压制、窗口 resize 跟随。 > 为什么自动配入口:内置的三个页面(Codex++ / 拓展 / 推荐内容)都是「图标栏入口 + 整页」的形态。第三方沿用同一种形态,用户才不会去弹窗里找入口。 ### `codexPlus.registerMenuItem(definition, options?)` 在 Codex++ 菜单的「主页」面板里加一行。根据你提供的字段决定形态: **开关形态**: | 字段 | 必填 | 说明 | | --- | --- | --- | | `label` | 是 | 标题 | | `onChange` | 是 | `(next: boolean) => void` | | `description` | 否 | 副标题说明 | | `toggleValue` | 否 | `() => boolean`,提供当前值 | **按钮形态**: | 字段 | 必填 | 说明 | | --- | --- | --- | | `label` | 是 | 标题 | | `onActivate` | 是 | `({ close }) => void` | | `description` | 否 | 副标题说明 | | `buttonLabel` | 否 | 按钮文字,默认「打开」 | 行为说明: - 注册时如果菜单已经打开,会**立刻补上** - 菜单重新打开时,开关状态从 `toggleValue()` 重新读取(所以你应当把状态存在自己那边,而不是指望 Codex++ 替你保存) - Codex++ 没有把拓展的设置项写进后端配置——需要持久化就自己用 `localStorage` 或调后端 **注册选项**(`register*` 通用的第二个参数): | 选项 | 说明 | | --- | --- | | `id` | 自定义 id 片段;不传自动生成。同一类别内 id 不能重复 | | `order` | 排序;内置项永远排在拓展项之前,此值只影响拓展项之间的相对顺序 | | `scriptKey` | 显式指定归属脚本;默认取当前正在初始化的脚本,一般不用传 | ### `codexPlus.onCleanup(fn)` 注册清理函数。热重载、禁用、删除脚本时,按「脚本逆序 + 注册逆序」执行。 > **注意**:只有当**所有**已加载脚本都注册了清理函数时,热重载才走原地清理。任何一个脚本没注册(或清理抛错、返回 Promise),Codex++ 会回退到**整页刷新**。这是为了保证不残留旧实例——也意味着**你注册清理函数能让所有人的重载体验更好**。 ### `codexPlus.fail(error)` 主动上报失败。 脚本**初始化期间的同步错误**会被 Codex++ 自动捕获并显示在管理页;但 `await` 之后的**异步错误不会**——这类错误要用这个方法上报,否则脚本在管理页会一直显示正常,实际已经死了。 ```js window.codexPlus.call("/thread-usage-history", { session_id }) .catch((error) => window.codexPlus.fail(error)); ``` --- ## 后端调用与路由白名单 `codexPlus.call` 只允许调用白名单内的路由。未列出的路由**即使后端支持也会被拒绝**(这是刻意的:新增路由默认不开放,需要显式决定)。 | 路由 | 用途 | | --- | --- | | `/diagnostics/log` | 写一条诊断日志 | | `/thread-usage-history` | 读会话用量历史 | | `/archived-thread` | 读归档会话 | | `/export-markdown` | 导出 Markdown | | `/user-scripts/list` | 读拓展清单 | | `/cdp/targets` | 列出可注入的 Codex 页面(`main` / `avatar-overlay` / `quick-chat`) | | `/cdp/evaluate` | 在匹配的 Codex 页面执行 JS 表达式 | | `/system/cursor-position` | 系统鼠标位置 `{ x, y }`(与 `window.screenX` 同一坐标系,只读;Windows、macOS) | | `/agent-pet/catalog`、`/agent-pet/image`、`/agent-pet/sound`、`/agent-pet/import-sound` | Agent 桌宠的图库、图片、音效与导入自定义音效(拓展要在清单 `permissions.routes` 里声明,且启用中才干活) | | `/request-log/recent` | 最近已完成的模型请求 `{ entries }`,按开始时间倒序;`limit` 默认 20、最多 100。每条有 `id`、`model` / `upstreamModel`、`relayName`、`durationMs`、`success`、`statusCode`、`usage.inputTokens` / `outputTokens`、`timestampMs` 等(只读,与托盘「实时请求」同源) | | `/proxy-status` | 进行中的模型请求 `{ requests }`,每条有 `id`、`model`、`threadId`、`relayName`、`startedAtMs`、`phase`、`firstTokenMs`、`attempts`、`autoRetries`、`retryReasons`、`estimatedOutputTokens`(只读,与官方拓展包「请求状态条」同源) | | `/thread-usage-live` | 会话最近的 token 事件 `{ session_id, events }`,参数同 `/thread-usage-history`。只读 rollout 尾部,可以秒级轮询(只读,与官方拓展包「用量状态栏」同源) | | `/backend/status` | Codex++ 版本与连接状态 `{ status, message, version }`(只读) | | `/settings/public` | 设置的公开子集:`codexPlusVersion`、`codexAppVersion`、`enhancementsEnabled`、`relayProfilesEnabled`、`modelCatalogFingerprint`,以及 `activeProfile { id, name, relayMode, protocol, model, contextWindow, autoCompactLimit, aggregate }`。不含地址、Key 与 config / auth 内容(只读) | | `/model-catalog/public` | 当前供应商的模型目录:`models`、`modelMetadata`(每模型的 `contextWindow` 等)、`model`、`default_model`、`provider_name`、`model_provider`、`service_tier`。去掉了来源地址与本机路径(只读) | **写能力没有开放**:`/settings/set`、`/delete`、`/undo` 等都不在名单内。如果你确实需要某个路由,去 Codex++ 仓库提 issue 说明用途,不要在脚本里绕过白名单直接调底层桥接(见下方「限制」第 1 条)。 `/cdp/evaluate` 是显式开放的底层注入能力,用来把脚本打进宠物 overlay 这类独立窗口。它**不是**任意 CDP 协议:只能 `Runtime.evaluate`,只能打到 `app://-/index.html` 的 Codex 页面,返回值里也不带 WebSocket URL。调用时必须提供 `kind` 或 `target_id` 其一: ```js const { targets } = await window.codexPlus.call("/cdp/targets"); await window.codexPlus.call("/cdp/evaluate", { kind: "avatar-overlay", expression: "window.__myOverlayHook = true", }); ``` --- ## 类名与属性契约 `codexPlus.constants` 暴露稳定的类名与属性。**这些值一旦发布不再更名**。 | 键 | 值 | 说明 | | --- | --- | --- | | `toastClass` | `codex-delete-toast` | 提示元素 | | `pageClass` | `codex-plus-page-overlay` | 整页 overlay 根节点 | | `actionGroupClass` | `codex-session-actions` | 会话行按钮组 | | `moreMenuClass` | `codex-session-more-menu` | 会话行「更多操作」菜单 | | `railSelector` | `nav[data-app-navigation-rail]` | Codex 图标栏 | | `railDestinationSelector` | `[data-sidebar-destination]` | 图标栏原生按钮 | | `pageNavAttribute` | `data-codex-plus-page-nav` | 页面左导航项属性 | | `extensionAttribute` | `data-codex-plus-ext` | 拓展节点标记,见下方说明 | **关于 `extensionAttribute`**:你自己 `appendChild` 进 Codex 容器的节点,应当带上 `data-codex-plus-ext` 属性。Codex++ 的扫描调度靠它把你的写入排除在自激循环之外——不带的话,你的每次写入都可能触发一轮全量扫描,严重时会吃满一个 CPU 核(这是真实踩过的 bug,见 issue #1960)。 通过 `register*` 接口创建的节点,Codex++ 会自动加上这个属性,你不用管。只有你自己直接往页面插节点时才需要手动加。 --- ## 样式 配色走 CSS 变量,直接复用即可自动适配深浅色主题: ```css .my-ext-panel { color: var(--codex-plus-text); background: var(--codex-plus-bg-elevated); border: 1px solid var(--codex-plus-border); } ``` 自己注入 `