xuym-codex-collab 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,8 @@ codex-collab setup
20
20
 
21
21
  管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员,密码单独传递。
22
22
 
23
+ 同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
24
+
23
25
  完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
24
26
 
25
27
  ## 一次任务如何接力
@@ -32,9 +34,12 @@ sequenceDiagram
32
34
  participant R as 阶段审核人
33
35
  participant G as 业务仓库 agent-collab
34
36
  participant B as Bob 已有 Codex 对话
35
- H->>A: @Alice 协作 创建 [frontend>backend] ...
36
- A->>G: 签名任务与能力路由
37
- C->>G: heartbeat 轮询并原子认领
37
+ H->>A: @Alice 用自然语言描述需求与分工
38
+ A->>G: 签名保存原始需求
39
+ C->>G: heartbeat 读取需求与团队职责
40
+ C->>G: 理解或追问,原子创建结构化任务
41
+ A->>H: 需求与分工确认,或补充问题
42
+ C->>G: 轮询并原子认领
38
43
  C->>G: 代码 commit、测试证据、阶段总结
39
44
  A->>H: 阶段结果、代码证据与审核命令
40
45
  H->>R: 通知本人审核
@@ -47,10 +52,17 @@ sequenceDiagram
47
52
  例如在群里选中实际 Alice 机器人发送:
48
53
 
49
54
  ```text
50
- @Alice 协作 创建 [frontend>backend] 给登录页增加密码显示切换 | npm test 通过
55
+ @Alice 给登录页增加密码显示切换,原有登录行为保持不变。
56
+ 你负责前端实现,Bob 补接口和测试,完成后在群里告诉我。
51
57
  ```
52
58
 
