xuym-codex-collab 0.3.0 → 0.3.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
@@ -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
  ## 一次任务如何接力
@@ -61,10 +63,12 @@ Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,
61
63
  协作 审核 T-实际任务ID R-7 退回 | 请补充键盘操作和相关测试
62
64
  ```
63
65
 
64
- 每次重新提交都产生新的版本号,旧审核命令不能批准新结果。只有指定审核人能审核;通知对象不因此获得审核权限。默认没有本人群身份的测试机器人由管理员审核,方便一个真人模拟两个同事。
66
+ 每次重新提交都产生新的版本号,旧审核命令不能批准新结果。只有指定审核人能审核;通知对象不因此获得审核权限。没有本人群身份的旧测试机器人仍由管理员审核。同一真人的新 Agent 分别验证后可各自处理审核,按通知指定的机器人操作。
65
67
 
66
68
  群通知会列出审核人与通知对象,并尝试用已验证的 `userid` 生成提及。**智能机器人长连接中的原生 @ 提醒仍需实群验证**;不能把收到结果通知等同于已触发原生 @。部分机器人得到的是加密 userid,本工具尚未集成企业应用的明文 ID 转换。群里的人需要先加入同一协作群,并完成自己的接入验证。
67
69
 
70
+ 共享 owner 的策略与本人邀请需要所有参与者使用支持此功能的新工具,并重启 Runner/MCP;旧安装不支持这些记录。
71
+
68
72
  ## 状态与数据
69
73
 
70
74
  工作台分别检查项目 MCP/Skill 配置、企微认证、目标对话绑定和实际轮询。机器人认证成功只能说明连接可用;还需要对话真实轮询才能执行任务。MCP 本身不能唤醒闲置对话,“启用指令”会要求在**当前选定对话**建立 heartbeat,应用需支持并运行该自动化。
@@ -36,12 +36,65 @@ function capabilities(value) {
36
36
  if (!Array.isArray(list) || !list.length || list.length > 16 || list.some((cap) => !idPattern.test(cap))) throw new SetupError('职责标签请使用英文、数字、下划线或连字符。');
37
37
  return [...new Set(list)];
38
38
  }
39
+ function gitFailure(error) {
40
+ const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
41
+ if (error?.killed || error?.code === 'ETIMEDOUT' || /timed out|timeout/.test(detail)) {
42
+ return new SetupError('Git 访问远程仓库超时,请检查网络或代理后重试。');
43
+ }
44
+ if (/authentication failed|could not read username|permission denied|access denied|repository not found|403|401/.test(detail)) {
45
+ return new SetupError('无法访问远程仓库,请检查 GitHub 登录状态、仓库地址和读写权限。不要把 Token 或 Secret 填入向导。');
46
+ }
47
+ if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
48
+ return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
49
+ }
50
+ return new SetupError('Git 无法读取远程仓库,请检查 origin 地址、Git 权限和网络后重试。');
51
+ }
52
+ function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
53
+ function mcpSection(text, serverName) {
54
+ let active = false; const lines = [];
55
+ for (const line of String(text || '').split(/\r?\n/)) {
56
+ if (/^\s*\[/.test(line)) {
57
+ active = line.trim() === `[mcp_servers.${serverName}]`;
58
+ if (active) lines.push(line);
59
+ continue;
60
+ }
61
+ if (active) lines.push(line);
62
+ }
63
+ return lines.join('\n');
64
+ }
65
+ function projectMcpConfigured(projectConfig, configPath, config) {
66
+ const paths = [configPath];
67
+ if (config.execution_mode === 'desktop_poll') paths.push(join(dirname(configPath), `${config.agent_id}-desktop-peer.json`));
68
+ const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
69
+ return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
70
+ }
39
71
  async function ensureRepo(path) {
40
72
  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 权限。');
73
+ let repo;
74
+ try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
75
+ try {
76
+ if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git 仓库根目录。');
77
+ } catch (error) {
78
+ if (error instanceof SetupError) throw error;
79
+ throw new SetupError('请选择 Git 仓库根目录。');
80
+ }
81
+ let remote;
82
+ try { remote = await git(repo, 'remote', 'get-url', 'origin'); }
83
+ catch (error) {
84
+ const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
85
+ if (/no such remote|does not appear to be a git repository/.test(detail)) throw new SetupError('该目录没有名为 origin 的远程仓库,请先配置 GitHub origin。');
86
+ throw gitFailure(error);
87
+ }
88
+ let main;
89
+ try { main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main'); }
90
+ catch (error) { throw gitFailure(error); }
91
+ if (!main) {
92
+ let heads;
93
+ try { heads = await git(repo, 'ls-remote', '--heads', 'origin'); }
94
+ catch (error) { throw gitFailure(error); }
95
+ if (!heads) throw new SetupError(`远程仓库为空,未找到 origin/main。请先在终端执行:\ngit -C ${shellQuote(repo)} push -u origin main`);
96
+ throw new SetupError(`远程仓库可访问,但没有 main 分支。请先将本地 main 推送到 origin:\ngit -C ${shellQuote(repo)} push -u origin main`);
97
+ }
45
98
  return { repo, remote };
46
99
  }
47
100
  function validateBundle(value) {
@@ -193,7 +246,7 @@ export class SetupManager {
193
246
  state.phase = 'configuring'; await this.save(state);
194
247
  }
195
248
  if (state.role === 'member' && ['verified', 'waiting'].includes(state.phase)) {
196
- const store = await this.profileStore(state, { enrollmentOnly: true });
249
+ let store = await this.profileStore(state, { enrollmentOnly: true });
197
250
  if (state.phase === 'verified') {
198
251
  await store.registryTransaction(async (history) => {
199
252
  const invitation = JSON.parse(await readFile(join(store.checkout, invitationFile(state.request.invite_id)), 'utf8'));
@@ -210,6 +263,14 @@ export class SetupManager {
210
263
  });
211
264
  state.phase = 'waiting'; await this.save(state);
212
265
  }
266
+ if (!store.team.agents[state.agent_id] && state.own_admin_id) {
267
+ const admin = await this.state(state.own_admin_id);
268
+ const invite = state.bundle.invitation;
269
+ if (admin.role !== 'admin' || admin.phase !== 'ready' || admin.agent_id !== invite.actor_id || admin.root_hash !== state.root_hash ||
270
+ !invite.owner_userid || invite.owner_userid !== store.team.agents[admin.agent_id]?.wecom_userid || state.request.wecom_userid !== invite.owner_userid) throw new SetupError('本人机器人接入身份不一致,请通过原管理员项目继续。');
271
+ await this.approve({ id: admin.id, request_id: state.request.request_id });
272
+ store = await this.profileStore(state, { enrollmentOnly: true });
273
+ }
213
274
  const member = store.team.agents[state.agent_id];
214
275
  const identity = await readPrivateJson(state.identity_path);
215
276
  if (!member) return this.detail(id, { localOnly: true });
@@ -239,13 +300,19 @@ export class SetupManager {
239
300
  state.last_error = null; await this.save(state);
240
301
  return this.detail(id, { localOnly: true });
241
302
  } catch (error) {
242
- state.last_error = '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
243
- throw error;
303
+ state.last_error = error instanceof SetupError ? error.message : '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
304
+ if (error instanceof SetupError) throw error;
305
+ throw new SetupError(state.last_error);
244
306
  }
245
307
  }
246
308
  async importBundle({ content, password, repo }) {
247
309
  if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
248
- const { bundle, history } = validateBundle(decryptBundle(JSON.parse(content), password));
310
+ let envelope;
311
+ try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
312
+ return this.importAssignment(decryptBundle(envelope, password), repo);
313
+ }
314
+ async importAssignment(value, repo, ownAdminId) {
315
+ const { bundle, history } = validateBundle(value);
249
316
  const selected = await ensureRepo(repo);
250
317
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
251
318
  if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
@@ -256,13 +323,15 @@ export class SetupManager {
256
323
  }
257
324
  const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
258
325
  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' };
326
+ bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
327
+ ...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
260
328
  await this.save(state); return this.detail(id, { localOnly: true });
261
329
  }
262
330
  async verifyMember({ id, discovery_id }) {
263
331
  const state = await this.state(id); if (state.phase !== 'imported') throw new SetupError('此成员已经完成群验证。');
264
332
  const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
265
333
  if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
334
+ if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
266
335
  const identity = await readPrivateJson(state.identity_path);
267
336
  state.request = enrollmentRequest({ invite, publicKey: createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }), userid: proof.userid,
268
337
  chatid: proof.chatid, requestId: randomUUID(), privateKey: identity.private_key, claimPrivateKey: state.bundle.claim_private_key });
@@ -270,14 +339,16 @@ export class SetupManager {
270
339
  default_workflow: [invite.capabilities[0]] };
271
340
  state.phase = 'verified'; await this.save(state); return this.resume(id);
272
341
  }
273
- async issueBundle(input) {
342
+ async issueBundle(input, { ownRepo } = {}) {
274
343
  const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
275
344
  const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
276
345
  const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
277
346
  if (store.team.agents[agentId]) throw new SetupError('成员已存在,请复用原有接入配置。');
278
347
  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.
348
+ if (input.bot_id.trim() === state.wecom.bot_id) throw new SetupError('每个 Agent 必须使用独立机器人。');
349
+ const ownerUserid = ownRepo ? store.team.agents[state.agent_id]?.wecom_userid : undefined;
350
+ if (ownRepo && !ownerUserid) throw new SetupError('请先验证管理员的企业微信本人身份。');
351
+ if (!ownRepo) encryptBundle({}, input.password); // Validate password before publishing an invitation.
281
352
  const claim = generateIdentity(); const identity = await readPrivateJson(state.identity_path);
282
353
  const inviteId = randomUUID();
283
354
  const bundle = await store.registryTransaction(async (history) => {
@@ -290,17 +361,20 @@ export class SetupManager {
290
361
  }
291
362
  const invitation = invitationRecord({ invite_id: inviteId, agent_id: agentId, capabilities: caps,
292
363
  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);
364
+ prefer: input.prefer !== false, claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString(),
365
+ ...(ownRepo ? { owner_userid: ownerUserid } : {}) }, history.latest, state.agent_id, identity.private_key);
294
366
  const value = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
295
367
  history: history.records, invitation, claim_private_key: claim.private_key,
296
368
  wecom: { bot_id: invitation.bot_id, secret: input.secret }, test_argv: state.test_argv };
297
369
  // 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) });
370
+ await savePrivate(join(this.path(input.id), 'exports', `${inviteId}.json`), ownRepo
371
+ ? { invitation, own_bundle: value, own_repo: ownRepo }
372
+ : { invitation, encrypted: encryptBundle(value, input.password) });
299
373
  return { files: [[invitationFile(inviteId), invitation]], value };
300
374
  });
301
375
  return { invite_id: bundle.invitation.invite_id, agent_id: agentId, filename: `${agentId}-collab-invite.json` };
302
376
  }
303
- async exportedBundle(id, inviteId) {
377
+ async publishAssignment(id, inviteId) {
304
378
  if (!uuidPattern.test(inviteId || '')) throw new SetupError('无效邀请标识。');
305
379
  const state = await this.state(id); const bundle = await readPrivateJson(join(this.path(id), 'exports', `${inviteId}.json`));
306
380
  const store = await this.profileStore(state);
@@ -317,8 +391,43 @@ export class SetupManager {
317
391
  });
318
392
  const published = JSON.parse(await readFile(join(store.checkout, invitationFile(inviteId)), 'utf8'));
319
393
  if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('邀请尚未发布成功,请重新生成接入包。');
394
+ return bundle;
395
+ }
396
+ async exportedBundle(id, inviteId) {
397
+ const bundle = await this.publishAssignment(id, inviteId);
398
+ if (!bundle.encrypted) throw new SetupError('本人机器人无需下载接入包,请点击继续接入我的机器人。');
320
399
  return { content: JSON.stringify(bundle.encrypted, null, 2), filename: `${bundle.invitation.agent_id}-collab-invite.json` };
321
400
  }
401
+ async createOwnAgent(input) {
402
+ const state = await this.state(input.id);
403
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
404
+ const selected = await ensureRepo(input.repo);
405
+ if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
406
+ if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
407
+ const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
408
+ for (const file of files) {
409
+ const saved = await readPrivateJson(join(this.path(input.id), 'exports', file));
410
+ if (saved.own_bundle && saved.invitation.agent_id === input.agent_id?.trim()) {
411
+ if (saved.own_repo !== selected.repo || saved.invitation.bot_id !== input.bot_id?.trim()) throw new SetupError('这个 Agent 已有本人接入配置,请从已生成的配置继续。');
412
+ return this.resumeOwnAgent({ id: input.id, invite_id: saved.invitation.invite_id });
413
+ }
414
+ }
415
+ for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
416
+ const issued = await this.issueBundle({ ...input, commands_enabled: true }, { ownRepo: selected.repo });
417
+ return this.resumeOwnAgent({ id: input.id, invite_id: issued.invite_id });
418
+ }
419
+ async resumeOwnAgent({ id, invite_id }) {
420
+ const state = await this.state(id);
421
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从原管理员项目继续接入。');
422
+ if (!uuidPattern.test(invite_id || '')) throw new SetupError('无效邀请标识。');
423
+ for (const profile of (await this.overview()).profiles) {
424
+ const existing = await this.state(profile.id);
425
+ if (existing.own_admin_id === id && existing.bundle?.invitation.invite_id === invite_id) return this.detail(profile.id, { localOnly: true });
426
+ }
427
+ const saved = await this.publishAssignment(id, invite_id);
428
+ if (!saved.own_bundle || !saved.own_repo || saved.invitation.owner_userid !== state.bootstrap_team.agents[state.agent_id]?.wecom_userid) throw new SetupError('这不是当前管理员的本人机器人配置。');
429
+ return this.importAssignment(saved.own_bundle, saved.own_repo, id);
430
+ }
322
431
  async approve({ id, request_id }) {
323
432
  if (!uuidPattern.test(request_id || '')) throw new SetupError('无效申请标识。');
324
433
  const state = await this.state(id); if (state.role !== 'admin') throw new SetupError('需要管理员确认。');
@@ -355,11 +464,18 @@ export class SetupManager {
355
464
  return this.detail(id);
356
465
  }
357
466
  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 };
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 };
359
468
  if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
360
469
  expires_at: state.bundle.invitation.expires_at };
361
470
  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 }));
471
+ const store = await this.profileStore(state); const owners = new Map();
472
+ result.members = Object.entries(store.team.agents).map(([id, agent]) => {
473
+ if (agent.wecom_userid) {
474
+ const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
475
+ }
476
+ return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
477
+ });
478
+ result.owners = [...owners].map(([userid, agent_ids]) => ({ agent_ids, is_you: userid === store.team.agents[state.agent_id]?.wecom_userid }));
363
479
  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
480
  const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
365
481
  for (const file of files) {
@@ -376,12 +492,16 @@ export class SetupManager {
376
492
  for (const file of exported) {
377
493
  const value = await readPrivateJson(join(this.path(id), 'exports', file));
378
494
  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 });
495
+ filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
380
496
  }
381
497
  }
382
498
  if (state.phase === 'ready') {
383
499
  result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
384
500
  const config = (await readPeerConfig(result.config_path)).config;
501
+ const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
502
+ const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
503
+ const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
504
+ result.project_setup = { mcp_configured: projectMcpConfigured(projectConfig, result.config_path, config), skill_installed: !!installedSkill.trim() };
385
505
  const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
386
506
  result.runner = { online: !!lock && alive(lock.pid), wecom: 'offline' };
387
507
  if (result.runner.online) {
@@ -392,14 +512,21 @@ export class SetupManager {
392
512
  result.last_poll_at = result.desktop.last_poll_at || null;
393
513
  result.poll_observed = !!result.last_poll_at;
394
514
  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;
515
+ 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
516
  result.heartbeat_confirmed = false;
397
517
  }
398
518
  return result;
399
519
  }
400
520
  async runner({ id, command }) {
401
521
  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 });
522
+ try {
523
+ this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
524
+ state.last_error = null; await this.save(state);
525
+ } catch {
526
+ state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
527
+ await this.save(state); throw new SetupError(state.last_error);
528
+ }
529
+ return this.detail(id, { localOnly: true });
403
530
  }
404
531
  close() { this.discovery.closeAll(); }
405
532
  }
@@ -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>
@@ -33,6 +33,7 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
33
33
  const sender = body.from?.userid;
34
34
  const owner = !!sender && sender === service.team.agents[service.agentId]?.wecom_userid;
35
35
  const admin = config.allow_admin_commands === true && !!sender && Object.values(service.team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === sender);
36
+ // Shared owners do not merge agent roles: the receiving bot selects its signer.
36
37
  // Admins may dispatch/query via a member bot. Review mutations must be signed by
37
38
  // the human reviewer's own identity, never by impersonating another member.
38
39
  if (!owner && !(admin && ['create', 'status'].includes(command.action))) return false;
@@ -92,15 +93,20 @@ export function formatPeerNotification(task, type, agentId, team) {
92
93
  if (members.length) {
93
94
  // WeCom's Markdown extension is best-effort on the smart-bot transport.
94
95
  // Encrypted callback userids and actual native @ rendering need group verification.
95
- detail.push(`通知本人:${members.map((id) => {
96
+ const owners = new Map();
97
+ for (const id of members) {
96
98
  const userid = team.agents[id].wecom_userid;
97
- return /^[a-zA-Z0-9_-]{1,128}$/.test(userid) ? `${id} <@${userid}>` : id;
98
- }).join('、')}`);
99
+ const ids = owners.get(userid) || []; ids.push(id); owners.set(userid, ids);
100
+ }
101
+ detail.push(`通知本人:${[...owners].map(([userid, ids]) => {
102
+ const names = ids.join('、');
103
+ return /^[a-zA-Z0-9_-]{1,128}$/.test(userid) ? `${names} <@${userid}>` : names;
104
+ }).join(';')}`);
99
105
  }
100
106
  if (type === 'handoff') detail.push(next());
101
107
  else {
102
- detail.push(`${type === 'stage_review_requested' ? '本阶段等待真人审核,通过后继续下一阶段。' : '全部阶段已提交,等待真人审核。'}\n审核人:${task.state.assignee};版本:R-${task.state.seq}`);
103
- detail.push(`请审核人 @ 自己绑定的机器人发送:\n协作 审核 ${task.spec.id} R-${task.state.seq} 通过\n协作 审核 ${task.spec.id} R-${task.state.seq} 退回 | 修改意见`);
108
+ detail.push(`${type === 'stage_review_requested' ? '本阶段等待真人审核,通过后继续下一阶段。' : '全部阶段已提交,等待真人审核。'}\n审核人:${task.state.assignee} 的所有者;审核身份:${task.state.assignee};版本:R-${task.state.seq}`);
109
+ detail.push(`请审核人 @ ${task.state.assignee} 对应的机器人发送:\n协作 审核 ${task.spec.id} R-${task.state.seq} 通过\n协作 审核 ${task.spec.id} R-${task.state.seq} 退回 | 修改意见`);
104
110
  }
105
111
  }
106
112
  if (type === 'reviewed') {
@@ -1,6 +1,6 @@
1
1
  # 企业微信任务 → 指定 Codex 桌面对话轮询执行
2
2
 
3
- 日常团队使用 [本机网页接入向导](onboarding.md) 自动配置项目、群身份和机器人。本页保留隔离测试与底层轮询机制。已有配置可从网页导入,无需重绑对话。
3
+ 日常团队使用 [本机网页接入向导](onboarding.md) 自动配置项目、群身份和机器人。本页保留隔离测试与底层轮询机制。已有配置可从网页导入,无需重绑对话。一个真人使用多个机器人建议走向导的“给自己增加机器人”,允许 Alice/Bob 共享 userid 并各自处理命令。本页的旧隔离测试脚本仍默认只给 Alice 设置群命令身份。
4
4
 
5
5
  本模式是 `desktop_poll`,不是后台 SDK 自动模式,也不是旧的手动 `desktop` 保留。企业微信适配器负责入口/通知,Git 保存协议,**指定已有 Codex 对话的 heartbeat**负责调用 MCP 接单并在该对话执行。
6
6
 
@@ -90,7 +90,7 @@ node scripts/peer/cli.mjs start "$root/alice-peer.json"
90
90
  node scripts/peer/cli.mjs start "$root/bob-peer.json"
91
91
  ```
92
92
 
93
- 日志应显示 `notifications only; automatic code execution disabled` 和两次各自的 `Peer WeCom authenticated`。Alice 接本人的群命令;Bob 在单真人模拟中只发送自己的通知。真实多用户需独立 owner 身份,不能复制同一真人 userid 绕过权限。
93
+ 日志应显示 `notifications only; automatic code execution disabled` 和两次各自的 `Peer WeCom authenticated`。本页旧脚本默认 Alice 接本人的群命令,Bob 只发送通知。这是测试夹具的默认设置;新网页向导支持同一真人拥有两个可接群命令的 Agent,各自用独立机器人和密钥。真实同事应验证各自本人账号。
94
94
 
95
95
  可用 CLI 检查“进程在线”和“企微已鉴权”是否是两个独立状态:
96
96
 
@@ -129,7 +129,7 @@ node scripts/peer/cli.mjs status "$root/bob-peer.json"
129
129
  @Alice 协作 完成 T-真实任务ID
130
130
  ```
131
131
 
132
- 示例 `R-7` 替换为最新通知版本;v2 任务仍兼容省略版本号的旧命令。一个真人模拟两个机器人时,无独立群命令身份的 Bob 由 Alice 管理员审核。原生 @ 提醒需实群确认;审核与 Git 接力不依赖原生 @。
132
+ 示例 `R-7` 替换为最新通知版本;v2 任务仍兼容省略版本号的旧命令。本页旧夹具中没有群命令身份的 Bob 由 Alice 管理员审核;新向导中用相同本人账号验证的 Bob 默认通过 Bob 机器人审核,按当前通知操作。原生 @ 提醒需实群确认;审核与 Git 接力不依赖原生 @。
133
133
 
134
134
  ## MCP 与运行约束
135
135
 
@@ -17,7 +17,7 @@
17
17
  | 企微连接 | Alice 接命令;Bob 发通知 | Alice 接命令并执行;Bob 不接命令但可执行并发通知 |
18
18
  | Git | 私有本机 bare remote | 另一个私有本机 bare remote,不推送 GitHub |
19
19
 
20
- **Bob 的 `commands_enabled=false` 只禁用群命令入口,不禁用自动执行。** 它不需要收到 Alice Bot 的 @ 消息,直接读取 Git 中的已签名 handoff 就会接力。一个真人模拟两位 agent 时,仅把你的 userid 绑定给 Alice;Bob 不重复绑定。
20
+ **Bob 的 `commands_enabled=false` 只禁用群命令入口,不禁用自动执行。** 它不需要收到 Alice Bot 的 @ 消息,直接读取 Git 中的已签名 handoff 就会接力。本页旧测试脚本仅给 Alice 绑定 userid,Bob 保持通知模式。新网页向导支持同一个 userid 拥有多个独立 Agent,并同时开启各机器人的群命令;模拟既有 Codex 对话接力请使用 [向导中的本人多机器人流程](onboarding.md#一个人使用多个机器人或模拟两个同事)。
21
21
 
22
22
  ## 1. 生成新的自动测试环境
23
23
 
@@ -18,7 +18,7 @@
18
18
  | 两个本地 Runner | `auto_execute=false`,只连接机器人和中继通知 |
19
19
  | `origin.git` | 本机共享 bare Git remote,不连接 GitHub |
20
20
 
21
- 团队策略要求不同成员使用不同企业微信真人 `userid`。本次只有你一位真人,不能把你的 userid 同时绑定给 Alice 和 Bob;Bob 用 `--notify-only`,不会放宽审核权限。真实同事加入时,用独立真人身份建立新的团队策略。
21
+ 本页保留的旧测试脚本只给 Alice 绑定真人 `userid`,Bob 用 `--notify-only` 保持通知模式。新工具允许同一个真人拥有多个独立 Agent,并分别通过自己的机器人处理群命令;建议使用 [网页向导中的“给自己增加机器人”](onboarding.md#一个人使用多个机器人或模拟两个同事),按新流程建立身份绑定,不直接修改旧任务的签名策略。真实同事使用各自的企业微信账号。
22
22
 
23
23
  ## 1. 只初始化一次
24
24
 
@@ -0,0 +1,88 @@
1
+ # 网页向导功能检查与测试记录
2
+
3
+ 检查日期:2026-10-13。对象是工具仓库中的网页向导源码,本记录对应 0.3.1 的发布前验证。
4
+
5
+ ## 测试范围
6
+
7
+ 使用独立的临时 Git 仓库、真实 Git 分支与签名协议、模拟企业微信连接进行验证。浏览器实际操作管理员与成员表单、文件下载/上传、状态与审核配置;自动测试覆盖接口鉴权、异常输入、任务接力、真人审核和失败候补。本次没有修改个人真实机器人凭据,没有停止原有 Alice/Bob Runner。
8
+
9
+ 浏览器中的“已认证”由模拟连接提供。测试中的对话绑定和轮询通过执行器 API 产生,不能据此宣称已完成真实企业微信群、桌面对话自动唤醒和多台电脑的联合验收。
10
+
11
+ ## 页面功能矩阵
12
+
13
+ | 区域 | 功能 | 测试方式与结果 |
14
+ | --- | --- | --- |
15
+ | 启动与首页 | 终端链接换取本机会话、项目列表、打开项目 | 浏览器及 HTTP 测试通过;刷新后会话有效,已有项目保留 |
16
+ | 访问控制 | 错误令牌、错误 Host/Origin、未登录接口、请求格式与体积 | HTTP 测试通过;拒绝未授权操作,返回安全提示 |
17
+ | 管理员初始化 | 仓库目录、成员名称、职责、固定验证命令、机器人凭据 | 浏览器创建新管理员成功;无效 JSON/非字符串数组在连接前拦截 |
18
+ | 仓库检查 | 缺少 origin、远程空仓库、缺少 main | 自动测试通过;给出具体的 origin/main 提示与推送指令 |
19
+ | 群身份验证 | 连接、验证码、复制消息、继续检查 | 浏览器通过;未验证不能继续 |
20
+ | 群身份约束 | 准确验证码、指定群、群消息发送人、连接认证前不得验证 | 自动测试通过;不匹配消息忽略 |
21
+ | 验证恢复 | 连接失败重试、断开、验证码过期 | 失败与重试经浏览器验证;过期经自动测试验证 |
22
+ | 团队初始化 | 生成本机身份、发布团队签名、安装 MCP/Skill、启动连接 | 浏览器与本地 Git 测试通过 |
23
+ | 初始化中断 | 已保存项目重新打开、继续接入、启动失败重试 | 浏览器及自动测试通过;不会因页面恢复再次提交验证码 |
24
+ | 分配机器人 | 独立机器人、职责、首选/候补、是否允许本人群命令 | 浏览器通过;取消两个选项后导出值均为 false |
25
+ | 生成接入包 | 加密、密码、邀请有效期、重复成员/机器人约束 | 自动测试通过;浏览器生成并实际下载文件 |
26
+ | 下载与保密 | 下载加密文件、文件不含明文 Secret | 浏览器下载后解密核验通过;Secret 仅存在加密正文 |
27
+ | 邀请发布中断 | 找回同一份导出、重试发布、未发布邀请不得加入 | 自动测试通过 |
28
+ | 成员导入 | 文件上传、密码、业务 clone 与 remote 一致性 | 浏览器正确文件/密码通过;错误密码能留在表单重试;remote 校验经自动测试覆盖 |
29
+ | 文件错误 | 损坏的 JSON、错误密码、被篡改密文 | 自动测试通过;损坏 JSON 有明确重新下载提示 |
30
+ | 重复导入 | 已验证、待批准、配置中、已就绪成员再次导入 | 自动测试通过;浏览器重复导入 ready 成员直接打开原项目,不再抢机器人连接 |
31
+ | 入队申请 | 群身份自动生成 userid、提交签名申请、等待管理员 | 浏览器通过;成员页面显示待批准状态 |
32
+ | 管理员批准 | 显示申请身份、确认入队、更新成员与职责路由 | 浏览器与协议测试通过;新增职责自动有审核规则 |
33
+ | 成员完成配置 | 批准后继续、安装个人 MCP/Skill、启动机器人 | 浏览器通过 |
34
+ | 旧项目导入 | 旧管理员先导入、保留身份/任务/绑定、重复导入 | 浏览器及自动测试通过;兼容原生成的 desktop MCP 配置路径 |
35
+ | 项目状态 | MCP、Skill、机器人、对话绑定、实际轮询、综合就绪 | 浏览器与自动测试通过;缺失/禁用配置、未绑定或轮询过期不会显示综合就绪 |
36
+ | 绑定指令 | 展示、复制、指定身份和项目 | 浏览器通过;桌面对话仍需执行指令,页面不能代替对话创建 heartbeat |
37
+ | 连接操作 | 启动、停止、启动失败、安全错误保存、成功后清理错误 | 浏览器及自动测试通过 |
38
+ | 团队分工 | 成员/职责列表、首选与候补顺序展示 | 浏览器通过;当前可查看,不能编辑既有顺序 |
39
+ | 本人多机器人 | 管理员添加自己的新 Agent、独立 clone 与 Bot、同一真人验证 | 浏览器完成 Alice → 新 Bob 接入;无需下载接入包或设置接入包密码 |
40
+ | 本人接入恢复 | 发布确认丢失、自动批准中断、首页重新打开已保存配置 | 自动测试通过;浏览器从已保存分配继续并打开 ready 项目,不重复创建身份 |
41
+ | 普通同事多机器人 | 同一 userid 分别导入多个接入包、逐个批准 | 自动测试通过;两个 Agent 均保留群命令权限 |
42
+ | 身份隔离 | 独立签名密钥、独立项目 MCP、错误真人/remote 拒绝 | 自动测试通过;同一所有者不合并 Agent 角色,Bob 仍不能签署管理员完成事件 |
43
+ | 审核与通知 | 同一真人 @ 去重、明确审核 Agent 和对应机器人、错误审核机器人拒绝 | 自动测试通过;按 Alice/Bob 的独立签名身份进行审核和任务接力 |
44
+ | 所有者与路由展示 | 显示同一真人的多个 Agent、首选与候补 | 浏览器确认“你的机器人:alice、bob”和 backend:bob → alice,无控制台错误 |
45
+ | 真人审核规则 | 必须审核开关、审核人、额外通知、指定已验证同事、保存与刷新 | 浏览器及自动测试通过;版本校验拒绝过时配置,新规则只用于新任务 |
46
+ | 协作执行 | 两位执行者接力固定提交、运行验证、发阶段通知、真人审批、完成 | 本地自动测试通过;真实群通知呈现仍需实群验证 |
47
+ | 诊断提示 | 配置路径、失败说明、Git 同步失败本机状态回退 | 浏览器确认配置路径展示;安全错误与 Git 同步回退经自动测试覆盖,不输出 SDK 原始异常 |
48
+
49
+ 完整测试命令是 `npm test`,最终结果为 **108 passed, 0 failed**。新增加的页面事件测试运行实际交付的 `app.js`,使用最小 DOM 验证 API 调用顺序与恢复;它不能替代真实浏览器交互。本次已结合两种测试。
50
+
51
+ ## 本次修复
52
+
53
+ 1. 重复导入已接入成员的接入包时,按已有阶段打开项目,避免重复验证抢占 Runner;首次导入后连接失败也能看到已保存项目和重新连接入口。
54
+ 2. 项目状态独立显示 MCP 与 Skill,并识别旧版生成的 desktop 配置。综合就绪要求项目配置、认证连接、绑定和最近三分钟内实际轮询同时满足。显式禁用 MCP 会显示未就绪。
55
+ 3. 验证成功后页面加载失败,不再因为清空 pending 而报空对象错误;管理员中断恢复识别本次新建的项目,兼容规范化后的目录路径。
56
+ 4. 中断接入与 Runner 启停错误转换成可恢复的安全中文提示;保存错误供刷新查看,成功后清理。
57
+ 5. 缺失 Git `info/exclude` 时正常创建父目录与排除文件,失败时恢复原状。
58
+ 6. 无效验证命令在连接前拦截;损坏接入包 JSON 有具体文件错误提示。
59
+
60
+ ## 本人多机器人补充验证
61
+
62
+ 新增 8 项多所有者/多 Agent 自动测试、3 项网页事件测试、1 项企业微信审核通知测试和 1 项网页接口鉴权测试。完整测试通过后,没有修改执行逻辑;本记录补充了验证范围与结果。
63
+
64
+ 浏览器使用新建的临时 Git 仓库、模拟机器人连接和模拟 Runner 启停,实际填写第二个 clone、新 Agent 和机器人配置,验证群身份后完成本人入队、安装 MCP/Skill,并从管理员页面继续打开该配置。这里的 ready 表示向导完成配置;模拟 Runner 不构成真实群连接或桌面对话验收。浏览器截图保存于本机临时目录,未加入发布包。
65
+
66
+ 本人邀请带有签名保护的可选 `owner_userid`,要求群验证账号与已验证管理员一致;接入记录沿用正常签名申请和管理员批准。邀请与恢复资料保存在仓库外的 0600 私有文件中。普通同事可以通过多份接入包使用同一个真人账号,但仍需分别验证和批准。新客户端接受旧邀请;旧客户端不识别新所有者字段或共享所有者策略,采用此功能前应统一升级并重启 Runner/MCP。
67
+
68
+ ## 尚缺少的页面功能
69
+
70
+ | 功能遗漏 | 当前影响 |
71
+ | --- | --- |
72
+ | 更换机器人、轮换 Secret、变更群 | 只能按本机配置与企业微信端操作;向导没有维护入口 |
73
+ | 撤销邀请、禁用/移除成员、轮换签名身份 | 当前没有产品化入口;需要补签名协议、权限与历史兼容,不能仅删本机文件 |
74
+ | 修改既有成员职责、重排首选与候补 | 页面只展示已有分工;生成邀请时可以指定新成员优先级 |
75
+ | 缺失 MCP/Skill 的一键修复 | 当前能识别缺失,但现有 Skill、受版本管理的配置等需要检查;不会自动覆盖 |
76
+ | 确认 heartbeat 持续启用、切换绑定对话 | 页面能观察绑定与最近轮询;不能控制 Codex 自动化,切换需要对话工具解除旧绑定 |
77
+ | 页面内展示日志与详细 Git 诊断 | 当前只有安全错误提示、私有配置路径;详细日志仍需本机查看 |
78
+ | 多管理员配置冲突的页面引导 | 后端按版本拒绝过时保存;页面需要刷新重新设置,不会自动合并 |
79
+ | 页面刷新保留正在进行的管理员验证码步骤 | 管理员保存 profile 前的验证码状态在当前页面内存中;刷新需重新填写和验证;成员已导入的项目可从首页恢复 |
80
+
81
+ ## 仍需联合验收的环境场景
82
+
83
+ - 两台不同电脑分别安装 npm 包,对同一远程仓库各自接入、绑定真实 Codex 对话。
84
+ - 真实企业微信群中创建任务、自动轮询启动执行、阶段总结、真人审核、下一位接力。
85
+ - 企业微信原生 @ 提醒效果,以及真实 WebSocket 断线/凭据轮换的提示和恢复。
86
+ - 应用退出、电脑休眠、heartbeat 暂停与网络恢复后,候补接管与旧执行者的租约隔离。
87
+
88
+ 页面的“项目 MCP / Skill 已就绪”表示本机文件已配置,并非读取了 Codex 的实际加载状态。近期成功轮询是额外证据,仍不能保证未来自动化持续运行。Git 启动检查与任务执行中的 Git 失败属于不同时间点,初始化通过不代表未来的 push 永远成功。
@@ -15,18 +15,20 @@ codex-collab setup
15
15
 
16
16
  网页自动打开;若未打开,执行 `codex-collab setup --no-open`,复制终端打印的本机链接。保留此终端以使用工作台。团队成员只需 clone 业务仓库,无需 clone 工具仓库。
17
17
 
18
- 同一台电脑模拟两位成员时,使用同一业务仓库的两个独立 clone、两个机器人、两个 Codex 对话;同一个 clone 只能接入一个身份。真正同事分别使用自己的电脑、企业微信账号和 Git 凭据。
18
+ 同一台电脑模拟两位成员时,使用同一业务仓库的两个独立 clone、两个机器人、两个 Codex 对话;同一个 clone 只能接入一个身份。同一个企业微信账号可以拥有多个 Agent,每个 Agent 的机器人、签名身份、clone 和对话独立。真正同事使用自己的企业微信账号和 Git 凭据。
19
19
 
20
20
  ## 管理员操作
21
21
 
22
22
  1. 在企业微信创建独立智能机器人,获取 Bot ID 和 Secret,拉进协作群。为每位成员分别创建机器人并加入同一个群。
23
23
  2. 工作台选择“初始化团队”:输入业务仓库本机目录、自己的成员名称、职责标签、固定验证命令,以及自己的机器人 Bot ID/Secret。成员名称与职责用英文、数字、下划线或连字符,例如 `alice`、`frontend,backend`。
24
24
  3. 点击“连接机器人并验证群”,用自己的企业微信账号 @ 此机器人,发送页面显示的 `协作 接入 六位数字`。返回网页点击“已发送,继续检查”。工具自动发现群 `chatid` 与你的 `userid`,创建身份、签名账本和项目 MCP/Skill,并启动本机机器人 Runner。
25
- 4. 项目页面“分配机器人给成员”:填写成员名称、职责、成员机器人 Bot ID/Secret,以及至少 12 字符的接入包密码。“首选执行者”决定新成员在职责路由中排第一还是候补;未使用的职责会新增路由。
25
+ 4. 项目页面“分配机器人给同事”:填写成员名称、职责、成员机器人 Bot ID/Secret,以及至少 12 字符的接入包密码。“首选执行者”决定新成员在职责路由中排第一还是候补;未使用的职责会新增路由。
26
26
  5. 生成并下载加密 JSON 接入包,交给指定成员;密码通过另一条可信渠道单独提供。包默认 7 天有效,同一成员或机器人不会同时分发多个有效邀请。
27
27
  6. 成员完成群验证后,刷新“待确认的成员”,核对成员名称、职责和 userid,点击“确认此成员入队”。新的签名策略通过 Git 同步到其他在线成员,无需重建已有任务。
28
28
  7. 项目页“真人审核与通知”按职责设置审核人和额外通知对象,默认需要阶段审核且由执行者本人处理。可选管理员或已经验证本人身份的指定同事。管理员也按下文把自己的 Codex 对话启用。
29
29
 
30
+ 同一位同事可以领取多份接入包,用同一个企业微信账号分别验证不同机器人;每份包选择独立 clone,并绑定不同 Codex 对话,不需要关闭群命令权限。
31
+
30
32
  真人同事和机器人都应加入协作群。每位真人通过自己的企业微信账号验证接入,以便工具核对审核权限;额外通知对象不会因此获得审核权限。
31
33
 
32
34
  机器人必须由一个本机 Runner 保持连接。同一个 Bot ID 不要同时运行旧服务、群发现命令和新 Runner。向导会检查可识别的本机重复连接;其他电脑上的连接需由团队自己核实。
@@ -92,17 +94,34 @@ codex-collab setup
92
94
 
93
95
  群里“任务已创建”和“机器人已认证”不能代替 Codex 已接单证据。最终任务不会自动批准或合并 `main`;管理员需要检查代码、真实测试证据再决定如何合并。消息中的 `npm test 通过` 是验收描述,真正运行的是管理员在本机初始化时固定的 JSON argv。
94
96
 
95
- ## 一个人模拟两个同事
97
+ ## 一个人使用多个机器人,或模拟两个同事
98
+
99
+ 准备同一业务仓库的两个独立 clone、两个企业微信机器人和两个 Codex 对话。管理员给自己增加 Agent 可以直接在同一工作台完成:
100
+
101
+ 1. 以 Alice 初始化团队,完成群验证并进入管理员项目。已有团队可直接打开已就绪的管理员项目。
102
+ 2. 点击“给自己增加机器人”,填写第二个 clone 的本机目录、新 Agent 名称 `bob`、职责 `backend`,以及新机器人的 Bot ID/Secret。
103
+ 3. 保留“首选执行者”时,Bob 成为 backend 首选;Alice 若也有 backend 能力则是候补。点击“连接我的新机器人”。无需包密码或下载接入包。
104
+ 4. 用初始化 Alice 时的**同一个企业微信账号** @Bob,发送页面验证码,再点击“已发送,继续检查”。工具校验本人身份,自动签名批准新 Agent、配置 Bob 项目的 MCP/Skill 并启动连接。Bob 仍是 member,不会继承 Alice 的管理员角色。
105
+ 5. 在 Alice clone 和 Bob clone 中分别打开要绑定的已有 Codex 对话,向各自对话粘贴该项目的“启用指令”。每个 Agent 各自创建 heartbeat,确认身份与最近轮询。
106
+ 6. 在群里 @Alice 创建包含两种职责的任务,例如 `[frontend>backend]`。默认 Alice 阶段提交后你 @Alice 审核,Bob 阶段提交后你 @Bob 审核,最后 @Alice 完成任务。以每次通知指定的审核身份和版本号为准。
107
+
108
+ 两份配置都允许所有者发送群命令。同一个 userid 只表示是同一个真人;收消息的机器人决定签名 Agent,权限不会合并,发给 Bob 的管理员完成命令仍会被拒绝。审核人与通知对象属于同一个真人时,群通知只提及一次,同时列出所对应的 Agent 和必须使用的审核机器人。
96
109
 
97
- 使用同一业务仓库的两个独立 clone、两个企业微信机器人和两个 Codex 对话。先以 Alice 初始化团队,再给 Bob 生成接入包;生成 Bob 接入包时**取消勾选“允许成员在群里给自己的机器人发任务”**,避免同一个真人 userid 同时绑定两个群命令身份。Bob 仍可领取、执行任务和向群里通知结果。任务通过 `@Alice` 创建,并按职责路由到 Alice 或 Bob;两份配置各自绑定对应项目中的对话。Bob 没有独立的本人命令身份时,默认审核会回落到管理员 Alice;两阶段都由你 @Alice 审核。
110
+ 普通团队成员拥有多个机器人时,由管理员给他分配多份独立接入包;成员用自己的同一个账号验证,管理员逐个确认,其余操作与成员流程一致。“给自己增加机器人”的自动批准入口仅供已经验证、就绪的管理员使用。
111
+
112
+ 接入中断时,从管理员项目“已分配的机器人”找到 Bob,点击“继续接入我的机器人”,或从首页打开已经保存的 Bob 项目继续。不要重新生成同名 Agent。若误选 Alice 的 clone、另一个 remote 或已分配的 Bot ID,工具会拒绝。
113
+
114
+ 使用这个功能前,所有参与者需运行包含多 Agent 所有权支持的新工具,并重启旧 Runner/MCP。旧客户端会拒绝重复 owner userid 或新邀请的可选字段。旧策略里没有 userid 的通知机器人仍按旧规则由管理员审核;已签名的旧身份绑定不能直接手改成新绑定。
98
115
 
99
116
  ## 工作台、停止和恢复
100
117
 
101
118
  默认本机数据位于 `~/.config/codex-collab/workspace`,机器人 Secret、私钥、对话 ID、Runner 日志和接入状态均在业务仓库之外;私有 JSON 使用 `0600`。Git 只同步签名协议、公钥、职责、任务证据和必要结果摘要。
102
119
 
120
+ - 重复导入同一接入包:已验证或已接入的成员会直接打开原项目,继续原进度;不会重复启动群验证。
121
+ - 项目接单状态:同时检查 MCP、Skill、认证连接、目标对话绑定和最近三分钟内实际轮询。配置文件存在不等于 Codex 已加载,自动化启用状态仍需在绑定对话确认。
103
122
  - 群验证断线/过期:点击“重新连接”,使用新的验证码。
104
123
  - Git/MCP 配置中断:从“我的项目”打开原项目,再点击“继续接入”;不要反复创建另一身份。
105
- - 管理员生成邀请时网络失败:回项目页面刷新已生成接入包,再下载;工具重试发布同一签名邀请。
124
+ - 管理员生成邀请时网络失败:回项目页面刷新已生成接入包,再下载;本人机器人点击“继续接入我的机器人”。工具重试发布同一签名邀请。
106
125
  - 本机工作台关闭:重新运行 `codex-collab setup`;已启动的机器人 Runner 会继续运行。
107
126
  - 页面“停止机器人连接”只断开群机器人。暂停接单需在对应 Codex 对话暂停 heartbeat;已有任务仍需完成、报告失败或按租约恢复处理。
108
127
  - CC Switch 与模型账号设置保持原样;项目 MCP 使用本机 Node 的绝对执行路径。
@@ -117,3 +136,5 @@ codex-collab doctor /absolute/path/to/private-peer.json
117
136
  Secret 若曾发到聊天或 Git,应在企业微信重新生成后更新本机配置。工具只发布协作任务的阶段总结,不会把整个私有 Codex 对话发到群里;目前尚无任意普通对话完成后自动发群总结的独立工具。
118
137
 
119
138
  旧命令兼容:`codex-collab setup --terminal` 或 `setup --role admin|member` 继续使用 [旧终端工作流](terminal-onboarding.md),不走加密包与动态签名入队。
139
+
140
+ 网页向导的逐项覆盖、故障恢复与功能遗漏见 [功能检查与测试记录](onboarding-audit.md)。
package/docs/peer.md CHANGED
@@ -88,7 +88,7 @@ unset WECOM_BOT_SECRET
88
88
 
89
89
  不要把上面的真实 Secret 放在命令历史、聊天或 Git 中;如果曾经泄露,先在企业微信后台轮换,再使用新 Secret。
90
90
 
91
- 同一个真人本机模拟两位 agent 时,第二个 Bot 可以在配置命令后加 `--notify-only`;它只发送该 agent 的事件,不接收群命令,不需要绑定第二个真人 userid。不得把同一个 userid 分配给两个团队成员。
91
+ 同一个真人可以拥有多个独立 Agent,并使用同一个已验证 `userid`;每个 Agent 仍需独立机器人、签名身份、clone 和对话。推荐通过 [网页向导](onboarding.md#一个人使用多个机器人或模拟两个同事) 添加。旧隔离测试也可以给第二个 Bot 加 `--notify-only`,让它只发送通知;这属于测试脚本的默认设置。不要手改既有签名任务的所有者绑定,旧客户端需要统一升级后再采用多 Agent 所有权。
92
92
 
93
93
  ## 安装当前 Codex 的 Skill 和 MCP
94
94
 
@@ -17,6 +17,8 @@
17
17
 
18
18
  客户端通过加密接入包或明确导入的旧可信策略 pin 根 hash。本机保存最高版本与对应 hash,拒绝回滚和从已观察版本分叉。后续版本须由上一策略中的管理员签名;已有成员公钥、角色和 userid 不可偷偷替换。
19
19
 
20
+ Agent ID 与签名公钥保持独立;多个 Agent 可以使用同一个已验证 `wecom_userid`。接收群命令的机器人选择本机 Agent 的签名身份,所有者相同不会合并角色或审核权限。
21
+
20
22
  每个新任务的 created 事件 pin 当时的 `team_hash`。重放、claim、续租、审核和候补均使用那个版本,而不是直接把最新路由用于旧任务。因此加入新成员或更新审核规则影响此后创建的任务,旧任务不改变授权或候补。
21
23
 
22
24
  ## 真人审核策略 v3
@@ -38,18 +40,26 @@
38
40
 
39
41
  ## 邀请、申请、批准
40
42
 
41
- 公开邀请由管理员签名,包含成员名称、职责、机器人 Bot ID、允许的群 ID、群命令开关、首选/候补、到期时间、邀请 claim 公钥和引用的团队 hash。默认 7 天有效。它不包含机器人 Secret、claim 私钥或成员私钥。
43
+ 公开邀请由管理员签名,包含成员名称、职责、机器人 Bot ID、允许的群 ID、群命令开关、首选/候补、到期时间、邀请 claim 公钥和引用的团队 hash。默认 7 天有效。“给自己增加机器人”会额外签名可选 `owner_userid`,限定为签发管理员已验证的 userid,并要求开启群命令;入队申请必须证明同一个真人账号。旧邀请不含此字段也继续有效。它不包含机器人 Secret、claim 私钥或成员私钥。
42
44
 
43
45
  成员的私钥在成员本机生成。通过群验证码得到 userid/chatid 后,申请包括成员公钥和这些公开绑定,并由成员密钥与邀请 claim 密钥分别签名。管理员核对后发布新的团队版本;版本的 enrollment 包含对应 invite_id 和 request_id。相同申请重试幂等,另一个身份复用已消费邀请会被拒绝。已经批准的同一身份恢复本机配置不受后续邀请过期影响。
44
46
 
47
+ 管理员在本人第二个 clone 的接入流程通过群验证码证明相同账号后,自动调用同一套签名入队批准协议;新 Agent 仍为 member。普通成员的多个 Agent 仍分别提交申请并由管理员确认。
48
+
45
49
  目前版本链用于追加成员、能力路由、审核规则和本机恢复。机器人轮换、密钥轮换、成员撤销与群迁移没有网页操作;不要手改签名文件实现这些动作。多个管理员同时写入时,经本机锁、Git fast-forward push 与重新校验重试处理。
46
50
 
47
51
  ## 加密接入包
48
52
 
49
53
  分发文件 `kind: codex-collab-encrypted-invite`,含 schema_version、随机 salt、随机 iv、GCM tag 和 ciphertext;scrypt 派生密钥,AES-256-GCM 加密内部 JSON。内部包包含公开签名历史、邀请、root_hash、remote、固定 test_argv、专用 claim 私钥及分配的机器人 Secret。密码至少 12 字符,文件与密码分开传递。
50
54
 
51
- 管理员保存可再次下载的密文。成员的签名私钥始终在成员机器上,管理员不会代为生成。包仍是敏感凭据,持有包和密码的人能连接该机器人并申请入队;管理员最终批准用于核对申请身份。
55
+ 给同事分配时,管理员保存可再次下载的密文。成员的签名私钥始终在成员机器上,管理员不会代为生成。包仍是敏感凭据,持有包和密码的人能连接该机器人并申请入队;管理员最终批准用于核对申请身份。
56
+
57
+ 管理员本人新增机器人不分发文件。其 Bot Secret 与 claim 私钥保存在工作台私有目录的 `0600` JSON,和各 Agent 私钥一样不进入业务仓库。先保存本机配置再发布邀请,丢失 Git 回执后可重试同一邀请;群验证和管理员签名批准仍然必需。
52
58
 
53
59
  本机向导使用随机打开链接交换 HttpOnly SameSite=Strict cookie;检查 Host、Origin、JSON 内容类型和请求长度,避免其他网站直接操作本机配置。SDK/Git 错误经脱敏,不把异常中的凭据返回网页。
54
60
 
55
- Schemas:`team-policy.schema.json`、`team-version.schema.json`、`team-invitation.schema.json`、`team-enrollment.schema.json`、`encrypted-invite.schema.json`。运行时另外校验签名、完整历史链、root/checkpoint、成员唯一性、邀请时效、群绑定、Git 权限与任务创建时策略。
61
+ Schemas:`team-policy.schema.json`、`team-version.schema.json`、`team-invitation.schema.json`、`team-enrollment.schema.json`、`encrypted-invite.schema.json`。运行时另外校验签名、完整历史链、root/checkpoint、Agent ID 与公钥唯一性、邀请时效、群绑定、Git 权限与任务创建时策略。
62
+
63
+ ## 多 Agent 所有权兼容
64
+
65
+ 邀请 schema 1 增加可选 `owner_userid`;旧邀请仍可由新客户端验证,任务及团队版本的 schema 编号保持不变。旧客户端对字段与 owner 唯一性的校验不支持此功能,会拒绝新邀请或共享 owner 的策略。在增加同一真人的第二个 Agent 前,所有参与者必须升级到支持此功能的工具,并重启 Runner 与 MCP;不能混用旧安装。
@@ -27,7 +27,7 @@ codex-collab setup --role admin
27
27
 
28
28
  向导会依次询问管理员 agent ID、能力标签、成员 agent ID 和公钥文件、每种能力的首选/候补顺序、人工审核管理员、默认能力、共享仓库标识(例如 `jack506255560/codex-collab-demo`),以及目标仓库 clone 路径。
29
29
 
30
- 企业微信 userid 可在初始化团队时填写,用来授权该真人通过群机器人发送协作命令。暂时没有 userid 时可以留空;该成员机器人只能发通知,不能处理群命令。不要把不同成员的真人 userid 填成同一个值。
30
+ 企业微信 userid 可在初始化团队时填写,用来授权该真人通过群机器人发送协作命令。暂时没有 userid 时可以留空;该成员机器人只能发通知,不能处理群命令。同一真人拥有多个 Agent 时可以填写同一个 userid,但各 Agent 必须使用不同名称、签名密钥、机器人、clone 和 Codex 对话;不同真人要填写各自账号。旧客户端不支持重复 userid,团队需先统一升级并重启 Runner/MCP。
31
31
 
32
32
  向导在本机创建公开团队策略文件(`team.json`)和管理员自己的私钥,不会替其他成员生成私钥。团队策略在首个签名任务写入账本后就被固定;新增成员、更换公钥或调整候补顺序应在正式建任务前完成。
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xuym-codex-collab",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Git-backed collaboration tools for Codex teams, with MCP, WeCom, and local runners.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,12 +12,13 @@ const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
12
12
  export function validateTeam(team) {
13
13
  if (![2, 3].includes(team?.schema_version) || typeof team.repo !== 'string' || !team.repo || !idPattern.test(team.default_route ?? '')) throw new Error('invalid peer team configuration');
14
14
  if (!team.agents || !team.routes || !Object.keys(team.agents).length) throw new Error('team needs agents and routes');
15
- const keys = new Set(); const users = new Set();
15
+ const keys = new Set();
16
16
  for (const [id, agent] of Object.entries(team.agents)) {
17
17
  if (!idPattern.test(id) || !['admin', 'member'].includes(agent.role) || !Array.isArray(agent.capabilities) || agent.capabilities.some((c) => !idPattern.test(c)) || !Number.isSafeInteger(agent.max_concurrent_tasks) || agent.max_concurrent_tasks < 1 || agent.max_concurrent_tasks > 16) throw new Error(`invalid agent ${id}`);
18
18
  if (createPublicKey(agent.public_key).asymmetricKeyType !== 'ed25519' || keys.has(agent.public_key)) throw new Error('agents require distinct Ed25519 public keys');
19
19
  keys.add(agent.public_key);
20
- if (agent.wecom_userid) { if (typeof agent.wecom_userid !== 'string' || users.has(agent.wecom_userid)) throw new Error('duplicate or invalid WeCom owner'); users.add(agent.wecom_userid); }
20
+ // An owner is a human account; several independent agents may share it.
21
+ if (Object.hasOwn(agent, 'wecom_userid') && (typeof agent.wecom_userid !== 'string' || !agent.wecom_userid.trim() || agent.wecom_userid.length > 200)) throw new Error('invalid WeCom owner');
21
22
  }
22
23
  if (team.agents[team.reviewer]?.role !== 'admin') throw new Error('reviewer must be an admin');
23
24
  // Reviewer is a human approval identity, not an execution exclusion. Its Runner
@@ -69,19 +69,21 @@ export function invitationRecord(input, record, actorId, privateKey) {
69
69
  capabilities: input.capabilities, bot_id: input.bot_id, chat_ids: input.chat_ids,
70
70
  commands_enabled: input.commands_enabled, prefer: input.prefer,
71
71
  claim_public_key: input.claim_public_key, expires_at: input.expires_at,
72
- team_hash: record.team_hash, actor_id: actorId };
72
+ team_hash: record.team_hash, actor_id: actorId,
73
+ ...(input.owner_userid !== undefined ? { owner_userid: input.owner_userid } : {}) };
73
74
  const signed = signRecord(invite, privateKey);
74
75
  validateInvitation(signed, { teams: new Map([[record.team_hash, record.team]]) });
75
76
  return signed;
76
77
  }
77
78
  export function validateInvitation(invite, history) {
78
- fields(invite, ['schema_version', 'invite_id', 'agent_id', 'capabilities', 'bot_id', 'chat_ids', 'commands_enabled', 'prefer', 'claim_public_key', 'expires_at', 'team_hash', 'actor_id', 'signature']);
79
+ fields(invite, ['schema_version', 'invite_id', 'agent_id', 'capabilities', 'bot_id', 'chat_ids', 'commands_enabled', 'prefer', 'claim_public_key', 'expires_at', 'team_hash', 'actor_id', 'signature', 'owner_userid']);
79
80
  const team = history.teams.get(invite.team_hash);
80
81
  if (invite.schema_version !== 1 || !uuidPattern.test(invite.invite_id || '') || !idPattern.test(invite.agent_id || '') || !team || team.agents[invite.actor_id]?.role !== 'admin' ||
81
82
  !Array.isArray(invite.capabilities) || !invite.capabilities.length || invite.capabilities.length > 16 || invite.capabilities.some((cap) => !idPattern.test(cap)) || new Set(invite.capabilities).size !== invite.capabilities.length ||
82
83
  typeof invite.bot_id !== 'string' || !invite.bot_id || invite.bot_id.length > 200 ||
83
84
  !Array.isArray(invite.chat_ids) || !invite.chat_ids.length || invite.chat_ids.some((id) => typeof id !== 'string' || !id || id.length > 200) ||
84
85
  typeof invite.commands_enabled !== 'boolean' || typeof invite.prefer !== 'boolean' || !Number.isFinite(Date.parse(invite.expires_at))) fail();
86
+ if (Object.hasOwn(invite, 'owner_userid') && (!invite.commands_enabled || typeof invite.owner_userid !== 'string' || !invite.owner_userid.trim() || invite.owner_userid.length > 200 || invite.owner_userid !== team.agents[invite.actor_id].wecom_userid)) fail();
85
87
  try { if (createPublicKey(invite.claim_public_key).asymmetricKeyType !== 'ed25519') fail(); } catch { fail(); }
86
88
  verifyRecord(invite, team.agents[invite.actor_id].public_key);
87
89
  return invite;
@@ -97,6 +99,7 @@ export function validateEnrollment(request, invite, history, { now = Date.now(),
97
99
  fields(request, ['schema_version', 'request_id', 'invite_id', 'agent_id', 'public_key', 'wecom_userid', 'chat_id', 'signature', 'invitation_signature']);
98
100
  if (request.schema_version !== 1 || !uuidPattern.test(request.request_id || '') || request.invite_id !== invite.invite_id || request.agent_id !== invite.agent_id ||
99
101
  !invite.chat_ids.includes(request.chat_id) || (invite.commands_enabled ? typeof request.wecom_userid !== 'string' || !request.wecom_userid || request.wecom_userid.length > 200 : request.wecom_userid !== null) ||
102
+ invite.owner_userid !== undefined && request.wecom_userid !== invite.owner_userid ||
100
103
  !allowExpired && now > Date.parse(invite.expires_at)) fail();
101
104
  const { invitation_signature, ...member } = request;
102
105
  try { if (createPublicKey(request.public_key).asymmetricKeyType !== 'ed25519') fail(); } catch { fail(); }
@@ -25,7 +25,8 @@ export function resolveHuman(selector, executorId, team, reviewerId = team.revie
25
25
  if (selector === 'reviewer') return reviewerId;
26
26
  if (selector === 'admin') return team.reviewer;
27
27
  // Notification-only identities cannot send approval commands. Route their review
28
- // to the administrator, including the one-human/two-bot local simulation.
28
+ // to the administrator. Verified agents keep their own review identity even
29
+ // when several agents share the same human owner.
29
30
  if (selector === 'owner') return team.agents[executorId]?.wecom_userid ? executorId : team.reviewer;
30
31
  return memberSelector.exec(selector || '')?.[1] || null;
31
32
  }
@@ -9,8 +9,15 @@ import { readRegistry, recordHash, registryPath } from './registry.mjs';
9
9
  const exec = promisify(execFile);
10
10
  const pause = (ms) => new Promise((r) => setTimeout(r, ms));
11
11
  export async function git(cwd, ...args) {
12
- const { stdout } = await exec('git', ['-c', 'core.hooksPath=/dev/null', '-C', cwd, ...args], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
13
- return stdout.trim();
12
+ try {
13
+ const { stdout } = await exec('git', ['-c', 'core.hooksPath=/dev/null', '-C', cwd, ...args], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
14
+ return stdout.trim();
15
+ } catch (error) {
16
+ // Keep stderr available to callers that can turn it into a safe, actionable message.
17
+ // Never expose the raw value directly to the browser: it may contain credentials.
18
+ error.git_args = args;
19
+ throw error;
20
+ }
14
21
  }
15
22
 
16
23
  /** Disposable ledger clone, cross-process local lock, remote fast-forward CAS and signed replay. */
@@ -80,7 +80,29 @@
80
80
  "signature": {
81
81
  "type": "string",
82
82
  "minLength": 1
83
+ },
84
+ "owner_userid": {
85
+ "type": "string",
86
+ "minLength": 1,
87
+ "maxLength": 200,
88
+ "description": "Optional owner pinned to the issuing administrator for adding their own independent agent."
83
89
  }
84
90
  },
85
- "$comment": "Runtime verifies administrator signature under team_hash, Ed25519 claim key, expiry, and conflicts with existing members or assignments. No bot Secret or private keys are allowed here."
91
+ "$comment": "Runtime verifies administrator signature under team_hash, Ed25519 claim key, expiry, and conflicts with existing members or assignments. No bot Secret or private keys are allowed here. A self-owned invitation pins owner_userid to the issuing administrator; enrollment must prove the same account. Existing invitations without this optional field remain valid.",
92
+ "allOf": [
93
+ {
94
+ "if": {
95
+ "required": [
96
+ "owner_userid"
97
+ ]
98
+ },
99
+ "then": {
100
+ "properties": {
101
+ "commands_enabled": {
102
+ "const": true
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ]
86
108
  }
@@ -155,7 +155,7 @@
155
155
  }
156
156
  }
157
157
  },
158
- "$comment": "Runtime verifies distinct Ed25519 keys and owner IDs, valid reviewer and route capabilities, and clock skew below half the lease. v3 requires a review rule for every route; specific members require a verified owner. v2 forbids review_rules so old clients cannot silently bypass gates.",
158
+ "$comment": "Runtime verifies distinct Ed25519 keys and valid owner IDs (several agents may share the same human owner), valid reviewer and route capabilities, and clock skew below half the lease. v3 requires a review rule for every route; specific members require a verified owner. v2 forbids review_rules so old clients cannot silently bypass gates.",
159
159
  "allOf": [
160
160
  {
161
161
  "if": {
@@ -187,7 +187,13 @@ export async function attachProject({ configPath, repoPath }) {
187
187
  const block = `[mcp_servers.${serverName}]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(join(root, 'apps/peer/mcp-main.mjs'))}, ${JSON.stringify(configPath)}]`;
188
188
  const next = mergeMcpToml(before, serverName, block);
189
189
  const excludePath = await git(repo, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude');
190
- const oldExclude = await readFile(excludePath, 'utf8');
190
+ let oldExclude = '';
191
+ let excludeExisted = true;
192
+ try { oldExclude = await readFile(excludePath, 'utf8'); }
193
+ catch (error) {
194
+ if (error.code !== 'ENOENT') throw error;
195
+ excludeExisted = false;
196
+ }
191
197
  const rules = [`/.codex/config.toml`, `/.agents/skills/${skillName}/`, '/.codex-collab-worktrees/'];
192
198
  const addedRules = rules.filter((rule) => !oldExclude.split(/\r?\n/).includes(rule));
193
199
  const template = await readFile(join(root, `packages/codex-kit/templates/.agents/skills/${skillName}/SKILL.md`), 'utf8');
@@ -197,12 +203,18 @@ export async function attachProject({ configPath, repoPath }) {
197
203
  await writeFile(configFile, next, { mode: 0o600 });
198
204
  await chmod(configFile, 0o600);
199
205
  await writeFile(skillFile, template, { mode: 0o600, flag: 'wx' });
200
- if (addedRules.length) await writeFile(excludePath, `${oldExclude}${oldExclude.endsWith('\n') || !oldExclude ? '' : '\n'}${addedRules.join('\n')}\n`);
206
+ if (addedRules.length) {
207
+ await mkdir(dirname(excludePath), { recursive: true, mode: 0o700 });
208
+ await writeFile(excludePath, `${oldExclude}${oldExclude.endsWith('\n') || !oldExclude ? '' : '\n'}${addedRules.join('\n')}\n`);
209
+ }
201
210
  } catch (error) {
202
211
  if (before) await writeFile(configFile, before, { mode: 0o600 });
203
212
  else await rm(configFile, { force: true });
204
213
  await rm(skillDir, { recursive: true, force: true });
205
- if (addedRules.length) await writeFile(excludePath, oldExclude);
214
+ if (addedRules.length) {
215
+ if (excludeExisted) await writeFile(excludePath, oldExclude);
216
+ else await rm(excludePath, { force: true });
217
+ }
206
218
  throw error;
207
219
  }
208
220
  return { repo, mcp_config: configFile, skill: skillFile };