xuym-codex-collab 0.4.0 → 0.4.2

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,34 @@
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
+ 机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
26
+
27
+ 本机配置完成后,向导只要求在目标 Codex 对话发送一条指令,例如:
28
+
29
+ ```text
30
+ 使用 $team-collab-desktop,启用当前项目的团队协作,将 bob 机器人绑定到当前对话。项目:项目绝对路径
31
+ ```
32
+
33
+ Skill 完成身份核对、对话绑定和 heartbeat 配置,页面自动观察实际轮询。管理员入队批准和选定对话启用仍由本人明确操作。群验证中刷新可恢复原进度;重启工作台后可从首页继续,失效验证码会重新连接。
34
+
25
35
  完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
26
36
 
27
37
  ## 一次任务如何接力
@@ -87,6 +97,16 @@ Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代
87
97
 
88
98
  停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
89
99
 
100
+ 项目页面展开 **管理与重置**,可以先查看影响,再选择:
101
+
102
+ - **重新绑定对话**:先在原对话暂停协作 heartbeat,输入当前 Agent 名称确认。向导停止本机机器人连接、解除对话绑定,并提供启用指令;将指令发到目标对话,完成绑定后点击“启动机器人连接”。
103
+ - **更新机器人凭据**:填写同一个 Bot ID 的新 Secret,用原企业微信本人账号在原群完成验证码验证,确认后保存。保留成员身份、群和对话绑定,机器人保持停止,点击“启动机器人连接”恢复。
104
+ - **移除本机接入**:停止本机连接、解除绑定,移除当前 clone 生成的协作 MCP 和 Skill,停用旧私有配置。原成员身份和本机工作目录保留,项目出现在首页“已移除的接入”,可恢复原身份;此 clone 也可以导入其他 Agent 的新接入包。
105
+
106
+ 有运行中任务时会阻止重置;无法同步 Git 确认任务状态时也会停止操作。自定义、已跟踪或符号链接配置需要手动处理。操作前写入本机私有恢复记录,项目代码、其他 MCP、模型配置与共享 `agent-collab` 记录不受影响。恢复后重新加载项目 MCP、绑定对话并启动连接;若 clone 已接入另一个身份,需先处理当前接入。
107
+
108
+ 这里移除的是本机接入,企业微信平台机器人、团队成员和群中的历史消息仍保留。身份、Secret 的旧版本及恢复记录仅留在本机;不需要恢复时可手动清理工作台显示的私有配置与备份。更换平台机器人或真人所有者,使用管理员提供的新接入包重新验证。
109
+
90
110
  ## 开发与兼容
91
111
 