53
- Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
59
+ 机器人先回复收到需求;Alice 的绑定对话下一次轮询时理解需求,按已配置的职责生成任务、验收条件和分工,并在群里确认。需求不清楚时先追问,直接 @同一个机器人补充即可;有多条追问时带上通知中的 `N-需求ID`。状态问题或闲聊可以只回复,不创建任务。
60
+
61
+ 自然语言入口使用 `desktop_poll`,沿用本人身份和群权限。对话从团队路由选择职责,指定的同事不存在或分工不明确时需要追问;机器人显示名不决定调度。旧的 `协作 创建 ...` 命令仍兼容。
62
+
63
+ Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
64
+
65
+ 自然语言“通过”或“完成”不会批准任务;真人审核仍复制通知中的明确命令。已有安装须同时更新工具、项目 Skill、MCP 和原对话的 heartbeat 提示词,步骤见 [已有项目升级](docs/desktop-poll.md#已有项目启用自然语言入口)。
54
66
 
55
67
  代码保存在独立 `codex/*` 分支;任务和签名策略保存在 `agent-collab`,两者不自动合并到 `main`。最后阶段始终等待真人审核,审核通过后由管理员完成任务。
56
68
 
@@ -61,15 +73,17 @@ Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,
61
73
  协作 审核 T-实际任务ID R-7 退回 | 请补充键盘操作和相关测试
62
74
  ```
63
75
 
64
- 每次重新提交都产生新的版本号,旧审核命令不能批准新结果。只有指定审核人能审核;通知对象不因此获得审核权限。默认没有本人群身份的测试机器人由管理员审核,方便一个真人模拟两个同事。
76
+ 每次重新提交都产生新的版本号,旧审核命令不能批准新结果。只有指定审核人能审核;通知对象不因此获得审核权限。没有本人群身份的旧测试机器人仍由管理员审核。同一真人的新 Agent 分别验证后可各自处理审核,按通知指定的机器人操作。
65
77
 
66
78
  群通知会列出审核人与通知对象,并尝试用已验证的 `userid` 生成提及。**智能机器人长连接中的原生 @ 提醒仍需实群验证**;不能把收到结果通知等同于已触发原生 @。部分机器人得到的是加密 userid,本工具尚未集成企业应用的明文 ID 转换。群里的人需要先加入同一协作群,并完成自己的接入验证。
67
79
 
80
+ 共享 owner 的策略与本人邀请需要所有参与者使用支持此功能的新工具,并重启 Runner/MCP;旧安装不支持这些记录。
81
+
68
82
  ## 状态与数据
69
83
 
70
84
  工作台分别检查项目 MCP/Skill 配置、企微认证、目标对话绑定和实际轮询。机器人认证成功只能说明连接可用;还需要对话真实轮询才能执行任务。MCP 本身不能唤醒闲置对话,“启用指令”会要求在**当前选定对话**建立 heartbeat,应用需支持并运行该自动化。
71
85
 
72
- 成员私钥在成员本机生成。Git 记录公钥、成员职责、签名邀请/申请、任务事件、代码引用、测试和阶段总结;Secret、对话 ID、运行令牌及聊天全文留在本机。团队新增成员或修改审核规则后会发布新的签名策略版本;旧任务继续遵循创建时的策略,避免改写已运行任务的权限和候补。
86
+ 成员私钥在成员本机生成。Git 记录公钥、成员职责、签名邀请/申请、原始群需求与解读结果、任务事件、代码引用、测试和阶段总结;Secret、对话 ID、运行令牌及完整私有 Codex 对话留在本机。团队新增成员或修改审核规则后会发布新的签名策略版本;旧任务继续遵循创建时的策略,避免改写已运行任务的权限和候补。
73
87
 
74
88
  停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
75
89
 
@@ -90,3 +104,5 @@ node bin/codex-collab.mjs setup
90
104
  - [旧中心服务与飞书接入](docs/legacy-center.md)
91
105
 
92
106
  2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
107
+
108
+ 2026-10-13 自然语言入口通过全量 **122 项自动测试**,覆盖签名需求、追问补充、并发去重、绑定对话 MCP、Alice/Bob 固定 SHA 接力和通知重试;Skill 与 npm 打包检查通过。测试用固定解读结果模拟 Codex 的理解,没有调用真实模型或连接真实企业微信群;本轮自然语言实群验收仍待进行。
@@ -10,6 +10,7 @@ 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 { installedProjectConfig, refreshProject } from '../../scripts/peer/refresh-project.mjs';
13
14
  import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
14
15
  import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
15
16
  import { DesktopExecutor } from '../peer/desktop.mjs';
@@ -36,12 +37,65 @@ function capabilities(value) {
36
37
  if (!Array.isArray(list) || !list.length || list.length > 16 || list.some((cap) => !idPattern.test(cap))) throw new SetupError('职责标签请使用英文、数字、下划线或连字符。');
37
38
  return [...new Set(list)];
38
39
  }
40
+ function gitFailure(error) {
41
+ const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
42
+ if (error?.killed || error?.code === 'ETIMEDOUT' || /timed out|timeout/.test(detail)) {
43
+ return new SetupError('Git 访问远程仓库超时,请检查网络或代理后重试。');
44
+ }
45
+ if (/authentication failed|could not read username|permission denied|access denied|repository not found|403|401/.test(detail)) {
46
+ return new SetupError('无法访问远程仓库,请检查 GitHub 登录状态、仓库地址和读写权限。不要把 Token 或 Secret 填入向导。');
47
+ }
48
+ if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
49
+ return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
50
+ }
51
+ return new SetupError('Git 无法读取远程仓库,请检查 origin 地址、Git 权限和网络后重试。');
52
+ }
53
+ function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
54
+ function mcpSection(text, serverName) {
55
+ let active = false; const lines = [];
56
+ for (const line of String(text || '').split(/\r?\n/)) {
57
+ if (/^\s*\[/.test(line)) {
58
+ active = line.trim() === `[mcp_servers.${serverName}]`;
59
+ if (active) lines.push(line);
60
+ continue;
61
+ }
62
+ if (active) lines.push(line);
63
+ }
64
+ return lines.join('\n');
65
+ }
66
+ function projectMcpConfigured(projectConfig, configPath, config) {
67
+ const paths = [configPath];
68
+ if (config.execution_mode === 'desktop_poll') paths.push(join(dirname(configPath), `${config.agent_id}-desktop-peer.json`));
69
+ const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
70
+ return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
71
+ }
39
72
  async function ensureRepo(path) {
40
73
  if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
41
- const repo = await realpath(path);
42
- if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git 仓库根目录。');
43
- const remote = await git(repo, 'remote', 'get-url', 'origin');
44
- if (!await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main')) throw new SetupError('无法读取 origin/main,请检查仓库和 Git 权限。');
74
+ let repo;
75
+ try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
76
+ try {
77
+ if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git 仓库根目录。');
78
+ } catch (error) {
79
+ if (error instanceof SetupError) throw error;
80
+ throw new SetupError('请选择 Git 仓库根目录。');
81
+ }
82
+ let remote;
83
+ try { remote = await git(repo, 'remote', 'get-url', 'origin'); }
84
+ catch (error) {
85
+ const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
86
+ if (/no such remote|does not appear to be a git repository/.test(detail)) throw new SetupError('该目录没有名为 origin 的远程仓库,请先配置 GitHub origin。');
87
+ throw gitFailure(error);
88
+ }
89
+ let main;
90
+ try { main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main'); }
91
+ catch (error) { throw gitFailure(error); }
92
+ if (!main) {
93
+ let heads;
94
+ try { heads = await git(repo, 'ls-remote', '--heads', 'origin'); }
95
+ catch (error) { throw gitFailure(error); }
96
+ if (!heads) throw new SetupError(`远程仓库为空,未找到 origin/main。请先在终端执行:\ngit -C ${shellQuote(repo)} push -u origin main`);
97
+ throw new SetupError(`远程仓库可访问,但没有 main 分支。请先将本地 main 推送到 origin:\ngit -C ${shellQuote(repo)} push -u origin main`);
98
+ }
45
99
  return { repo, remote };
46
100
  }
47
101
  function validateBundle(value) {
@@ -174,6 +228,16 @@ export class SetupManager {
174
228
  await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
175
229
  await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
176
230
  }
231
+ async refreshAttachment(state, configPath) {
232
+ try {
233
+ const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id });
234
+ const source = (await readPeerConfig(configPath)).config;
235
+ const bound = (await readPeerConfig(installed.config_path)).config;
236
+ if (bound.agent_id !== source.agent_id || bound.private_key !== source.private_key ||
237
+ await realpath(bound.repo) !== await realpath(source.repo) || resolve(bound.data_dir) !== resolve(source.data_dir)) throw new SetupError('项目 MCP 使用其他身份或数据目录,请手动检查原绑定。');
238
+ await refreshProject({ configPath: installed.config_path });
239
+ } catch (error) { throw new SetupError(error.message); }
240
+ }
177
241
  async resume(id) {
178
242
  const state = await this.state(id);
179
243
  try {
@@ -193,7 +257,7 @@ export class SetupManager {
193
257
  state.phase = 'configuring'; await this.save(state);
194
258
  }
195
259
  if (state.role === 'member' && ['verified', 'waiting'].includes(state.phase)) {
196
- const store = await this.profileStore(state, { enrollmentOnly: true });
260
+ let store = await this.profileStore(state, { enrollmentOnly: true });
197
261
  if (state.phase === 'verified') {
198
262
  await store.registryTransaction(async (history) => {
199
263
  const invitation = JSON.parse(await readFile(join(store.checkout, invitationFile(state.request.invite_id)), 'utf8'));
@@ -210,6 +274,14 @@ export class SetupManager {
210
274
  });
211
275
  state.phase = 'waiting'; await this.save(state);
212
276
  }
277
+ if (!store.team.agents[state.agent_id] && state.own_admin_id) {
278
+ const admin = await this.state(state.own_admin_id);
279
+ const invite = state.bundle.invitation;
280
+ if (admin.role !== 'admin' || admin.phase !== 'ready' || admin.agent_id !== invite.actor_id || admin.root_hash !== state.root_hash ||
281
+ !invite.owner_userid || invite.owner_userid !== store.team.agents[admin.agent_id]?.wecom_userid || state.request.wecom_userid !== invite.owner_userid) throw new SetupError('本人机器人接入身份不一致,请通过原管理员项目继续。');
282
+ await this.approve({ id: admin.id, request_id: state.request.request_id });
283
+ store = await this.profileStore(state, { enrollmentOnly: true });
284
+ }
213
285
  const member = store.team.agents[state.agent_id];
214
286
  const identity = await readPrivateJson(state.identity_path);
215
287
  if (!member) return this.detail(id, { localOnly: true });
@@ -227,25 +299,34 @@ export class SetupManager {
227
299
  await diagnosePeer(configPath, { testPush: true });
228
300
  if (!state.attached) {
229
301
  const existing = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch(() => '');
230
- const template = await readFile(join(packageRoot, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
231
- const installed = await readFile(join(state.repo, '.agents/skills/team-collab-desktop/SKILL.md'), 'utf8').catch(() => '');
232
- if (!existing.includes(JSON.stringify(configPath)) || installed !== template) await attachProject({ configPath, repoPath: state.repo });
302
+ if (projectMcpConfigured(existing, configPath, await readPrivateJson(configPath))) await this.refreshAttachment(state, configPath);
303
+ else await attachProject({ configPath, repoPath: state.repo });
233
304
  state.attached = true; await this.save(state);
234
305
  }
235
306
  await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
236
307
  state.phase = 'ready'; state.last_error = null; await this.save(state);
237
308
  }
238
- if (state.phase === 'ready') this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
309
+ if (state.phase === 'ready') {
310
+ await this.refreshAttachment(state, state.existing_config || join(this.path(id), 'peer.json'));
311
+ await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
312
+ this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
313
+ }
239
314
  state.last_error = null; await this.save(state);
240
315
  return this.detail(id, { localOnly: true });
241
316
  } catch (error) {
242
- state.last_error = '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
243
- throw error;
317
+ state.last_error = error instanceof SetupError ? error.message : '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
318
+ if (error instanceof SetupError) throw error;
319
+ throw new SetupError(state.last_error);
244
320
  }
245
321
  }
246
322
  async importBundle({ content, password, repo }) {
247
323
  if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
248
- const { bundle, history } = validateBundle(decryptBundle(JSON.parse(content), password));
324
+ let envelope;
325
+ try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
326
+ return this.importAssignment(decryptBundle(envelope, password), repo);
327
+ }
328
+ async importAssignment(value, repo, ownAdminId) {
329
+ const { bundle, history } = validateBundle(value);
249
330
  const selected = await ensureRepo(repo);
250
331
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
251
332
  if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
@@ -256,13 +337,15 @@ export class SetupManager {
256
337
  }
257
338
  const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
258
339
  const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
259
- bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported' };
340
+ bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
341
+ ...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
260
342
  await this.save(state); return this.detail(id, { localOnly: true });
261
343
  }
262
344
  async verifyMember({ id, discovery_id }) {
263
345
  const state = await this.state(id); if (state.phase !== 'imported') throw new SetupError('此成员已经完成群验证。');
264
346
  const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
265
347
  if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
348
+ if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
266
349
  const identity = await readPrivateJson(state.identity_path);
267
350
  state.request = enrollmentRequest({ invite, publicKey: createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }), userid: proof.userid,
268
351
  chatid: proof.chatid, requestId: randomUUID(), privateKey: identity.private_key, claimPrivateKey: state.bundle.claim_private_key });
@@ -270,14 +353,16 @@ export class SetupManager {
270
353
  default_workflow: [invite.capabilities[0]] };
271
354
  state.phase = 'verified'; await this.save(state); return this.resume(id);
272
355
  }
273
- async issueBundle(input) {
356
+ async issueBundle(input, { ownRepo } = {}) {
274
357
  const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
275
358
  const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
276
359
  const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
277
360
  if (store.team.agents[agentId]) throw new SetupError('成员已存在,请复用原有接入配置。');
278
361
  if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret) throw new SetupError('请填写该成员机器人的 Bot ID 和 Secret。');
279
- if (input.bot_id.trim() === state.wecom.bot_id) throw new SetupError('每位成员必须使用独立机器人。');
280
- encryptBundle({}, input.password); // Validate password before publishing an invitation.
362
+ if (input.bot_id.trim() === state.wecom.bot_id) throw new SetupError('每个 Agent 必须使用独立机器人。');
363
+ const ownerUserid = ownRepo ? store.team.agents[state.agent_id]?.wecom_userid : undefined;
364
+ if (ownRepo && !ownerUserid) throw new SetupError('请先验证管理员的企业微信本人身份。');
365
+ if (!ownRepo) encryptBundle({}, input.password); // Validate password before publishing an invitation.
281
366
  const claim = generateIdentity(); const identity = await readPrivateJson(state.identity_path);
282
367
  const inviteId = randomUUID();
283
368
  const bundle = await store.registryTransaction(async (history) => {
@@ -290,17 +375,20 @@ export class SetupManager {
290
375
  }
291
376
  const invitation = invitationRecord({ invite_id: inviteId, agent_id: agentId, capabilities: caps,
292
377
  bot_id: input.bot_id.trim(), chat_ids: state.wecom.chat_ids, commands_enabled: input.commands_enabled !== false,
293
- prefer: input.prefer !== false, claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString() }, history.latest, state.agent_id, identity.private_key);
378
+ prefer: input.prefer !== false, claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString(),
379
+ ...(ownRepo ? { owner_userid: ownerUserid } : {}) }, history.latest, state.agent_id, identity.private_key);
294
380
  const value = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
295
381
  history: history.records, invitation, claim_private_key: claim.private_key,
296
382
  wecom: { bot_id: invitation.bot_id, secret: input.secret }, test_argv: state.test_argv };
297
383
  // Save export before push: a lost acknowledgment can be retried from disk.
298
- await savePrivate(join(this.path(input.id), 'exports', `${inviteId}.json`), { invitation, encrypted: encryptBundle(value, input.password) });
384
+ await savePrivate(join(this.path(input.id), 'exports', `${inviteId}.json`), ownRepo
385
+ ? { invitation, own_bundle: value, own_repo: ownRepo }
386
+ : { invitation, encrypted: encryptBundle(value, input.password) });
299
387
  return { files: [[invitationFile(inviteId), invitation]], value };
300
388
  });
301
389
  return { invite_id: bundle.invitation.invite_id, agent_id: agentId, filename: `${agentId}-collab-invite.json` };
302
390
  }
303
- async exportedBundle(id, inviteId) {
391
+ async publishAssignment(id, inviteId) {
304
392
  if (!uuidPattern.test(inviteId || '')) throw new SetupError('无效邀请标识。');
305
393
  const state = await this.state(id); const bundle = await readPrivateJson(join(this.path(id), 'exports', `${inviteId}.json`));
306
394
  const store = await this.profileStore(state);
@@ -317,8 +405,43 @@ export class SetupManager {
317
405
  });
318
406
  const published = JSON.parse(await readFile(join(store.checkout, invitationFile(inviteId)), 'utf8'));
319
407
  if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('邀请尚未发布成功,请重新生成接入包。');
408
+ return bundle;
409
+ }
410
+ async exportedBundle(id, inviteId) {
411
+ const bundle = await this.publishAssignment(id, inviteId);
412
+ if (!bundle.encrypted) throw new SetupError('本人机器人无需下载接入包,请点击继续接入我的机器人。');
320
413
  return { content: JSON.stringify(bundle.encrypted, null, 2), filename: `${bundle.invitation.agent_id}-collab-invite.json` };
321
414
  }
415
+ async createOwnAgent(input) {
416
+ const state = await this.state(input.id);
417
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
418
+ const selected = await ensureRepo(input.repo);
419
+ if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
420
+ if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
421
+ const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
422
+ for (const file of files) {
423
+ const saved = await readPrivateJson(join(this.path(input.id), 'exports', file));
424
+ if (saved.own_bundle && saved.invitation.agent_id === input.agent_id?.trim()) {
425
+ if (saved.own_repo !== selected.repo || saved.invitation.bot_id !== input.bot_id?.trim()) throw new SetupError('这个 Agent 已有本人接入配置,请从已生成的配置继续。');
426
+ return this.resumeOwnAgent({ id: input.id, invite_id: saved.invitation.invite_id });
427
+ }
428
+ }
429
+ for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
430
+ const issued = await this.issueBundle({ ...input, commands_enabled: true }, { ownRepo: selected.repo });
431
+ return this.resumeOwnAgent({ id: input.id, invite_id: issued.invite_id });
432
+ }
433
+ async resumeOwnAgent({ id, invite_id }) {
434
+ const state = await this.state(id);
435
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从原管理员项目继续接入。');
436
+ if (!uuidPattern.test(invite_id || '')) throw new SetupError('无效邀请标识。');
437
+ for (const profile of (await this.overview()).profiles) {
438
+ const existing = await this.state(profile.id);
439
+ if (existing.own_admin_id === id && existing.bundle?.invitation.invite_id === invite_id) return this.detail(profile.id, { localOnly: true });
440
+ }
441
+ const saved = await this.publishAssignment(id, invite_id);
442
+ if (!saved.own_bundle || !saved.own_repo || saved.invitation.owner_userid !== state.bootstrap_team.agents[state.agent_id]?.wecom_userid) throw new SetupError('这不是当前管理员的本人机器人配置。');
443
+ return this.importAssignment(saved.own_bundle, saved.own_repo, id);
444
+ }
322
445
  async approve({ id, request_id }) {
323
446
  if (!uuidPattern.test(request_id || '')) throw new SetupError('无效申请标识。');
324
447
  const state = await this.state(id); if (state.role !== 'admin') throw new SetupError('需要管理员确认。');
@@ -355,11 +478,18 @@ export class SetupManager {
355
478
  return this.detail(id);
356
479
  }
357
480
  async detail(id, { localOnly = false } = {}) {
358
- const state = await this.state(id); const result = { id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null };
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 };
359
482
  if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
360
483
  expires_at: state.bundle.invitation.expires_at };
361
484
  if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
362
- const store = await this.profileStore(state); result.members = Object.entries(store.team.agents).map(([id, agent]) => ({ id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid }));
485
+ const store = await this.profileStore(state); const owners = new Map();
486
+ result.members = Object.entries(store.team.agents).map(([id, agent]) => {
487
+ if (agent.wecom_userid) {
488
+ const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
489
+ }
490
+ return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
491
+ });
492
+ result.owners = [...owners].map(([userid, agent_ids]) => ({ agent_ids, is_you: userid === store.team.agents[state.agent_id]?.wecom_userid }));
363
493
  result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
364
494
  const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
365
495
  for (const file of files) {
@@ -376,12 +506,16 @@ export class SetupManager {
376
506
  for (const file of exported) {
377
507
  const value = await readPrivateJson(join(this.path(id), 'exports', file));
378
508
  result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
379
- filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at });
509
+ filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
380
510
  }
381
511
  }
382
512
  if (state.phase === 'ready') {
383
513
  result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
384
514
  const config = (await readPeerConfig(result.config_path)).config;
515
+ const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
516
+ const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
517
+ const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
518
+ result.project_setup = { mcp_configured: projectMcpConfigured(projectConfig, result.config_path, config), skill_installed: !!installedSkill.trim() };
385
519
  const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
386
520
  result.runner = { online: !!lock && alive(lock.pid), wecom: 'offline' };
387
521
  if (result.runner.online) {
@@ -392,14 +526,21 @@ export class SetupManager {
392
526
  result.last_poll_at = result.desktop.last_poll_at || null;
393
527
  result.poll_observed = !!result.last_poll_at;
394
528
  result.poll_recent = !!result.last_poll_at && Date.now() - Date.parse(result.last_poll_at) < 180000;
395
- result.can_receive_tasks = result.runner.online && result.runner.wecom === 'authenticated' && !!result.desktop.binding && result.poll_recent;
529
+ result.can_receive_tasks = result.project_setup.mcp_configured && result.project_setup.skill_installed && result.runner.online && result.runner.wecom === 'authenticated' && !!result.desktop.binding && result.poll_recent;
396
530
  result.heartbeat_confirmed = false;
397
531
  }
398
532
  return result;
399
533
  }
400
534
  async runner({ id, command }) {
401
535
  const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
402
- this.runCli(command, state.existing_config || join(this.path(id), 'peer.json')); return this.detail(id, { localOnly: true });
536
+ try {
537
+ this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
538
+ state.last_error = null; await this.save(state);
539
+ } catch {
540
+ state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
541
+ await this.save(state); throw new SetupError(state.last_error);
542
+ }
543
+ return this.detail(id, { localOnly: true });
403
544
  }
404
545
  close() { this.discovery.closeAll(); }
405
546
  }
@@ -40,7 +40,7 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
40
40
  const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
41
41
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
42
42
  }
43
- const actions = { discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
43
+ const actions = { 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
44
44
  const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
45
45
  if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
46
46
  return json(404, { error: '操作不存在。' });
@@ -1,5 +1,5 @@
1
1
  const $ = (id) => document.getElementById(id);
2
- let currentId, pending, discovery, timer, busy = false, reviewVersion = null;
2
+ let currentId, pending, discovery, timer, busy = false, reviewVersion = null, ownAdminId;
3
3
  function notice(text) { $('notice').textContent = text; $('notice').hidden = !text; }
4
4
  function show(view) { for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
5
5
  async function api(path, body) {
@@ -35,26 +35,31 @@ async function loadProfile(id) {
35
35
  $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
36
36
  if (value.phase === 'ready') {
37
37
  $('binding-text').textContent = value.binding_prompt;
38
- for (const [label, ok, wait] of [['项目 MCP 和 Skill', true, '待配置'], ['企业微信机器人', value.runner?.wecom === 'authenticated', value.runner?.online ? '连接中' : '已停止'], ['目标 Codex 对话', !!value.desktop?.binding, '待绑定'], ['对话实际轮询', value.poll_recent, value.poll_observed ? '最近未观察到轮询' : '待观察']]) {
38
+ for (const [label, ok, wait] of [['项目 MCP', value.project_setup?.mcp_configured, '待配置'], ['项目 Skill', value.project_setup?.skill_installed, '待配置'], ['企业微信机器人', value.runner?.wecom === 'authenticated', value.runner?.online ? '连接中' : '已停止'], ['目标 Codex 对话', !!value.desktop?.binding, '待绑定'], ['对话实际轮询', value.poll_recent, value.poll_observed ? '最近未观察到轮询' : '待观察'], ['可接收任务', value.can_receive_tasks, '尚未满足条件']]) {
39
39
  const row = document.createElement('div'); row.className = 'check'; const badge = text('span', ok ? '已就绪' : wait); badge.className = `badge${ok ? '' : ' pending'}`; row.append(text('span', label), badge); $('checks').append(row);
40
40
  }
41
41
  $('checks').append(text('p', '轮询状态来自实际调用记录。自动化是否启用,请在绑定对话的 Codex 自动化中确认。'));
42
42
  } else {
43
- const phases = { imported: '接入包已导入,等待群身份验证。', initializing: '团队初始化尚未完成。', configuring: '正在配置本机项目。', verified: '正在提交入队申请。', waiting: '申请已提交,请管理员刷新工作台并确认,然后点击继续。' };
43
+ const phases = { imported: '机器人配置已保存,等待群身份验证。', initializing: '团队初始化尚未完成。', configuring: '正在配置本机项目。', verified: '正在提交入队申请。', waiting: value.own_agent ? '申请已提交,点击继续完成本人机器人入队。' : '申请已提交,请管理员刷新工作台并确认,然后点击继续。' };
44
44
  $('progress').append(text('p', phases[value.phase] || value.phase), button(value.phase === 'imported' ? '连接分配的机器人' : '继续接入', async () => {
45
45
  if (value.phase === 'imported') { pending = { type: 'member', id }; await verifyView(await api('discovery', { profile_id: id })); }
46
- else { await api('resume', { id }); await loadProfile(id); }
46
+ else { try { await api('resume', { id }); } finally { await loadProfile(id); } }
47
47
  }));
48
48
  }
49
49
  if (value.error) notice(value.error);
50
- $('members').replaceChildren(); $('routes').replaceChildren();
50
+ $('members').replaceChildren(); $('routes').replaceChildren(); $('owners').replaceChildren();
51
51
  for (const member of value.members || []) $('members').append(text('p', `${member.id} · ${member.role === 'admin' ? '管理员' : '成员'} · ${member.capabilities.join(', ')}`));
52
52
  for (const [capability, candidates] of Object.entries(value.routes || {})) $('routes').append(text('p', `${capability}:${candidates.join(' → ')}(首选 → 候补)`));
53
+ for (const owner of value.owners || []) $('owners').append(text('p', `${owner.is_you ? '你的机器人' : '同一位同事的机器人'}:${owner.agent_ids.join('、')}`));
53
54
  renderReviewRules(value);
54
55
  $('exports').replaceChildren(); $('requests').replaceChildren();
55
56
  for (const invitation of value.exports || []) {
56
57
  const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${invitation.agent_id} · 有效至 ${new Date(invitation.expires_at).toLocaleDateString()}`));
