feat: add host commander skeleton

This commit is contained in:
Codex
2026-05-21 12:28:13 +00:00
parent 9f166d0580
commit 67f6d0e820
16 changed files with 1187 additions and 277 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md``docs/reference/microservices.md`
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod`met-nonlinear``k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`
- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR 创建/评论 dry-run 与 runner PR preflight`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务第一阶段 source/contract、bridge/state/trace/审批边界和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不启动守护进程、不打开 SSH/PTY/stdio、不发送消息,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2ecatalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md``run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`Tekton 规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`
- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]` / `codex pr-preflight [--remote]`:前者通过 backend-core 私有代理提交 Code Queue 任务,`--dry-run` 会给出 MiniMax/GPT/人工路由建议但不改写 payload;后者只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
@@ -87,7 +87,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `docs/reference/cicd-standardization.md``CI.json` catalog、CI producer summary、blocked/upstream-image 服务、File Browser 上游镜像例外、legacy CI/CD 路径分类和 CD consumer 分工。
- `docs/reference/release-governance.md``release/v1` 稳定维护线、`master` 集成线、CI/CD server 版本固定、master CLI 兼容和 feature flag 治理规则;决策记录见 GitHub issue #6
- `docs/reference/artifact-registry.md`D601 host-managed CNCF Distribution registry、loopback-only 边界和 backend-core artifact CD 目标流程。
- `docs/reference/host-codex-commander.md`host Codex 指挥官直管微服务第一阶段 source/contract、CLI dry-run、状态模型、SSH/PTY/stdio bridge、#20/#46 入口和 ClaudeQQ 高风险审批边界。
- `docs/reference/host-codex-commander.md`host Codex 指挥官 skeleton 的 source/contract、CLI dry-run、状态模型、SSH/PTY/stdio bridge 预留边界#20/#46 入口和 ClaudeQQ 高风险审批边界。
- `docs/reference/user-service-delivery.md`:用户服务默认交付流程、CI 镜像构建与 registry、Baidu Netdisk 主 server 直管微服务样板、dev 自动测试、prod 拉镜像部署和 Decision Center 产品化需求管理规则。
- `docs/reference/dev-environment.md`D601 `unidesk-dev` persistent dev 环境、18083 dev frontend proxy、`deploy apply --env dev` 服务范围和 Rust backend-core 只在 D601 编译的边界。
- `docs/reference/ci.md`D601 k3s Tekton CI、只读主数据库性能门禁和 CLI 入口规则。
+1 -1
View File
@@ -143,6 +143,6 @@
阅读 `AGENTS.md``docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <title> --body-file <file> [--label label[,label...]]...``gh issue scan-escape``gh issue cleanup-plan``gh issue board-row list``gh issue board-row update`notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 结构化 unsupported。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run``bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>``bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run``bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。
## T28 Host Codex Commander Contract
## T28 Host Codex Commander Skeleton Contract
阅读 `AGENTS.md``docs/reference/host-codex-commander.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/host-codex-commander-contract-test.ts`,确认输出 `ok=true`;运行 `bun scripts/cli.ts commander contract`,确认返回 `phase=source-contract``serviceId=host-codex-commander``daemonImplemented=false``liveOperationsImplemented=false`,且 required capabilities 包含 host Codex 进程发现/启动计划、SSH/PTY/stdio bridge、prompt guidance、trace summary、#20/#46 入口和 ClaudeQQ 高风险审批入口;运行 `bun scripts/cli.ts commander plan --dry-run --session-id primary`,确认所有 top-level plan 均为 `mutation=false`start plan `enabled=false`,不会打开 SSH/PTY/stdio、不会注入 prompt、不会发送 ClaudeQQ;运行 `bun scripts/cli.ts commander plan`,确认非 dry-run 返回非零状态和 `error=dry-run-required`;运行 `bun scripts/cli.ts commander approval request --action code-queue-task-interrupt --task-id <taskId> --reason '<reason>' --dry-run`,确认只生成 ClaudeQQ 审批草案且 `claudeqq.mutation=false``sendImplemented=false`;运行 `bun scripts/cli.ts commander approval request --action read-token-file --dry-run`,确认返回 `validation-failed`。本测试不得部署、不得重启 Code Queue backend、不得 cancel/interrupt 运行任务、不得读取或输出 token 明文。
+1 -1
View File
@@ -29,7 +29,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml``src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guardcore manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/ServiceCode Queue dev manifest 必须包含 `code-queue-scheduler-dev``code-queue-read-dev``code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills``skills-dir` volume。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine``rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`
- `commander contract|plan --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务第一阶段 contract 入口。当前返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界和 dry-run 计划,不启动 daemon、不打开 SSH/PTY/stdio、不注入 prompt、不发送 ClaudeQQ。`plan``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `commander contract|plan --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型和 dry-run 计划,服务骨架只提供本地 `/health``/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`plan``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。
- `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。
- `gh issue read <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`view` 只保留为兼容别名。兼容旧脚本的 `--json body``--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed``gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]``gh issue comment create <number> --body-file <file> [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payloadGitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`
+39 -208
View File
@@ -1,42 +1,15 @@
# Host Codex Commander Contract
# Host Codex Commander Skeleton
本文定义第一阶段的 host Codex 指挥官正规化设计。当前阶段只建立 source/contract、CLI dry-run stub、状态模型和安全边界;不部署、不重启、不上线 production,也不实现会实际执行后台动作的守护进程
本文定义 host Codex 指挥官的本地 skeleton 阶段。当前只提供 `/health``/api/commander/contract``.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官
## 目标边界
## 边界
host Codex 指挥官是独立用户服务/基础设施:在 master server host 上保留一个常驻 Codex 指挥会话,由未来的直管微服务负责保活、prompt 注入、trace 采集和高风险动作请示。它不替代 Code Queue runner,也不让 Code Queue 自己上线自己
- `host-codex-commander` 是独立的本地 skeleton,不是运行中的 live daemon
- 只允许本地文件状态、trace 摘要和审批草案预览。
- 所有输出必须 redaction token/secret/URL credential。
- 不得重启或接管 Code Queue backend,不得 cancel/interrupt 运行任务,不得打印 token 明文。
服务边界固定为三层:
- host Codex 进程:运行在 master server host 的常驻 Codex 指挥会话,只负责监督、派单、审阅和恢复计划。
- 直管控制微服务:运行在 host 侧或能直接接入 host TTY/stdio 的受控位置,负责发现/启动计划、SSH/PTY/stdio bridge、事件持久化、prompt plan、trace summary 和审批状态。
- UniDesk/Code Queue/ClaudeQQCode Queue 继续作为任务执行面,GitHub issue 继续作为长期记录,ClaudeQQ 作为高风险动作请示入口。
第一阶段只允许这些产物:
- `docs/reference/host-codex-commander.md` 长期 contract
- `bun scripts/cli.ts commander contract``commander plan --dry-run` 这类只读/dry-run 输出;
- contract test 证明 CLI 不执行 live operation
- 后续任务拆分。
## 非目标
当前阶段明确不做:
- 不启动、停止或重启任何常驻 commander daemon
- 不打开真实 PTY、stdio 或 SSH bridge
- 不向 host Codex 注入真实 prompt
- 不发送 ClaudeQQ 消息;
- 不部署、不重启、不上线 prod
- 不直接重启 Code Queue backend,不重建 Code Queue backend 容器,不重启 Code Queue 执行面;
- 不 cancel 或 interrupt 运行中的 Code Queue task
- 不读取、打印或持久化 token 明文。
这些动作即使未来实现,也必须先通过本文的审批和安全边界。
## CLI Contract
统一入口:
## CLI
```bash
bun scripts/cli.ts commander contract
@@ -44,188 +17,46 @@ bun scripts/cli.ts commander plan --dry-run [--session-id primary]
bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]
```
所有命令默认输出 JSON,失败也必须有结构化 stdout 和非零退出码。`plan` `approval request` 在第一阶段必须要求 `--dry-run`;没有 `--dry-run` 时必须返回 `ok=false``error=dry-run-required`,不能降级成真实执行
`plan` `approval request` 必须显式使用 `--dry-run`,缺失时返回 `error=dry-run-required`
`commander contract` 必须暴露:
## HTTP
- `phase=source-contract`
- `serviceId=host-codex-commander`
- `daemonImplemented=false`
- `liveOperationsImplemented=false`
- 需要的 host Codex 发现/启动计划、SSH/PTY/stdio bridge、prompt guidance、trace summary、#20/#46 issue 入口、ClaudeQQ 审批入口;
- `safetyBoundary`
| Method | Path | 说明 |
| --- | --- | --- |
| GET | `/health` | 返回 service id、启动时间、state root 和日志文件 |
| GET | `/api/commander/contract` | 返回机器可读 contract |
| GET | `/api/commander/sessions` | 读取本地 session 摘要 |
| POST | `/api/commander/state` | 写入本地 session state |
| GET | `/api/commander/trace-summary` | 对 mock trace JSONL 做 dry-run 摘要 |
| POST | `/api/commander/trace-summary` | 读取 mock trace JSONL 并更新本地 session 状态 |
| POST | `/api/commander/approvals` | 生成 approval draft preview 并落盘 |
`commander plan --dry-run` 必须输出:
## State
- process discovery signal 列表;
- host Codex start command shape,但 `enabled=false`
- SSH/PTY/stdio bridge 设计和 guardrail
- prompt guidance pipeline
- trace summary sources 和 summary shape
- #20/#46 read/write 入口;
- ClaudeQQ high-risk approval command shape
- `mutation=false`
状态根目录固定为 `.state/commander/`,至少包含:
`commander approval request --dry-run` 只生成审批草案。允许的 `--action` 固定为:
- `sessions/<sessionId>.json`
- `events/<sessionId>.jsonl`
- `approvals/<approvalId>.json`
- `logs/commander.jsonl`
- `code-queue-backend-restart`
- `code-queue-backend-rebuild`
- `code-queue-execution-plane-restart`
- `code-queue-task-interrupt`
- `code-queue-task-cancel`
- `prod-runtime-mutation`
session 状态只保留 `unknown``discovered``planned``starting``running``attention_required``stopping``stopped``degraded`
输出中 `claudeqq.mutation=false``sendImplemented=false`。真实发送和审批消费属于后续阶段。
## Trace summary
## Future API Contract
trace summary 输入 mock Code Queue trace JSONL 和可选 task summary,输出:
后续微服务 API 以 REST 为主,所有写入口默认异步、有 request id、有事件序列、有 redaction 结果:
- `taskId`
- `sessionId`
- `lastSeq`
- `status`
- `keyEvents`
- `openQuestions`
- `recommendedNextActions`
- `redactionsApplied`
| Method | Path | 用途 | 第一阶段状态 |
| --- | --- | --- | --- |
| GET | `/health` | 服务健康、版本、日志路径、state root | contract only |
| GET | `/api/commander/contract` | 返回本文对应机器可读 contract | contract only |
| GET | `/api/commander/sessions` | 列出 host Codex session 摘要 | contract only |
| POST | `/api/commander/sessions/:sessionId/plan-start` | 生成发现/启动计划 | contract only |
| POST | `/api/commander/sessions/:sessionId/prompt-plan` | 生成 prompt 注入计划 | contract only |
| GET | `/api/commander/trace-summary` | 读取有界 trace summary | contract only |
| POST | `/api/commander/issues/:issueNumber/write-plan` | 生成 #20/#46 写入计划 | contract only |
| POST | `/api/commander/approvals` | 创建 ClaudeQQ 高风险请示草案 | contract only |
输出只做摘要,不返回 live transcript。
后续实现不得绕过现有 CLI/服务边界。GitHub issue 写入仍使用 `bun scripts/cli.ts gh issue ... --body-file`、dry-run-first 和并发 guardCode Queue 读写仍优先使用 `codex task/tasks/steer/read` 等正式入口。
## Approval draft
## State Model
状态根目录规划为 `.state/commander/`。后续服务至少拆成这些文件或等价表:
- `sessions/<sessionId>.json`host Codex session 摘要、pid/cwd 指纹、bridge 状态、lastSeq、heartbeat
- `events/<sessionId>.jsonl`prompt、trace、approval、bridge lifecycle 事件;
- `approvals/<approvalId>.json`:ClaudeQQ 请示草案、状态、授权绑定动作、过期时间;
- `locks/<name>.lock.d/`:启动、prompt injection、issue write、approval consume 的互斥锁;
- `redactions/<eventId>.json`:脱敏摘要,不保存明文 secret。
session 状态:
| State | 含义 |
| --- | --- |
| `unknown` | 没有足够信号判断 host Codex 是否存在 |
| `discovered` | 通过 state/process/bridge heartbeat 找到候选会话 |
| `planned` | 只生成启动或接管计划,尚未执行 |
| `starting` | 后续 live executor 正在启动或 attach |
| `running` | heartbeat 和 trace 新鲜 |
| `attention_required` | 需要人工判断或审批 |
| `stopping` | 后续 live executor 正在退出 |
| `stopped` | 会话已终止 |
| `degraded` | bridge、trace 或状态持久化部分失败 |
prompt 状态:`draft``planned``queued_for_injection``injected``rejected``failed`
approval 状态:`draft``requested``approved``rejected``expired``consumed`。审批只能绑定一个具体 action、taskId 或 target,不得作为泛授权复用。
## Bridge Contract
SSH bridge 只复用现有 UniDesk Host SSH / WSL SSH 维护桥。它用于 provider 只读诊断、受控维护命令和未来已审批恢复动作;不得作为 provider-gateway 自重建通道,也不得绕过 provider.upgrade 调度规则。
PTY bridge 用于 host Codex 交互会话保活和窗口化 transcript 采集。后续实现必须:
- 有 heartbeat
- stdout/stderr 分流或标注;
- 按 seq 写入事件;
- 默认有 byte/line 上限;
- prompt 注入前先落 plan 和 redaction summary
- 注入失败必须可见,不得静默重试。
stdio bridge 用于非交互 Codex 或 helper subprocess。它必须有 argv 记录、cwd、env key allowlist、exit code、timeout 和 bounded output。env 只能记录 key 存在性或来源,不能记录值。
## Prompt Guidance
prompt 注入前必须按顺序执行:
1. 分类意图和风险;
2. 汇总当前 #20/#46/task/queue 的有界上下文;
3. 运行 forbidden-action guard
4. 对 prompt 和上下文做 secret-like redaction
5. 持久化 prompt plan
6. 后续 live executor 仅在 policy pass 后注入。
遇到 Code Queue backend 重启/重建、执行面重启、task interrupt/cancel、prod mutation、token 访问、破坏性 Git 操作时,必须转入 ClaudeQQ 审批草案,而不是注入执行 prompt。
## Trace Summary
trace summary 不是 raw transcript dump。默认输出应包含:
- `taskId``sessionId``lastSeq`
- 当前状态和 freshness
- 最近关键事件;
- open questions
- recommended next actions
- redactionsApplied
- drill-down 命令。
原始 transcript 必须分页读取,默认不在 summary 中展开。summary 可以引用 Code Queue `codex task --trace``codex output`、host Codex event JSONL 和 approval event,但不能把任一路径失败升级为全局故障,仍需遵守 `docs/reference/code-queue-supervision.md` 的多信号裁决规则。
## Issue Entrypoints
#20 是 Code Queue 总看板入口。读取优先:
```bash
bun scripts/cli.ts gh issue board-audit --board-issue 20 --dry-run
bun scripts/cli.ts gh issue board-row list --board-issue 20
```
写入必须先 dry-run,再带 body SHA 或 updatedAt
```bash
bun scripts/cli.ts gh issue board-row update <issueNumber> --board-issue 20 --field progress --value <text> --expect-body-sha <sha>
```
#46 是每日指挥简报入口。读取:
```bash
bun scripts/cli.ts gh issue read 46 --json body,title,state,updatedAt
```
写入:
```bash
bun scripts/cli.ts gh issue update 46 --body-profile commander-brief --body-file <file> --expect-updated-at <ts>
```
所有 Markdown 正文必须来自 `--body-file`,禁止把正文拼入 shell 参数。任何 issue 写入都不能自动触发 ClaudeQQ,除非该动作本身是高风险请示或用户通知策略明确要求。
## Safety Boundary
第一阶段所有 commander CLI 输出都必须是 `mutation=false`
未来 live executor 在没有用户明确同意前也不得执行:
- 重启或重建 Code Queue backend
- 重启 Code Queue 执行面;
- interrupt 或 cancel 运行任务;
- 修改 production runtime
- 读取或输出 token 明文;
- 直写 PostgreSQL 修补任务状态;
- 破坏性 Git 操作;
- 绕过 GitHub issue body-file 和并发 guard。
高风险动作流程固定为:
1. 生成 ClaudeQQ 请示草案,说明原因、影响范围、拟执行动作和可回滚性;
2. 发送给配置的主用户私聊入口;
3. 等待明确批准;
4. 将批准绑定到唯一 action 和 target
5. 执行前再次校验审批未过期且未被消费;
6. 执行后写入 #46 或对应 issue 的结果摘要。
ClaudeQQ 不可达时,不得把请求视为已批准;只能记录通知失败和继续只读诊断。
## Next Stage Tasks
可派单的下一阶段任务:
- 实现 `src/components/microservices/host-codex-commander` 服务骨架,提供 `/health``/api/commander/contract`,仍不启动 live bridge。
- 增加 `.state/commander/` 文件状态读写模块和 redaction 单元测试。
- 实现只读 host Codex process discovery,输出候选 pid/cwd/age,不 attach、不 kill。
- 实现 trace summary 聚合器,读取 mock event JSONL 和 Code Queue bounded trace。
- 实现 ClaudeQQ approval draft service,不发送消息,只落审批草案和 preview。
- 设计第二阶段 live PTY/stdio bridge 的权限、日志、锁和超时测试。
高风险动作只生成 approval draft JSON / Markdown preview。preview 必须显示 redaction 结果,并明确 `sendImplemented=false`
@@ -124,13 +124,13 @@ assertCondition(secretReasonResult.stdout.includes("<redacted>"), "redacted appr
const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
for (const snippet of [
"不直接重启 Code Queue backend",
"不 cancel 或 interrupt 运行中的 Code Queue task",
"不读取、打印或持久化 token 明文",
"SSH/PTY/stdio",
"#20",
"#46",
"ClaudeQQ",
"本地 skeleton 阶段",
"/health",
"/api/commander/contract",
".state/commander/",
"trace summary dry-run",
"approval draft preview",
"sendImplemented=false",
]) {
assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`);
}
@@ -0,0 +1,146 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { commanderContract } from "../src/components/microservices/host-codex-commander/src/contract";
import { createCommanderRequestHandler, type RuntimeConfig } from "../src/components/microservices/host-codex-commander/src/index";
import {
buildCommanderApprovalDraft,
commanderApprovalPreview,
commanderHealth,
commanderSessionPreview,
commanderStatePaths,
listCommanderSessions,
readCommanderApproval,
readCommanderSession,
summarizeCommanderTrace,
writeCommanderApproval,
writeCommanderSession,
} from "../src/components/microservices/host-codex-commander/src/state";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
return value as JsonRecord;
}
function dataOf(response: JsonRecord): JsonRecord {
return asRecord(response.body, "body");
}
async function readJson(response: Response): Promise<JsonRecord> {
const body = await response.json();
return asRecord(body, "response body");
}
const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-"));
const runtime: RuntimeConfig = {
rootDir: tmp,
host: "127.0.0.1",
port: 4261,
logFile: join(tmp, "logs", "commander.jsonl"),
serviceId: "host-codex-commander",
stateRoot: tmp,
sessionId: "primary",
};
const handler = createCommanderRequestHandler(runtime);
try {
const contract = commanderContract();
assertCondition(contract.ok === true, "contract must be ok", contract);
assertCondition(contract.serviceId === "host-codex-commander", "contract must expose service id", contract);
assertCondition(contract.daemonImplemented === false, "contract must remain skeleton only", contract);
assertCondition(contract.currentImplementation === "host-codex-commander-skeleton", "contract must identify skeleton implementation", contract);
const session = writeCommanderSession(runtime, {
sessionId: "primary",
state: "running",
promptState: "planned",
approvalState: "draft",
pid: 321,
cwd: "/workspace/unidesk",
lastSeq: 7,
heartbeatAt: "2026-05-21T00:00:00.000Z",
updatedAt: "2026-05-21T00:00:00.000Z",
notes: ["token=ghp_abcdef1234567890", "trace-summary:running"],
});
assertCondition(session.notes[0] === "<redacted>", "session notes must be redacted", session);
assertCondition(readCommanderSession(runtime, "primary").state === "running", "session read must round-trip", readCommanderSession(runtime, "primary"));
assertCondition(listCommanderSessions(runtime).length >= 1, "session listing must include stored session", listCommanderSessions(runtime));
assertCondition(commanderSessionPreview(session).notes.includes("<redacted>"), "session preview must redact notes", commanderSessionPreview(session));
const trace = summarizeCommanderTrace({
taskId: "task-123",
sessionId: "primary",
traceJsonl: [
JSON.stringify({ seq: 1, kind: "message", status: "running", summary: "prompt token=ghp_1234567890abcdef" }),
JSON.stringify({ seq: 4, kind: "command", status: "attention_required", command: "ask-for-approval", output: "reason=https://user:secret@example.com" }),
JSON.stringify({ seq: 8, kind: "event", status: "completed", text: "done" }),
].join("\n"),
taskSummary: "task summary token=ghp_aaaaaaaaaaaaaaaa",
});
assertCondition(trace.taskId === "task-123", "trace summary must preserve task id", trace);
assertCondition(trace.lastSeq === 8, "trace summary must preserve last seq", trace);
assertCondition(trace.status === "terminal", "trace summary should reach terminal status", trace);
assertCondition(trace.redactionsApplied >= 2, "trace summary must redact secrets", trace);
assertCondition(trace.taskSummaryPreview?.includes("<redacted>") === true, "task summary preview must redact", trace);
assertCondition(trace.keyEvents.length > 0, "trace summary must include key events", trace);
const approval = buildCommanderApprovalDraft({
approvalId: "draft-1",
action: "code-queue-task-interrupt",
taskId: "task-123",
reason: "token=ghp_1234567890abcdef https://user:secret@example.com",
sessionId: "primary",
});
assertCondition(approval.reason.includes("<redacted>"), "approval reason must redact", approval);
assertCondition(approval.previewMarkdown.includes("<redacted>"), "approval preview must redact", approval);
assertCondition(approval.previewJson["sendImplemented"] === false, "approval preview must not imply sending", approval.previewJson);
writeCommanderApproval(runtime, approval);
assertCondition(readCommanderApproval(runtime, "draft-1").reason.includes("<redacted>"), "approval round-trip must preserve redaction", readCommanderApproval(runtime, "draft-1"));
const health = commanderHealth(runtime, "2026-05-21T00:00:00.000Z");
assertCondition(health.ok === true && health.service === "host-codex-commander", "health must expose service metadata", health);
assertCondition(health.stateRoot === tmp, "health must point at temp state root", health);
const healthBody = await readJson(await handler(new Request("http://localhost/health")));
assertCondition(healthBody.ok === true, "health route must succeed", healthBody);
const contractBody = await readJson(await handler(new Request("http://localhost/api/commander/contract")));
assertCondition(contractBody.serviceId === "host-codex-commander", "HTTP contract route must expose service id", contractBody);
const sessionsBody = await readJson(await handler(new Request("http://localhost/api/commander/sessions")));
assertCondition(Array.isArray(sessionsBody.sessions) && sessionsBody.sessions.length >= 1, "sessions route must list sessions", sessionsBody);
const traceBody = await readJson(await handler(new Request(`http://localhost/api/commander/trace-summary?taskId=task-123&traceJsonl=${encodeURIComponent(JSON.stringify({ seq: 1, status: "running", summary: "hello token=ghp_1234567890abcdef" }))}`)));
assertCondition(traceBody.ok === true && asRecord(traceBody.summary, "summary").redactionsApplied >= 1, "trace route must redact and summarize", traceBody);
const approvalBody = await readJson(await handler(new Request("http://localhost/api/commander/approvals", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "code-queue-task-cancel", reason: "cookie=session=secret", taskId: "task-123" }),
})));
assertCondition(approvalBody.ok === true, "approval route must succeed", approvalBody);
assertCondition(String(JSON.stringify(approvalBody)).includes("<redacted>"), "approval route must redact sensitive text", approvalBody);
const statePath = commanderStatePaths(runtime);
assertCondition(readFileSync(statePath.stateFile, "utf8").length > 0, "state file must be written", statePath);
assertCondition(readFileSync(statePath.approvalFile, "utf8").length > 0, "approval file must be written", statePath);
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"commander contract exposes skeleton contract boundaries",
"state files round-trip and redact secrets",
"trace summary aggregates mock jsonl input",
"approval draft preview stays preview-only and redacted",
"HTTP handler serves /health, /api/commander/contract, /api/commander/sessions, /api/commander/trace-summary, and /api/commander/approvals",
],
}, null, 2)}\n`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
+18
View File
@@ -22,7 +22,10 @@ const syntaxFiles = [
"scripts/src/docker.ts",
"scripts/src/e2e.ts",
"scripts/src/help.ts",
"scripts/src/commander.ts",
"scripts/src/remote.ts",
"scripts/host-codex-commander-contract-test.ts",
"scripts/host-codex-commander-skeleton-contract-test.ts",
"src/components/frontend/src/index.ts",
"src/components/frontend/src/app.tsx",
"src/components/frontend/src/decision-center.tsx",
@@ -34,6 +37,10 @@ const syntaxFiles = [
"src/components/microservices/decision-center/src/index.ts",
"src/components/microservices/code-queue-mgr/src/index.ts",
"src/components/microservices/code-agent-sandbox/src/index.ts",
"src/components/microservices/host-codex-commander/src/index.ts",
"src/components/microservices/host-codex-commander/src/contract.ts",
"src/components/microservices/host-codex-commander/src/redaction.ts",
"src/components/microservices/host-codex-commander/src/state.ts",
];
export interface CheckOptions {
@@ -167,6 +174,7 @@ function unifiedLogRotationItem(): CheckItem {
"src/components/microservices/oa-event-flow/src/index.ts",
"src/components/microservices/decision-center/src/index.ts",
"src/components/microservices/code-agent-sandbox/src/index.ts",
"src/components/microservices/host-codex-commander/src/index.ts",
];
const offenders = serviceFiles.flatMap((path) => {
const text = readFileSync(rootPath(path), "utf8");
@@ -275,6 +283,13 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("src/components/microservices/decision-center/src/index.ts"),
fileItem("src/components/microservices/code-queue-mgr/src/index.ts"),
fileItem("src/components/microservices/code-agent-sandbox/src/index.ts"),
fileItem("src/components/microservices/host-codex-commander/package.json"),
fileItem("src/components/microservices/host-codex-commander/tsconfig.json"),
fileItem("src/components/microservices/host-codex-commander/Dockerfile"),
fileItem("src/components/microservices/host-codex-commander/src/index.ts"),
fileItem("src/components/microservices/host-codex-commander/src/contract.ts"),
fileItem("src/components/microservices/host-codex-commander/src/redaction.ts"),
fileItem("src/components/microservices/host-codex-commander/src/state.ts"),
fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"),
fileItem("scripts/src/deploy.ts"),
fileItem("scripts/code-queue-issue3-regression-test.ts"),
@@ -283,6 +298,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("scripts/code-queue-trace-summary-contract-test.ts"),
fileItem("scripts/code-queue-pr-preflight-contract-test.ts"),
fileItem("scripts/code-queue-submit-routing-contract-test.ts"),
fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"),
fileItem("scripts/provider-runner-triage-contract-test.ts"),
fileItem("scripts/src/provider-triage.ts"),
fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"),
@@ -309,6 +325,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000));
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000));
items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000));
items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000));
@@ -329,6 +346,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full"));
+5 -55
View File
@@ -1,13 +1,6 @@
const requiredDryRunMessage = "This first-phase commander contract only supports dry-run planning; live daemon/control operations are not implemented.";
import { commanderContract as hostCommanderContract, commanderHighRiskActions as highRiskActions } from "../../src/components/microservices/host-codex-commander/src/contract";
const highRiskActions = [
"code-queue-backend-restart",
"code-queue-backend-rebuild",
"code-queue-execution-plane-restart",
"code-queue-task-interrupt",
"code-queue-task-cancel",
"prod-runtime-mutation",
] as const;
const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented.";
type HighRiskAction = typeof highRiskActions[number];
@@ -37,6 +30,7 @@ function redactText(value: string): { text: string; redactionsApplied: number }
/\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/g,
/\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/gi,
/\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi,
/https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/gi,
];
let text = value;
for (const pattern of patterns) {
@@ -52,7 +46,7 @@ function commanderHelp(): Record<string, unknown> {
return {
command: "commander",
output: "json",
description: "First-phase source/contract stub for the host Codex commander control microservice; no daemon or live control action is implemented.",
description: "Local skeleton contract for the host Codex commander control microservice; no daemon or live control action is implemented.",
usage: [
"bun scripts/cli.ts commander contract",
"bun scripts/cli.ts commander plan --dry-run [--session-id id]",
@@ -64,51 +58,7 @@ function commanderHelp(): Record<string, unknown> {
}
export function commanderContract(): Record<string, unknown> {
return {
ok: true,
phase: "source-contract",
serviceId: "host-codex-commander",
currentImplementation: "cli-contract-stub-only",
daemonImplemented: false,
liveOperationsImplemented: false,
purpose: "Keep a host Codex commander session observable and controllable through a future direct-managed microservice without replacing Code Queue runners.",
ownershipBoundary: {
hostCodexProcess: "Long-lived Codex process on the master server host.",
controlMicroservice: "Future direct-managed bridge that records state, mediates PTY/stdio/SSH streams, injects prompts, and summarizes traces.",
codeQueue: "Remains the task execution plane; the commander only supervises through existing safe CLI/API contracts.",
claudeqq: "Approval and user-notification path for high-risk actions.",
},
requiredCapabilities: [
"host-codex-process-discovery",
"host-codex-start-plan",
"ssh-bridge-contract",
"pty-bridge-contract",
"stdio-bridge-contract",
"prompt-guidance-plan",
"trace-summary-plan",
"issue-20-board-read-write-entry",
"issue-46-brief-read-write-entry",
"claudeqq-high-risk-approval-entry",
],
apiContract: {
health: "GET /health",
contract: "GET /api/commander/contract",
sessions: "GET /api/commander/sessions",
sessionPlan: "POST /api/commander/sessions/:sessionId/plan-start",
promptPlan: "POST /api/commander/sessions/:sessionId/prompt-plan",
traceSummary: "GET /api/commander/trace-summary?taskId=<taskId>",
issueWritePlan: "POST /api/commander/issues/:issueNumber/write-plan",
approvalRequest: "POST /api/commander/approvals",
},
stateModel: {
sessionStates: ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"],
promptStates: ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"],
approvalStates: ["draft", "requested", "approved", "rejected", "expired", "consumed"],
storageRoot: ".state/commander/",
redactionPolicy: "Never persist or print token, secret, password, key, cookie, or authorization values in cleartext.",
},
safetyBoundary: safetyBoundary(),
};
return hostCommanderContract();
}
function safetyBoundary(): Record<string, unknown> {
+3 -3
View File
@@ -44,7 +44,7 @@ export function rootHelp(): unknown {
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." },
{ command: "commander contract|plan --dry-run|approval request --dry-run", description: "First-phase host Codex commander source/contract design stub; returns boundaries and approval plans without starting daemons or executing live control actions." },
{ command: "commander contract|plan --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
@@ -195,9 +195,9 @@ function commanderHelp(): unknown {
"bun scripts/cli.ts commander plan --dry-run [--session-id id]",
"bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]",
],
description: "Inspect the first-phase source/contract design for the future host Codex commander microservice.",
description: "Inspect the local host Codex commander skeleton contract, dry-run planner, state helpers, trace summary aggregator, and approval draft preview.",
boundary: [
"phase one is contract-only and never starts a daemon",
"the current skeleton is local-only and never attaches to live bridges",
"dry-run commands never open SSH, PTY, or stdio bridges",
"high-risk actions only produce a ClaudeQQ approval draft",
"token and secret values must never be printed",
@@ -0,0 +1,11 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/microservices/host-codex-commander
COPY src/components/microservices/host-codex-commander/package.json ./package.json
RUN bun install --production
COPY src/components/microservices/host-codex-commander/tsconfig.json ./tsconfig.json
COPY src/components/shared /app/src/components/shared
COPY src/components/microservices/host-codex-commander/src ./src
EXPOSE 4261
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,15 @@
{
"name": "@unidesk/host-codex-commander",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}
@@ -0,0 +1,141 @@
export const commanderServiceId = "host-codex-commander";
export const commanderPhase = "source-contract";
export const commanderHighRiskActions = [
"code-queue-backend-restart",
"code-queue-backend-rebuild",
"code-queue-execution-plane-restart",
"code-queue-task-interrupt",
"code-queue-task-cancel",
"prod-runtime-mutation",
] as const;
export type CommanderHighRiskAction = typeof commanderHighRiskActions[number];
export type CommanderSessionState =
| "unknown"
| "discovered"
| "planned"
| "starting"
| "running"
| "attention_required"
| "stopping"
| "stopped"
| "degraded";
export type CommanderPromptState =
| "draft"
| "planned"
| "queued_for_injection"
| "injected"
| "rejected"
| "failed";
export type CommanderApprovalState =
| "draft"
| "requested"
| "approved"
| "rejected"
| "expired"
| "consumed";
export interface CommanderContract {
ok: true;
phase: typeof commanderPhase;
serviceId: typeof commanderServiceId;
currentImplementation: "host-codex-commander-skeleton";
daemonImplemented: false;
liveOperationsImplemented: false;
purpose: string;
ownershipBoundary: {
hostCodexProcess: string;
controlMicroservice: string;
codeQueue: string;
claudeqq: string;
};
requiredCapabilities: string[];
apiContract: {
health: string;
contract: string;
sessions: string;
sessionPlan: string;
promptPlan: string;
traceSummary: string;
issueWritePlan: string;
approvalRequest: string;
};
stateModel: {
sessionStates: CommanderSessionState[];
promptStates: CommanderPromptState[];
approvalStates: CommanderApprovalState[];
storageRoot: ".state/commander/";
redactionPolicy: string;
};
safetyBoundary: {
phaseOneMutationAllowed: false;
forbiddenWithoutExplicitUserApproval: CommanderHighRiskAction[];
alwaysForbidden: string[];
confirmationPolicy: string;
};
}
export function commanderContract(): CommanderContract {
return {
ok: true,
phase: commanderPhase,
serviceId: commanderServiceId,
currentImplementation: "host-codex-commander-skeleton",
daemonImplemented: false,
liveOperationsImplemented: false,
purpose: "Keep host Codex supervision observable through a local-only skeleton that can persist state, summarize traces, and draft approvals without touching live bridges.",
ownershipBoundary: {
hostCodexProcess: "Long-lived Codex process on the master server host.",
controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.",
codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.",
claudeqq: "Approval draft destination only; no messages are sent from this stage.",
},
requiredCapabilities: [
"host-codex-process-discovery",
"host-codex-start-plan",
"ssh-bridge-contract",
"pty-bridge-contract",
"stdio-bridge-contract",
"prompt-guidance-plan",
"trace-summary-plan",
"issue-20-board-read-write-entry",
"issue-46-brief-read-write-entry",
"claudeqq-high-risk-approval-entry",
],
apiContract: {
health: "GET /health",
contract: "GET /api/commander/contract",
sessions: "GET /api/commander/sessions",
sessionPlan: "POST /api/commander/sessions/:sessionId/plan-start",
promptPlan: "POST /api/commander/sessions/:sessionId/prompt-plan",
traceSummary: "GET /api/commander/trace-summary",
issueWritePlan: "POST /api/commander/issues/:issueNumber/write-plan",
approvalRequest: "POST /api/commander/approvals",
},
stateModel: {
sessionStates: ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"],
promptStates: ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"],
approvalStates: ["draft", "requested", "approved", "rejected", "expired", "consumed"],
storageRoot: ".state/commander/",
redactionPolicy: "Never persist or print token, secret, password, key, cookie, authorization, or credential URLs in cleartext.",
},
safetyBoundary: {
phaseOneMutationAllowed: false,
forbiddenWithoutExplicitUserApproval: commanderHighRiskActions,
alwaysForbidden: [
"print-token-values",
"read-token-files-for-display",
"direct-database-state-patch",
"bypass-code-queue-backend-confirmation-policy",
"replace-code-queue-runner",
"deploy-or-restart-production-runtime-from-this-contract-stub",
],
confirmationPolicy: "High-risk actions must draft a ClaudeQQ request, wait for explicit user approval, bind approval to one exact action, and record the decision before any future live executor may proceed.",
},
};
}
@@ -0,0 +1,283 @@
import { existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
import { commanderContract } from "./contract";
import { commanderHighRiskActions } from "./contract";
import {
appendCommanderTraceEvent,
buildCommanderApprovalDraft,
commanderApprovalPreview,
commanderHealth,
commanderSessionPreview,
commanderStatePaths,
listCommanderSessions,
normalizeCommanderApprovalState,
normalizeCommanderPromptState,
normalizeCommanderSessionState,
readCommanderApproval,
readCommanderSession,
writeCommanderApproval,
writeCommanderSession,
summarizeCommanderTrace,
type CommanderStorageConfig,
type CommanderApprovalDraftRecord,
type CommanderSessionRecord,
} from "./state";
export interface RuntimeConfig extends CommanderStorageConfig {
host: string;
port: number;
logFile: string;
serviceId: string;
stateRoot: string;
sessionId: string;
}
type JsonRecord = Record<string, unknown>;
const startedAt = new Date().toISOString();
const recentLogs: JsonRecord[] = [];
const isCheckMode = process.execArgv.includes("--check");
function envString(name: string, fallback: string): string {
const value = process.env[name];
return value === undefined || value.length === 0 ? fallback : value;
}
function envNumber(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim().length === 0) return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
function buildConfig(): RuntimeConfig {
const stateRoot = envString("COMMANDER_STATE_ROOT", "/var/lib/unidesk/commander");
const logFile = envString("LOG_FILE", "/var/log/unidesk/commander.jsonl");
return {
rootDir: stateRoot,
host: envString("HOST", "0.0.0.0"),
port: envNumber("PORT", 4261),
logFile,
serviceId: "host-codex-commander",
stateRoot,
sessionId: envString("COMMANDER_SESSION_ID", "primary"),
};
}
let config = buildConfig();
let logWriter: ReturnType<typeof createHourlyJsonlWriter> | null = null;
export function setCommanderRuntimeConfig(next: RuntimeConfig): void {
config = next;
logWriter = null;
}
function getLogWriter(): ReturnType<typeof createHourlyJsonlWriter> {
if (logWriter === null) {
mkdirSync(dirname(config.logFile), { recursive: true });
mkdirSync(config.stateRoot, { recursive: true });
logWriter = createHourlyJsonlWriter({
baseLogFile: config.logFile,
service: "host-codex-commander",
maxBytes: logRetentionBytesForService("host-codex-commander"),
});
logWriter.prune();
}
return logWriter;
}
function log(event: string, detail: JsonRecord = {}): void {
const record: JsonRecord = { at: new Date().toISOString(), service: config.serviceId, event, ...detail };
recentLogs.push(record);
while (recentLogs.length > 200) recentLogs.shift();
try {
getLogWriter().appendJson(record, new Date(String(record.at)));
} catch {
// Logging must never block the skeleton service.
}
console.log(JSON.stringify(record));
}
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8", ...headers },
});
}
function errorToJson(error: unknown): JsonRecord {
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" };
return { message: String(error) };
}
function errorResponse(error: unknown, status = 500): Response {
log("request_failed", { status, error: errorToJson(error) });
return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status);
}
function readJsonBody(body: Request): Promise<unknown> {
return body.json();
}
function routePath(url: URL): string {
return url.pathname.replace(/\/+$/u, "") || "/";
}
function commanderInfo(): JsonRecord {
const paths = commanderStatePaths(config);
const session = readCommanderSession(config, config.sessionId);
return {
ok: true,
service: config.serviceId,
serviceId: config.serviceId,
status: "healthy",
startedAt,
config: {
host: config.host,
port: config.port,
stateRoot: config.stateRoot,
logFile: config.logFile,
sessionId: config.sessionId,
},
state: commanderSessionPreview(session),
files: {
stateFile: paths.stateFile,
approvalFile: paths.approvalFile,
traceFile: paths.traceFile,
logFile: paths.logFile,
stateRootExists: existsSync(config.stateRoot),
},
};
}
export function createCommanderRequestHandler(runtimeConfig: RuntimeConfig): (req: Request) => Promise<Response> | Response {
return async (req: Request) => {
const previousConfig = config;
config = runtimeConfig;
try {
return await Promise.resolve(handleCommanderRequest(req));
} finally {
config = previousConfig;
}
};
}
function normalizeSessionUpdate(input: unknown): CommanderSessionRecord {
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
const current = readCommanderSession(config, typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId);
current.state = typeof body.state === "string" ? normalizeCommanderSessionState(body.state) : current.state;
current.promptState = typeof body.promptState === "string" ? normalizeCommanderPromptState(body.promptState) : current.promptState;
current.approvalState = typeof body.approvalState === "string" ? normalizeCommanderApprovalState(body.approvalState) : current.approvalState;
current.pid = typeof body.pid === "number" && Number.isFinite(body.pid) ? Math.floor(body.pid) : current.pid;
current.cwd = typeof body.cwd === "string" ? body.cwd : current.cwd;
current.lastSeq = typeof body.lastSeq === "number" && Number.isFinite(body.lastSeq) ? Math.max(0, Math.floor(body.lastSeq)) : current.lastSeq;
current.heartbeatAt = typeof body.heartbeatAt === "string" ? body.heartbeatAt : current.heartbeatAt;
current.updatedAt = new Date().toISOString();
if (Array.isArray(body.notes)) {
current.notes = body.notes.filter((item): item is string => typeof item === "string");
}
return writeCommanderSession(config, current);
}
function updateSessionFromTrace(input: unknown): JsonRecord {
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
const sessionId = typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId;
const traceJsonl = typeof body.traceJsonl === "string" ? body.traceJsonl : "";
const taskSummary = typeof body.taskSummary === "string" ? body.taskSummary : null;
const taskId = typeof body.taskId === "string" ? body.taskId : null;
const summary = summarizeCommanderTrace({ taskId, sessionId, traceJsonl, taskSummary });
appendCommanderTraceEvent(config, sessionId, {
type: "trace-summary-dry-run",
taskId,
summary,
});
const nextState = summary.status === "terminal"
? "stopped"
: summary.status === "attention_required"
? "attention_required"
: summary.status === "blocked"
? "degraded"
: "running";
const session = writeCommanderSession(config, {
...readCommanderSession(config, sessionId),
sessionId,
state: nextState,
promptState: summary.status === "attention_required" ? "planned" : "draft",
approvalState: summary.status === "attention_required" ? "requested" : "draft",
lastSeq: summary.lastSeq,
heartbeatAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
notes: [...readCommanderSession(config, sessionId).notes, `trace-summary:${summary.status}`].slice(-20),
pid: readCommanderSession(config, sessionId).pid,
cwd: readCommanderSession(config, sessionId).cwd,
});
return { ok: true, summary, session: commanderSessionPreview(session) };
}
function writeApprovalDraft(input: unknown): JsonRecord {
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
const action = typeof body.action === "string" ? body.action : "unknown";
if (!commanderHighRiskActions.includes(action as typeof commanderHighRiskActions[number])) {
return { ok: false, error: "validation-failed", message: `unsupported high-risk action: ${action}`, highRiskActions: commanderHighRiskActions };
}
const taskId = typeof body.taskId === "string" ? body.taskId : null;
const reason = typeof body.reason === "string" ? body.reason : "";
const approvalId = typeof body.approvalId === "string" && body.approvalId.length > 0 ? body.approvalId : "draft";
const draft = buildCommanderApprovalDraft({
approvalId,
action,
taskId,
reason,
sessionId: config.sessionId,
});
const stored = writeCommanderApproval(config, draft);
return { ok: true, approval: commanderApprovalPreview(stored), previewMarkdown: stored.previewMarkdown, previewJson: stored.previewJson };
}
export function handleCommanderRequest(req: Request): Promise<Response> | Response {
const url = new URL(req.url);
const path = routePath(url);
if (req.method === "GET" && (path === "/" || path === "/health")) return jsonResponse(commanderHealth(config, startedAt));
if (req.method === "GET" && path === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-100), logFile: config.logFile, stateRoot: config.stateRoot, startedAt });
if (req.method === "GET" && path === "/api/commander/contract") return jsonResponse(commanderContract());
if (req.method === "GET" && path === "/api/commander/sessions") return jsonResponse({ ok: true, sessions: listCommanderSessions(config).map(commanderSessionPreview) });
if (req.method === "GET" && path === "/api/commander/state") return jsonResponse({ ok: true, session: commanderSessionPreview(readCommanderSession(config, config.sessionId)), approval: commanderApprovalPreview(readCommanderApproval(config, "draft")) });
if (req.method === "POST" && path === "/api/commander/state") {
return readJsonBody(req).then((body) => jsonResponse({ ok: true, session: commanderSessionPreview(normalizeSessionUpdate(body)) }));
}
if (req.method === "GET" && path === "/api/commander/trace-summary") {
const traceJsonl = url.searchParams.get("traceJsonl") ?? "";
const taskSummary = url.searchParams.get("taskSummary");
const taskId = url.searchParams.get("taskId");
return jsonResponse({ ok: true, summary: summarizeCommanderTrace({ taskId, sessionId: config.sessionId, traceJsonl, taskSummary }) });
}
if (req.method === "POST" && path === "/api/commander/trace-summary") {
return readJsonBody(req).then((body) => jsonResponse(updateSessionFromTrace(body)));
}
if (req.method === "POST" && path === "/api/commander/approvals") {
return readJsonBody(req).then((body) => {
const result = writeApprovalDraft(body);
return jsonResponse(result, result.ok === false ? 400 : 200);
});
}
return jsonResponse({
ok: false,
service: config.serviceId,
status: "not_found",
available: ["/health", "/logs", "/api/commander/contract", "/api/commander/sessions", "/api/commander/state", "/api/commander/trace-summary", "/api/commander/approvals"],
}, 404);
}
if (import.meta.main && !isCheckMode) {
const server = Bun.serve({
hostname: config.host,
port: config.port,
idleTimeout: 120,
fetch(req) {
return Promise.resolve().then(() => handleCommanderRequest(req)).catch((error) => errorResponse(error));
},
});
log("service_started", { startedAt, service: config.serviceId, stateRoot: config.stateRoot, logFile: config.logFile, port: server.port });
}
@@ -0,0 +1,35 @@
const secretPatterns = [
/\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/gu,
/\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/giu,
/\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/giu,
/https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/giu,
] as const;
export interface RedactionResult {
text: string;
redactionsApplied: number;
}
export function redactText(value: string): RedactionResult {
let redactionsApplied = 0;
let text = value;
for (const pattern of secretPatterns) {
text = text.replace(pattern, () => {
redactionsApplied += 1;
return "<redacted>";
});
}
return { text, redactionsApplied };
}
export function redactJsonValue(value: unknown): unknown {
if (typeof value === "string") return redactText(value).text;
if (Array.isArray(value)) return value.map((item) => redactJsonValue(item));
if (typeof value !== "object" || value === null) return value;
const record = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(record)) {
result[key] = redactJsonValue(item);
}
return result;
}
@@ -0,0 +1,462 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { CommanderApprovalState, CommanderPromptState, CommanderSessionState } from "./contract";
import { redactJsonValue, redactText } from "./redaction";
export interface CommanderStorageConfig {
rootDir: string;
}
export interface CommanderSessionRecord {
sessionId: string;
state: CommanderSessionState;
promptState: CommanderPromptState;
approvalState: CommanderApprovalState;
pid: number | null;
cwd: string | null;
lastSeq: number;
heartbeatAt: string | null;
updatedAt: string;
notes: string[];
}
export interface CommanderApprovalDraftRecord {
id: string;
action: string;
taskId: string | null;
reason: string;
status: CommanderApprovalState;
previewMarkdown: string;
previewJson: Record<string, unknown>;
redactionsApplied: number;
createdAt: string;
updatedAt: string;
}
export interface CommanderApprovalDraftInput {
approvalId?: string;
action: string;
taskId?: string | null;
reason: string;
sessionId?: string | null;
}
export interface CommanderTraceInput {
taskId: string | null;
sessionId: string;
traceJsonl: string;
taskSummary?: string | null;
}
export interface CommanderTraceSummary {
taskId: string | null;
sessionId: string;
lastSeq: number;
status: "running" | "attention_required" | "blocked" | "terminal" | "unknown";
keyEvents: string[];
openQuestions: string[];
recommendedNextActions: string[];
redactionsApplied: number;
sourceCount: number;
taskSummaryPreview: string | null;
traceLineCount: number;
}
export interface CommanderHealth {
ok: true;
service: "host-codex-commander";
status: "healthy";
startedAt: string;
stateRoot: string;
stateRootExists: boolean;
currentStateFile: string;
currentApprovalFile: string;
currentTraceFile: string;
currentLogFile: string;
}
interface PersistedSessionFile {
sessionId: string;
state: CommanderSessionState;
promptState: CommanderPromptState;
approvalState: CommanderApprovalState;
pid: number | null;
cwd: string | null;
lastSeq: number;
heartbeatAt: string | null;
updatedAt: string;
notes: string[];
}
interface PersistedApprovalFile {
id: string;
action: string;
taskId: string | null;
reason: string;
status: CommanderApprovalState;
previewMarkdown: string;
previewJson: Record<string, unknown>;
redactionsApplied: number;
createdAt: string;
updatedAt: string;
}
const defaultSessionId = "primary";
function ensureDir(path: string): void {
mkdirSync(path, { recursive: true });
}
function nowIso(): string {
return new Date().toISOString();
}
function safeJsonParse(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function clampText(value: string, maxChars = 1200): string {
const compact = value.replace(/\s+/gu, " ").trim();
return compact.length <= maxChars ? compact : `${compact.slice(0, Math.max(0, maxChars - 12))}…<truncated>`;
}
function stateFilePath(rootDir: string, sessionId = defaultSessionId): string {
return join(rootDir, "sessions", `${sessionId}.json`);
}
function traceFilePath(rootDir: string, sessionId = defaultSessionId): string {
return join(rootDir, "events", `${sessionId}.jsonl`);
}
function approvalFilePath(rootDir: string, approvalId = "draft"): string {
return join(rootDir, "approvals", `${approvalId}.json`);
}
function logFilePath(rootDir: string): string {
return join(rootDir, "logs", "commander.jsonl");
}
export function commanderStatePaths(config: CommanderStorageConfig, sessionId = defaultSessionId, approvalId = "draft"): {
stateFile: string;
approvalFile: string;
traceFile: string;
logFile: string;
} {
return {
stateFile: stateFilePath(config.rootDir, sessionId),
approvalFile: approvalFilePath(config.rootDir, approvalId),
traceFile: traceFilePath(config.rootDir, sessionId),
logFile: logFilePath(config.rootDir),
};
}
export function readCommanderSession(config: CommanderStorageConfig, sessionId = defaultSessionId): CommanderSessionRecord {
const path = stateFilePath(config.rootDir, sessionId);
if (!existsSync(path)) {
return {
sessionId,
state: "unknown",
promptState: "draft",
approvalState: "draft",
pid: null,
cwd: null,
lastSeq: 0,
heartbeatAt: null,
updatedAt: nowIso(),
notes: [],
};
}
const parsed = safeJsonParse(readFileSync(path, "utf8"));
if (parsed === null) throw new Error(`invalid commander session file: ${path}`);
return normalizeSessionRecord(parsed, sessionId);
}
export function writeCommanderSession(config: CommanderStorageConfig, session: CommanderSessionRecord): CommanderSessionRecord {
const path = stateFilePath(config.rootDir, session.sessionId);
ensureDir(dirname(path));
const normalized = normalizeSessionRecord(session, session.sessionId);
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
return normalized;
}
export function readCommanderApproval(config: CommanderStorageConfig, approvalId = "draft"): CommanderApprovalDraftRecord {
const path = approvalFilePath(config.rootDir, approvalId);
if (!existsSync(path)) {
return {
id: approvalId,
action: "unknown",
taskId: null,
reason: "",
status: "draft",
previewMarkdown: "",
previewJson: { ok: true, status: "draft" },
redactionsApplied: 0,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
const parsed = safeJsonParse(readFileSync(path, "utf8"));
if (parsed === null) throw new Error(`invalid commander approval file: ${path}`);
return normalizeApprovalRecord(parsed, approvalId);
}
export function writeCommanderApproval(config: CommanderStorageConfig, approval: CommanderApprovalDraftRecord): CommanderApprovalDraftRecord {
const path = approvalFilePath(config.rootDir, approval.id);
ensureDir(dirname(path));
const normalized = normalizeApprovalRecord(approval, approval.id);
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
return normalized;
}
export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): CommanderApprovalDraftRecord {
const approvalId = input.approvalId ?? "draft";
const reason = redactText(input.reason);
const action = String(input.action || "unknown");
const taskId = input.taskId ?? null;
const previewMarkdown = [
"# Commander approval draft",
"",
`- approvalId: ${approvalId}`,
`- action: ${action}`,
`- taskId: ${taskId ?? "null"}`,
`- status: draft`,
`- redactionsApplied: ${reason.redactionsApplied}`,
input.sessionId ? `- sessionId: ${input.sessionId}` : "- sessionId: null",
"",
"## Reason",
"",
reason.text || "(empty)",
"",
"## Boundary",
"",
"- ClaudeQQ send is not implemented in this skeleton.",
"- Approval is preview-only until explicit user approval is recorded.",
].join("\n");
return {
id: approvalId,
action,
taskId,
reason: reason.text,
status: "draft",
previewMarkdown,
previewJson: redactJsonValue({
ok: true,
service: "host-codex-commander",
approvalId,
action,
taskId,
reason: reason.text,
redactionsApplied: reason.redactionsApplied,
requiresExplicitUserApproval: true,
sendImplemented: false,
claudeqq: {
mutation: false,
target: "preview-only",
messageTemplate: `Approval required for ${action}. Reason: ${reason.text}.`,
},
blockedUntilApproved: [action],
}) as Record<string, unknown>,
redactionsApplied: reason.redactionsApplied,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
export function commanderSessionPreview(session: CommanderSessionRecord): Record<string, unknown> {
return redactJsonValue({
sessionId: session.sessionId,
state: session.state,
promptState: session.promptState,
approvalState: session.approvalState,
pid: session.pid,
cwd: session.cwd,
lastSeq: session.lastSeq,
heartbeatAt: session.heartbeatAt,
updatedAt: session.updatedAt,
notes: session.notes,
}) as Record<string, unknown>;
}
export function commanderApprovalPreview(approval: CommanderApprovalDraftRecord): Record<string, unknown> {
return redactJsonValue({
id: approval.id,
action: approval.action,
taskId: approval.taskId,
reason: approval.reason,
status: approval.status,
redactionsApplied: approval.redactionsApplied,
createdAt: approval.createdAt,
updatedAt: approval.updatedAt,
}) as Record<string, unknown>;
}
export function listCommanderSessions(config: CommanderStorageConfig): CommanderSessionRecord[] {
const dir = join(config.rootDir, "sessions");
if (!existsSync(dir)) return [readCommanderSession(config)];
const sessions = readdirSync(dir)
.filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -".json".length))
.map((sessionId) => {
try {
return readCommanderSession(config, sessionId);
} catch {
return null;
}
})
.filter((item): item is CommanderSessionRecord => item !== null);
if (sessions.length === 0) sessions.push(readCommanderSession(config));
return sessions.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function appendCommanderTraceEvent(config: CommanderStorageConfig, sessionId: string, event: Record<string, unknown>): string {
const path = traceFilePath(config.rootDir, sessionId);
ensureDir(dirname(path));
const redactedEvent = redactJsonValue({ ...event, at: nowIso(), sessionId });
writeFileSync(path, `${JSON.stringify(redactedEvent)}\n`, { flag: "a" });
return path;
}
export function summarizeCommanderTrace(input: CommanderTraceInput): CommanderTraceSummary {
const lines = input.traceJsonl.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean);
const keyEvents: string[] = [];
const openQuestions: string[] = [];
const recommendedNextActions: string[] = [];
let lastSeq = 0;
let status: CommanderTraceSummary["status"] = "unknown";
let redactionsApplied = 0;
for (const line of lines) {
const parsed = safeJsonParse(line);
if (parsed === null) continue;
const seq = Number(parsed.seq ?? parsed.sequence ?? parsed.eventSequence ?? 0);
if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, Math.floor(seq));
const rawText = String(parsed.summary ?? parsed.text ?? parsed.message ?? parsed.command ?? parsed.output ?? "");
const redacted = redactText(rawText);
redactionsApplied += redacted.redactionsApplied;
const compact = clampText(redacted.text, 180);
const kind = String(parsed.kind ?? parsed.channel ?? parsed.type ?? "event");
const statusText = String(parsed.status ?? "").toLowerCase();
if (statusText.includes("failed") || statusText.includes("error")) status = "blocked";
else if (statusText.includes("completed") || statusText.includes("succeeded")) status = "terminal";
else if (statusText.includes("blocked") || statusText.includes("attention")) status = "attention_required";
else if (status === "unknown") status = "running";
if (compact.length > 0 && keyEvents.length < 8) keyEvents.push(`${kind}: ${compact}`);
}
const taskSummary = input.taskSummary ? redactText(input.taskSummary) : null;
if (taskSummary !== null) redactionsApplied += taskSummary.redactionsApplied;
const summaryPreview = taskSummary === null ? null : clampText(taskSummary.text, 300);
if (status === "unknown" && lines.length > 0) status = "running";
if (status === "running") {
openQuestions.push("Confirm whether current host Codex session still needs direct control.");
recommendedNextActions.push("Review the last session events and keep the state file current.");
} else if (status === "attention_required") {
openQuestions.push("Identify the action that needs approval or operator intervention.");
recommendedNextActions.push("Draft approval text before any live command path is considered.");
} else if (status === "blocked") {
openQuestions.push("Identify the blocking error or missing prerequisite.");
recommendedNextActions.push("Capture the failure summary and keep the action non-live.");
} else if (status === "terminal") {
recommendedNextActions.push("Persist the terminal summary and mark the session read.");
} else {
recommendedNextActions.push("Collect more trace lines before making a state claim.");
}
return {
taskId: input.taskId,
sessionId: input.sessionId,
lastSeq,
status,
keyEvents,
openQuestions,
recommendedNextActions,
redactionsApplied,
sourceCount: lines.length,
taskSummaryPreview: summaryPreview,
traceLineCount: lines.length,
};
}
export function commanderHealth(config: CommanderStorageConfig, startedAt: string): CommanderHealth {
const paths = commanderStatePaths(config);
return {
ok: true,
service: "host-codex-commander",
status: "healthy",
startedAt,
stateRoot: config.rootDir,
stateRootExists: existsSync(config.rootDir),
currentStateFile: paths.stateFile,
currentApprovalFile: paths.approvalFile,
currentTraceFile: paths.traceFile,
currentLogFile: paths.logFile,
};
}
function normalizeSessionRecord(record: Record<string, unknown>, sessionId: string): CommanderSessionRecord {
const notes = Array.isArray(record.notes) ? record.notes.filter((item): item is string => typeof item === "string").map((item) => redactText(item).text) : [];
return {
sessionId: String(record.sessionId ?? sessionId),
state: normalizeSessionState(String(record.state ?? "unknown")),
promptState: normalizePromptState(String(record.promptState ?? "draft")),
approvalState: normalizeApprovalState(String(record.approvalState ?? "draft")),
pid: typeof record.pid === "number" && Number.isFinite(record.pid) ? Math.floor(record.pid) : null,
cwd: typeof record.cwd === "string" && record.cwd.length > 0 ? record.cwd : null,
lastSeq: Number.isFinite(Number(record.lastSeq ?? 0)) ? Math.max(0, Math.floor(Number(record.lastSeq ?? 0))) : 0,
heartbeatAt: typeof record.heartbeatAt === "string" && record.heartbeatAt.length > 0 ? record.heartbeatAt : null,
updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(),
notes,
};
}
function normalizeApprovalRecord(record: Record<string, unknown>, approvalId: string): CommanderApprovalDraftRecord {
const previewMarkdown = typeof record.previewMarkdown === "string" ? redactText(record.previewMarkdown).text : "";
const previewJson = typeof record.previewJson === "object" && record.previewJson !== null && !Array.isArray(record.previewJson)
? redactJsonValue(record.previewJson) as Record<string, unknown>
: { ok: true, status: "draft" };
return {
id: String(record.id ?? approvalId),
action: typeof record.action === "string" ? record.action : "unknown",
taskId: typeof record.taskId === "string" ? record.taskId : null,
reason: typeof record.reason === "string" ? redactText(record.reason).text : "",
status: normalizeApprovalState(String(record.status ?? "draft")),
previewMarkdown,
previewJson,
redactionsApplied: Number.isFinite(Number(record.redactionsApplied ?? 0)) ? Math.max(0, Math.floor(Number(record.redactionsApplied ?? 0))) : 0,
createdAt: typeof record.createdAt === "string" && record.createdAt.length > 0 ? record.createdAt : nowIso(),
updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(),
};
}
function normalizeSessionState(value: string): CommanderSessionState {
return ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"].includes(value)
? value as CommanderSessionState
: "unknown";
}
function normalizePromptState(value: string): CommanderPromptState {
return ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"].includes(value)
? value as CommanderPromptState
: "draft";
}
function normalizeApprovalState(value: string): CommanderApprovalState {
return ["draft", "requested", "approved", "rejected", "expired", "consumed"].includes(value)
? value as CommanderApprovalState
: "draft";
}
export function normalizeCommanderSessionState(value: string): CommanderSessionState {
return normalizeSessionState(value);
}
export function normalizeCommanderPromptState(value: string): CommanderPromptState {
return normalizePromptState(value);
}
export function normalizeCommanderApprovalState(value: string): CommanderApprovalState {
return normalizeApprovalState(value);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"rootDir": "../../",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts", "../../shared/src/**/*.ts"]
}