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 CHANGED
@@ -4,24 +4,32 @@
4
4
 
5
5
  ## 安装与入口
6
6
 
7
- 需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。先 clone 业务仓库;成员无需 clone 本工具源码。
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
- | 成员 | 导入个人接入包与密码;选择自己的业务仓库 clone;在群里发送验证码;等待管理员确认;复制启用指令到选定 Codex 对话 | 本机机器人连接,以及该对话的轮询自动化 |
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 协作 创建 [frontend>backend] ...
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 协作 创建 [frontend>backend] 给登录页增加密码显示切换 | npm test 通过
63
+ @Alice 给登录页增加密码显示切换,原有登录行为保持不变。
64
+ 你负责前端实现,Bob 补接口和测试,完成后在群里告诉我。
53
65
  ```
54
66
 
55
- Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
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 记录公钥、成员职责、签名邀请/申请、任务事件、代码引用、测试和阶段总结;Secret、对话 ID、运行令牌及聊天全文留在本机。团队新增成员或修改审核规则后会发布新的签名策略版本;旧任务继续遵循创建时的策略,避免改写已运行任务的权限和候补。
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 { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
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
- return { profiles };
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
- return this.discovery.begin(credentials);
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 createAdmin(input) {
168
- const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '')) throw new SetupError('请填写有效的成员名称。');
169
- const caps = capabilities(input.capabilities); const argv = testArgv(input.test_argv || ['sh', 'test.sh']);
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
- const template = await readFile(join(packageRoot, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
292
- const installed = await readFile(join(state.repo, '.agents/skills/team-collab-desktop/SKILL.md'), 'utf8').catch(() => '');
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') this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
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
- async importBundle({ content, password, repo }) {
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 this.importAssignment(decryptBundle(envelope, password), repo);
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') throw new SetupError('此成员已经完成群验证。');
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
  }