xuym-codex-collab 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +140 -2
  2. package/apps/peer/desktop.mjs +183 -0
  3. package/apps/peer/main.mjs +18 -0
  4. package/apps/peer/mcp-main.mjs +10 -0
  5. package/apps/peer/mcp.mjs +32 -0
  6. package/apps/peer/private-state.mjs +42 -0
  7. package/apps/peer/process.mjs +47 -0
  8. package/apps/peer/provider.mjs +39 -0
  9. package/apps/peer/runner.mjs +110 -0
  10. package/apps/peer/runtime.mjs +26 -0
  11. package/apps/peer/wecom.mjs +140 -0
  12. package/bin/codex-collab.mjs +95 -0
  13. package/docs/architecture.md +7 -0
  14. package/docs/desktop-poll.md +159 -0
  15. package/docs/local-automatic-test.md +170 -0
  16. package/docs/local-peer-test.md +193 -0
  17. package/docs/onboarding.md +117 -0
  18. package/docs/peer.md +188 -0
  19. package/docs/protocol.md +19 -0
  20. package/package.json +57 -4
  21. package/packages/codex-kit/templates/.agents/skills/team-collab/SKILL.md +18 -0
  22. package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +26 -0
  23. package/packages/codex-kit/templates/.codex/config.toml.example +5 -0
  24. package/packages/codex-kit/templates/AGENTS.md +3 -0
  25. package/packages/peer/config.mjs +57 -0
  26. package/packages/peer/protocol.mjs +113 -0
  27. package/packages/peer/service.mjs +82 -0
  28. package/packages/peer/store.mjs +126 -0
  29. package/scripts/peer/cli.mjs +113 -0
  30. package/scripts/peer/configure-wecom.mjs +17 -0
  31. package/scripts/peer/create-identity.mjs +11 -0
  32. package/scripts/peer/create-team.mjs +26 -0
  33. package/scripts/peer/init-agent.mjs +15 -0
  34. package/scripts/peer/onboarding.mjs +383 -0
  35. package/scripts/peer/setup-desktop-project.mjs +64 -0
  36. package/scripts/peer/setup-local.mjs +93 -0
  37. package/scripts/wecom-diagnostic.mjs +15 -0
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # Codex Collab
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让同事的 Codex 通过一个 MCP + Skill 和可选本地 Runner 协作;使用**目标仓库** `agent-collab` 分支保存任务协议/事件,飞书/企业微信群通过一个 Bot 下达任务与查看状态。并不共享完整私有会话,也不能向任意已打开的桌面会话注入消息。
4
+
5
+ ## 运行状况
6
+
7
+ 已实现 Git 签名账本、MCP、企微/飞书适配、后台 Codex SDK Runner,以及桌面手动交接。2026-10-09 已在真实企业微信群完成后台 SDK Alice → Bob 的准确 SHA 接力,最终测试通过并等待真人审核;这不等于绑定已有桌面对话,跨两台机器仍未验证。
8
+
9
+ **指定已有桌面对话轮询模式 `desktop_poll` 已完成本机真实闭环验证**。据用户于 2026-10-10 确认,真实企业微信群任务已由 Alice 的既有 Codex 对话接单执行,再由 Bob 的既有 Codex 对话接力;本机适配器负责收群消息/发通知,MCP 提供对话绑定、原子接单、隔离工作树、续租、固定测试和交接,已有对话的 app heartbeat 负责轮询。跨两台机器部署仍未验证。当前全套自动测试 62 项通过。操作见 [`docs/desktop-poll.md`](docs/desktop-poll.md)。
10
+
11
+ ## 推荐:Peer 模式(不部署中心服务)
12
+
13
+ 如果不想运行共享 HTTP 服务,使用已实现的 Peer 模式:每位同事本机安装 Skill + stdio MCP,并运行自己的 Peer Runner;任务、签名事件、候补分配和交接 commit 直接写入目标仓库的 `agent-collab` 分支。企业微信机器人是可选的命令/通知适配器,不是调度真相。
14
+
15
+ 面向团队成员的 npm CLI 接入说明见 [`docs/onboarding.md`](docs/onboarding.md)。公开 npm 包名为 `xuym-codex-collab`,安装后使用 `codex-collab` 命令:
16
+
17
+ ```sh
18
+ npm install --global xuym-codex-collab
19
+ codex-collab --help
20
+ ```
21
+
22
+ ```sh
23
+ pnpm peer:create-team /abs/team.json repo-id alice frontend \
24
+ alice:admin:frontend,backend bob:member:frontend carol:member:backend
25
+ pnpm peer:init-agent /abs/team.json /abs/bob.private.json \
26
+ /abs/work/repo /abs/private/bob-data /abs/private/bob-peer.json
27
+ pnpm peer doctor /abs/private/bob-peer.json
28
+ pnpm peer start /abs/private/bob-peer.json
29
+ ```
30
+
31
+ MCP 创建的任务默认保留给当前桌面 Codex,完成并 push 后调用 `handoff_task`;旧自动模式下群任务路由给本地 Runner;配置 `desktop_poll` 后群任务等待指定已有对话的 heartbeat 认领。完整初始化、企业微信配置、候补和故障恢复见 [`docs/peer.md`](docs/peer.md)。
32
+
33
+ **群 @ 后在新的后台 SDK 会话执行(不是已有桌面对话)**:见 [`docs/local-automatic-test.md`](docs/local-automatic-test.md)。新建 automatic 测试环境后,Alice/Bob Runner 都自动执行,按 frontend → backend 路由交接;可安全复用旧私有机器人配置,但启动前必须停旧连接。Bot 对应 Runner 创建的新 Codex SDK 会话,**不是已有桌面对话**。
34
+
35
+ **本机用两个 Codex 对话和两个企业微信机器人手动模拟同事**:见 [`docs/local-peer-test.md`](docs/local-peer-test.md)。`peer:setup-local` 一次生成隔离 demo、副本身份和项目 MCP,不改 CC Switch、不推送 GitHub;第二个对话可用 `claim_desktop_task` 手动接力。本页不自动唤起对话;要让指定已有对话轮询接单,使用 [`docs/desktop-poll.md`](docs/desktop-poll.md)。
36
+
37
+ 以下为保留的**旧中心服务模式**操作;不要与上面的 Peer 测试混用机器人连接和命令。Peer 不使用 `localhost:38765`、HTTP 令牌或手工 `协作 分配` / `协作 交接` 命令。
38
+
39
+ ## Codex 之间的代码接力
40
+
41
+ 1. 已绑定身份的 Alice 在桌面 Codex 用 MCP `create_task` 创建任务(或在群里发 `协作 创建 ...`),无需先把任务分配给她;她也可以使用自己已被分配的任务。桌面 Codex 安装 Skill/MCP **不会自动被唤醒**。
42
+ 2. Alice 在目标仓库 `codex/*` 分支完成自己的部分,执行可信测试并提交 Git commit;跨机器时还要将分支推送到共同的 `origin`。不要将密钥、聊天全文写入仓库。
43
+ 3. Alice 用 `read_task` 获取最新 `state.seq`,调用 MCP `handoff_task`:传 `task_id`、`expected_seq`、`summary`(已完成与下一步)、`branch`、完整 `commit` SHA、真实 `tests` 结果。服务会核实分支 tip 与 commit,写入目标仓库的 `agent-collab` 账本,任务进入 `waiting`。
44
+ 4. Alice 作为管理员在同一个企微群 @机器人,发送 `协作 交接 T-... bob`;服务器验证身份后分配任务。Bob 本机持续运行的个人 Runner 领取任务,从交接 commit 建独立 worktree 和新的 Codex SDK 会话;完成后写代码分支、测试证据和 `review_requested`。只有真人审核可批准/完成。
45
+
46
+ 目前 `协作 交接` 只路由给一个已配置的企微 Bot;仅在**更新并重启中心服务**后生效。要显示两个不同的机器人身份并分别接受 @ 消息,还需增加同一中心服务的双 Bot 路由;不要运行两个服务并发写同一账本。MCP/Skill 负责桌面 Codex **主动发布交接**,Runner 才负责同事端的**自动领取和新建会话**。
47
+
48
+ ## 两个仓库本地测试
49
+
50
+ Node.js >= 20,pnpm 11;在本仓库执行:
51
+
52
+ ```sh
53
+ pnpm install --frozen-lockfile
54
+ pnpm test
55
+ ```
56
+
57
+ 演示目标仓库 `codex-collab-demo` 的 `main` 执行 `sh test.sh`,而任务事件在 `agent-collab`。示例任务 T-001 为故意未完成的编程任务;可用两个不同用户通过 API/MCP 和 Runner 测试。现有 T-001 未指定审核人,建议通过飞书命令创建**新任务**以获得默认管理员审核人。
58
+
59
+ ## 本地启动
60
+
61
+ 生成账号文件(不要放进 Git,执行完把终端输出的个人令牌安全地分发给对应用户):
62
+
63
+ ```sh
64
+ node scripts/create-accounts.mjs /tmp/collab-accounts.json alice:admin:ou_alice bob:member:ou_bob
65
+ COLLAB_REPO=/path/to/codex-collab-demo \
66
+ COLLAB_ACCOUNTS_FILE=/tmp/collab-accounts.json \
67
+ COLLAB_DATA_DIR=/tmp/collab-service \
68
+ COLLAB_REPO_ID=jack506255560/codex-collab-demo \
69
+ node apps/server/main.mjs
70
+ ```
71
+
72
+ 运行成功后 `curl http://127.0.0.1:38765/health`。`ou_alice` 与 `ou_bob` 需要换为飞书用户的**真实** `open_id`。管理员身份为默认审核人;编码负责人不能与审核人为同一人。如需自动领取任务,同事机器运行:
73
+
74
+ ```sh
75
+ COLLAB_REPO=/path/to/codex-collab-demo \
76
+ COLLAB_API=https://your-server \
77
+ COLLAB_TOKEN=<bob的个人令牌> COLLAB_USER_ID=bob \
78
+ COLLAB_DATA_DIR=/path/to/private-runner-data \
79
+ COLLAB_TEST_ARGV='["sh","test.sh"]' \
80
+ node apps/runner/main.mjs
81
+ ```
82
+
83
+ `COLLAB_API` 单机实验可用 `http://127.0.0.1:38765`;不同机器应提供 HTTPS,且每台机器的目标仓库含 `main` 和 `agent-collab` 分支。若跨机器分发代码分支,需要在服务和 Runner 均设置 `COLLAB_PUSH=1`,保证 origin 可访问且个人 Git 凭据已配置。服务向 `agent-collab` 推送账本,Runner 向 `codex/task-*` 推送代码;**没有凭据会失败**。不能直接把 localhost 地址给其他同事。
84
+
85
+ ## 飞书接入
86
+
87
+ 1. 飞书开放平台创建企业自建应用,启用机器人、申请“以应用身份发送消息”和接收群消息所需权限;启用 `im.message.receive_v1` 事件,选择**长连接**接收并发布/授权应用(具体权限以飞书控制台当前要求为准)。
88
+ 2. 将 Bot 加到群中,先在本机设置 `FEISHU_APP_ID`、`FEISHU_APP_SECRET` 并运行 `node scripts/discover-feishu.mjs`;在目标群发一条普通消息,终端只打印 `chat_id` 和 `sender_open_id`(不会保存正文),确认所有同事的 open_id 后停止该脚本,再填入账号文件与 `FEISHU_CHAT_IDS` 允许列表。**不要**写死或提交 App Secret。
89
+ 3. 启动中心服务时追加:`FEISHU_APP_ID=... FEISHU_APP_SECRET=... FEISHU_CHAT_IDS=oc_...`。看到 WebSocket 订阅日志后,在允许的群由已映射成员发:`协作 创建 写一个功能 | 验收一;验收二`,管理员 `协作 分配 T-... bob`,所有人 `协作 状态 T-...`。Runner 提交审核请求后审核人 `协作 审核 T-... 通过` 或 `协作 审核 T-... 退回`,最后管理员 `协作 完成 T-...`。
90
+ 4. 检查群回复、服务日志、目标仓库 `agent-collab` 事件与本机代码分支。未配置飞书凭据时可以跑本地模拟 E2E;模拟通过**不等于**真实群接通。
91
+
92
+ ## 本机接企业微信群(已有隔离 demo 和两个 Runner 时)
93
+
94
+ **先在企业微信管理端创建/配置支持 API 长连接的智能机器人**,获取 Bot ID 和**新** Secret,加入测试群。此前曾在聊天里暴露的 Secret 已不安全:必须先重置,不要再次发送到聊天或提交 Git。此处用的是「智能机器人 API 模式」,不是只有 webhook URL 的群机器人;连接通过官方 `@wecom/aibot-node-sdk` WebSocket 出站发起,不需要公网地址。确认组织管理员允许此类型机器人、目标群确实能收到其群消息。一个 Bot 足够;它通过账号映射和本地两个 Runner 分配身份,不对应两个桌面 Codex 会话。群消息能否到达以真机发现为准,尝试在群内 @ 机器人发送文本。
95
+
96
+ 当前单机测试目录可以这样找(不要打印里面的令牌或环境文件):
97
+
98
+ ```sh
99
+ cd "$HOME/codex-collab"
100
+ # 确保 Node.js >= 20 已安装并且 node 位于 PATH。
101
+ root="$(cat "$HOME/.codex-collab-local-test/current")"
102
+ # 仅在你的本机交互终端输入;不要把实际值放在命令行参数或聊天中。
103
+ printf 'Bot ID: '; IFS= read -r WECOM_BOT_ID
104
+ printf '新 Bot Secret(不回显): '; IFS= read -rs WECOM_BOT_SECRET; echo
105
+ export WECOM_BOT_ID WECOM_BOT_SECRET
106
+ node scripts/discover-wecom.mjs
107
+ ```
108
+
109
+ 等待输出 `WeCom authenticated`,在目标群里向机器人发送文本,例如 `@机器人 协作 状态 T-NONE`;发现脚本仅输出 `{"chatid":"...","userid":"..."}`,**不会输出正文**。这一步如果只有认证而没有群消息,不代表群已接通:检查群是否已加入机器人、消息触发方式和企业管理员配置。在发现脚本按 `Ctrl-C` 关闭连接,再继续下面步骤;**同一个机器人不要让发现脚本和服务同时建立连接**。至少记下一个真实群 `chatid` 及你(管理员 alice)的 `userid`,若同事要在群里发命令,再收集其 `userid`。不要猜 ID、不要贴到 Git。以下命令的引号和交互输入要保留:
110
+
111
+ ```sh
112
+ printf '刚发现的群 chatid: '; IFS= read -r WECOM_CHAT_IDS
113
+ printf '你自己的 userid(alice): '; IFS= read -r ALICE_WECOM_USERID
114
+ export WECOM_CHAT_IDS
115
+ node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.wecom.json" "alice:$ALICE_WECOM_USERID"
116
+ # 可选:另一位真人需在群里操作时,用他的 userid 重建私有文件;不要把两人的 userid 填成同一个。
117
+ # node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.team.json" "alice:$ALICE_WECOM_USERID" 'bob:<同事的真实userid>'
118
+ # 新文件只保留原有的个人 token 哈希,原有 Runner 令牌不会变化。
119
+ . "$root/private.env"
120
+ export COLLAB_ACCOUNTS_FILE="$root/accounts.wecom.json"
121
+ # 仅停原中心服务,保持两个 Runner;先确认旧 PID 确实是本工具的服务。
122
+ old="$(cat "$root/server.pid")"
123
+ ps -p "$old" -o command=
124
+ # 仅在确认该进程属于当前仓库的 apps/server/main.mjs 后,才手动执行:kill "$old"
125
+ node apps/server/main.mjs
126
+ ```
127
+
128
+ 上面最后一条**前台运行**,该终端不要退出;另开终端 `curl http://127.0.0.1:38765/health` 检查服务。若提示端口占用,先核对旧服务 PID,勿盲目杀其他进程。若群中收到真实回复才算接通。群里的任务示例:
129
+
130
+ 1. `协作 创建 给 greet.sh 增加 --upper 行为 | sh test.sh 通过;sh greet.sh --upper 输出 HELLO, WORLD;保留原有默认输出`,记下新任务 `T-...`;不要重复使用历史任务。
131
+ 2. `协作 分配 T-... bob`;本机 Bob Runner 自动领取并在隔离副本创建 `codex/task-*` 代码分支。Alice Runner 可读取审查任务并写私有审查记录。
132
+ 3. `协作 状态 T-...`;人工核对 `git -C "$root/demo" branch --list 'codex/task-*'`、`sh test.sh`(需要在**任务分支**的 worktree 执行;`main` 故意未完成,不能直接把它的失败当任务失败)和 `$root/server-data` 的 Git 账本;不能把 Runner 记录当人工批准。
133
+ 4. 确认代码和测试证据后,由 **alice 真人**发 `协作 审核 T-... 通过`,再发 `协作 完成 T-...`;这两个动作不会由 Runner 自动执行。失败就 `协作 审核 T-... 退回`。
134
+
135
+ **注意**:发现、认证、模拟 E2E、真实群消息送达、群回复、主动任务通知是不同的成功条件;逐项核实。这里使用 `$root/demo` 隔离副本,不修改或推送原始 demo 仓库。`COLLAB_PUSH` 单机保持默认关闭;源仓库与真实企业账号信息勿放入 Git。若之前本机 Runner 不在运行,查看 `ps` 与 `apps/runner/README.md`;不要以为启动 HTTP 服务就会自动启动 Runner。
136
+
137
+ ## 同事接入 MCP 和 Skill
138
+
139
+ 执行 `node scripts/install/install.mjs https://your-server/mcp`,将生成的 TOML 示例加入个人 Codex 配置,将 `COLLAB_TOKEN` 安全注入启动 Codex 的环境并重启。详见 `scripts/install/README.md`。安装 Skill 不会自动启动 Runner;只要桌面人工协作可不跑 Runner。MCP endpoint 用 Bearer 身份,未登录用户无法写任务。
140
+
141
+ 实现细节和限制见 `docs/architecture.md`、`docs/protocol.md`、`apps/runner/README.md`。**单写服务**假设,不支持多个中心服务实例同时写 Git。
@@ -0,0 +1,183 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { Conflict, Denied, leaseToken } from '../../packages/peer/protocol.mjs';
7
+ import { git } from '../../packages/peer/store.mjs';
8
+ import { PrivateDesktopState } from './private-state.mjs';
9
+ const exec = promisify(execFile);
10
+ const threadPattern = /^[a-zA-Z0-9_-]{1,128}$/;
11
+ const active = (job) => ['claimed', 'prepared', 'executing'].includes(job.status);
12
+
13
+ /** Desktop tools manage leases/worktrees, NOT model turns or app wakeups. */
14
+ export class DesktopExecutor {
15
+ constructor(service, config) {
16
+ this.service = service; this.config = config;
17
+ this.private = new PrivateDesktopState(config.data_dir);
18
+ }
19
+ checkMode() {
20
+ if (this.config.execution_mode !== 'desktop_poll' || this.config.auto_execute !== false) throw new Denied('configure execution_mode=desktop_poll and auto_execute=false first');
21
+ }
22
+ checkBinding(state, threadId) {
23
+ this.checkMode();
24
+ if (!threadPattern.test(threadId || '') || state.binding?.thread_id !== threadId || state.binding.agent_id !== this.service.agentId) throw new Denied('this conversation is not the bound desktop thread');
25
+ }
26
+ async bind(threadId) {
27
+ this.checkMode();
28
+ if (!threadPattern.test(threadId || '')) throw new Conflict('provide the actual target conversation ID; do not invent an ID');
29
+ return this.private.locked(async (state) => {
30
+ if (state.binding && (state.binding.thread_id !== threadId || state.binding.agent_id !== this.service.agentId)) throw new Conflict('identity is already bound; explicitly unbind before choosing another conversation');
31
+ state.binding ||= { agent_id: this.service.agentId, thread_id: threadId, bound_at: new Date().toISOString() };
32
+ await this.private.write(state);
33
+ return { ...state.binding, execution_mode: 'desktop_poll', automation_created: false, requires_heartbeat_in_this_thread: true };
34
+ });
35
+ }
36
+ async status() {
37
+ this.checkMode();
38
+ return this.private.locked(async (state) => ({ agent_id: this.service.agentId, binding: state.binding, jobs: Object.values(state.jobs).map((job) => this.jobView(job)), background_sdk_enabled: false }));
39
+ }
40
+ async unbind(threadId) {
41
+ return this.private.locked(async (state) => {
42
+ this.checkBinding(state, threadId);
43
+ const tasks = await this.service.list();
44
+ if (tasks.some((task) => task.spec.execution_mode === 'desktop_poll' && task.state.status === 'running' && task.state.assignee === this.service.agentId && this.service.store.clock() < Date.parse(task.state.lease.expires_at))) throw new Conflict('cannot unbind while a task lease is active');
45
+ state.binding = null; await this.private.write(state);
46
+ return { unbound: true, automation_paused: false, next_action: 'Pause/delete the thread heartbeat separately; this tool cannot control app automations.' };
47
+ });
48
+ }
49
+ jobView(job) {
50
+ const { token, ...view } = job;
51
+ return view;
52
+ }
53
+ async ensureJob(state, threadId, taskId) {
54
+ this.checkBinding(state, threadId);
55
+ const job = state.jobs[taskId];
56
+ if (!job || job.thread_id !== threadId || !active(job)) throw new Conflict('no active desktop job for this conversation and task');
57
+ const task = await this.service.assertLease(taskId, job.token);
58
+ if (task.spec.execution_mode !== 'desktop_poll') throw new Conflict('task is not a desktop polling job');
59
+ return { job, task };
60
+ }
61
+ async prepare(job, task) {
62
+ const refs = task.state.checkpoint;
63
+ const fetched = `refs/collab-desktop/${this.service.agentId}/${task.spec.id}/${job.token.epoch}`;
64
+ await git(this.config.repo, 'fetch', '--no-tags', '--no-write-fetch-head', 'origin', `${refs ? `refs/heads/${refs.branch}` : 'refs/heads/main'}:${fetched}`);
65
+ const tip = await git(this.config.repo, 'rev-parse', '--verify', `${fetched}^{commit}`);
66
+ if (refs) await git(this.config.repo, 'merge-base', '--is-ancestor', refs.commit, tip);
67
+ const source = refs?.commit || tip;
68
+ await this.service.assertLease(task.spec.id, job.token);
69
+ const worktree = join(this.config.repo, '.codex-collab-worktrees', `${task.spec.id.toLowerCase()}-${this.service.agentId}-${job.token.epoch}`);
70
+ const branch = `codex/desktop-${task.spec.id.toLowerCase()}-${this.service.agentId}-${job.token.epoch}`;
71
+ await mkdir(join(this.config.repo, '.codex-collab-worktrees'), { recursive: true, mode: 0o700 });
72
+ const excludePath = await git(this.config.repo, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude');
73
+ const excluded = await readFile(excludePath, 'utf8');
74
+ if (!excluded.split('\n').includes('/.codex-collab-worktrees/')) await writeFile(excludePath, `${excluded}\n/.codex-collab-worktrees/\n`);
75
+ let exists = false;
76
+ try { await stat(worktree); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
77
+ if (!exists) await git(this.config.repo, 'worktree', 'add', '-b', branch, worktree, source);
78
+ else {
79
+ // Recovery after an interrupted MCP call must never adopt an unrelated tree.
80
+ if (await git(worktree, 'rev-parse', '--show-toplevel') !== worktree || await git(worktree, 'branch', '--show-current') !== branch) throw new Conflict('unexpected existing desktop worktree');
81
+ await git(worktree, 'merge-base', '--is-ancestor', source, 'HEAD');
82
+ }
83
+ Object.assign(job, { source_commit: source, branch, worktree, status: 'prepared' });
84
+ }
85
+ async poll(threadId) {
86
+ return this.private.locked(async (state) => {
87
+ this.checkBinding(state, threadId);
88
+ const tasks = await this.service.list();
89
+ // Resume a still-live lease, including a claim whose acknowledgement/state
90
+ // write was interrupted. App thread binding is private and stays unchanged.
91
+ let task = tasks.find((t) => t.spec.execution_mode === 'desktop_poll' && t.state.status === 'running' && t.state.assignee === this.service.agentId && this.service.store.clock() < Date.parse(t.state.lease.expires_at));
92
+ for (const job of Object.values(state.jobs).filter(active)) {
93
+ const current = tasks.find((t) => t.spec.id === job.task_id);
94
+ if (!current || current.state.status !== 'running' || current.state.assignee !== this.service.agentId || current.state.epoch !== job.token.epoch || this.service.store.clock() >= Date.parse(current.state.lease.expires_at)) job.status = 'superseded';
95
+ }
96
+ if (!task) {
97
+ const next = await this.service.nextJob({ executionMode: 'desktop_poll' });
98
+ if (!next) { await this.private.write(state); return { agent_id: this.service.agentId, job: null, notify: false }; }
99
+ try { task = await this.service.claim(next.spec.id, `desktop-claim:${this.service.agentId}:${next.spec.id}:${next.state.epoch + 1}`, { executionMode: 'desktop_poll' }); }
100
+ catch (error) { if (error.status === 409) return { agent_id: this.service.agentId, job: null, notify: false }; throw error; }
101
+ }
102
+ const id = task.spec.id;
103
+ let job = state.jobs[id];
104
+ if (!job || job.token.epoch !== task.state.epoch) {
105
+ job = state.jobs[id] = { task_id: id, agent_id: this.service.agentId, thread_id: threadId, stage: task.state.stage, token: leaseToken(task), status: 'claimed', claimed_at: new Date().toISOString() };
106
+ await this.private.write(state);
107
+ }
108
+ if (job.thread_id !== threadId) throw new Denied('active job belongs to another bound conversation');
109
+ if (job.status === 'claimed') {
110
+ try { await this.prepare(job, task); await this.private.write(state); }
111
+ catch {
112
+ try { await this.service.report(id, { type: 'run_failed', summary: 'Desktop worktree preparation failed; inspect local Git configuration.' }, job.token); } catch { /* fenced/expired */ }
113
+ job.status = 'failed'; await this.private.write(state);
114
+ throw new Conflict('desktop preparation failed; no source worktree was overwritten');
115
+ }
116
+ }
117
+ return { agent_id: this.service.agentId, job: this.jobView(job), task, test_argv: this.config.test_argv || ['sh', 'test.sh'], renew_after_ms: Math.floor(this.service.team.policy.lease_ms / 3), instructions: 'Execute only this stage in this conversation and assigned worktree. Renew before long operations. Commit explicitly; finish runs fixed tests, pushes and publishes. Do not start a Codex SDK/CLI worker.' };
118
+ });
119
+ }
120
+ async renew(threadId, taskId) {
121
+ return this.private.locked(async (state) => {
122
+ const { job } = await this.ensureJob(state, threadId, taskId);
123
+ const task = await this.service.renew(taskId, job.token);
124
+ job.status = 'executing'; job.last_activity_at = new Date().toISOString(); await this.private.write(state);
125
+ return { task_id: taskId, epoch: task.state.epoch, expires_at: task.state.lease.expires_at };
126
+ });
127
+ }
128
+ async fail(threadId, taskId, summary) {
129
+ return this.private.locked(async (state) => {
130
+ const { job } = await this.ensureJob(state, threadId, taskId);
131
+ const task = await this.service.report(taskId, { type: 'run_failed', summary }, job.token, `desktop-fail:${taskId}:${job.token.epoch}`);
132
+ job.status = 'failed'; await this.private.write(state);
133
+ return { task_id: taskId, status: task.state.status, worktree_preserved: true };
134
+ });
135
+ }
136
+ async finish(threadId, taskId, summary) {
137
+ return this.private.locked(async (state) => {
138
+ this.checkBinding(state, threadId);
139
+ const previous = state.jobs[taskId];
140
+ if (previous && ['handoff', 'review_requested'].includes(previous.status)) return this.service.read(taskId);
141
+ // Recover an accepted publication after a lost acknowledgement. Never rerun
142
+ // tests or claim another epoch in order to retry an already accepted finish.
143
+ const current = await this.service.read(taskId);
144
+ if (previous && current.events.some((e) => e.idempotency_key === `desktop-finish:${taskId}:${previous.token.epoch}` && e.actor_id === this.service.agentId)) {
145
+ previous.status = current.events.find((e) => e.idempotency_key === `desktop-finish:${taskId}:${previous.token.epoch}`).type;
146
+ await this.private.write(state); return current;
147
+ }
148
+ const { job, task } = await this.ensureJob(state, threadId, taskId);
149
+ if (await git(job.worktree, 'branch', '--show-current') !== job.branch || await git(job.worktree, 'status', '--porcelain')) throw new Conflict('commit all intended changes explicitly on the assigned branch before finishing');
150
+ const commit = await git(job.worktree, 'rev-parse', 'HEAD');
151
+ if (commit === job.source_commit) throw new Conflict('stage has no committed code changes');
152
+ await git(job.worktree, 'merge-base', '--is-ancestor', job.source_commit, commit);
153
+ await this.service.renew(taskId, job.token);
154
+ const controller = new AbortController(); let pulse = Promise.resolve(); let renewalFailed = false;
155
+ // Renewal exists only while THIS bounded foreground publication/test call
156
+ // runs. Bot/MCP process uptime cannot keep an abandoned editing turn alive.
157
+ const timer = setInterval(() => { pulse = pulse.then(async () => { try { await this.service.renew(taskId, job.token); } catch { renewalFailed = true; controller.abort(); } }); }, Math.max(25, Math.floor(this.service.team.policy.lease_ms / 3)));
158
+ try {
159
+ const argv = this.config.test_argv || ['sh', 'test.sh'];
160
+ try {
161
+ await exec(argv[0], argv.slice(1), { cwd: job.worktree, env: { PATH: process.env.PATH, HOME: job.worktree, LANG: process.env.LANG || 'C', TMPDIR: process.env.TMPDIR }, timeout: 120000, maxBuffer: 1000000, signal: controller.signal });
162
+ if (await git(job.worktree, 'status', '--porcelain') || await git(job.worktree, 'rev-parse', 'HEAD') !== commit || await git(job.worktree, 'branch', '--show-current') !== job.branch) throw new Error('tests changed code or branch');
163
+ } catch {
164
+ if (!renewalFailed) {
165
+ try { await this.service.report(taskId, { type: 'run_failed', summary: 'Fixed desktop verification failed or changed the worktree; remaining candidates may take over.' }, job.token, `desktop-fail:${taskId}:${job.token.epoch}`); } catch { /* fenced */ }
166
+ }
167
+ job.status = 'failed'; await this.private.write(state);
168
+ throw new Conflict('fixed verification failed; worktree preserved; no successful handoff was published');
169
+ }
170
+ await this.service.assertLease(taskId, job.token);
171
+ if (renewalFailed) throw new Conflict('execution lease lost; stop publishing');
172
+ await git(job.worktree, 'push', 'origin', `HEAD:refs/heads/${job.branch}`);
173
+ await this.service.assertLease(taskId, job.token);
174
+ clearInterval(timer); await pulse;
175
+ const more = task.state.stage + 1 < task.spec.workflow.length;
176
+ const type = more ? 'handoff' : 'review_requested';
177
+ const result = await this.service.report(taskId, { type, summary, refs: { branch: job.branch, commit, tests: `${argv.join(' ')}: pass` } }, job.token, `desktop-finish:${taskId}:${job.token.epoch}`);
178
+ job.status = type; job.commit = commit; job.finished_at = new Date().toISOString(); await this.private.write(state);
179
+ return result;
180
+ } finally { clearInterval(timer); await pulse; }
181
+ });
182
+ }
183
+ }
@@ -0,0 +1,18 @@
1
+ import { loadRuntime, readPeerConfig } from './runtime.mjs';
2
+ import { PeerRunner } from './runner.mjs';
3
+ import { startPeerWecom } from './wecom.mjs';
4
+ import { acquireRunnerLock } from './process.mjs';
5
+ const path = process.argv[2] || process.env.COLLAB_PEER_CONFIG;
6
+ const { config } = await readPeerConfig(path);
7
+ const release = await acquireRunnerLock(config.data_dir, path);
8
+ try {
9
+ const runtime = await loadRuntime(path);
10
+ let bridge;
11
+ if (runtime.config.wecom) bridge = startPeerWecom({ service: runtime.service, config: { ...runtime.config.wecom, execution_mode: runtime.config.execution_mode === 'desktop_poll' ? 'desktop_poll' : runtime.config.wecom.execution_mode } });
12
+ const runner = new PeerRunner(runtime.service, runtime.config);
13
+ process.once('SIGINT', () => { runner.stop(); bridge?.close?.(); });
14
+ process.once('SIGTERM', () => { runner.stop(); bridge?.close?.(); });
15
+ console.error(`Codex peer runner online as ${runtime.config.agent_id}; ${runtime.config.auto_execute === false ? 'notifications only; automatic code execution disabled' : 'automatic claim/fallback is Git-backed'}`);
16
+ try { await runner.loop({ pollNotifications: () => bridge?.poll?.() }); }
17
+ finally { bridge?.close?.(); }
18
+ } finally { await release(); }
@@ -0,0 +1,10 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
+ import { makePeerMcp } from './mcp.mjs';
4
+ import { loadRuntime, readPeerConfig } from './runtime.mjs';
5
+ const path = process.argv[2] || process.env.COLLAB_PEER_CONFIG;
6
+ if (!path) throw new Error('usage: node apps/peer/mcp-main.mjs /absolute/path/to/private-peer.json');
7
+ const { config } = await readPeerConfig(resolve(path));
8
+ const runtime = await loadRuntime(resolve(path), { dataDir: join(config.data_dir, 'mcp', String(process.pid)) });
9
+ const service = runtime.service;
10
+ serveStdio(() => makePeerMcp(service, { config }), { onerror: (error) => console.error('peer MCP error:', error.message) });
@@ -0,0 +1,32 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import * as z from 'zod/v4';
3
+ import { DesktopExecutor } from './desktop.mjs';
4
+ const required = z.string().min(1);
5
+ export function makePeerMcp(service, { config } = {}) {
6
+ const server = new McpServer({ name: 'codex-collab-peer', version: '0.3.0' });
7
+ const tool = (name, description, schema, fn) => server.registerTool(name, { description, inputSchema: schema }, async (input) => {
8
+ try { return { content: [{ type: 'text', text: JSON.stringify(await fn(input)) }] }; }
9
+ catch (error) { return { isError: true, content: [{ type: 'text', text: error.status ? error.message : 'Peer operation failed; inspect local configuration and Git access.' }] }; }
10
+ });
11
+ tool('get_identity', 'Check this MCP identity before creating, claiming or editing a task. Does not return keys or local paths.', z.object({}), () => ({ agent_id: service.agentId, repo: service.team.repo, role: service.team.agents[service.agentId].role, capabilities: service.team.agents[service.agentId].capabilities, execution_mode: config?.execution_mode || 'legacy' }));
12
+ tool('list_tasks', 'Read signed shared tasks directly from Git; no central HTTP service', z.object({}), () => service.list());
13
+ tool('read_task', 'Read the task, exact handoff commit, lease and workflow stage', z.object({ task_id: required }), ({ task_id }) => service.read(task_id));
14
+ tool('claim_desktop_task', 'Manually reserve the next stage of a waiting desktop-created task for this desktop identity. No automatic wakeup or lease expiry; requires latest seq and configured route eligibility. Other Runners cannot execute it once reserved.', z.object({ task_id: required, expected_seq: z.number().int().min(1), idempotency_key: required.optional() }), ({ task_id, expected_seq, idempotency_key }) => service.claimDesktop(task_id, expected_seq, idempotency_key));
15
+ tool('create_task', 'Create a signed task. By default the current desktop Codex owns workflow[0]; include a second configured role for a teammate. Call handoff_task after pushing code. Use execution_mode=automatic for Runner dispatch, or desktop_poll for the bound existing conversation heartbeat.', z.object({ title: required.max(200), description: z.string().max(4000).default(''), acceptance: z.array(required.max(1000)).min(1).max(32), workflow: z.array(required).min(1).max(16).optional(), execution_mode: z.enum(['desktop', 'automatic', 'desktop_poll']).default('desktop'), chat_id: required.optional(), idempotency_key: required.optional() }), ({ idempotency_key, chat_id, ...input }) => service.create({ ...input, chatId: chat_id }, idempotency_key));
16
+ tool('handoff_task', 'Publish a pushed code commit and next-step summary for the next workflow role. Requires the next configured stage; never publishes private conversations.', z.object({ task_id: required, expected_seq: z.number().int().min(1), summary: required.max(4000), branch: required, commit: required, tests: required.max(1000), idempotency_key: required.optional() }), ({ task_id, expected_seq, idempotency_key, summary, branch, commit, tests }) => service.handoff(task_id, { summary, refs: { branch, commit, tests } }, expected_seq, idempotency_key));
17
+ tool('request_review', 'Publish the final stage code and test evidence for explicit human review; this does not approve or complete the task.', z.object({ task_id: required, expected_seq: z.number().int().min(1), summary: required.max(4000), branch: required, commit: required, tests: required.max(1000), idempotency_key: required.optional() }), ({ task_id, expected_seq, idempotency_key, summary, branch, commit, tests }) => service.requestReview(task_id, { summary, refs: { branch, commit, tests } }, expected_seq, idempotency_key));
18
+ if (config) {
19
+ const desktop = new DesktopExecutor(service, config);
20
+ const thread = required.max(128).describe('Actual bound Codex desktop conversation ID. This is explicit client binding, not host-attested caller authentication.');
21
+ const job = z.object({ thread_id: thread, task_id: required });
22
+ tool('bind_desktop_thread', 'Bind this agent to the actual current desktop conversation. Call once in the intended Alice/Bob thread, then separately create a heartbeat in THAT thread. Does not create an automation or launch a model.', z.object({ thread_id: thread }), ({ thread_id }) => desktop.bind(thread_id));
23
+ tool('desktop_status', 'Read private local desktop binding and job metadata, not credentials. Binding/thread IDs are never written to the Git task ledger.', z.object({}), () => desktop.status());
24
+ tool('unbind_desktop_thread', 'Explicitly remove the local binding only when no live job is held. Pause/delete the app heartbeat separately.', z.object({ thread_id: thread }), ({ thread_id }) => desktop.unbind(thread_id));
25
+ tool('poll_desktop_task', 'Called by the bound desktop thread heartbeat: atomically claim or resume one desktop_poll task and prepare its isolated worktree. Returns job=null if nothing is actionable. Execute the stage IN THIS conversation; no SDK session is launched.', z.object({ thread_id: thread }), ({ thread_id }) => desktop.poll(thread_id));
26
+ tool('renew_desktop_task', 'Renew a live task only while this bound conversation is actively working. Call before/after long editing or command operations. Bot uptime does not renew the lease.', job, ({ thread_id, task_id }) => desktop.renew(thread_id, task_id));
27
+ tool('finish_desktop_task', 'After explicitly committing code in the assigned worktree, run the trusted fixed test command, verify lease and ancestry, push, and publish handoff or final review. Does not approve, merge main, send arbitrary commands, or start the next conversation. Bounded foreground tests renew their lease.', job.extend({ summary: required.max(4000) }), ({ thread_id, task_id, summary }) => desktop.finish(thread_id, task_id, summary));
28
+ tool('fail_desktop_task', 'Report a concise sanitized stage failure; release the lease for configured fallback. Preserves the worktree. Never include private prose, secrets or raw errors.', job.extend({ summary: required.max(4000) }), ({ thread_id, task_id, summary }) => desktop.fail(thread_id, task_id, summary));
29
+ }
30
+ // Human approval is deliberately not an agent tool: owner group commands / CLI only.
31
+ return server;
32
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir, open, readFile, rename, rm, chmod } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { alive } from './process.mjs';
6
+ import { readPrivateJson } from '../../packages/peer/config.mjs';
7
+
8
+ // Shared by all project MCP processes. Never use a process-local MCP data dir for
9
+ // thread binding or job tokens, and never persist this file in the Git ledger.
10
+ export class PrivateDesktopState {
11
+ constructor(dataDir) { this.directory = dataDir; this.path = join(dataDir, 'desktop-state.json'); }
12
+ async read() {
13
+ try { return await readPrivateJson(this.path); }
14
+ catch (error) { if (error.code === 'ENOENT') return { schema_version: 1, binding: null, jobs: {} }; throw error; }
15
+ }
16
+ async write(value) {
17
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
18
+ const file = await open(temporary, 'wx', 0o600);
19
+ try { await file.writeFile(JSON.stringify(value, null, 2) + '\n'); await file.sync(); }
20
+ finally { await file.close(); }
21
+ await rename(temporary, this.path); await chmod(this.path, 0o600);
22
+ }
23
+ async locked(fn) {
24
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
25
+ const path = join(this.directory, 'desktop-state.lock');
26
+ const deadline = Date.now() + 180000; let file;
27
+ while (!file) {
28
+ try { file = await open(path, 'wx', 0o600); await file.writeFile(String(process.pid)); }
29
+ catch (error) {
30
+ if (error.code !== 'EEXIST') throw error;
31
+ try {
32
+ const pid = Number(await readFile(path, 'utf8'));
33
+ if (Number.isSafeInteger(pid) && pid > 0 && !alive(pid)) await rm(path, { force: true });
34
+ } catch (readError) { if (readError.code !== 'ENOENT') throw readError; }
35
+ if (Date.now() > deadline) throw new Error('another desktop operation is still active');
36
+ await delay(30);
37
+ }
38
+ }
39
+ try { return await fn(await this.read()); }
40
+ finally { await file.close(); await rm(path, { force: true }); }
41
+ }
42
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdir, open, readFile, rm } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ export function alive(pid) {
6
+ if (!Number.isSafeInteger(pid) || pid < 1) return false;
7
+ try { process.kill(pid, 0); return true; }
8
+ catch (error) { return error.code !== 'ESRCH'; }
9
+ }
10
+ // A newly created exclusive lock is visible before its JSON has been written.
11
+ // Never mistake an in-progress write for a stale lock, or remove it.
12
+ export async function readRunnerLock(path) {
13
+ for (let attempt = 0; attempt < 30; attempt++) {
14
+ try {
15
+ const state = JSON.parse(await readFile(path, 'utf8'));
16
+ if (Number.isSafeInteger(state.pid) && state.pid > 0 && typeof state.config === 'string') return state;
17
+ } catch (error) {
18
+ if (error.code === 'ENOENT') return null;
19
+ if (!(error instanceof SyntaxError)) throw error;
20
+ }
21
+ await delay(50);
22
+ }
23
+ throw new Error('Runner lock is incomplete; refusing to start another bot connection');
24
+ }
25
+
26
+ export async function acquireRunnerLock(dataDir, configPath) {
27
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
28
+ const path = join(dataDir, 'runner.pid.json');
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ const lock = await open(path, 'wx', 0o600);
32
+ await lock.writeFile(JSON.stringify({ pid: process.pid, config: resolve(configPath), started_at: new Date().toISOString() }));
33
+ await lock.close();
34
+ return async () => {
35
+ const current = await readRunnerLock(path);
36
+ if (current?.pid === process.pid) await rm(path, { force: true });
37
+ };
38
+ } catch (error) {
39
+ if (error.code !== 'EEXIST') throw error;
40
+ const previous = await readRunnerLock(path);
41
+ if (!previous) continue;
42
+ if (alive(previous.pid)) throw new Error('Runner already active for this data directory; do not connect the same bot twice');
43
+ await rm(path, { force: true });
44
+ }
45
+ }
46
+ throw new Error('cannot acquire local Runner lock');
47
+ }
@@ -0,0 +1,39 @@
1
+ import { Codex } from '@openai/codex-sdk';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { git } from '../../packages/peer/store.mjs';
5
+ import { agentEnvironment } from './runner.mjs';
6
+
7
+ const expected = 'CODEX_COLLAB_PROVIDER_OK';
8
+ // An explicit diagnostic request: no bot connection, ledger mutation, source
9
+ // edits or secrets in stdout. Uses the same home/provider env as the Runner.
10
+ export async function probeProvider(config, { agentFactory, timeoutMs = 120000 } = {}) {
11
+ await mkdir(config.data_dir, { recursive: true, mode: 0o700 });
12
+ const directory = await mkdtemp(join(config.data_dir, 'provider-probe-'));
13
+ const controller = new AbortController();
14
+ let timer; let thread;
15
+ const resultPath = join(config.data_dir, 'provider-probe.json');
16
+ const record = async (status) => {
17
+ const threadId = typeof thread?.id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(thread.id) ? thread.id : null;
18
+ const result = { agent_id: config.agent_id, status, thread_id: threadId, checked_at: new Date().toISOString() };
19
+ await writeFile(resultPath, JSON.stringify(result, null, 2) + '\n', { mode: 0o600 });
20
+ return result;
21
+ };
22
+ try {
23
+ await git(directory, 'init', '-b', 'main');
24
+ const agent = agentFactory ? agentFactory() : new Codex({ env: agentEnvironment(config) });
25
+ thread = agent.startThread({ workingDirectory: directory, sandboxMode: 'read-only', approvalPolicy: 'never', networkAccessEnabled: false, webSearchMode: 'disabled' });
26
+ const deadline = new Promise((_resolve, reject) => {
27
+ timer = setTimeout(() => { controller.abort(); reject(new Error('probe timeout')); }, timeoutMs);
28
+ });
29
+ const turn = await Promise.race([thread.run(`Reply with exactly ${expected}. Do not use tools, read files, execute commands, or change anything. This is only a model-provider connectivity check.`, { signal: controller.signal }), deadline]);
30
+ if (turn.finalResponse?.trim() !== expected) throw new Error('unexpected probe response');
31
+ return await record('passed');
32
+ } catch {
33
+ try { await record(controller.signal.aborted ? 'timeout' : 'failed'); } catch { /* never echo private errors */ }
34
+ throw new Error('Codex SDK provider probe failed or timed out; check local model/auth/proxy configuration. Raw response and credentials were not printed.');
35
+ } finally {
36
+ clearTimeout(timer); controller.abort();
37
+ await rm(directory, { recursive: true, force: true });
38
+ }
39
+ }