92
112
  ```sh
@@ -106,3 +126,5 @@ node bin/codex-collab.mjs setup
106
126
  2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
107
127
 
108
128
  2026-10-13 自然语言入口通过全量 **122 项自动测试**,覆盖签名需求、追问补充、并发去重、绑定对话 MCP、Alice/Bob 固定 SHA 接力和通知重试;Skill 与 npm 打包检查通过。测试用固定解读结果模拟 Codex 的理解,没有调用真实模型或连接真实企业微信群;本轮自然语言实群验收仍待进行。
129
+
130
+ 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';
@@ -11,12 +11,14 @@ 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
13
  import { installedProjectConfig, refreshProject } from '../../scripts/peer/refresh-project.mjs';
14
- import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
14
+ import { bindingPrompt, detailedBindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
15
15
  import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
16
16
  import { DesktopExecutor } from '../peer/desktop.mjs';
17
17
  import { alive, readRunnerLock } from '../peer/process.mjs';
18
18
  import { BotDiscovery } from './discovery.mjs';
19
+ import { ResetActions } from './reset.mjs';
19
20
  import { encryptBundle, decryptBundle, remoteIdentity } from './bundle.mjs';
21
+ import { cloneRepository, commandArgv, memberFields, recommendTests, suggestedRepo } from './guided.mjs';
20
22
  const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
21
23
  const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
22
24
  const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
@@ -110,7 +112,7 @@ function validateBundle(value) {
110
112
 
111
113
  export class SetupManager {
112
114
  constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
113
- 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);
114
116
  this.runCli = runCli || ((command, config) => {
115
117
  const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
116
118
  if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
@@ -130,12 +132,18 @@ export class SetupManager {
130
132
  async overview() {
131
133
  await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
132
134
  const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
133
- const profiles = [];
135
+ const profiles = [], removed_profiles = [];
134
136
  for (const id of entries) {
135
137
  const state = await this.state(id);
136
- 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 });
137
139
  }
138
- 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 };
139
147
  }
140
148
  async checkBotAvailable(botId) {
141
149
  for (const profile of (await this.overview()).profiles) {
@@ -148,6 +156,7 @@ export class SetupManager {
148
156
  let credentials = input;
149
157
  if (input.profile_id) {
150
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 {} }
151
160
  if (!state.bundle) throw new SetupError('此项目没有待验证的接入包。');
152
161
  credentials = { ...state.bundle.wecom, chat_ids: state.bundle.invitation.chat_ids };
153
162
  }
@@ -163,11 +172,57 @@ export class SetupManager {
163
172
  } catch (error) { if (error instanceof SetupError) throw error; }
164
173
  }
165
174
  }
166
- 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 };
167
209
  }
168
- async createAdmin(input) {
169
- const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '')) throw new SetupError('请填写有效的成员名称。');
170
- const caps = capabilities(input.capabilities); const argv = testArgv(input.test_argv || ['sh', 'test.sh']);
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
+ } });
217
+ }
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']);
171
226
  const { repo, remote } = await ensureRepo(input.repo);
172
227
  if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
173
228
  for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
@@ -179,10 +234,11 @@ export class SetupManager {
179
234
  routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
180
235
  team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
181
236
  const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
182
- 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,
183
238
  root_hash: recordHash(record), genesis: record, test_argv: argv,
184
239
  wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
185
240
  await this.save(state);
241
+ if (onSaved) await onSaved(id);
186
242
  return this.resume(id);
187
243
  }
188
244
  async importExisting({ config_path }) {
@@ -240,6 +296,7 @@ export class SetupManager {
240
296
  }
241
297
  async resume(id) {
242
298
  const state = await this.state(id);
299
+ if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
243
300
  try {
244
301
  if (state.role === 'admin' && state.phase === 'initializing') {
245
302
  const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
@@ -319,11 +376,27 @@ export class SetupManager {
319
376
  throw new SetupError(state.last_error);
320
377
  }
321
378
  }
322
- async importBundle({ content, password, repo }) {
379
+ readBundle({ content, password }) {
323
380
  if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
324
381
  let envelope;
325
382
  try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
326
- 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);
327
400
  }
328
401
  async importAssignment(value, repo, ownAdminId) {
329
402
  const { bundle, history } = validateBundle(value);
@@ -336,13 +409,13 @@ export class SetupManager {
336
409
  if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
337
410
  }
338
411
  const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
339
- 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,
340
413
  bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
341
414
  ...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
342
415
  await this.save(state); return this.detail(id, { localOnly: true });
343
416
  }
344
417
  async verifyMember({ id, discovery_id }) {
345
- 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 });
346
419
  const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
347
420
  if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
348
421
  if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
@@ -353,7 +426,60 @@ export class SetupManager {
353
426
  default_workflow: [invite.capabilities[0]] };
354
427
  state.phase = 'verified'; await this.save(state); return this.resume(id);
355
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
+ }
356
481
  async issueBundle(input, { ownRepo } = {}) {
482
+ if (!ownRepo) return (await this.issueBatch({ id: input.id, members: [input] })).assignments[0];
357
483
  const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
358
484
  const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
359
485
  const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
@@ -478,7 +604,7 @@ export class SetupManager {
478
604
  return this.detail(id);
479
605
  }
480
606
  async detail(id, { localOnly = false } = {}) {
481
- 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 };
482
608
  if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
483
609
  expires_at: state.bundle.invitation.expires_at };
484
610
  if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
@@ -506,11 +632,11 @@ export class SetupManager {
506
632
  for (const file of exported) {
507
633
  const value = await readPrivateJson(join(this.path(id), 'exports', file));
508
634
  result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
509
- 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 });
510
636
  }
511
637
  }
512
638
  if (state.phase === 'ready') {
513
- 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);
514
640
  const config = (await readPeerConfig(result.config_path)).config;
515
641
  const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
516
642
  const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
@@ -534,13 +660,19 @@ export class SetupManager {
534
660
  async runner({ id, command }) {
535
661
  const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
536
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('机器人正在验证,请先取消验证,再启动连接。');
537
664
  this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
538
665
  state.last_error = null; await this.save(state);
539
- } catch {
666
+ } catch (error) {
667
+ if (error instanceof SetupError) throw error;
540
668
  state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
541
669
  await this.save(state); throw new SetupError(state.last_error);
542
670
  }
543
671
  return this.detail(id, { localOnly: true });
544
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); }
545
677
  close() { this.discovery.closeAll(); }
546
678
  }