diff --git a/README.md b/README.md index a19997f..ae90280 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ - 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;桌面 GUI 控制默认先走 `scripts/codex-computer-use-runtime.mjs`,由 Codex App Server 发起 Codex Computer Use 执行;失败后自动回退 `scripts/cua-driver-computer-use-runtime.mjs`,通过外部 `cua-driver` 执行 `launch_app -> get_window_state -> 可选 type_text/press_key -> get_window_state` 闭环;`scripts/computer-use-smoke.mjs` 仍保留为旧兜底和回归资产 - 受控 Mac 需要先安装并授权 `cua-driver`;Boss runtime 会优先搜索 `PATH`,再搜索 `~/.local/bin/cua-driver`、`/usr/local/bin/cua-driver`、`/opt/homebrew/bin/cua-driver` 和 `/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果仍找不到,会明确返回 `CUA_DRIVER_COMMAND_NOT_FOUND`,不会伪装成执行成功 - 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json` -- 当前 `local-agent` 已新增 `Codex App Server` runner:boss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:` 或 `unix://PATH` 本机长驻 App Server;WebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer `,优先用 `codexAppServerAuthTokenFile` 保存本地 token。失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability。2026-05-31 起,runner 会吸收 App Server 的 plan / diff / item / subagent 事件并归一到 Boss `execution_progress` 进度卡,执行中通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;同日第二批已补 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 到 `approvals / warnings / fileChanges`,Android 原生进度卡可显示安全提醒、审批状态和文件变更摘要,且不展示完整命令、diff、系统提示词或密钥。本机 `codex-cli 0.135.0-alpha.1` 协议快照已生成在 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/`。同日新增第一版 Inter-Thread Broker:任务携带源/目标 Codex 线程时可通过 `thread/read -> thread/inject_items -> turn/start` 完成受控线程协作;服务端新增 `POST /api/v1/projects/[projectId]/thread-collaboration` 作为 APP/后台可调用入口;任务携带 `targetCodexTurnId` 时 runner 会改用 `turn/steer` 干预活跃 turn。 +- 当前 `local-agent` 已新增 `Codex App Server` runner:boss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:` 或 `unix://PATH` 本机长驻 App Server;WebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer `,优先用 `codexAppServerAuthTokenFile` 保存本地 token。失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability,并按 `codexAppServerDiscoveryTtlMs` 缓存 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read` 的能力摘要,供 APP/后台模型选择和治理页读取。2026-05-31 起,runner 会吸收 App Server 的 plan / diff / item / subagent 事件并归一到 Boss `execution_progress` 进度卡,执行中通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;同日第二批已补 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 到 `approvals / warnings / fileChanges`,Android 原生进度卡可显示安全提醒、审批状态和文件变更摘要,且不展示完整命令、diff、系统提示词或密钥。本机 `codex-cli 0.135.0-alpha.1` 协议快照已生成在 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/`。同日新增第一版 Inter-Thread Broker:任务携带源/目标 Codex 线程时可通过 `thread/read -> thread/inject_items -> turn/start` 完成受控线程协作;服务端新增 `POST /api/v1/projects/[projectId]/thread-collaboration` 作为 APP/后台可调用入口;任务携带 `targetCodexTurnId` 时 runner 会改用 `turn/steer` 干预活跃 turn。 - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 - `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json` 或 `SKILL.md` 的 sha256,失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json` diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index e98327c..834ebbd 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -153,7 +153,7 @@ - Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路 - 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败 - 当前量产方向已经明确为“Boss 企业控制面 + 可插拔执行协议”:多租户、权限、审批、审计、备份、回退和 Skill 治理由 Boss 承担,Codex App Server / Codex MCP / Codex CLI / Computer Use / 业务系统 API 都作为 provider 接入;详见 `docs/architecture/enterprise_ai_ops_architecture_cn.md` -- 当前 Codex App Server 已完成第一批和第二批接入:boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio,也可灰度连接 `ws://127.0.0.1:` 或 `unix://PATH` 同机长驻 App Server;长驻连接支持 `Authorization: Bearer `,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLI,turn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-05-31 已按本机 `codex-cli 0.135.0-alpha.1` 生成协议快照 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/`,并把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started`、`item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 归一到 Boss `execution_progress` 卡片;同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。 +- 当前 Codex App Server 已完成第一批和第二批接入:boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio,也可灰度连接 `ws://127.0.0.1:` 或 `unix://PATH` 同机长驻 App Server;长驻连接支持 `Authorization: Bearer `,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLI,turn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-05-31 已按本机 `codex-cli 0.135.0-alpha.1` 生成协议快照 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/`,并把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started`、`item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 归一到 Boss `execution_progress` 卡片;heartbeat 已能缓存 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read` 的能力摘要;同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。 - 当前 boss-agent 已支持 Mac OTA:`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。 - 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA;`MasterAgentTask` claim 会记录 attempt 和 lease,运行中任务可按租约重试,超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。 - 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 275c9ef..85d3582 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -117,7 +117,7 @@ - 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录 - 当前普通单线程 `conversation_reply` 在真正执行 `codex exec resume` 前,会先把 Boss 用户消息镜像进目标 Codex Desktop rollout;定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并按 `sourceMessageId` 去重 - 当前 Codex Desktop 同步新增常驻刷新桥:`scripts/codex-desktop-refresh-bridge-daemon.mjs` 通过 launchd 监听 `127.0.0.1:4318`,暴露 `POST /api/v1/codex-desktop/refresh`、`GET /api/v1/codex-desktop/events`、`GET /api/v1/codex-desktop/events/recent` 和 `GET /api/v1/codex-desktop/capabilities`;`local-agent` 会优先调用 refresh endpoint,失败时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。SSE 事件只包含线程引用、消息 ID、状态、deep link 等安全元数据,不包含用户正文或内部 prompt;`scripts/codex-desktop-event-consumer.mjs` 可作为 Desktop 插件/IPC 接入前的订阅 smoke;`scripts/codex-desktop-integration-probe.mjs` 负责只读探测 Codex.app 能力 -- 当前新增 Codex App Server runner:`local-agent/codex-app-server-runner.mjs`。boss-agent 默认配置 `codexAppServerEnabled=true`,会接管 `conversation_reply / dispatch_execution`;它默认通过 stdio 启动 `codex app-server`,也支持 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:` 或 `codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server,bearer token 可通过 `codexAppServerAuthTokenFile` 读取并在握手时发送 `Authorization: Bearer `。runner 执行 `initialize -> thread/resume|thread/start -> turn/start|turn/steer`,并把 `item/agentMessage/delta` 或 `item/completed` 归一成 Boss 任务回复;当 App Server 对单个 JSON-RPC 请求返回 `-32001 / retry later` 时,runner 会做最多 3 次指数退避重试。turn 启动前失败可回退 CLI,turn 启动后失败不回退,避免重复执行。2026-05-31 起,runner 会把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 归一成 `executionProgress.steps / branch / artifacts / agents`,并把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 归一成 `executionProgress.approvals / warnings / fileChanges`;服务端 complete 回写会与本地 Git/GitHub 进度合并。 +- 当前新增 Codex App Server runner:`local-agent/codex-app-server-runner.mjs`。boss-agent 默认配置 `codexAppServerEnabled=true`,会接管 `conversation_reply / dispatch_execution`;它默认通过 stdio 启动 `codex app-server`,也支持 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:` 或 `codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server,bearer token 可通过 `codexAppServerAuthTokenFile` 读取并在握手时发送 `Authorization: Bearer `。runner 执行 `initialize -> thread/resume|thread/start -> turn/start|turn/steer`,并把 `item/agentMessage/delta` 或 `item/completed` 归一成 Boss 任务回复;当 App Server 对单个 JSON-RPC 请求返回 `-32001 / retry later` 时,runner 会做最多 3 次指数退避重试。turn 启动前失败可回退 CLI,turn 启动后失败不回退,避免重复执行。2026-05-31 起,runner 会把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 归一成 `executionProgress.steps / branch / artifacts / agents`,并把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 归一成 `executionProgress.approvals / warnings / fileChanges`;服务端 complete 回写会与本地 Git/GitHub 进度合并。heartbeat 同时支持按 TTL 拉取 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read`,并把摘要保存在 `capabilities.codexAppServer.metadata`。 - 当前 Codex App Server runner 已新增第一版 Boss Inter-Thread Broker:任务携带 `intentCategory=thread_collaboration`、`sourceCodexThreadRef` 和 `targetCodexThreadRef` 时,会先 `thread/read` 源线程,再通过 `thread/inject_items` 向目标线程注入受控摘要,最后 `turn/start` 目标线程;服务端入口是 `POST /api/v1/projects/[projectId]/thread-collaboration`,负责权限、源/目标线程校验和任务排队。这不是假设官方线程 P2P,而是 Boss 自己做线程协作编排。 - 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper,并拉起本机安装器;安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config,并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。 - 当前 `local-agent` 还新增了两条统一电脑控制 runtime: @@ -128,7 +128,7 @@ - 相关配置项: - `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs` - `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs` - - `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli / codexAppServerTransport / codexAppServerUrl / codexAppServerAuthTokenFile` + - `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli / codexAppServerTransport / codexAppServerUrl / codexAppServerAuthTokenFile / codexAppServerDiscoveryEnabled / codexAppServerDiscoveryTtlMs / codexAppServerDiscoveryLimit` - `scripts/codex-app-server-protocol-snapshot.mjs`:生成本机 Codex App Server help、JSON Schema、TypeScript bindings、协议方法清单和 support matrix;当前快照目录为 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/` #### `POST /api/v1/master-agent/tasks/[taskId]/progress` diff --git a/docs/architecture/codex_server_progress_card_cn.md b/docs/architecture/codex_server_progress_card_cn.md index 08d06d3..6202dae 100644 --- a/docs/architecture/codex_server_progress_card_cn.md +++ b/docs/architecture/codex_server_progress_card_cn.md @@ -38,6 +38,7 @@ Boss 不能直接把 App Server 原始 Thread / Turn / Item 字段写进业务 - 本机协议快照已生成到 `docs/protocol-snapshots/codex-app-server/0.135.0-alpha.1/`,共识别 137 个协议方法;确认支持 `thread/inject_items`、`thread/rollback`、`thread/goal/*`、`turn/steer`、`command/exec`、`thread/realtime/*`、`model/list` - Boss 当前默认仍以 `stdio` 作为本机 agent 接入方式;`ws://127.0.0.1:` 和 `unix://PATH` 本地长驻 transport 已可灰度接入,WebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer `;非 loopback signed bearer/JWT、自动重连和健康探测仍保留为后续增强,不直接替换当前稳定链路 - 官方文档提示 WebSocket ingress 满载时会返回 JSON-RPC `-32001 / Server overloaded; retry later.`;Boss runner 已对该错误做最多 3 次指数退避重试,避免长驻连接瞬时拥塞直接把用户任务打失败 +- Boss heartbeat 已新增 App Server 能力发现缓存:按 `codexAppServerDiscoveryTtlMs` 拉取 `model/list`、`modelProvider/capabilities/read`、`skills/list`、`plugin/list`、`app/list`,归一成设备 `capabilities.codexAppServer.metadata`;发现失败只记录 warn,不阻塞心跳 - Boss 第一批只用 App Server 做任务级 provider,不直接复用 ChatGPT Mobile 到 Codex App 的官方 relay;官方移动控制链路仍属于 ChatGPT App 与 Codex App 同账号/工作区之间的产品能力,不是第三方 Boss 可以稳定依赖的私有通道 下一轮再核对版本时,不要只看 npm 包版本号;必须同时读取 App Server schema / TypeScript 定义,并把 protocol snapshot 保存到 `docs/protocol-snapshots/codex-app-server//`。 @@ -124,10 +125,11 @@ UI 参考: - 新增活跃 turn 干预:任务携带 `targetCodexTurnId` / `targetTurnId` 时,App Server runner 会调用 `turn/steer`,并把 `turnControl=steer`、`turnId` 写回执行结果;没有活跃 turn id 时仍使用 `turn/start` - `getCodexAppServerRunnerConfig` 已识别 `codexAppServerTransport` / `BOSS_CODEX_APP_SERVER_TRANSPORT`、`codexAppServerUrl` / `BOSS_CODEX_APP_SERVER_URL`、`codexAppServerAuthTokenFile` / `BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE`;`local-agent/codex-app-server-runner.mjs` 现已支持 `stdio`、`ws://127.0.0.1:` 与 `unix://PATH` 三种 JSON-RPC transport,默认仍是 stdio,ws/unix 适合作为同机长驻 App Server 灰度路径 - 新增 App Server 过载退避:单个 JSON-RPC 请求收到 `-32001` 或 `retry later` 文案时,会在同一个任务生命周期内重试,超出上限后才进入失败/CLI fallback 判定 +- 新增 App Server capability discovery:`local-agent` 会把可用模型、默认/快速/深度模型建议、provider 能力、Skill、Plugin、App 摘要写入设备 heartbeat;Web 设备详情已显示 App Server、模型和扩展数量,为后续 APP/后台模型配置页提供真实数据来源 后续建议按两步继续: -1. 把当前 runner 提升为完整 `CodexAppServerBackendAdapter`:继续补 Approval / file change patch / skill / plugin / app / MCP tool / realtime 事件映射,但保持 feature flag 默认关闭。 +1. 把当前 runner 提升为完整 `CodexAppServerBackendAdapter`:继续补 MCP tool / realtime / account / rate-limit / config 事件映射,但保持 feature flag 默认关闭。 2. 完善长驻 transport 灰度:`ws://127.0.0.1:`、`unix://PATH` 和 bearer token handshake 已可用,下一步补 signed bearer JWT 的 issuer / audience 校验联调、断线重连和健康探测;失败自动回退 stdio。 3. 新增 `CodexMcpBackendAdapter`:让 `codex mcp-server` 成为 `ExecutionBackend` 的兼容实现,用于 App Server 不可用或只需要轻量会话时。 4. 每次 Codex 协议升级时生成 schema、跑映射测试、灰度打开新 capability,避免把某个 Codex 版本写死到 APP 或后台。 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 11046b4..9b42623 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -250,6 +250,7 @@ cd /Users/kris/code/boss - 当前已新增 Boss 自有 Inter-Thread Broker 第一版:服务端入口 `POST /api/v1/projects/[projectId]/thread-collaboration` 会创建带源/目标 Codex 线程引用的协作任务;App Server runner 执行 `thread/read(source) -> thread/inject_items(target) -> turn/start(target)`,用于让一个线程的结论受控进入另一个线程,不依赖官方任意线程 P2P 互聊能力 - 当前 `local-agent` 对 `dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody` - 当前 `local-agent` 会在 Codex 任务执行中和完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failed,Android 原生聊天页会显示“进度 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。2026-05-31 起,Codex App Server 的 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 会直接映射为进度步骤、变更统计、生成产物和后台智能体;第二批已把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 映射为审批、安全提醒和文件变更摘要,并通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;complete 回写仍会携带最终进度兜底 +- 当前 `local-agent` heartbeat 已新增 Codex App Server capability discovery:按 TTL 拉取模型、provider 能力、Skill、Plugin、App 摘要,写入 `capabilities.codexAppServer.metadata`;Web 设备详情会展示 App Server 连接状态、模型数量、默认/快速/深度模型和扩展数量 - 当前 `MasterAgentTask` 已具备服务端租约和取消基础状态机:claim 会写入 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期后可被重新认领,超过重试上限会转 `timed_out`;`POST /api/v1/master-agent/tasks/[taskId]/cancel` 会把任务转 `canceled`,迟到的成功 complete 不会覆盖终态 - 当前 `local-agent` 对 `browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand` 或 `computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed,返回明确的 runtime disabled 错误,不再假装执行成功 - 远程电脑控制链路当前已有可复用压测基线:`npm run stress:remote-control` 可按参数压测 `local-agent -> MasterAgentTask -> browser_control / desktop_control runtime -> complete 回写` 全链路;`npm run stress:remote-control:ci` 固定 120 条链路任务和 360 条 runtime 并发任务,并用 p95 延迟预算判断是否退化。压测报告可通过 `--report-json=PATH` 落盘,便于后续接入真实 macOS AX / Windows UIA helper 后复用同一套稳定性判断。 diff --git a/local-agent/codex-app-server-runner.mjs b/local-agent/codex-app-server-runner.mjs index 15eff26..ce298b4 100644 --- a/local-agent/codex-app-server-runner.mjs +++ b/local-agent/codex-app-server-runner.mjs @@ -58,6 +58,25 @@ function normalizeTimeoutMs(value) { return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 120_000; } +function normalizePositiveInteger(value, fallback) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback; +} + +function boolFromConfigOrEnv(configValue, envValue, fallback) { + if (configValue === true || configValue === false) { + return configValue; + } + const normalized = trimToDefined(envValue)?.toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "yes") { + return true; + } + if (normalized === "0" || normalized === "false" || normalized === "no") { + return false; + } + return fallback; +} + function normalizeTransport(value) { const normalized = trimToDefined(value)?.toLowerCase(); return normalized === "ws" || normalized === "websocket" @@ -117,6 +136,19 @@ export function getCodexAppServerRunnerConfig(env = process.env, config = {}) { authTokenFile: trimToDefined(config.codexAppServerAuthTokenFile) || trimToDefined(env.BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE), + discoveryEnabled: boolFromConfigOrEnv( + config.codexAppServerDiscoveryEnabled, + env.BOSS_CODEX_APP_SERVER_DISCOVERY_ENABLED, + true, + ), + discoveryTtlMs: normalizePositiveInteger( + config.codexAppServerDiscoveryTtlMs ?? env.BOSS_CODEX_APP_SERVER_DISCOVERY_TTL_MS, + 300_000, + ), + discoveryLimit: normalizePositiveInteger( + config.codexAppServerDiscoveryLimit ?? env.BOSS_CODEX_APP_SERVER_DISCOVERY_LIMIT, + 20, + ), }; } @@ -727,6 +759,226 @@ function buildServerRequestFallbackResponse(message) { }; } +function normalizeDiscoveryModel(model) { + const id = trimToDefined(model?.id) || trimToDefined(model?.model); + if (!id) { + return null; + } + return { + id, + model: trimToDefined(model?.model) || id, + displayName: trimToDefined(model?.displayName) || id, + description: safeProgressText(model?.description, 160), + hidden: Boolean(model?.hidden), + isDefault: Boolean(model?.isDefault), + supportsPersonality: Boolean(model?.supportsPersonality), + supportedReasoningEfforts: asArray(model?.supportedReasoningEfforts).map(String).slice(0, 8), + inputModalities: asArray(model?.inputModalities).map(String).slice(0, 8), + }; +} + +function pickFastModelId(models) { + const fast = models.find((model) => + /mini|fast|flash|lite|haiku/i.test(`${model.id} ${model.displayName} ${model.description}`), + ); + return fast?.id || models[0]?.id || ""; +} + +function normalizeDiscoverySkills(result) { + return asArray(result?.data) + .flatMap((entry) => asArray(entry?.skills)) + .map((skill) => { + const name = trimToDefined(skill?.name); + if (!name) return null; + return { + name, + description: safeProgressText(skill?.description || skill?.shortDescription, 180), + scope: trimToDefined(skill?.scope), + enabled: skill?.enabled !== false, + }; + }) + .filter(Boolean); +} + +function normalizeDiscoveryPlugins(result) { + return asArray(result?.marketplaces) + .flatMap((marketplace) => asArray(marketplace?.plugins)) + .map((plugin) => { + const id = trimToDefined(plugin?.id) || trimToDefined(plugin?.name); + if (!id) return null; + return { + id, + name: trimToDefined(plugin?.name) || id, + installed: Boolean(plugin?.installed), + enabled: plugin?.enabled !== false, + localVersion: trimToDefined(plugin?.localVersion), + }; + }) + .filter(Boolean); +} + +function normalizeDiscoveryApps(result) { + return asArray(result?.data) + .map((app) => { + const id = trimToDefined(app?.id) || trimToDefined(app?.name); + if (!id) return null; + return { + id, + name: trimToDefined(app?.name) || id, + description: safeProgressText(app?.description, 160), + isAccessible: app?.isAccessible !== false, + isEnabled: app?.isEnabled !== false, + pluginDisplayNames: asArray(app?.pluginDisplayNames).map(String).slice(0, 8), + }; + }) + .filter(Boolean); +} + +async function withCodexAppServerRpcSession(runnerConfig, callback) { + const cwd = runnerConfig.cwd || process.cwd(); + let closed = false; + let rpcTransport; + let nextId = 1; + const pending = new Map(); + const timeout = setTimeout(() => { + for (const { reject } of pending.values()) { + reject(new Error("CODEX_APP_SERVER_DISCOVERY_TIMEOUT")); + } + pending.clear(); + rpcTransport?.close?.("SIGKILL"); + }, Math.min(runnerConfig.timeoutMs, 10_000)); + + const request = (method, params = {}) => + new Promise((resolveRequest, rejectRequest) => { + if (closed) { + rejectRequest(new Error("CODEX_APP_SERVER_CLOSED")); + return; + } + const id = nextId++; + pending.set(id, { resolve: resolveRequest, reject: rejectRequest }); + rpcTransport.send(JSON.stringify({ method, id, params }), (error) => { + if (error) { + pending.delete(id); + rejectRequest(error); + } + }); + }); + + const notify = (method, params = {}) => { + if (!closed) { + rpcTransport.send(JSON.stringify({ method, params })); + } + }; + + try { + rpcTransport = await openCodexAppServerTransport(runnerConfig, cwd, { + onLine(line) { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (!Object.hasOwn(message, "id")) return; + const pendingRequest = pending.get(message.id); + if (!pendingRequest) return; + pending.delete(message.id); + if (message.error) { + pendingRequest.reject(new Error(message.error.message || JSON.stringify(message.error))); + } else { + pendingRequest.resolve(message.result ?? {}); + } + }, + onError(error) { + closed = true; + for (const { reject } of pending.values()) { + reject(error); + } + pending.clear(); + }, + onClose({ code, message }) { + closed = true; + const error = new Error(message || `CODEX_APP_SERVER_EXITED:${code ?? "unknown"}`); + for (const { reject } of pending.values()) { + reject(error); + } + pending.clear(); + }, + }); + + await request("initialize", { + clientInfo: { + name: runnerConfig.clientName, + title: runnerConfig.clientTitle, + version: runnerConfig.clientVersion, + }, + }); + notify("initialized", {}); + return await callback(request); + } finally { + clearTimeout(timeout); + rpcTransport?.close?.("SIGTERM"); + } +} + +export async function discoverCodexAppServerCapabilities(runnerConfig) { + if (!runnerConfig?.enabled || runnerConfig.discoveryEnabled === false) { + return undefined; + } + const safeRequest = async (request, method, params = {}) => { + try { + return await request(method, params); + } catch (error) { + return { __bossError: error instanceof Error ? error.message : String(error) }; + } + }; + + return withCodexAppServerRpcSession(runnerConfig, async (request) => { + const limit = runnerConfig.discoveryLimit ?? 20; + const [modelResult, providerCapabilities, skillsResult, pluginResult, appsResult] = await Promise.all([ + safeRequest(request, "model/list", { includeHidden: false, limit }), + safeRequest(request, "modelProvider/capabilities/read", {}), + safeRequest(request, "skills/list", { cwds: [runnerConfig.cwd || process.cwd()], forceReload: false }), + safeRequest(request, "plugin/list", { cwds: [runnerConfig.cwd || process.cwd()] }), + safeRequest(request, "app/list", { limit }), + ]); + + const models = asArray(modelResult?.data) + .map(normalizeDiscoveryModel) + .filter(Boolean) + .slice(0, limit); + const defaultModelId = models.find((model) => model.isDefault)?.id || models[0]?.id || ""; + const fastModelId = pickFastModelId(models); + const deepModelId = models.find((model) => model.id !== fastModelId)?.id || defaultModelId; + return { + version: trimToDefined(runnerConfig.version), + discoveredAt: new Date().toISOString(), + models, + defaultModelId, + fastModelId, + deepModelId, + providerCapabilities: { + namespaceTools: Boolean(providerCapabilities?.namespaceTools), + imageGeneration: Boolean(providerCapabilities?.imageGeneration), + webSearch: Boolean(providerCapabilities?.webSearch), + }, + skills: normalizeDiscoverySkills(skillsResult).slice(0, limit), + plugins: normalizeDiscoveryPlugins(pluginResult).slice(0, limit), + apps: normalizeDiscoveryApps(appsResult).slice(0, limit), + errors: [ + modelResult?.__bossError ? `model/list:${modelResult.__bossError}` : undefined, + providerCapabilities?.__bossError + ? `modelProvider/capabilities/read:${providerCapabilities.__bossError}` + : undefined, + skillsResult?.__bossError ? `skills/list:${skillsResult.__bossError}` : undefined, + pluginResult?.__bossError ? `plugin/list:${pluginResult.__bossError}` : undefined, + appsResult?.__bossError ? `app/list:${appsResult.__bossError}` : undefined, + ].filter(Boolean), + }; + }); +} + function createProgressCollector() { const steps = []; const artifacts = []; diff --git a/local-agent/config.cloud.json b/local-agent/config.cloud.json index a2fb725..cbb2373 100644 --- a/local-agent/config.cloud.json +++ b/local-agent/config.cloud.json @@ -30,6 +30,9 @@ ], "codexAppServerWorkdir": "/Users/kris/code/boss", "codexAppServerTimeoutMs": 120000, + "codexAppServerDiscoveryEnabled": true, + "codexAppServerDiscoveryTtlMs": 300000, + "codexAppServerDiscoveryLimit": 20, "codexAppServerFallbackToCli": true, "codexComputerUseEnabled": true, "codexComputerUseCommand": "node", diff --git a/local-agent/config.example.json b/local-agent/config.example.json index f8a7d74..1ee8355 100644 --- a/local-agent/config.example.json +++ b/local-agent/config.example.json @@ -32,6 +32,9 @@ ], "codexAppServerWorkdir": "/Users/kris/code/boss", "codexAppServerTimeoutMs": 120000, + "codexAppServerDiscoveryEnabled": true, + "codexAppServerDiscoveryTtlMs": 300000, + "codexAppServerDiscoveryLimit": 20, "codexAppServerFallbackToCli": true, "codexComputerUseEnabled": true, "codexComputerUseCommand": "node", diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 31d9683..73ea872 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -8,6 +8,7 @@ import { delimiter, isAbsolute, join, resolve } from "node:path"; import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs"; import { prepareCodexTaskExecution } from "./codex-task-runner.mjs"; import { + discoverCodexAppServerCapabilities, executeCodexAppServerTask, getCodexAppServerRunnerConfig, shouldUseCodexAppServerTaskRunner, @@ -132,6 +133,12 @@ async function postHeartbeat(config, runtime, heartbeatProjects) { const codexAppServerRuntime = getCodexAppServerRunnerConfig(process.env, config); const computerUseConnected = await resolveComputerUseCapabilityConnected(config, computerUseRuntime); const codexAppServerConnected = await resolveCodexAppServerCapabilityConnected(codexAppServerRuntime); + const codexAppServerMetadata = await resolveCodexAppServerCapabilityMetadata( + config, + runtime, + codexAppServerRuntime, + codexAppServerConnected, + ); const guiConnected = config.guiConnected === true || (config.guiConnected !== false && heartbeatProjects.guiConnected === true); @@ -173,6 +180,7 @@ async function postHeartbeat(config, runtime, heartbeatProjects) { connected: codexAppServerConnected, lastSeenAt: now, lastActiveProjectId: "", + metadata: codexAppServerMetadata, }, }, preferredExecutionMode, @@ -261,6 +269,40 @@ async function resolveCodexAppServerCapabilityConnected(codexAppServerRuntime) { return canExecuteCommand(codexAppServerRuntime.command, codexAppServerRuntime.cwd || process.cwd()); } +async function resolveCodexAppServerCapabilityMetadata(config, runtime, codexAppServerRuntime, connected) { + if (!connected || !codexAppServerRuntime?.enabled || codexAppServerRuntime.discoveryEnabled === false) { + return undefined; + } + const now = Date.now(); + const ttlMs = codexAppServerRuntime.discoveryTtlMs ?? 300_000; + if ( + runtime.codexAppServerCapabilityMetadata && + runtime.codexAppServerCapabilityMetadataAtMs && + now - runtime.codexAppServerCapabilityMetadataAtMs < ttlMs + ) { + return runtime.codexAppServerCapabilityMetadata; + } + + try { + const metadata = await discoverCodexAppServerCapabilities(codexAppServerRuntime); + runtime.codexAppServerCapabilityMetadata = metadata; + runtime.codexAppServerCapabilityMetadataAtMs = now; + runtime.codexAppServerCapabilityMetadataError = ""; + return metadata; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + runtime.codexAppServerCapabilityMetadataError = message; + await postAppLog(config, runtime, { + level: "warn", + category: "local_agent.codex_app_server_capability_discovery_failed", + message: "Codex App Server 能力清单发现失败,设备心跳继续上报连接状态。", + detail: message, + mirrorToMaster: false, + }); + return runtime.codexAppServerCapabilityMetadata; + } +} + function deviceTokenHeaders(config, runtime) { const token = runtime.issuedToken ?? config.token; return token ? { "x-boss-device-token": token } : {}; diff --git a/src/app/api/device-heartbeat/route.ts b/src/app/api/device-heartbeat/route.ts index 76418c5..5415a32 100644 --- a/src/app/api/device-heartbeat/route.ts +++ b/src/app/api/device-heartbeat/route.ts @@ -86,6 +86,7 @@ export async function POST(request: NextRequest) { connected?: boolean; lastSeenAt?: string; lastActiveProjectId?: string; + metadata?: Record; }; }; preferredExecutionMode?: "gui" | "cli"; diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 2f7a2f6..96ff482 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -67,9 +67,22 @@ function boundDeviceIdFromDom() { return document.body.dataset.boundDeviceId || "mac-studio"; } +function arrayLength(value: unknown) { + return Array.isArray(value) ? value.length : 0; +} + +function textFromMetadata(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : "未发现"; +} + export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) { const selectedDevice = workspace.selectedDevice; const primaryPolicy = workspace.projectExecutionPolicies?.[0]; + const codexAppServerMetadata = selectedDevice?.capabilities?.codexAppServer?.metadata ?? {}; + const codexModelsCount = arrayLength(codexAppServerMetadata.models); + const codexSkillCount = arrayLength(codexAppServerMetadata.skills); + const codexPluginCount = arrayLength(codexAppServerMetadata.plugins); + const codexAppCount = arrayLength(codexAppServerMetadata.apps); return { capabilities: { @@ -83,6 +96,18 @@ export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) computerUse: `桌面控制:${ selectedDevice?.capabilities?.computerUse?.connected ? "已连接" : "未连接" }`, + codexAppServer: `Codex App Server:${ + selectedDevice?.capabilities?.codexAppServer?.connected ? "已连接" : "未连接" + }`, + codexModels: + codexModelsCount > 0 + ? `模型:${codexModelsCount} 个 · 默认 ${textFromMetadata( + codexAppServerMetadata.defaultModelId, + )} · 快速 ${textFromMetadata(codexAppServerMetadata.fastModelId)} · 深度 ${textFromMetadata( + codexAppServerMetadata.deepModelId, + )}` + : "模型:未发现", + codexExtensions: `扩展:Skill ${codexSkillCount} 个 · Plugin ${codexPluginCount} 个 · App ${codexAppCount} 个`, preferredExecutionMode: `默认执行模式:${ selectedDevice?.preferredExecutionMode === "gui" ? "GUI" @@ -705,6 +730,15 @@ export function DeviceEditorCard({
{detailCards.capabilities.items.computerUse}
+
+ {detailCards.capabilities.items.codexAppServer} +
+
+ {detailCards.capabilities.items.codexModels} +
+
+ {detailCards.capabilities.items.codexExtensions} +
{detailCards.capabilities.items.preferredExecutionMode}
diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index dd18310..c358ccd 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -610,6 +610,7 @@ export interface DeviceCapabilityState { connected: boolean; lastSeenAt?: string; lastActiveProjectId?: string; + metadata?: Record; } export interface DeviceCapabilities { @@ -2297,6 +2298,7 @@ function normalizeDeviceCapabilityState( connected: Boolean(raw?.connected), lastSeenAt: trimToDefined(raw?.lastSeenAt) ?? fallbackLastSeenAt, lastActiveProjectId: trimToDefined(raw?.lastActiveProjectId) ?? "", + metadata: raw?.metadata && typeof raw.metadata === "object" ? JSON.parse(JSON.stringify(raw.metadata)) : undefined, }; } diff --git a/tests/device-detail-capabilities-route.test.ts b/tests/device-detail-capabilities-route.test.ts index e62b4d4..34a0e32 100644 --- a/tests/device-detail-capabilities-route.test.ts +++ b/tests/device-detail-capabilities-route.test.ts @@ -50,6 +50,42 @@ test("device detail exposes gui cli capability state and preferred execution mod assert.equal(cards.capabilities.items.preferredExecutionMode, "默认执行模式:CLI"); }); +test("device detail exposes Codex App Server discovered model and extension summary", async () => { + await setup(); + + const state = await readState(); + const device = state.devices.find((item) => item.id === "mac-studio"); + assert.ok(device); + device!.capabilities = { + ...(device!.capabilities ?? {}), + codexAppServer: { + connected: true, + lastSeenAt: "2026-05-31T10:00:00.000Z", + lastActiveProjectId: "", + metadata: { + models: [ + { id: "gpt-5.4", displayName: "GPT-5.4" }, + { id: "gpt-5.4-mini", displayName: "GPT-5.4 mini" }, + ], + defaultModelId: "gpt-5.4", + fastModelId: "gpt-5.4-mini", + deepModelId: "gpt-5.4", + skills: [{ name: "image2-ui-prototype" }], + plugins: [{ id: "github" }], + apps: [{ id: "canva" }], + }, + }, + }; + await writeState(state); + + const workspace = getDeviceWorkspaceView(await readState(), "mac-studio"); + const cards = buildDeviceWorkspaceDetailCards(workspace); + + assert.equal(cards.capabilities.items.codexAppServer, "Codex App Server:已连接"); + assert.equal(cards.capabilities.items.codexModels, "模型:2 个 · 默认 gpt-5.4 · 快速 gpt-5.4-mini · 深度 gpt-5.4"); + assert.equal(cards.capabilities.items.codexExtensions, "扩展:Skill 1 个 · Plugin 1 个 · App 1 个"); +}); + test("device detail exposes folder and project conflict skeleton from workspace policy", async () => { await setup(); diff --git a/tests/device-heartbeat-capability-metadata.test.ts b/tests/device-heartbeat-capability-metadata.test.ts new file mode 100644 index 0000000..92116e9 --- /dev/null +++ b/tests/device-heartbeat-capability-metadata.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"]; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-capability-metadata-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, heartbeatModule] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/app/api/device-heartbeat/route.ts"), + ]); + readState = data.readState; + deviceHeartbeatRoute = heartbeatModule.POST; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("device heartbeat preserves Codex App Server capability metadata", async () => { + await setup(); + const state = await readState(); + const device = state.devices.find((item) => item.id === "mac-studio"); + assert.ok(device, "expected seeded mac-studio device"); + + const response = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + deviceId: device!.id, + token: device!.token, + name: device!.name, + avatar: device!.avatar, + account: device!.account, + status: "online", + quota5h: device!.quota5h, + quota7d: device!.quota7d, + projects: device!.projects, + endpoint: device!.endpoint, + capabilities: { + codexAppServer: { + connected: true, + lastSeenAt: "2026-05-31T10:00:00.000Z", + metadata: { + models: [{ id: "gpt-5.4", displayName: "GPT-5.4" }], + defaultModelId: "gpt-5.4", + fastModelId: "gpt-5.4-mini", + providerCapabilities: { webSearch: true }, + }, + }, + }, + }), + }), + ); + + assert.equal(response.status, 200); + const nextState = await readState(); + const updatedDevice = nextState.devices.find((item) => item.id === device!.id); + assert.equal(updatedDevice?.capabilities?.codexAppServer.metadata?.models?.[0]?.id, "gpt-5.4"); + assert.equal(updatedDevice?.capabilities?.codexAppServer.metadata?.providerCapabilities?.webSearch, true); +}); diff --git a/tests/fixtures/codex-app-server-runtime.mjs b/tests/fixtures/codex-app-server-runtime.mjs index 17a9140..9ca2e86 100644 --- a/tests/fixtures/codex-app-server-runtime.mjs +++ b/tests/fixtures/codex-app-server-runtime.mjs @@ -31,6 +31,131 @@ rl.on("line", (line) => { return; } + if (message.method === "model/list") { + send({ + id: message.id, + result: { + data: [ + { + id: "gpt-5.4", + model: "gpt-5.4", + displayName: "GPT-5.4", + description: "Deep reasoning model", + hidden: false, + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + inputModalities: ["text", "image"], + supportsPersonality: true, + serviceTiers: [{ id: "default", displayName: "Default" }], + defaultServiceTier: "default", + isDefault: true, + }, + { + id: "gpt-5.4-mini", + model: "gpt-5.4-mini", + displayName: "GPT-5.4 mini", + description: "Fast response model", + hidden: false, + supportedReasoningEfforts: ["none", "low"], + defaultReasoningEffort: "none", + inputModalities: ["text"], + supportsPersonality: true, + serviceTiers: [], + defaultServiceTier: null, + isDefault: false, + }, + ], + nextCursor: null, + }, + }); + return; + } + + if (message.method === "modelProvider/capabilities/read") { + send({ + id: message.id, + result: { + namespaceTools: true, + imageGeneration: true, + webSearch: true, + }, + }); + return; + } + + if (message.method === "skills/list") { + send({ + id: message.id, + result: { + data: [ + { + cwd: "/Users/kris/code/boss", + skills: [ + { + name: "image2-ui-prototype", + description: "Generate high fidelity UI prototypes", + path: "/Users/kris/.codex/skills/image2-ui-prototype/SKILL.md", + scope: "user", + enabled: true, + }, + ], + errors: [], + }, + ], + }, + }); + return; + } + + if (message.method === "plugin/list") { + send({ + id: message.id, + result: { + marketplaces: [ + { + name: "local", + path: "/Users/kris/.codex/plugins/marketplace.json", + interface: null, + plugins: [ + { + id: "github", + remotePluginId: null, + localVersion: "1.0.0", + name: "GitHub", + installed: true, + enabled: true, + keywords: ["repo"], + }, + ], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: ["github"], + }, + }); + return; + } + + if (message.method === "app/list") { + send({ + id: message.id, + result: { + data: [ + { + id: "canva", + name: "Canva", + description: "Design app", + isAccessible: true, + isEnabled: true, + pluginDisplayNames: ["Canva"], + }, + ], + nextCursor: null, + }, + }); + return; + } + if (message.method === "thread/resume") { send({ id: message.id, diff --git a/tests/local-agent-heartbeat-capabilities.test.mjs b/tests/local-agent-heartbeat-capabilities.test.mjs index 5a22bef..417cc9b 100644 --- a/tests/local-agent-heartbeat-capabilities.test.mjs +++ b/tests/local-agent-heartbeat-capabilities.test.mjs @@ -189,3 +189,85 @@ test("local-agent heartbeat reports Codex App Server capability only when enable await rm(runtimeRoot, { recursive: true, force: true }); } }); + +test("local-agent heartbeat reports Codex App Server discovered models, skills, plugins, and apps", async () => { + const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-discovery-")); + const skillsDir = path.join(runtimeRoot, "skills"); + await mkdir(skillsDir, { recursive: true }); + + const mockControlPlane = await startMockControlPlane(); + const exampleConfig = JSON.parse( + await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"), + ); + const configPath = path.join(runtimeRoot, "config.json"); + await writeFile( + configPath, + JSON.stringify( + { + ...exampleConfig, + bindHost: "127.0.0.1", + port: 0, + controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`, + heartbeatIntervalMs: 60_000, + masterAgentPollIntervalMs: 60_000, + masterAgentEnabled: false, + codexSessionDiscoveryEnabled: false, + codexAppServerEnabled: true, + codexAppServerCommand: process.execPath, + codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"], + codexAppServerWorkdir: repoRoot, + codexAppServerTimeoutMs: 5000, + codexAppServerDiscoveryTtlMs: 60_000, + projects: [], + projectCandidates: [], + skillsDir, + }, + null, + 2, + ), + "utf8", + ); + + const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + try { + const heartbeatRequest = await Promise.race([ + mockControlPlane.heartbeatReceived, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`timed out waiting for heartbeat\n${stderr}`)); + }, 8000); + }), + ]); + + const payload = JSON.parse(heartbeatRequest.bodyText); + const metadata = payload.capabilities.codexAppServer.metadata; + + assert.equal(payload.capabilities.codexAppServer.connected, true); + assert.equal(metadata.models[0].id, "gpt-5.4"); + assert.equal(metadata.models[0].displayName, "GPT-5.4"); + assert.equal(metadata.defaultModelId, "gpt-5.4"); + assert.equal(metadata.fastModelId, "gpt-5.4-mini"); + assert.equal(metadata.providerCapabilities.webSearch, true); + assert.equal(metadata.skills[0].name, "image2-ui-prototype"); + assert.equal(metadata.plugins[0].id, "github"); + assert.equal(metadata.apps[0].id, "canva"); + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("close", resolve); + }).catch(() => null); + await new Promise((resolve) => { + mockControlPlane.server.close(resolve); + }); + await rm(runtimeRoot, { recursive: true, force: true }); + } +});