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 +23 -7
- package/apps/onboarding/manager.mjs +165 -24
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/web/app.js +44 -18
- package/apps/onboarding/web/index.html +5 -3
- package/apps/peer/desktop.mjs +21 -0
- package/apps/peer/mcp.mjs +4 -2
- package/apps/peer/wecom.mjs +58 -12
- package/bin/codex-collab.mjs +8 -0
- package/docs/desktop-poll.md +32 -8
- package/docs/local-automatic-test.md +1 -1
- package/docs/local-peer-test.md +1 -1
- package/docs/onboarding-audit.md +88 -0
- package/docs/onboarding.md +33 -17
- package/docs/peer.md +11 -2
- package/docs/team-registry.md +13 -3
- package/docs/terminal-onboarding.md +1 -1
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +14 -9
- package/packages/peer/config.mjs +3 -2
- package/packages/peer/inbox.mjs +48 -0
- package/packages/peer/registry.mjs +5 -2
- package/packages/peer/review.mjs +2 -1
- package/packages/peer/service.mjs +70 -1
- package/packages/peer/store.mjs +90 -2
- package/schemas/peer-inbox-event.schema.json +284 -0
- package/schemas/peer-inbox.schema.json +63 -0
- package/schemas/team-invitation.schema.json +23 -1
- package/schemas/team-policy.schema.json +1 -1
- package/scripts/peer/onboarding.mjs +15 -3
- package/scripts/peer/refresh-project.mjs +76 -0
- package/scripts/peer/setup-desktop-project.mjs +1 -1
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
|
|
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
|
|
55
|
+
@Alice 给登录页增加密码显示切换,原有登录行为保持不变。
|
|
56
|
+
你负责前端实现,Bob 补接口和测试,完成后在群里告诉我。
|
|
51
57
|
```
|
|
52
58
|
|
|
53
|
-
Alice
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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`),
|
|
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
|
|
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);
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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}`,
|
|
86
|
-
row.append(select('审核人', 'reviewer', rule.reviewer, [['owner', '
|
|
87
|
-
select('额外通知对象', 'notify', rule.notify, [['owner', '
|
|
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 () => {
|
|
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
|
-
|
|
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
|
-
//
|
|
138
|
+
// Setup may be persisted before Git/MCP work fails; recover the same profile.
|
|
114
139
|
const profiles = (await api('overview')).profiles;
|
|
115
|
-
|
|
116
|
-
if (profile)
|
|
117
|
-
|
|
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
|
|
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
|
|
12
|
-
<div id="admin-tools" hidden><h3
|
|
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>
|