xuym-codex-collab 0.3.1 → 0.4.1
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.
- package/README.md +43 -11
- package/apps/onboarding/discovery.mjs +4 -0
- package/apps/onboarding/guided.mjs +105 -0
- package/apps/onboarding/manager.mjs +169 -23
- package/apps/onboarding/reset.mjs +247 -0
- package/apps/onboarding/server.mjs +2 -1
- package/apps/onboarding/web/app.js +216 -38
- package/apps/onboarding/web/index.html +30 -7
- package/apps/onboarding/web/style.css +4 -0
- package/apps/peer/desktop.mjs +23 -0
- package/apps/peer/mcp.mjs +4 -2
- package/apps/peer/runtime.mjs +2 -1
- package/apps/peer/wecom.mjs +47 -7
- package/bin/codex-collab.mjs +8 -0
- package/docs/desktop-poll.md +29 -5
- package/docs/onboarding-audit.md +27 -8
- package/docs/onboarding.md +54 -36
- package/docs/peer.md +10 -1
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +16 -10
- package/packages/peer/inbox.mjs +48 -0
- package/packages/peer/service.mjs +70 -1
- package/packages/peer/store.mjs +81 -0
- package/schemas/peer-inbox-event.schema.json +284 -0
- package/schemas/peer-inbox.schema.json +63 -0
- package/scripts/peer/refresh-project.mjs +76 -0
- package/scripts/peer/setup-desktop-project.mjs +4 -1
package/README.md
CHANGED
|
@@ -4,24 +4,32 @@
|
|
|
4
4
|
|
|
5
5
|
## 安装与入口
|
|
6
6
|
|
|
7
|
-
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push
|
|
7
|
+
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有 `main` 分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global xuym-codex-collab
|
|
11
11
|
codex-collab setup
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`setup`
|
|
14
|
+
`setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“初始化团队”,成员选择“加入团队”;群身份验证、等待审批和本机配置会自动衔接,页面显示当前进度。已在使用旧配置的成员选择“导入已有配置”。终端保持运行即可继续使用工作台,关闭工作台后已启动的机器人连接仍运行。
|
|
15
15
|
|
|
16
16
|
| 谁 | 在工作台做什么 | 接入后需要保留什么 |
|
|
17
17
|
| --- | --- | --- |
|
|
18
|
-
| 管理员 |
|
|
19
|
-
| 成员 |
|
|
18
|
+
| 管理员 | 选择或拉取项目,确认推荐的验证命令;在群里验证自己;批量分配机器人与职责;下载加密接入包并复制接入说明;确认自动出现的成员申请 | 本机身份、管理员配置和机器人连接 |
|
|
19
|
+
| 成员 | 导入接入包与密码,使用已有 clone 或让向导拉取;在群里发送验证码;审批通过后自动配置;将一条启用指令发送到选定 Codex 对话 | 本机机器人连接,以及该对话的轮询自动化 |
|
|
20
20
|
|
|
21
|
-
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret
|
|
21
|
+
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员;批量分配自动生成密码,可在“已分配的机器人”查看并单独复制。接入说明不含密码和 Secret。显示名称支持中文,职责可从前端、后端、全栈、测试等预设中选择;内部 ID 和自定义标签放在高级选项中。
|
|
22
22
|
|
|
23
23
|
同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
|
|
24
24
|
|
|
25
|
+
本机配置完成后,向导只要求在目标 Codex 对话发送一条指令,例如:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
使用 $team-collab-desktop,启用当前项目的团队协作,将 bob 机器人绑定到当前对话。项目:项目绝对路径
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Skill 完成身份核对、对话绑定和 heartbeat 配置,页面自动观察实际轮询。管理员入队批准和选定对话启用仍由本人明确操作。群验证中刷新可恢复原进度;重启工作台后可从首页继续,失效验证码会重新连接。
|
|
32
|
+
|
|
25
33
|
完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
|
|
26
34
|
|
|
27
35
|
## 一次任务如何接力
|
|
@@ -34,9 +42,12 @@ sequenceDiagram
|
|
|
34
42
|
participant R as 阶段审核人
|
|
35
43
|
participant G as 业务仓库 agent-collab
|
|
36
44
|
participant B as Bob 已有 Codex 对话
|
|
37
|
-
H->>A: @Alice
|
|
38
|
-
A->>G:
|
|
39
|
-
C->>G: heartbeat
|
|
45
|
+
H->>A: @Alice 用自然语言描述需求与分工
|
|
46
|
+
A->>G: 签名保存原始需求
|
|
47
|
+
C->>G: heartbeat 读取需求与团队职责
|
|
48
|
+
C->>G: 理解或追问,原子创建结构化任务
|
|
49
|
+
A->>H: 需求与分工确认,或补充问题
|
|
50
|
+
C->>G: 轮询并原子认领
|
|
40
51
|
C->>G: 代码 commit、测试证据、阶段总结
|
|
41
52
|
A->>H: 阶段结果、代码证据与审核命令
|
|
42
53
|
H->>R: 通知本人审核
|
|
@@ -49,10 +60,17 @@ sequenceDiagram
|
|
|
49
60
|
例如在群里选中实际 Alice 机器人发送:
|
|
50
61
|
|
|
51
62
|
```text
|
|
52
|
-
@Alice
|
|
63
|
+
@Alice 给登录页增加密码显示切换,原有登录行为保持不变。
|
|
64
|
+
你负责前端实现,Bob 补接口和测试,完成后在群里告诉我。
|
|
53
65
|
```
|
|
54
66
|
|
|
55
|
-
Alice
|
|
67
|
+
机器人先回复收到需求;Alice 的绑定对话下一次轮询时理解需求,按已配置的职责生成任务、验收条件和分工,并在群里确认。需求不清楚时先追问,直接 @同一个机器人补充即可;有多条追问时带上通知中的 `N-需求ID`。状态问题或闲聊可以只回复,不创建任务。
|
|
68
|
+
|
|
69
|
+
自然语言入口使用 `desktop_poll`,沿用本人身份和群权限。对话从团队路由选择职责,指定的同事不存在或分工不明确时需要追问;机器人显示名不决定调度。旧的 `协作 创建 ...` 命令仍兼容。
|
|
70
|
+
|
|
71
|
+
Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
|
|
72
|
+
|
|
73
|
+
自然语言“通过”或“完成”不会批准任务;真人审核仍复制通知中的明确命令。已有安装须同时更新工具、项目 Skill、MCP 和原对话的 heartbeat 提示词,步骤见 [已有项目升级](docs/desktop-poll.md#已有项目启用自然语言入口)。
|
|
56
74
|
|
|
57
75
|
代码保存在独立 `codex/*` 分支;任务和签名策略保存在 `agent-collab`,两者不自动合并到 `main`。最后阶段始终等待真人审核,审核通过后由管理员完成任务。
|
|
58
76
|
|
|
@@ -73,10 +91,20 @@ Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,
|
|
|
73
91
|
|
|
74
92
|
工作台分别检查项目 MCP/Skill 配置、企微认证、目标对话绑定和实际轮询。机器人认证成功只能说明连接可用;还需要对话真实轮询才能执行任务。MCP 本身不能唤醒闲置对话,“启用指令”会要求在**当前选定对话**建立 heartbeat,应用需支持并运行该自动化。
|
|
75
93
|
|
|
76
|
-
成员私钥在成员本机生成。Git
|
|
94
|
+
成员私钥在成员本机生成。Git 记录公钥、成员职责、签名邀请/申请、原始群需求与解读结果、任务事件、代码引用、测试和阶段总结;Secret、对话 ID、运行令牌及完整私有 Codex 对话留在本机。团队新增成员或修改审核规则后会发布新的签名策略版本;旧任务继续遵循创建时的策略,避免改写已运行任务的权限和候补。
|
|
77
95
|
|
|
78
96
|
停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
|
|
79
97
|
|
|
98
|
+
项目页面展开 **管理与重置**,可以先查看影响,再选择:
|
|
99
|
+
|
|
100
|
+
- **重新绑定对话**:先在原对话暂停协作 heartbeat,输入当前 Agent 名称确认。向导停止本机机器人连接、解除对话绑定,并提供启用指令;将指令发到目标对话,完成绑定后点击“启动机器人连接”。
|
|
101
|
+
- **更新机器人凭据**:填写同一个 Bot ID 的新 Secret,用原企业微信本人账号在原群完成验证码验证,确认后保存。保留成员身份、群和对话绑定,机器人保持停止,点击“启动机器人连接”恢复。
|
|
102
|
+
- **移除本机接入**:停止本机连接、解除绑定,移除当前 clone 生成的协作 MCP 和 Skill,停用旧私有配置。原成员身份和本机工作目录保留,项目出现在首页“已移除的接入”,可恢复原身份;此 clone 也可以导入其他 Agent 的新接入包。
|
|
103
|
+
|
|
104
|
+
有运行中任务时会阻止重置;无法同步 Git 确认任务状态时也会停止操作。自定义、已跟踪或符号链接配置需要手动处理。操作前写入本机私有恢复记录,项目代码、其他 MCP、模型配置与共享 `agent-collab` 记录不受影响。恢复后重新加载项目 MCP、绑定对话并启动连接;若 clone 已接入另一个身份,需先处理当前接入。
|
|
105
|
+
|
|
106
|
+
这里移除的是本机接入,企业微信平台机器人、团队成员和群中的历史消息仍保留。身份、Secret 的旧版本及恢复记录仅留在本机;不需要恢复时可手动清理工作台显示的私有配置与备份。更换平台机器人或真人所有者,使用管理员提供的新接入包重新验证。
|
|
107
|
+
|
|
80
108
|
## 开发与兼容
|
|
81
109
|
|
|
82
110
|
```sh
|
|
@@ -94,3 +122,7 @@ node bin/codex-collab.mjs setup
|
|
|
94
122
|
- [旧中心服务与飞书接入](docs/legacy-center.md)
|
|
95
123
|
|
|
96
124
|
2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
|
|
125
|
+
|
|
126
|
+
2026-10-13 自然语言入口通过全量 **122 项自动测试**,覆盖签名需求、追问补充、并发去重、绑定对话 MCP、Alice/Bob 固定 SHA 接力和通知重试;Skill 与 npm 打包检查通过。测试用固定解读结果模拟 Codex 的理解,没有调用真实模型或连接真实企业微信群;本轮自然语言实群验收仍待进行。
|
|
127
|
+
|
|
128
|
+
2026-10-14 接入简化与管理重置通过全量 **154 项自动测试**、隔离仓库的实际浏览器接入流程、Skill 校验和 npm 打包检查。群验证后自动继续、成员批准后自动配置、批量机器人分配及刷新恢复均已验证;本轮使用模拟机器人,未新增真实企微或跨机器联合验收。具体范围见 [向导检查记录](docs/onboarding-audit.md)。
|
|
@@ -40,5 +40,9 @@ export class BotDiscovery {
|
|
|
40
40
|
this.sessions.delete(id); clearTimeout(state.timer); state.client.disconnect();
|
|
41
41
|
return { bot_id: state.bot_id, secret: state.secret, ...state.proof };
|
|
42
42
|
}
|
|
43
|
+
cancel(id) {
|
|
44
|
+
const state = this.sessions.get(id); if (!state) return;
|
|
45
|
+
this.sessions.delete(id); clearTimeout(state.timer); state.client.disconnect();
|
|
46
|
+
}
|
|
43
47
|
closeAll() { for (const state of this.sessions.values()) { clearTimeout(state.timer); state.client.disconnect(); } this.sessions.clear(); }
|
|
44
48
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { git } from '../../packages/peer/store.mjs';
|
|
6
|
+
import { remoteIdentity } from './bundle.mjs';
|
|
7
|
+
import { SetupError } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
export const responsibilities = {
|
|
10
|
+
frontend: ['frontend'], backend: ['backend'], fullstack: ['frontend', 'backend'],
|
|
11
|
+
testing: ['testing'], docs: ['docs'], general: ['general'],
|
|
12
|
+
};
|
|
13
|
+
const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
14
|
+
export function memberFields(input) {
|
|
15
|
+
if (input.display_name != null && typeof input.display_name !== 'string' || input.agent_id != null && typeof input.agent_id !== 'string') throw new SetupError('成员名称和内部 Agent ID 应是文字。');
|
|
16
|
+
const displayName = (input.display_name || input.agent_id || '').trim();
|
|
17
|
+
if (!displayName || displayName.length > 80 || /[\x00-\x1f\x7f]/.test(displayName)) throw new SetupError('请填写成员或机器人名称,最多 80 个字符。');
|
|
18
|
+
const agentId = input.agent_id?.trim() || displayName.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || `agent-${randomBytes(4).toString('hex')}`;
|
|
19
|
+
if (!idPattern.test(agentId)) throw new SetupError('内部 Agent ID 请使用英文、数字、下划线或连字符。');
|
|
20
|
+
const raw = input.capabilities || responsibilities[input.responsibility || 'general'];
|
|
21
|
+
const caps = typeof raw === 'string' ? raw.split(/[\s,,]+/).filter(Boolean) : raw;
|
|
22
|
+
if (!Array.isArray(caps) || !caps.length || caps.length > 16 || caps.some((cap) => typeof cap !== 'string' || !idPattern.test(cap))) throw new SetupError('自定义职责标签请使用英文、数字、下划线或连字符。');
|
|
23
|
+
return { agent_id: agentId, display_name: displayName, capabilities: [...new Set(caps)] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Convert a confirmed command to argv. Never add a shell or evaluate substitutions.
|
|
27
|
+
export function commandArgv(command) {
|
|
28
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 4000 || /[\x00\r\n]/.test(command)) throw new SetupError('请确认验证命令,例如 npm test 或 go test ./...。');
|
|
29
|
+
const argv = []; let word = '', quote = null, escaped = false, started = false;
|
|
30
|
+
for (const char of command.trim()) {
|
|
31
|
+
if (escaped) { word += char; escaped = false; started = true; continue; }
|
|
32
|
+
if (char === '\\' && quote !== "'") { escaped = true; started = true; continue; }
|
|
33
|
+
if (quote) { if (char === quote) quote = null; else word += char; continue; }
|
|
34
|
+
if (char === '"' || char === "'") { quote = char; started = true; continue; }
|
|
35
|
+
if (/\s/.test(char)) { if (started) { argv.push(word); word = ''; started = false; } continue; }
|
|
36
|
+
if (/[;&|<>`$()]/.test(char)) throw new SetupError('验证命令请填写一个程序及参数,不支持管道、重定向或命令拼接。多个检查请写进项目测试脚本。');
|
|
37
|
+
word += char; started = true;
|
|
38
|
+
}
|
|
39
|
+
if (quote || escaped) throw new SetupError('验证命令的引号或转义未闭合。');
|
|
40
|
+
if (started) argv.push(word);
|
|
41
|
+
if (!argv.length || argv.length > 32 || argv.some((part) => !part || part.length > 2000)) throw new SetupError('验证命令参数无效。');
|
|
42
|
+
return argv;
|
|
43
|
+
}
|
|
44
|
+
export function displayCommand(argv) {
|
|
45
|
+
return argv.map((part) => /^[\w./:@=-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`).join(' ');
|
|
46
|
+
}
|
|
47
|
+
export async function recommendTests(repo) {
|
|
48
|
+
const files = new Set(await readdir(repo)); const commands = [];
|
|
49
|
+
if (files.has('test.sh')) commands.push(['sh', 'test.sh']);
|
|
50
|
+
if (files.has('package.json')) {
|
|
51
|
+
try { const value = JSON.parse(await readFile(join(repo, 'package.json'), 'utf8')); if (typeof value.scripts?.test === 'string' && value.scripts.test.trim()) commands.push(['npm', 'test']); } catch { /* A suggestion never executes or repairs project files. */ }
|
|
52
|
+
}
|
|
53
|
+
if (files.has('go.mod')) commands.push(['go', 'test', './...']);
|
|
54
|
+
if (files.has('Cargo.toml')) commands.push(['cargo', 'test']);
|
|
55
|
+
return commands.map((argv) => ({ argv, command: displayCommand(argv) }));
|
|
56
|
+
}
|
|
57
|
+
export function suggestedRepo(remote, agentId) {
|
|
58
|
+
const name = remote.split(/[/:]/).at(-1).replace(/\.git$/, '').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80) || 'project';
|
|
59
|
+
return join(homedir(), 'codex-projects', `${name}-${agentId}`);
|
|
60
|
+
}
|
|
61
|
+
function validCloneRemote(remote) {
|
|
62
|
+
if (typeof remote !== 'string' || !remote || remote.length > 4000 || /[\x00-\x1f\x7f]/.test(remote) || remote.startsWith('-')) throw new SetupError('仓库地址无效。');
|
|
63
|
+
if (isAbsolute(remote)) return;
|
|
64
|
+
if (/\s/.test(remote)) throw new SetupError('仓库地址不能包含空白字符。'); // Also supports a team's local/offline Git mirror.
|
|
65
|
+
if (/^[\w.-]+@[\w.-]+:[\w./-]+$/.test(remote)) return;
|
|
66
|
+
let url; try { url = new URL(remote); } catch { throw new SetupError('请使用 HTTPS 或 SSH 仓库地址。'); }
|
|
67
|
+
if (!['https:', 'http:', 'ssh:'].includes(url.protocol) || !url.hostname || url.password || url.search || url.hash || url.protocol !== 'ssh:' && url.username) throw new SetupError('请使用 HTTPS 或 SSH 仓库地址,并通过本机 Git 登录;不要在地址中填写 Token。');
|
|
68
|
+
}
|
|
69
|
+
const inside = (parent, child) => { const path = relative(parent, child); return !path || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
|
|
70
|
+
export async function cloneRepository({ remote, destination, privateHome }) {
|
|
71
|
+
validCloneRemote(remote);
|
|
72
|
+
if (typeof destination !== 'string' || !isAbsolute(destination)) throw new SetupError('请选择新 clone 的本机绝对路径。');
|
|
73
|
+
const target = resolve(destination);
|
|
74
|
+
privateHome = await realpath(privateHome).catch((error) => { if (error.code === 'ENOENT') return resolve(privateHome); throw error; });
|
|
75
|
+
// Keep the private workspace outside the target repo and vice versa.
|
|
76
|
+
if (inside(target, privateHome) || inside(privateHome, target)) throw new SetupError('项目目录和本机私有配置目录不能相互包含。');
|
|
77
|
+
let existing; try { existing = await lstat(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (existing.isSymbolicLink()) throw new SetupError('拉取目录不能是符号链接,请选择独立目录。');
|
|
80
|
+
try {
|
|
81
|
+
const actual = await realpath(target);
|
|
82
|
+
if (await git(actual, 'rev-parse', '--show-toplevel') === actual && remoteIdentity(await git(actual, 'remote', 'get-url', 'origin')) === remoteIdentity(remote)) return actual;
|
|
83
|
+
} catch { /* Reject existing unrelated folders instead of overwriting them. */ }
|
|
84
|
+
throw new SetupError('目标目录已存在,请选择新的目录,或切换到使用已有项目。');
|
|
85
|
+
}
|
|
86
|
+
await mkdir(dirname(target), { recursive: true });
|
|
87
|
+
const parent = await realpath(dirname(target)); const actualTarget = join(parent, basename(target));
|
|
88
|
+
if (inside(actualTarget, privateHome) || inside(privateHome, actualTarget)) throw new SetupError('项目目录和本机私有配置目录不能相互包含。');
|
|
89
|
+
await mkdir(actualTarget, { mode: 0o700 });
|
|
90
|
+
const staging = join(parent, `.collab-clone-${randomUUID()}`);
|
|
91
|
+
try {
|
|
92
|
+
await git(parent, '-c', 'protocol.ext.allow=never', 'clone', '--', remote, staging);
|
|
93
|
+
if ((await readdir(actualTarget)).length) throw new SetupError('拉取目录出现其他文件,请选择新目录后重试。');
|
|
94
|
+
await rename(staging, actualTarget);
|
|
95
|
+
return actualTarget;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof SetupError) throw error;
|
|
98
|
+
const detail = `${error.stderr || ''}\n${error.message || ''}`.toLowerCase();
|
|
99
|
+
if (/permission denied|authentication failed|could not read username|repository not found|403|401/.test(detail)) throw new SetupError('拉取仓库失败,请先完成本机 Git 登录并确认仓库读取权限。');
|
|
100
|
+
throw new SetupError('拉取仓库未完成,请检查仓库地址、网络和本机 Git 权限后重试。');
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(staging, { recursive: true, force: true });
|
|
103
|
+
await rmdir(actualTarget).catch(() => {}); // Only remove our empty reservation, never project files.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
2
2
|
import { SetupError } from './errors.mjs';
|
|
3
|
-
import { createPublicKey, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
@@ -10,12 +10,15 @@ import { canonical, digest, generateIdentity, readPrivateJson, validateTeam } fr
|
|
|
10
10
|
import { PeerGitStore, git } from '../../packages/peer/store.mjs';
|
|
11
11
|
import { approveEnrollment, enrollmentRequest, invitationFile, invitationRecord, policyVersion, recordHash, registryPath, requestFile, validateEnrollment, validateHistory, validateInvitation, versionFile, readRegistry } from '../../packages/peer/registry.mjs';
|
|
12
12
|
import { attachProject, buildTeamPolicy, createMemberConfig, diagnosePeer, getOrCreateIdentityFiles } from '../../scripts/peer/onboarding.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { installedProjectConfig, refreshProject } from '../../scripts/peer/refresh-project.mjs';
|
|
14
|
+
import { bindingPrompt, detailedBindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
|
|
14
15
|
import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
|
|
15
16
|
import { DesktopExecutor } from '../peer/desktop.mjs';
|
|
16
17
|
import { alive, readRunnerLock } from '../peer/process.mjs';
|
|
17
18
|
import { BotDiscovery } from './discovery.mjs';
|
|
19
|
+
import { ResetActions } from './reset.mjs';
|
|
18
20
|
import { encryptBundle, decryptBundle, remoteIdentity } from './bundle.mjs';
|
|
21
|
+
import { cloneRepository, commandArgv, memberFields, recommendTests, suggestedRepo } from './guided.mjs';
|
|
19
22
|
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
20
23
|
const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
21
24
|
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
@@ -109,7 +112,7 @@ function validateBundle(value) {
|
|
|
109
112
|
|
|
110
113
|
export class SetupManager {
|
|
111
114
|
constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
|
|
112
|
-
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve();
|
|
115
|
+
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve(); this.resets = new ResetActions(this);
|
|
113
116
|
this.runCli = runCli || ((command, config) => {
|
|
114
117
|
const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
|
|
115
118
|
if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
|
|
@@ -129,12 +132,18 @@ export class SetupManager {
|
|
|
129
132
|
async overview() {
|
|
130
133
|
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
131
134
|
const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
|
|
132
|
-
const profiles = [];
|
|
135
|
+
const profiles = [], removed_profiles = [];
|
|
133
136
|
for (const id of entries) {
|
|
134
137
|
const state = await this.state(id);
|
|
135
|
-
profiles.push({ id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
138
|
+
(state.phase === 'removed' ? removed_profiles : profiles).push({ id, agent_id: state.agent_id, role: state.role, display_name: state.display_name || state.agent_id, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
136
139
|
}
|
|
137
|
-
|
|
140
|
+
const drafts = [];
|
|
141
|
+
for (const file of await readdir(join(this.home, 'drafts')).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error))) {
|
|
142
|
+
if (!uuidPattern.test(file.replace(/\.json$/, ''))) continue;
|
|
143
|
+
const draft = await readPrivateJson(join(this.home, 'drafts', file));
|
|
144
|
+
if (!draft.profile_id) drafts.push({ id: draft.id, agent_id: draft.input.agent_id, display_name: draft.input.display_name, repo: draft.input.repo });
|
|
145
|
+
}
|
|
146
|
+
return { profiles, removed_profiles, drafts };
|
|
138
147
|
}
|
|
139
148
|
async checkBotAvailable(botId) {
|
|
140
149
|
for (const profile of (await this.overview()).profiles) {
|
|
@@ -147,6 +156,7 @@ export class SetupManager {
|
|
|
147
156
|
let credentials = input;
|
|
148
157
|
if (input.profile_id) {
|
|
149
158
|
const state = await this.state(input.profile_id);
|
|
159
|
+
if (state.discovery_id && !input.reconnect) { try { const session = this.discovery.view(state.discovery_id); if (!['failed', 'expired', 'disconnected'].includes(session.status)) return session; } catch {} }
|
|
150
160
|
if (!state.bundle) throw new SetupError('此项目没有待验证的接入包。');
|
|
151
161
|
credentials = { ...state.bundle.wecom, chat_ids: state.bundle.invitation.chat_ids };
|
|
152
162
|
}
|
|
@@ -162,11 +172,57 @@ export class SetupManager {
|
|
|
162
172
|
} catch (error) { if (error instanceof SetupError) throw error; }
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
|
-
|
|
175
|
+
const session = this.discovery.begin(credentials);
|
|
176
|
+
if (input.profile_id) { const state = await this.state(input.profile_id); state.discovery_id = session.id; await this.save(state); }
|
|
177
|
+
return session;
|
|
178
|
+
}
|
|
179
|
+
async prepareProject(input) {
|
|
180
|
+
const path = input.repo_mode === 'clone'
|
|
181
|
+
? await cloneRepository({ remote: input.remote, destination: input.destination, privateHome: this.home }) : input.repo;
|
|
182
|
+
const selected = await ensureRepo(path);
|
|
183
|
+
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
184
|
+
return { ...selected, tests: await recommendTests(selected.repo) };
|
|
185
|
+
}
|
|
186
|
+
draftPath(id) { if (!uuidPattern.test(id || '')) throw new SetupError('无效创建记录。'); return join(this.home, 'drafts', `${id}.json`); }
|
|
187
|
+
async prepareAdmin(input) {
|
|
188
|
+
const fields = memberFields(input);
|
|
189
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret.trim()) throw new SetupError('请填写机器人 Bot ID 和 Secret。');
|
|
190
|
+
if (input.team_name != null && (typeof input.team_name !== 'string' || input.team_name.length > 80 || /[\x00-\x1f\x7f]/.test(input.team_name))) throw new SetupError('团队名称最多 80 个字符。');
|
|
191
|
+
const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv);
|
|
192
|
+
const selected = await this.prepareProject(input);
|
|
193
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
194
|
+
const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, test_argv: argv, team_name: input.team_name?.trim() || null,
|
|
195
|
+
bot_id: input.bot_id, secret: input.secret } };
|
|
196
|
+
await savePrivate(this.draftPath(draft.id), draft);
|
|
197
|
+
return this.adminDraft({ id: draft.id });
|
|
198
|
+
}
|
|
199
|
+
async adminDraft({ id, reconnect = false }) {
|
|
200
|
+
const draft = await readPrivateJson(this.draftPath(id));
|
|
201
|
+
if (draft.profile_id) return { profile: await this.detail(draft.profile_id, { localOnly: true }) };
|
|
202
|
+
let session;
|
|
203
|
+
if (draft.discovery_id && !reconnect) { try { session = this.discovery.view(draft.discovery_id); if (['failed', 'expired', 'disconnected'].includes(session.status)) session = null; } catch {} }
|
|
204
|
+
if (!session) {
|
|
205
|
+
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo });
|
|
206
|
+
draft.discovery_id = session.id; await savePrivate(this.draftPath(id), draft);
|
|
207
|
+
}
|
|
208
|
+
return { ...session, draft_id: id, agent_id: draft.input.agent_id, repo: draft.input.repo };
|
|
209
|
+
}
|
|
210
|
+
async completeAdmin({ draft_id, discovery_id }) {
|
|
211
|
+
const draft = await readPrivateJson(this.draftPath(draft_id));
|
|
212
|
+
if (draft.profile_id) return this.detail(draft.profile_id, { localOnly: true });
|
|
213
|
+
if (draft.discovery_id !== discovery_id) throw new SetupError('请使用当前创建流程的群验证码。');
|
|
214
|
+
return this.createAdmin({ ...draft.input, discovery_id }, { onSaved: async (id) => {
|
|
215
|
+
draft.profile_id = id; delete draft.input.secret; await savePrivate(this.draftPath(draft_id), draft);
|
|
216
|
+
} });
|
|
166
217
|
}
|
|
167
|
-
async
|
|
168
|
-
const
|
|
169
|
-
|
|
218
|
+
async progress({ id, sync = false }) {
|
|
219
|
+
const state = await this.state(id);
|
|
220
|
+
if (['initializing', 'verified', 'waiting', 'configuring'].includes(state.phase) && !state.last_error) return this.resume(id);
|
|
221
|
+
return this.detail(id, { localOnly: !sync || state.phase !== 'ready' });
|
|
222
|
+
}
|
|
223
|
+
async createAdmin(input, { onSaved } = {}) {
|
|
224
|
+
const fields = memberFields(input), agentId = fields.agent_id;
|
|
225
|
+
const caps = fields.capabilities; const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
170
226
|
const { repo, remote } = await ensureRepo(input.repo);
|
|
171
227
|
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
172
228
|
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
@@ -178,10 +234,11 @@ export class SetupManager {
|
|
|
178
234
|
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
179
235
|
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
180
236
|
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
181
|
-
const state = { id, role: 'admin', agent_id: agentId, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
237
|
+
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
182
238
|
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
183
239
|
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
184
240
|
await this.save(state);
|
|
241
|
+
if (onSaved) await onSaved(id);
|
|
185
242
|
return this.resume(id);
|
|
186
243
|
}
|
|
187
244
|
async importExisting({ config_path }) {
|
|
@@ -227,8 +284,19 @@ export class SetupManager {
|
|
|
227
284
|
await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
|
|
228
285
|
await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
|
|
229
286
|
}
|
|
287
|
+
async refreshAttachment(state, configPath) {
|
|
288
|
+
try {
|
|
289
|
+
const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id });
|
|
290
|
+
const source = (await readPeerConfig(configPath)).config;
|
|
291
|
+
const bound = (await readPeerConfig(installed.config_path)).config;
|
|
292
|
+
if (bound.agent_id !== source.agent_id || bound.private_key !== source.private_key ||
|
|
293
|
+
await realpath(bound.repo) !== await realpath(source.repo) || resolve(bound.data_dir) !== resolve(source.data_dir)) throw new SetupError('项目 MCP 使用其他身份或数据目录,请手动检查原绑定。');
|
|
294
|
+
await refreshProject({ configPath: installed.config_path });
|
|
295
|
+
} catch (error) { throw new SetupError(error.message); }
|
|
296
|
+
}
|
|
230
297
|
async resume(id) {
|
|
231
298
|
const state = await this.state(id);
|
|
299
|
+
if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
|
|
232
300
|
try {
|
|
233
301
|
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
234
302
|
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
@@ -288,15 +356,18 @@ export class SetupManager {
|
|
|
288
356
|
await diagnosePeer(configPath, { testPush: true });
|
|
289
357
|
if (!state.attached) {
|
|
290
358
|
const existing = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch(() => '');
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!existing.includes(JSON.stringify(configPath)) || installed !== template) await attachProject({ configPath, repoPath: state.repo });
|
|
359
|
+
if (projectMcpConfigured(existing, configPath, await readPrivateJson(configPath))) await this.refreshAttachment(state, configPath);
|
|
360
|
+
else await attachProject({ configPath, repoPath: state.repo });
|
|
294
361
|
state.attached = true; await this.save(state);
|
|
295
362
|
}
|
|
296
363
|
await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
|
|
297
364
|
state.phase = 'ready'; state.last_error = null; await this.save(state);
|
|
298
365
|
}
|
|
299
|
-
if (state.phase === 'ready')
|
|
366
|
+
if (state.phase === 'ready') {
|
|
367
|
+
await this.refreshAttachment(state, state.existing_config || join(this.path(id), 'peer.json'));
|
|
368
|
+
await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
|
|
369
|
+
this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
|
|
370
|
+
}
|
|
300
371
|
state.last_error = null; await this.save(state);
|
|
301
372
|
return this.detail(id, { localOnly: true });
|
|
302
373
|
} catch (error) {
|
|
@@ -305,11 +376,27 @@ export class SetupManager {
|
|
|
305
376
|
throw new SetupError(state.last_error);
|
|
306
377
|
}
|
|
307
378
|
}
|
|
308
|
-
|
|
379
|
+
readBundle({ content, password }) {
|
|
309
380
|
if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
|
|
310
381
|
let envelope;
|
|
311
382
|
try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
|
|
312
|
-
return
|
|
383
|
+
return validateBundle(decryptBundle(envelope, password)).bundle;
|
|
384
|
+
}
|
|
385
|
+
async inspectBundle(input) {
|
|
386
|
+
const bundle = this.readBundle(input), invite = bundle.invitation;
|
|
387
|
+
return { agent_id: invite.agent_id, display_name: bundle.display_name || invite.agent_id, team_name: bundle.team_name || null,
|
|
388
|
+
remote: bundle.remote, capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
389
|
+
}
|
|
390
|
+
async importBundle(input) {
|
|
391
|
+
const bundle = this.readBundle(input);
|
|
392
|
+
// An interrupted import resumes its original profile without making another clone.
|
|
393
|
+
for (const profile of (await this.overview()).profiles) {
|
|
394
|
+
const state = await this.state(profile.id);
|
|
395
|
+
if (state.bundle?.invitation.invite_id === bundle.invitation.invite_id) return this.detail(profile.id, { localOnly: true });
|
|
396
|
+
}
|
|
397
|
+
const repo = input.repo_mode === 'clone'
|
|
398
|
+
? await cloneRepository({ remote: bundle.remote, destination: input.destination || suggestedRepo(bundle.remote, bundle.invitation.agent_id), privateHome: this.home }) : input.repo;
|
|
399
|
+
return this.importAssignment(bundle, repo);
|
|
313
400
|
}
|
|
314
401
|
async importAssignment(value, repo, ownAdminId) {
|
|
315
402
|
const { bundle, history } = validateBundle(value);
|
|
@@ -322,13 +409,13 @@ export class SetupManager {
|
|
|
322
409
|
if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
323
410
|
}
|
|
324
411
|
const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
325
|
-
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
412
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
326
413
|
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
|
|
327
414
|
...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
|
|
328
415
|
await this.save(state); return this.detail(id, { localOnly: true });
|
|
329
416
|
}
|
|
330
417
|
async verifyMember({ id, discovery_id }) {
|
|
331
|
-
const state = await this.state(id); if (state.phase !== 'imported')
|
|
418
|
+
const state = await this.state(id); if (state.phase !== 'imported') return this.detail(id, { localOnly: true });
|
|
332
419
|
const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
|
|
333
420
|
if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
|
|
334
421
|
if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
|
|
@@ -339,7 +426,60 @@ export class SetupManager {
|
|
|
339
426
|
default_workflow: [invite.capabilities[0]] };
|
|
340
427
|
state.phase = 'verified'; await this.save(state); return this.resume(id);
|
|
341
428
|
}
|
|
429
|
+
async issueBatch({ id, members }) {
|
|
430
|
+
const state = await this.state(id);
|
|
431
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
432
|
+
if (!Array.isArray(members) || !members.length || members.length > 25) throw new SetupError('每次请分配 1 到 25 个机器人。');
|
|
433
|
+
const store = await this.profileStore(state), identity = await readPrivateJson(state.identity_path);
|
|
434
|
+
const ids = new Set([state.agent_id]), bots = new Set([state.wecom.bot_id]);
|
|
435
|
+
const prepared = members.map((input) => {
|
|
436
|
+
const fields = memberFields(input);
|
|
437
|
+
if (ids.has(fields.agent_id) || store.team.agents[fields.agent_id]) throw new SetupError(`成员 ${fields.agent_id} 已存在或在本次分配中重复。`);
|
|
438
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret.trim()) throw new SetupError('请为每位成员填写独立机器人的 Bot ID 和 Secret。');
|
|
439
|
+
const botId = input.bot_id.trim();
|
|
440
|
+
if (bots.has(botId)) throw new SetupError('本次分配的机器人重复;每个 Agent 必须使用独立机器人。');
|
|
441
|
+
ids.add(fields.agent_id); bots.add(botId);
|
|
442
|
+
const password = input.password || randomBytes(18).toString('base64url');
|
|
443
|
+
encryptBundle({}, password); // Validate the whole batch before any invitation is published.
|
|
444
|
+
return { ...fields, bot_id: botId, secret: input.secret, commands_enabled: input.commands_enabled !== false,
|
|
445
|
+
prefer: input.prefer !== false, password, claim: generateIdentity(), invite_id: randomUUID() };
|
|
446
|
+
});
|
|
447
|
+
await store.registryTransaction(async (history) => {
|
|
448
|
+
for (const item of prepared) if (history.latest.team.agents[item.agent_id]) throw new SetupError(`成员 ${item.agent_id} 已存在,请刷新工作台。`);
|
|
449
|
+
const existing = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error));
|
|
450
|
+
for (const file of existing) {
|
|
451
|
+
const invite = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
|
|
452
|
+
validateInvitation(invite, history);
|
|
453
|
+
if (prepared.some((item) => (item.agent_id === invite.agent_id || item.bot_id === invite.bot_id) && item.invite_id !== invite.invite_id) &&
|
|
454
|
+
(Date.now() < Date.parse(invite.expires_at) || history.records.some((record) => record.enrollment?.invite_id === invite.invite_id))) throw new SetupError('成员或机器人已有有效接入包,请使用已生成的文件。');
|
|
455
|
+
}
|
|
456
|
+
const files = [];
|
|
457
|
+
for (const item of prepared) {
|
|
458
|
+
const invitation = invitationRecord({ invite_id: item.invite_id, agent_id: item.agent_id, capabilities: item.capabilities,
|
|
459
|
+
bot_id: item.bot_id, chat_ids: state.wecom.chat_ids, commands_enabled: item.commands_enabled, prefer: item.prefer,
|
|
460
|
+
claim_public_key: item.claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString() }, history.latest, state.agent_id, identity.private_key);
|
|
461
|
+
const bundle = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
|
|
462
|
+
history: history.records, invitation, claim_private_key: item.claim.private_key, display_name: item.display_name, team_name: state.team_name || null,
|
|
463
|
+
wecom: { bot_id: item.bot_id, secret: item.secret }, test_argv: state.test_argv };
|
|
464
|
+
await savePrivate(join(this.path(id), 'exports', `${item.invite_id}.json`), { invitation, display_name: item.display_name,
|
|
465
|
+
encrypted: encryptBundle(bundle, item.password), delivery_password: item.password });
|
|
466
|
+
files.push([invitationFile(item.invite_id), invitation]);
|
|
467
|
+
}
|
|
468
|
+
return { files, value: null };
|
|
469
|
+
});
|
|
470
|
+
return { assignments: prepared.map((item) => ({ invite_id: item.invite_id, agent_id: item.agent_id,
|
|
471
|
+
display_name: item.display_name, filename: `${item.agent_id}-collab-invite.json`, password: item.password })) };
|
|
472
|
+
}
|
|
473
|
+
async delivery({ id, invite_id }) {
|
|
474
|
+
const state = await this.state(id);
|
|
475
|
+
if (state.role !== 'admin' || state.phase !== 'ready' || !uuidPattern.test(invite_id || '')) throw new SetupError('只有管理员可查看已分配的接入说明。');
|
|
476
|
+
const saved = await this.publishAssignment(id, invite_id);
|
|
477
|
+
if (!saved.encrypted) throw new SetupError('本人机器人直接在本机接入,无需分发接入包。');
|
|
478
|
+
return { agent_id: saved.invitation.agent_id, display_name: saved.display_name || saved.invitation.agent_id, password: saved.delivery_password || null,
|
|
479
|
+
instructions: `已为你分配 ${saved.display_name || saved.invitation.agent_id} 协作机器人。\n1. 安装 Node.js 20 或以上版本及 Git。\n2. 执行 npx xuym-codex-collab@latest setup 打开接入向导。\n3. 导入我提供的加密接入包,使用单独发送的密码,选择已有项目或自动拉取。\n4. 按页面提示在群里确认身份;批准后自动完成配置。\n5. 在目标 Codex 项目对话发送页面提供的启用指令。` };
|
|
480
|
+
}
|
|
342
481
|
async issueBundle(input, { ownRepo } = {}) {
|
|
482
|
+
if (!ownRepo) return (await this.issueBatch({ id: input.id, members: [input] })).assignments[0];
|
|
343
483
|
const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
344
484
|
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
|
|
345
485
|
const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
|
|
@@ -464,7 +604,7 @@ export class SetupManager {
|
|
|
464
604
|
return this.detail(id);
|
|
465
605
|
}
|
|
466
606
|
async detail(id, { localOnly = false } = {}) {
|
|
467
|
-
const state = await this.state(id); const result = { id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
607
|
+
const state = await this.state(id); const result = { id, agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
468
608
|
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
469
609
|
expires_at: state.bundle.invitation.expires_at };
|
|
470
610
|
if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
|
|
@@ -492,11 +632,11 @@ export class SetupManager {
|
|
|
492
632
|
for (const file of exported) {
|
|
493
633
|
const value = await readPrivateJson(join(this.path(id), 'exports', file));
|
|
494
634
|
result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
|
|
495
|
-
filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
|
|
635
|
+
filename: `${value.invitation.agent_id}-collab-invite.json`, display_name: value.display_name || value.invitation.agent_id, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
|
|
496
636
|
}
|
|
497
637
|
}
|
|
498
638
|
if (state.phase === 'ready') {
|
|
499
|
-
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
|
|
639
|
+
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo); result.detailed_binding_prompt = detailedBindingPrompt(state.agent_id, state.repo);
|
|
500
640
|
const config = (await readPeerConfig(result.config_path)).config;
|
|
501
641
|
const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
|
|
502
642
|
const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
@@ -520,13 +660,19 @@ export class SetupManager {
|
|
|
520
660
|
async runner({ id, command }) {
|
|
521
661
|
const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
|
|
522
662
|
try {
|
|
663
|
+
if (command === 'start' && [...this.discovery.sessions.values()].some((session) => session.bot_id === state.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) throw new SetupError('机器人正在验证,请先取消验证,再启动连接。');
|
|
523
664
|
this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
|
|
524
665
|
state.last_error = null; await this.save(state);
|
|
525
|
-
} catch {
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (error instanceof SetupError) throw error;
|
|
526
668
|
state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
|
|
527
669
|
await this.save(state); throw new SetupError(state.last_error);
|
|
528
670
|
}
|
|
529
671
|
return this.detail(id, { localOnly: true });
|
|
530
672
|
}
|
|
673
|
+
resetPreview(input) { return this.resets.preview(input); }
|
|
674
|
+
resetDiscovery(input) { return this.resets.discover(input); }
|
|
675
|
+
resetCancel(input) { return this.resets.cancel(input); }
|
|
676
|
+
reset(input) { return this.resets.apply(input); }
|
|
531
677
|
close() { this.discovery.closeAll(); }
|
|
532
678
|
}
|