57
- const link = text('a', '下载加密接入包'); link.href = `/api/export?id=${id}&invite=${invitation.invite_id}`; link.download = invitation.filename; row.append(link); $('exports').append(row);
58
+ if (invitation.own_agent) row.append(button('继续接入我的机器人', async () => {
59
+ const profile = await api('own-agent-resume', { id, invite_id: invitation.invite_id }); await connectMember(profile);
60
+ }));
61
+ else { const link = text('a', '下载加密接入包'); link.href = `/api/export?id=${id}&invite=${invitation.invite_id}`; link.download = invitation.filename; row.append(link); }
62
+ $('exports').append(row);
58
63
  }
59
64
  for (const request of value.requests || []) {
60
65
  const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${request.agent_id} · ${request.capabilities.join(', ')} · userid: ${request.userid || '只接通知'}`), button('确认此成员入队', async () => { await api('approve', { id, request_id: request.request_id }); await loadProfile(id); })); $('requests').append(row);
@@ -68,7 +73,7 @@ function renderReviewRules(profile) {
68
73
  if (!ready) { $('review-policy-note').textContent = '团队同步成功后可设置审核规则。'; return; }
69
74
  reviewVersion = profile.version;
70
75
  $('review-policy-note').textContent = profile.policy_schema_version === 3
71
- ? '默认由执行者本人审核。没有本人群身份的测试机器人由管理员审核。'
76
+ ? '默认由执行 Agent 的所有者通过对应机器人审核。一人多个 Agent 时审核身份仍独立;未配置群命令身份的 Agent 由管理员审核。'
72
77
  : '此团队使用旧版规则。保存后升级审核协议;请先让所有成员升级工具并重启机器人与 MCP。';
73
78
  const members = (profile.members || []).filter((member) => member.verified_owner);
74
79
  function select(label, field, selected, options) {
@@ -82,9 +87,9 @@ function renderReviewRules(profile) {
82
87
  const check = text('label', '中间阶段提交后需要真人审核'); check.className = 'checkbox';
83
88
  const required = document.createElement('input'); required.type = 'checkbox'; required.dataset.field = 'required'; required.checked = rule.required; check.prepend(required);
84
89
  const row = document.createElement('div'); row.className = 'row';
85
- const people = members.map((member) => [`member:${member.id}`, `指定同事:${member.id}`]);
86
- row.append(select('审核人', 'reviewer', rule.reviewer, [['owner', '执行者本人'], ['admin', '管理员'], ...people]),
87
- select('额外通知对象', 'notify', rule.notify, [['owner', '执行者本人'], ['reviewer', '审核人'], ['none', '不额外通知'], ...people]));
90
+ const people = members.map((member) => [`member:${member.id}`, `${member.id} 的所有者(通过其机器人)`]);
91
+ row.append(select('审核人', 'reviewer', rule.reviewer, [['owner', '执行 Agent 的所有者(通过其机器人)'], ['admin', '管理员'], ...people]),
92
+ select('额外通知对象', 'notify', rule.notify, [['owner', '执行 Agent 的所有者(通过其机器人)'], ['reviewer', '审核人'], ['none', '不额外通知'], ...people]));
88
93
  card.append(check, row); $('review-rules').append(card);
89
94
  }
90
95
  }
@@ -99,23 +104,44 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
99
104
  await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
100
105
  }); };
101
106
  for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
102
- $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.test_argv = JSON.parse(input.test_argv); pending = { type: 'admin', input }; const session = await api('discovery', { bot_id: input.bot_id, secret: input.secret, repo: input.repo }); pending.credentials = { bot_id: input.bot_id, secret: input.secret, repo: input.repo }; delete pending.input.secret; event.target.elements.secret.value = ''; await verifyView(session); }); };
103
- $('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.content = await input.file.text(); delete input.file; const profile = await api('import', input); event.target.elements.password.value = ''; currentId = profile.id; pending = { type: 'member', id: profile.id }; await verifyView(await api('discovery', { profile_id: profile.id })); }); };
107
+ $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); try { input.test_argv = JSON.parse(input.test_argv); } catch { throw new Error('验证命令必须是 JSON 字符串数组,例如 [\"npm\",\"test\"]。'); } if (!Array.isArray(input.test_argv) || !input.test_argv.length || input.test_argv.length > 32 || input.test_argv.some((part) => typeof part !== 'string' || !part || part.length > 2000)) throw new Error('验证命令必须是 JSON 字符串数组,例如 ["npm","test"]。'); pending = { type: 'admin', input, existing_ids: (await api('overview')).profiles.map((profile) => profile.id) }; const session = await api('discovery', { bot_id: input.bot_id, secret: input.secret, repo: input.repo }); pending.credentials = { bot_id: input.bot_id, secret: input.secret, repo: input.repo }; delete pending.input.secret; event.target.elements.secret.value = ''; await verifyView(session); }); };
108
+ $('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
109
+ const input = values(event.target); input.content = await input.file.text(); delete input.file;
110
+ const profile = await api('import', input); event.target.elements.password.value = ''; await connectMember(profile);
111
+ }); };
112
+ async function connectMember(profile) {
113
+ currentId = profile.id;
114
+ if (profile.phase !== 'imported') { pending = null; await loadProfile(profile.id); return; }
115
+ pending = { type: 'member', id: profile.id };
116
+ try { await verifyView(await api('discovery', { profile_id: profile.id })); }
117
+ catch (error) { await loadProfile(profile.id); throw error; }
118
+ }
119
+ $('add-own-agent').onclick = () => action(async () => { ownAdminId = currentId; show('own'); });
120
+ $('own-cancel').onclick = () => action(() => loadProfile(ownAdminId));
121
+ $('own-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
122
+ const input = values(event.target); input.id = ownAdminId; input.prefer = event.target.elements.prefer.checked;
123
+ let profile;
124
+ try { profile = await api('own-agent', input); }
125
+ catch (error) { await loadProfile(ownAdminId); throw error; }
126
+ finally { event.target.elements.secret.value = ''; }
127
+ await connectMember(profile);
128
+ }); };
104
129
  $('retry-code').onclick = () => action(async () => { await verifyView(await api('discovery', pending.type === 'member' ? { profile_id: pending.id } : pending.credentials)); });
105
130
  $('confirm-code').onclick = () => action(async () => {
106
131
  const session = await api(`discovery?id=${discovery.id}`);
107
132
  if (session.status !== 'verified') { updateConnection(session); throw new Error('请先完成群验证码验证。'); }
108
133
  clearInterval(timer);
134
+ const setup = pending; let profile;
109
135
  try {
110
- const profile = pending.type === 'admin' ? await api('admin', { ...pending.input, discovery_id: discovery.id }) : await api('verify', { id: pending.id, discovery_id: discovery.id });
111
- pending = null; await loadProfile(profile.id);
136
+ profile = setup.type === 'admin' ? await api('admin', { ...setup.input, discovery_id: discovery.id }) : await api('verify', { id: setup.id, discovery_id: discovery.id });
112
137
  } catch (error) {
113
- // The server persists setup before Git/MCP work, so resume from that profile after an interruption.
138
+ // Setup may be persisted before Git/MCP work fails; recover the same profile.
114
139
  const profiles = (await api('overview')).profiles;
115
- const profile = profiles.find((value) => pending.type === 'member' ? value.id === pending.id : value.agent_id === pending.input.agent_id && value.role === 'admin');
116
- if (profile) { pending = null; await loadProfile(profile.id); notice(`${error.message} 可在此继续接入。`); }
117
- else throw error;
140
+ profile = profiles.find((value) => setup.type === 'member' ? value.id === setup.id : value.agent_id === setup.input.agent_id.trim() && value.role === 'admin' && !setup.existing_ids.includes(value.id));
141
+ if (!profile) throw error;
142
+ pending = null; await loadProfile(profile.id); notice(`${error.message} 可在此继续接入。`); return;
118
143
  }
144
+ pending = null; await loadProfile(profile.id);
119
145
  });
120
146
  $('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
121
147
  $('issue-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.id = currentId; input.prefer = event.target.elements.prefer.checked; input.commands_enabled = event.target.elements.commands_enabled.checked; await api('issue', input); event.target.reset(); await loadProfile(currentId); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
@@ -4,10 +4,12 @@
4
4
  <nav><button data-view="home">我的项目</button><button data-view="admin">初始化团队</button><button data-view="join">加入团队</button><button data-view="existing">导入已有配置</button></nav>
5
5
  <section id="home"><h2>我的项目</h2><div id="profiles"></div></section>
6
6
  <section id="admin" hidden><h2>管理员初始化</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
7
- <form id="admin-form"><label>业务仓库的本机目录<input name="repo" required placeholder="业务仓库根目录的完整路径"></label><div class="row"><label>你的成员名称<input name="agent_id" required placeholder="alice" pattern="[a-zA-Z0-9_-]{1,64}"></label><label>职责标签<input name="capabilities" required placeholder="frontend,backend"></label></div><label>验证命令(JSON 数组)<input name="test_argv" value='["sh","test.sh"]' required></label><div class="row"><label>你的机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><button type="submit">连接机器人并验证群</button></form></section>
7
+ <form id="admin-form"><label>业务仓库的本机目录<input name="repo" required placeholder="业务仓库根目录的完整路径"></label><div class="row"><label>你的 Agent 名称<input name="agent_id" required placeholder="alice" pattern="[a-zA-Z0-9_-]{1,64}"></label><label>职责标签<input name="capabilities" required placeholder="frontend,backend"></label></div><label>验证命令(JSON 数组)<input name="test_argv" value='["sh","test.sh"]' required></label><div class="row"><label>你的机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><button type="submit">连接机器人并验证群</button></form></section>
8
+ <section id="own" hidden><h2>给自己增加机器人</h2><p>同一个企业微信账号可以拥有多个 Agent。每个 Agent 使用独立机器人、独立 clone 和独立 Codex 对话。验证后自动完成本机接入,无需下载接入包。</p>
9
+ <form id="own-form"><label>另一个 clone 的本机目录<input name="repo" required placeholder="同一业务仓库的另一个独立 clone"></label><div class="row"><label>新的 Agent 名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>新机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>新机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label class="checkbox"><input name="prefer" type="checkbox" checked>该 Agent 为这些职责的首选执行者</label><button type="submit">连接我的新机器人</button><button id="own-cancel" type="button" class="secondary">返回原项目</button></form></section>
8
10
  <section id="join" hidden><h2>加入团队</h2><p>管理员会交给你一个接入包和单独的密码。选择已克隆的业务仓库;签名身份会在本机自动生成。</p><form id="join-form"><label>接入包文件<input name="file" type="file" accept=".json" required></label><label>接入包密码<input name="password" type="password" required autocomplete="off"></label><label>业务仓库的本机目录<input name="repo" required placeholder="业务仓库根目录的完整路径"></label><button type="submit">导入并连接机器人</button></form></section>
9
11
  <section id="existing" hidden><h2>导入已有协作项目</h2><p>先停止原 Runner。导入会保留原成员身份、任务和对话绑定,并重新启动机器人。旧团队由管理员先导入。</p><form id="existing-form"><label>已有私有 peer.json 路径<input name="config_path" required placeholder="完整路径"></label><button type="submit">导入已有项目</button></form></section>
10
12
  <section id="verification" hidden><h2>在群里确认身份</h2><p id="connection"></p><p>请用自己的企业微信账号,<strong>@ 刚刚分配的机器人</strong>,发送下面这条消息:</p><pre id="code"></pre><button id="copy-code">复制消息</button><button id="confirm-code">已发送,继续检查</button><button id="retry-code" class="secondary" hidden>重新连接</button></section>
11
- <section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh">刷新项目状态</button></div><p id="profile-repo"></p><details id="profile-diagnostics" hidden><summary>查看本机配置路径</summary><pre id="config-path"></pre><button id="copy-config-path">复制配置路径</button></details><div id="checks"></div><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP,将下面的指令发到要绑定的对话。对话需要创建轮询自动化;本页会显示实际观察到的绑定和轮询状态。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
12
- <div id="admin-tools" hidden><h3>团队成员与分工</h3><div id="members"></div><div id="routes"></div><h3>真人审核与通知</h3><p>按职责设置:阶段结果提交后由谁审核、通知谁。关闭中间审核可直接接力;最终提交仍需真人审核,并由管理员完成任务。关闭审核的职责作为最后阶段时由管理员审核。</p><p id="review-policy-note" class="muted"></p><form id="review-form" hidden><div id="review-rules"></div><button type="submit">保存审核规则</button></form><p class="muted">规则保存后用于新任务。现有任务继续使用创建时的规则。审核通知附带分支、提交、测试和可复制的群命令;企业微信原生 @ 的显示效果需要在实际群中确认。</p><h3>分配机器人给成员</h3><p>管理员创建一个独立机器人并拉入群,然后为成员生成加密接入包。成员自行验证群身份,管理员在这里确认入队。</p><form id="issue-form"><div class="row"><label>成员名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>成员机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>成员机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label>接入包密码(至少 12 个字符)<input name="password" type="password" minlength="12" required autocomplete="off"></label><label class="checkbox"><input name="prefer" type="checkbox" checked>该成员为这些职责的首选执行者</label><label class="checkbox"><input name="commands_enabled" type="checkbox" checked>允许成员在群里给自己的机器人发任务</label><button type="submit">生成接入包</button></form><h3>已生成的接入包</h3><div id="exports"></div><h3>待确认的成员</h3><div id="requests"></div></div>
13
+ <section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh">刷新项目状态</button></div><p id="profile-repo"></p><details id="profile-diagnostics" hidden><summary>查看本机配置路径</summary><pre id="config-path"></pre><button id="copy-config-path">复制配置路径</button></details><div id="checks"></div><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP,将下面的指令发到要绑定的对话。对话需要创建轮询自动化;本页会显示实际观察到的 MCP、Skill、机器人、对话绑定、轮询状态,以及是否已满足接收任务条件。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
14
+ <div id="admin-tools" hidden><h3>团队 Agent 与分工</h3><div id="members"></div><div id="owners"></div><button id="add-own-agent" class="secondary">给自己增加机器人</button><div id="routes"></div><h3>真人审核与通知</h3><p>按职责设置:阶段结果提交后由谁审核、通知谁。关闭中间审核可直接接力;最终提交仍需真人审核,并由管理员完成任务。关闭审核的职责作为最后阶段时由管理员审核。</p><p id="review-policy-note" class="muted"></p><form id="review-form" hidden><div id="review-rules"></div><button type="submit">保存审核规则</button></form><p class="muted">规则保存后用于新任务。现有任务继续使用创建时的规则。审核通知附带分支、提交、测试和可复制的群命令;企业微信原生 @ 的显示效果需要在实际群中确认。</p><h3>分配机器人给同事</h3><p>为每个 Agent 创建独立机器人。同一位同事可以领取多份接入包,用同一个企业微信账号验证,分别选择独立 clone 并绑定不同对话。</p><form id="issue-form"><div class="row"><label>Agent 名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>成员机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>成员机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label>接入包密码(至少 12 个字符)<input name="password" type="password" minlength="12" required autocomplete="off"></label><label class="checkbox"><input name="prefer" type="checkbox" checked>该成员为这些职责的首选执行者</label><label class="checkbox"><input name="commands_enabled" type="checkbox" checked>允许所有者通过此机器人发送群命令</label><button type="submit">生成接入包</button></form><h3>已分配的机器人</h3><div id="exports"></div><h3>待确认的成员</h3><div id="requests"></div></div>
13
15
  </section></main><footer>配置仅保存在本机。任务与签名协议记录在业务仓库的 agent-collab 分支。</footer><script src="/app.js" defer></script></body></html>