xuym-codex-collab 0.4.3 → 0.5.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.
@@ -18,7 +18,13 @@ const values = (form) => Object.fromEntries(new FormData(form));
18
18
  function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
19
19
  function button(label, fn) { const node = text('button', label); node.onclick = () => action(fn); return node; }
20
20
  async function overview() {
21
- const value = await api('overview'); $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
21
+ const value = await api('overview');
22
+ if (value.defaults) {
23
+ if (!$('admin-form').elements.repo.value) $('admin-form').elements.repo.value = value.defaults.repo || '';
24
+ if (!$('admin-form').elements.display_name.value) $('admin-form').elements.display_name.value = value.defaults.display_name || '';
25
+ }
26
+ $('startup-status').textContent = value.startup?.enabled ? '后台服务已运行,登录时自动恢复已开启的连接。可关闭终端和浏览器。' : value.startup?.error || '后台工作台会保持连接;登录自启状态可用 codex-collab autostart status 查看。';
27
+ $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
22
28
  $('removed-projects').hidden = !(value.removed_profiles || []).length;
23
29
  for (const profile of value.removed_profiles || []) {
24
30
  const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
@@ -29,7 +35,7 @@ async function overview() {
29
35
  const card = document.createElement('div'); card.className = 'card';
30
36
  card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
31
37
  }
32
- if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。管理员选择“初始化团队”,成员选择“加入团队”。'));
38
+ if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。选择“连接我的机器人”,或导入同事提供的接入包。'));
33
39
  for (const profile of value.profiles) {
34
40
  const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
35
41
  info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
@@ -119,7 +125,7 @@ function renderProfile(value, automatic = false) {
119
125
  $('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
120
126
  $('profile-title').textContent = `${value.display_name && value.display_name !== value.agent_id ? `${value.display_name}(${value.agent_id})` : value.agent_id} · ${value.role === 'admin' ? '管理员' : '成员'}`; $('profile-repo').textContent = value.repo;
121
127
  $('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
122
- $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
128
+ $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('diagnostic-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
123
129
  if (value.phase === 'ready') {
124
130
  $('binding-text').textContent = value.binding_prompt; $('binding-details').textContent = value.detailed_binding_prompt || value.binding_prompt;
125
131
  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, '尚未满足条件']]) {
@@ -191,30 +197,46 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
191
197
  await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
192
198
  await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
193
199
  }); };
194
- for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; } remember(null); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
200
+ for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; } remember(null); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); if (button.dataset.view === 'admin' && $('admin-form').elements.repo.value) await checkProject(); });
195
201
  function repoMode(prefix) {
196
202
  const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
197
203
  }
198
- for (const prefix of ['admin', 'join']) $(`${prefix}-repo-mode`).onchange = () => repoMode(prefix);
199
- $('check-project').onclick = () => action(async () => {
200
- const selected = await api('project-check', values($('admin-form')));
204
+ for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange = () => repoMode(prefix);
205
+ async function checkProject() {
206
+ const snapshot = values($('admin-form'));
207
+ const selected = await api('project-check', snapshot);
208
+ const current = values($('admin-form'));
209
+ if (['repo_mode', 'repo', 'remote', 'destination'].some((key) => current[key] !== snapshot[key])) return;
201
210
  const form = $('admin-form'); form.elements.repo.value = selected.repo; $('admin-repo-mode').value = 'existing'; repoMode('admin');
202
211
  $('project-recommendation').textContent = `项目已检查:${selected.repo}。${selected.tests.length ? '推荐:' + selected.tests.map((test) => test.command).join(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
203
212
  if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
204
- });
213
+ }
214
+ $('check-project').onclick = () => action(checkProject);
215
+ for (const id of ['admin-repo', 'admin-remote', 'admin-destination']) $(id).onchange = () => {
216
+ const form = $('admin-form');
217
+ if ($('admin-repo-mode').value === 'clone' ? form.elements.remote.value && form.elements.destination.value : form.elements.repo.value) action(checkProject);
218
+ };
205
219
  $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
206
220
  const input = values(event.target);
207
221
  const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
208
222
  pending = { type: 'admin', draft_id: session.draft_id }; remember({ draft_id: session.draft_id }); await verifyView(session);
209
223
  }); };
210
224
  async function invitationInput(form) { const input = values(form); input.content = await input.file.text(); delete input.file; return input; }
211
- $('inspect-invite').onclick = () => action(async () => {
225
+ async function inspectInvite() {
212
226
  $('invite-info').hidden = true; $('invite-summary').textContent = ''; $('invite-remote').textContent = '';
213
- const value = await api('inspect-invite', await invitationInput($('join-form')));
227
+ const file = $('join-form').elements.file.files?.[0];
228
+ const input = await invitationInput($('join-form'));
229
+ const value = await api('inspect-invite', input);
230
+ if ($('join-form').elements.password.value !== input.password || $('join-form').elements.file.files?.[0] !== file) return;
214
231
  $('invite-info').hidden = false; $('invite-summary').textContent = `${value.display_name || value.agent_id}(${value.agent_id})· 职责:${value.capabilities.join('、')}${value.team_name ? ' · ' + value.team_name : ''}`;
215
232
  $('invite-remote').textContent = value.remote;
216
233
  if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
217
- });
234
+ }
235
+ $('inspect-invite').onclick = () => action(inspectInvite);
236
+ for (const id of ['invite-file', 'invite-password']) $(id).onchange = () => {
237
+ $('invite-info').hidden = true;
238
+ if ($('invite-file').files?.length && $('invite-password').value) action(inspectInvite);
239
+ };
218
240
  $('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
219
241
  const profile = await api('import', await invitationInput(event.target)); event.target.elements.password.value = ''; await connectMember(profile);
220
242
  }); };
@@ -242,6 +264,14 @@ $('retry-code').onclick = () => action(async () => {
242
264
  $('confirm-code').onclick = () => action(completeVerification);
243
265
  $('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
244
266
  $('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; const result = await api('issue', input); event.target.reset(); await loadProfile(currentId); await showDelivery(currentId, result.invite_id); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
267
+ $('run-diagnostics').onclick = () => action(async () => {
268
+ const value = await api('diagnostics', { id: currentId }); $('diagnostic-result').replaceChildren();
269
+ for (const check of value.checks) $('diagnostic-result').append(text('p', `${check.ok ? '✓' : '待处理'} ${check.label}${check.action ? ':' + check.action : ''}`));
270
+ $('diagnostic-result').append(text('p', value.note));
271
+ });
272
+ $('repair-project').onclick = () => action(async () => {
273
+ const value = await api('repair', { id: currentId }); await loadProfile(currentId); notice(value.next_step);
274
+ });
245
275
  $('refresh').onclick = () => action(() => loadProfile(currentId));
246
276
  $('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
247
277
  $('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
@@ -1,32 +1,31 @@
1
1
  <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Collab · 团队接入</title><link rel="stylesheet" href="/style.css"></head><body>
2
2
  <header><span class="brand">Codex Collab</span><span>本机团队工作台</span></header>
3
- <main><h1>把群里的任务,交给你的 Codex 对话</h1><p class="intro">管理员分配机器人,成员导入接入包。项目配置与连接检查集中在这里。</p><div id="notice" role="status" hidden></div>
4
- <nav><button data-view="home">我的项目</button><button data-view="admin">初始化团队</button><button data-view="join">加入团队</button><button data-view="existing">导入已有配置</button></nav>
5
- <section id="home"><h2>我的项目</h2><div id="profiles"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
6
- <section id="admin" hidden><h2>管理员初始化</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
3
+ <main><h1>把群里的任务,交给你的 Codex 对话</h1><p class="intro">选一种接入方式,按提示完成群验证,再把启用指令发给你的 Codex 对话。</p><div id="notice" role="status" hidden></div>
4
+ <nav><button data-view="home">我的项目</button><button data-view="admin">连接我的机器人</button><button data-view="join">接受同事邀请</button><button data-view="existing">导入已有配置</button></nav>
5
+ <section id="home"><div class="entry-options"><button data-view="admin">连接我的机器人<small>首次接入,或为团队建立协作项目</small></button><button data-view="join">接受同事邀请<small>已有接入包,自动带入机器人和项目信息</small></button></div><p id="startup-status" class="muted"></p><h2>我的项目</h2><div id="profiles"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
6
+ <section id="admin" hidden><h2>连接我的机器人</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
7
7
  <form id="admin-form">
8
- <label>团队名称<input name="team_name" placeholder="例如:产品研发团队" maxlength="80"></label>
9
8
  <fieldset><legend>选择业务项目</legend><label>项目来源<select id="admin-repo-mode" name="repo_mode"><option value="existing">使用本机已有项目</option><option value="clone">从 Git 地址拉取新项目</option></select></label>
10
- <label id="admin-existing-repo">项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
11
- <div id="admin-clone-repo" hidden><label>Git 仓库地址<input name="remote" placeholder="HTTPS 或 SSH 地址,不含 Token"></label><label>保存到新目录<input name="destination" placeholder="新目录的完整路径"></label></div>
9
+ <label id="admin-existing-repo">项目目录<input name="repo" id="admin-repo" placeholder="Git 仓库根目录的完整路径"></label>
10
+ <div id="admin-clone-repo" hidden><label>Git 仓库地址<input name="remote" id="admin-remote" placeholder="HTTPS 或 SSH 地址,不含 Token"></label><label>保存到新目录(可选)<input name="destination" id="admin-destination" placeholder="留空按项目与名称生成独立目录"></label></div>
12
11
  <button id="check-project" type="button" class="secondary">检查项目并推荐验证命令</button><p id="project-recommendation" class="muted">自动拉取使用你本机已有的 Git 登录。仓库需要已有 main 分支。</p>
13
12
  <label>验证命令<input id="test-command" name="test_command" required placeholder="例如 npm test、go test ./...、sh test.sh"></label><p class="muted">请确认这个命令适合项目。工具使用固定程序和参数执行,不运行管道或拼接命令。</p></fieldset>
14
13
  <div class="row"><label>你的显示名称<input name="display_name" required placeholder="例如 Alice 或张三" maxlength="80"></label><label>主要职责<select name="responsibility"><option value="fullstack">全栈开发</option><option value="frontend">前端开发</option><option value="backend">后端开发</option><option value="testing">测试</option><option value="docs">文档</option><option value="general">通用开发</option></select></label></div>
15
- <details><summary>自定义内部身份与职责</summary><label>内部 Agent ID(可选)<input name="agent_id" pattern="[a-zA-Z0-9_-]{1,64}" placeholder="留空自动生成"></label><label>自定义职责标签(可选)<input name="capabilities" placeholder="英文标签,以逗号分隔"></label></details>
14
+ <details><summary>更多设置:团队名称、内部身份与职责</summary><label>团队名称(可选)<input name="team_name" placeholder="留空使用项目名称" maxlength="80"></label><label>内部 Agent ID(可选)<input name="agent_id" pattern="[a-zA-Z0-9_-]{1,64}" placeholder="留空自动生成"></label><label>自定义职责标签(可选)<input name="capabilities" placeholder="英文标签,以逗号分隔"></label></details>
16
15
  <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>
17
- <button type="submit">创建团队并连接机器人</button></form></section>
16
+ <button type="submit">连接并进入群验证</button></form></section>
18
17
  <section id="own" hidden><h2>给自己增加机器人</h2><p>同一个企业微信账号可以拥有多个 Agent。每个 Agent 使用独立机器人、独立 clone 和独立 Codex 对话。验证后自动完成本机接入,无需下载接入包。</p>
19
- <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>
20
- <section id="join" hidden><h2>加入团队</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
21
- <form id="join-form"><label>接入包文件<input name="file" type="file" accept=".json" required></label><label>接入包密码<input name="password" type="password" required autocomplete="off"></label>
22
- <button id="inspect-invite" type="button" class="secondary">读取接入信息</button><div id="invite-info" hidden><p id="invite-summary"></p><p id="invite-remote" class="muted"></p></div>
18
+ <form id="own-form"><label>准备项目<select id="own-repo-mode" name="repo_mode"><option value="clone">自动拉取独立项目</option><option value="existing">使用已有的独立 clone</option></select></label><label id="own-clone-repo">新项目目录(可选)<input name="destination" placeholder="留空按项目和 Agent 名称创建"></label><label id="own-existing-repo" hidden>另一个 clone 的本机目录<input name="repo" 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>
19
+ <section id="join" hidden><h2>接受同事邀请</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
20
+ <form id="join-form"><label>接入包文件<input name="file" id="invite-file" type="file" accept=".json" required></label><label>接入包密码<input name="password" id="invite-password" type="password" required autocomplete="off"></label>
21
+ <button id="inspect-invite" type="button" class="secondary">重新读取接入信息</button><div id="invite-info" hidden><p id="invite-summary"></p><p id="invite-remote" class="muted"></p></div>
23
22
  <label>准备项目<select id="join-repo-mode" name="repo_mode"><option value="clone">帮我拉取到新目录</option><option value="existing">使用本机已有项目</option></select></label>
24
- <label id="join-clone-repo">新项目目录<input name="destination" placeholder="读取接入包后自动建议,亦可填写完整路径"></label>
23
+ <label id="join-clone-repo">新项目目录<input name="destination" placeholder="留空使用推荐目录,亦可填写完整路径"></label>
25
24
  <label id="join-existing-repo" hidden>已有项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
26
25
  <button type="submit">接入我的机器人</button><p class="muted">工具使用本机 Git 登录拉取代码,不会改写已有目录。导入后请按提示在群里确认一次身份,审批和配置会自动继续。</p></form></section>
27
26
  <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>
28
27
  <section id="verification" hidden><h2>在群里确认身份</h2><p id="connection" role="status"></p><p class="muted">发送消息后页面会自动继续,无需回来确认。刷新页面可恢复当前接入进度。</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>
29
- <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><p id="next-step" class="next-step" role="status"></p><ol id="setup-steps" class="setup-steps"></ol><details><summary>连接检查详情</summary><div id="checks"></div></details><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP,将下面的指令发到要绑定的对话。Skill 会完成身份检查、对话绑定和轮询启用。完成后本页自动更新实际连接状态。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button><details><summary>无法识别 Skill 时使用详细指令</summary><pre id="binding-details"></pre><button id="copy-binding-details">复制详细指令</button></details></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
28
+ <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><p id="next-step" class="next-step" role="status"></p><ol id="setup-steps" class="setup-steps"></ol><details><summary>连接检查详情</summary><div id="checks"></div></details><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><details><summary>无法识别 Skill 时使用详细指令</summary><pre id="binding-details"></pre><button id="copy-binding-details">复制详细指令</button></details></div><div id="diagnostic-actions" hidden><button id="run-diagnostics" class="secondary">检查整条连接</button><button id="repair-project" class="secondary">修复本机配置</button><div id="diagnostic-result" role="status"></div></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
30
29
  <details id="manage-project"><summary>管理与重置</summary><p>选择要调整的配置,先查看影响,再确认。每次只操作当前 Agent 和 clone。</p><div class="manage-actions"><button id="reset-conversation" class="secondary">重新绑定对话</button><button id="reset-robot" class="secondary">更新机器人凭据</button><button id="reset-project" class="danger">移除本机接入</button></div></details>
31
30
  <div id="admin-tools" hidden><details><summary>团队分工、审核与通知设置</summary><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></details><h3>批量分配机器人给同事</h3><p>每行分配一个独立机器人。同一个同事可以领取多行,分别绑定独立项目和对话。接入包密码自动生成。</p>
32
31
  <form id="batch-form"><div id="assignment-rows"></div><div class="row"><button id="add-assignment" type="button" class="secondary">增加一位成员/机器人</button><button type="submit">生成全部接入包</button></div></form>
@@ -5,3 +5,6 @@ form[hidden]{display:none}fieldset{border:1px solid #dbe6df;border-radius:9px;pa
5
5
  button[hidden],details[hidden],label[hidden]{display:none}details{margin:24px 0}summary{cursor:pointer;font-weight:600;line-height:1.7}.manage-actions{display:flex;gap:12px;flex-wrap:wrap}.danger{background:#fff;border:1px solid #d9aca4;color:#943c30}#reset-effects{line-height:1.9;padding-left:24px}#reset-target{overflow-wrap:anywhere}#reset-verification{padding:16px;background:#f0f5f2;border-radius:8px}
6
6
 
7
7
  #setup-steps{display:flex;flex-wrap:wrap;gap:10px;list-style:none;padding:0;margin:22px 0}#setup-steps li{font-size:13px;border:1px solid #e0e7e2;border-radius:7px;padding:9px 12px;color:#738379}#setup-steps .done{color:#176b4a;background:#eaf5ed;border-color:#cbe4d3}#setup-steps .active{color:#845d26;background:#fff5e2;border-color:#ebd29f}#next-step{padding:14px 16px;background:#edf6f0;border-radius:8px}#delivery-panel{padding:18px;background:#f6f9f7;border:1px solid #dbe6df;border-radius:9px;margin-top:20px}#delivery-panel h3{margin-top:0}#delivery-panel button{margin:6px 6px 6px 0}.assignment details{margin:0}.assignment>button{justify-self:start;background:#fff;color:#657970;border-color:#c9dacf}#assignment-rows{display:grid;gap:14px}#assignment-rows fieldset{margin:0}@media(max-width:640px){#setup-steps{gap:7px}#setup-steps li{flex:1 1 42%}.card>a{display:inline-block}.card{gap:14px}}
8
+
9
+ .entry-options{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:12px 0 24px}.entry-options button{text-align:left;padding:24px;font-size:19px}.entry-options small{display:block;margin-top:10px;font-size:13px;font-weight:normal;line-height:1.6}#diagnostic-result p{line-height:1.7}#diagnostic-actions{margin:18px 0}
10
+ form,label,fieldset,details,input,select{min-width:0}#invite-remote{overflow-wrap:anywhere}
@@ -0,0 +1,36 @@
1
+ import { homedir, userInfo } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { readFile, readdir, realpath } from 'node:fs/promises';
4
+ import { git } from '../../packages/peer/store.mjs';
5
+
6
+ export const workspaceHome = () => resolve(process.env.CODEX_COLLAB_HOME || join(homedir(), '.config/codex-collab/workspace'));
7
+ export async function localDefaults(cwd = process.cwd()) {
8
+ let repo = '';
9
+ try { repo = await git(cwd, 'rev-parse', '--show-toplevel'); } catch { /* Opening outside a project is supported. */ }
10
+ return { repo, display_name: userInfo().username };
11
+ }
12
+ export async function profiles(home = workspaceHome()) {
13
+ const root = join(home, 'profiles'); const result = [];
14
+ const entries = await readdir(root).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
15
+ for (const id of entries) {
16
+ if (!/^[a-f0-9-]{36}$/.test(id)) continue;
17
+ const state = JSON.parse(await readFile(join(root, id, 'setup.json'), 'utf8'));
18
+ if (state.phase !== 'removed') result.push({ ...state, config_path: state.existing_config || join(root, id, 'peer.json') });
19
+ }
20
+ return result;
21
+ }
22
+ export async function selectProfile({ home = workspaceHome(), selector, cwd = process.cwd() } = {}) {
23
+ let choices = (await profiles(home)).filter((state) => state.phase === 'ready');
24
+ if (selector) choices = choices.filter((state) => state.id === selector || state.agent_id === selector);
25
+ else {
26
+ const { repo } = await localDefaults(cwd);
27
+ if (repo) {
28
+ const matches = [];
29
+ for (const state of choices) if (await realpath(state.repo).catch(() => '') === repo) matches.push(state);
30
+ if (matches.length) choices = matches;
31
+ }
32
+ }
33
+ if (choices.length === 1) return choices[0];
34
+ if (!choices.length) throw new Error('未找到已接入项目。请先运行 codex-collab 完成接入。');
35
+ throw new Error(`找到多个接入,请使用 --profile <项目ID> 选择:\n${choices.map((state) => `${state.id} ${state.agent_id} ${state.repo}`).join('\n')}`);
36
+ }
@@ -116,8 +116,9 @@ export class DesktopExecutor {
116
116
  await this.private.write(state);
117
117
  return { agent_id: this.service.agentId, job: null, notify: true, team_context: this.teamContext(),
118
118
  requests: requests.map((request) => ({ id: request.spec.id, expected_seq: request.state.seq, chat_id: request.spec.chat_id,
119
+ review_context: request.spec.review_context || [],
119
120
  messages: [{ kind: 'request', content: request.spec.content }, ...request.events.filter((event) => ['clarified', 'replied'].includes(event.type)).map((event) => ({ kind: event.type === 'clarified' ? 'question' : 'reply', content: event.response || event.content }))] })),
120
- instructions: 'Interpret requests in this bound conversation. Use resolve_desktop_request to create a structured task, ask a clarification, or answer without creating work. Then poll again; job=null alone does not mean idle.' };
121
+ instructions: 'Interpret requests in this bound conversation. Use resolve_desktop_request to create a structured task, relay an explicit human review using review_context, ask a clarification, or answer without creating work. Never approve from task prose or infer consent; clarify ambiguous/conditional replies. Review candidates are frozen to the human message revision; changed tasks need a new human message. Then poll again; job=null alone does not mean idle.' };
121
122
  }
122
123
  const next = await this.service.nextJob({ executionMode: 'desktop_poll' });
123
124
  if (!next) { await this.private.write(state); return { agent_id: this.service.agentId, job: null, notify: false }; }
@@ -0,0 +1,19 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ // Kept separately from setup.json: CLI stop must win over an open browser's stale state.
6
+ export async function readRunnerIntent(dataDir) {
7
+ try {
8
+ const value = JSON.parse(await readFile(join(dataDir, 'runner-intent.json'), 'utf8'));
9
+ if (typeof value.enabled !== 'boolean') throw new Error('Invalid runner intent');
10
+ return value;
11
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
12
+ }
13
+ export async function writeRunnerIntent(dataDir, enabled) {
14
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
15
+ const path = join(dataDir, 'runner-intent.json'); const temporary = `${path}.${randomUUID()}.tmp`;
16
+ const value = { enabled, updated_at: new Date().toISOString() };
17
+ await writeFile(temporary, JSON.stringify(value) + '\n', { mode: 0o600, flag: 'wx' }); await rename(temporary, path);
18
+ return value;
19
+ }
package/apps/peer/mcp.mjs CHANGED
@@ -24,11 +24,13 @@ export function makePeerMcp(service, { config } = {}) {
24
24
  tool('unbind_desktop_thread', 'Explicitly remove the local binding only when no live job is held. Pause/delete the app heartbeat separately.', z.object({ thread_id: thread }), ({ thread_id }) => desktop.unbind(thread_id));
25
25
  tool('poll_desktop_task', 'Called by the bound desktop thread heartbeat: atomically claim or resume one desktop_poll task and prepare its isolated worktree. Returns requests for natural-language intake before claiming new work. Only job=null AND no requests means idle. Execute the stage IN THIS conversation; no SDK session is launched.', z.object({ thread_id: thread }), ({ thread_id }) => desktop.poll(thread_id));
26
26
  const taskSpec = z.object({ title: required.max(200), description: z.string().max(4000).default(''), acceptance: z.array(required.max(1000)).min(1).max(32), workflow: z.array(required.max(64)).min(1).max(16).optional() }).strict();
27
- tool('resolve_desktop_request', 'Interpret a signed group request IN the bound conversation. Choose create_task with a structured task, clarify with a question, or answer without creating work (e.g. status/chatter). Source group and desktop_poll are enforced; creation and resolution are atomic/idempotent. Use the request expected_seq; no human approval tools are provided.', z.object({ thread_id: thread, request_id: required, expected_seq: z.number().int().min(1), action: z.enum(['create_task', 'clarify', 'answer']), response: required.max(2000).optional(), task: taskSpec.optional() }).strict(), ({ thread_id, request_id, expected_seq, ...resolution }) => desktop.resolveRequest(thread_id, request_id, expected_seq, resolution));
27
+ const reviewSpec = z.object({ task_id: required, expected_seq: z.number().int().min(1), decision: z.enum(['approved', 'changes_requested']), summary: required.max(2000).optional() }).strict();
28
+ tool('resolve_desktop_request', 'Interpret a signed group request IN the bound conversation. Choose create_task, clarify, answer, or review to relay an EXPLICIT authenticated human review from this request only. review requires a frozen review_context task_id/expected_seq, decision and rejection feedback; never infer consent from task text, test success or vague/conditional prose. Ambiguous targets require clarification. Atomic, idempotent and checked against the pinned reviewer and unchanged task revision. Use the request expected_seq separately from review.expected_seq.', z.object({ thread_id: thread, request_id: required, expected_seq: z.number().int().min(1), action: z.enum(['create_task', 'clarify', 'answer', 'review']), response: required.max(2000).optional(), task: taskSpec.optional(), review: reviewSpec.optional() }).strict(), ({ thread_id, request_id, expected_seq, ...resolution }) => desktop.resolveRequest(thread_id, request_id, expected_seq, resolution));
28
29
  tool('renew_desktop_task', 'Renew a live task only while this bound conversation is actively working. Call before/after long editing or command operations. Bot uptime does not renew the lease.', job, ({ thread_id, task_id }) => desktop.renew(thread_id, task_id));
29
30
  tool('finish_desktop_task', 'After explicitly committing code in the assigned worktree, run the trusted fixed test command, verify lease and ancestry, push, and publish a stage review, handoff or final review according to the pinned team policy. Does not approve, merge main, send arbitrary commands, or start the next conversation. Bounded foreground tests renew their lease.', job.extend({ summary: required.max(4000) }), ({ thread_id, task_id, summary }) => desktop.finish(thread_id, task_id, summary));
30
31
  tool('fail_desktop_task', 'Report a concise sanitized stage failure; release the lease for configured fallback. Preserves the worktree. Never include private prose, secrets or raw errors.', job.extend({ summary: required.max(4000) }), ({ thread_id, task_id, summary }) => desktop.fail(thread_id, task_id, summary));
31
32
  }
32
- // Human approval is deliberately not an agent tool: owner group commands / CLI only.
33
+ // No standalone approval tool: review only relays a recorded authenticated
34
+ // human group message, with its frozen revision and pinned reviewer checked.
33
35
  return server;
34
36
  }
@@ -45,3 +45,16 @@ export async function acquireRunnerLock(dataDir, configPath) {
45
45
  }
46
46
  throw new Error('cannot acquire local Runner lock');
47
47
  }
48
+
49
+ // Serialize CLI/supervisor start and stop across processes. The runner's own lock
50
+ // still prevents duplicate bot connections; this control lock orders user intent.
51
+ export async function acquireRunnerControl(dataDir, configPath) {
52
+ const deadline = Date.now() + 35000;
53
+ while (true) {
54
+ try { return await acquireRunnerLock(join(dataDir, 'runner-control'), configPath); }
55
+ catch (error) {
56
+ if (!error.message.startsWith('Runner already active') || Date.now() > deadline) throw error;
57
+ await delay(100);
58
+ }
59
+ }
60
+ }
@@ -61,7 +61,7 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
61
61
  }
62
62
  const request = await service.receiveRequest({ chatId: body.chatid, messageId: body.msgid, senderId: sender, content, allowAdmin: admin });
63
63
  text = request.state.task_id ? `该消息已创建任务 ${request.state.task_id},不会重复创建。`
64
- : request.state.status === 'answered' ? '这条消息已处理。'
64
+ : ['answered', 'reviewed'].includes(request.state.status) ? '这条消息已处理。'
65
65
  : request.state.status === 'clarification' ? `需求 ${request.spec.id} 等待你补充说明,请查看群里的追问。`
66
66
  : `已收到你的需求(${request.spec.id});等待绑定的 Codex 对话轮询理解和处理。`;
67
67
  } else if (command.action === 'create') {
@@ -129,7 +129,9 @@ export function formatPeerNotification(task, type, agentId, team) {
129
129
  if (type === 'handoff') detail.push(next());
130
130
  else {
131
131
  detail.push(`${type === 'stage_review_requested' ? '本阶段等待真人审核,通过后继续下一阶段。' : '全部阶段已提交,等待真人审核。'}\n审核人:${task.state.assignee} 的所有者;审核身份:${task.state.assignee};版本:R-${task.state.seq}`);
132
- detail.push(`请审核人 @ ${task.state.assignee} 对应的机器人发送:\n协作 审核 ${task.spec.id} R-${task.state.seq} 通过\n协作 审核 ${task.spec.id} R-${task.state.seq} 退回 | 修改意见`);
132
+ detail.push(task.spec.execution_mode === 'desktop_poll'
133
+ ? `请审核人 @ ${task.state.assignee} 对应的机器人直接说“通过,继续下一步”,或“先别通过,安装步骤再补一下”(写清具体修改意见)。多条待审核任务时,我会先请你选择。`
134
+ : `请审核人 @ ${task.state.assignee} 对应的机器人发送:\n协作 审核 ${task.spec.id} R-${task.state.seq} 通过\n协作 审核 ${task.spec.id} R-${task.state.seq} 退回 | 修改意见`);
133
135
  }
134
136
  }
135
137
  if (type === 'reviewed') {
@@ -177,7 +179,7 @@ export function startPeerWecom({ service, config, clientFactory = (options) => n
177
179
  // process; never rely on receiving another bot's group messages for scheduling.
178
180
  const sendInbox = async (request, event) => {
179
181
  if (!config.chat_ids.includes(request.spec.chat_id)) return;
180
- const label = event.type === 'clarified' ? '需要补充说明' : event.type === 'task_created' ? '任务与分工确认' : '回复';
182
+ const label = event.type === 'clarified' ? '需要补充说明' : event.type === 'task_created' ? '任务与分工确认' : event.type === 'reviewed' ? '审核结果' : '回复';
181
183
  const content = `[Codex Collab · ${service.agentId}] ${request.spec.id} ${label}\n${event.type === 'task_created' ? `任务:${event.task_id}\n` : ''}${notificationText(event.response)}${event.type === 'clarified' ? '\n请 @这个机器人直接补充;有多条追问时附上需求 N-ID。' : ''}`;
182
184
  assertReceipt(await client.sendMessage(request.spec.chat_id, { msgtype: 'markdown', markdown: { content } }));
183
185
  };
@@ -225,7 +227,7 @@ export class PeerNotificationRelay {
225
227
  if (request.spec.target_agent !== this.service.agentId) continue;
226
228
  const cursor = `inbox:${request.spec.id}`;
227
229
  for (const event of request.events.slice(this.cursors[cursor] || 0)) {
228
- if (['clarified', 'answered', 'task_created'].includes(event.type)) await this.sendInbox(request, event);
230
+ if (['clarified', 'answered', 'task_created', 'reviewed'].includes(event.type)) await this.sendInbox(request, event);
229
231
  this.cursors[cursor] = event.seq;
230
232
  const temp = `${this.path}.${process.pid}.tmp`;
231
233
  await writeFile(temp, JSON.stringify(this.cursors) + '\n', { mode: 0o600 });
@@ -12,15 +12,20 @@ function usage() {
12
12
  console.log(`Codex Collab
13
13
 
14
14
  Usage:
15
- codex-collab setup [--no-open]
15
+ codex-collab setup [--no-open] [--foreground] [--no-startup]
16
16
  codex-collab setup --terminal [--role admin|member]
17
17
  codex-collab
18
18
  codex-collab identity create <agent-id> [--dir /absolute/private/directory]
19
19
  codex-collab wecom discover
20
- codex-collab refresh-project /absolute/private-peer.json
21
- codex-collab <doctor|start|stop|status|list|read|review|complete|reopen|...> /abs/private-peer.json [args]
20
+ codex-collab <status|start|stop|doctor|repair> [--profile <project-id|agent-id>]
21
+ codex-collab projects
22
+ codex-collab autostart on|off|status
23
+ codex-collab service-status
24
+ codex-collab refresh-project [--profile <project-id|agent-id>]
25
+ codex-collab <list|read|review|complete|reopen|...> [--profile <id>] [args]
26
+ Advanced: commands also accept /absolute/private-peer.json before args.
22
27
 
23
- setup opens the local web wizard. --terminal keeps the legacy terminal workflow. Never pass a bot Secret as a command argument.`);
28
+ setup opens the local workspace and runs its service in the background. It restores enabled connections at login on macOS and Linux with a user service. --terminal keeps the legacy terminal workflow. Never pass a bot Secret as a command argument.`);
24
29
  }
25
30
 
26
31
  async function discoverWeCom() {
@@ -70,9 +75,10 @@ async function main() {
70
75
  }
71
76
 
72
77
  if (args[0] === 'refresh-project') {
73
- if (args.length !== 2) throw new Error('usage: codex-collab refresh-project /absolute/private-peer.json');
78
+ const { resolveCommand } = await import('../apps/onboarding/commands.mjs');
79
+ const { configPath } = await resolveCommand(args);
74
80
  const { refreshProject } = await import('../scripts/peer/refresh-project.mjs');
75
- console.log(JSON.stringify(await refreshProject({ configPath: args[1] }), null, 2));
81
+ console.log(JSON.stringify(await refreshProject({ configPath }), null, 2));
76
82
  return;
77
83
  }
78
84
 
@@ -84,19 +90,25 @@ async function main() {
84
90
 
85
91
  if (args[0] === 'setup' || args.length === 0) {
86
92
  const options = args[0] === 'setup' ? args.slice(1) : [];
87
- let role, terminal = false, open = true;
93
+ let role, terminal = false, open = true, foreground = false, startup = true;
88
94
  for (let i = 0; i < options.length; i++) {
89
95
  if (options[i] === '--role' && ['admin', 'member'].includes(options[i + 1])) { role = options[++i]; terminal = true; }
90
96
  else if (options[i] === '--terminal') terminal = true;
91
97
  else if (options[i] === '--no-open') open = false;
98
+ else if (options[i] === '--foreground') foreground = true;
99
+ else if (options[i] === '--no-startup') startup = false;
92
100
  else throw new Error('usage: codex-collab setup [--no-open] or setup --terminal [--role admin|member]');
93
101
  }
94
102
  if (terminal) { const { runSetupWizard } = await import('../scripts/peer/onboarding.mjs'); await runSetupWizard({ role }); }
95
- else { const { runLocalSetup } = await import('../apps/onboarding/main.mjs'); await runLocalSetup({ open }); }
103
+ else { const { runLocalSetup } = await import('../apps/onboarding/main.mjs'); await runLocalSetup({ open, foreground, startup }); }
96
104
  return;
97
105
  }
98
106
 
99
- process.argv = [process.execPath, join(packageRoot, 'scripts/peer/cli.mjs'), ...args];
107
+ const { workspaceCommand, resolveCommand, guidedCommand } = await import('../apps/onboarding/commands.mjs');
108
+ if (await workspaceCommand(args)) return;
109
+ const resolved = await resolveCommand(args);
110
+ if (await guidedCommand(resolved)) return;
111
+ process.argv = [process.execPath, join(packageRoot, 'scripts/peer/cli.mjs'), resolved.command, resolved.configPath, ...resolved.args];
100
112
  await import('../scripts/peer/cli.mjs');
101
113
  }
102
114
 
@@ -123,7 +123,14 @@ sh test.sh 要通过,完成后在群里发总结。
123
123
 
124
124
  仅测试通过、后台日志出现 claimed、或群里有回复,都不能代替“实际指定已有桌面对话自动执行”的验收。应用是否自动切到该对话不是验收条件,打开该对话应看到执行记录。
125
125
 
126
- 审核仍由真人群命令完成:
126
+ 审核可由指定真人直接 @自己的审核身份机器人表达决定:
127
+
128
+ ```text
129
+ @Alice 通过,交给 Bob 继续。
130
+ @Bob 先别通过,安装步骤再补一下。
131
+ ```
132
+
133
+ 只有一条待审核任务时不需要任务 ID 或版本号;多条时先选择任务名称、机器人列出的编号或 ID。含糊的“收到”、条件句、暂缓意见不会触发通过;退回没有具体意见时先追问。中间审核通过后开放已配置的下一阶段,最终审核通过后仍等待管理员完成。精确命令继续保留:
127
134
 
128
135
  ```text
129
136
  @审核人自己的机器人 协作 审核 T-真实任务ID R-7 通过
@@ -135,10 +142,10 @@ sh test.sh 要通过,完成后在群里发总结。
135
142
 
136
143
  ## 已有项目启用自然语言入口
137
144
 
138
- 已有绑定不需要重新创建身份。先处理正在执行的任务,再对每个 Agent 用其**项目 MCP 实际使用的私有配置路径**操作:
145
+ 已有绑定不需要重新创建身份。自然语言审核新增签名 inbox 字段与事件,旧版本会拒绝读取;团队所有参与者必须一起升级后再使用,不能只升级一个机器人。先处理正在执行的任务,再对每个 Agent 用其**项目 MCP 实际使用的私有配置路径**操作:
139
146
 
140
147
  ```sh
141
- npm install --global xuym-codex-collab@0.4.2
148
+ npm install --global xuym-codex-collab@latest
142
149
  codex-collab stop /absolute/private-peer.json
143
150
  codex-collab refresh-project /absolute/private-peer.json
144
151
  codex-collab start /absolute/private-peer.json
@@ -152,7 +159,15 @@ codex-collab start /absolute/private-peer.json
152
159
 
153
160
  新一次群消息到达后,Runner 先把需求保存到业务仓库 `agent-collab` 的 `.agent-collab/peer/inbox/N-ID/`,使用签名与消息 ID 去重。MCP 轮询返回原文、追问/回复历史和团队职责,由绑定对话理解;任务与解读结果在同一次 Git 提交中发布。原始群需求和处理结果由团队共享,完整私有 Codex 对话和绑定 ID 不写入 Git。
154
161
 
155
- 需求不清时机器人会追问:只有一条待补充需求,可直接 @它回复;多条时带上对应 N-ID。澄清等待中不产生执行任务;状态问题使用回答,不创建新任务。自然语言“通过/完成”只作为请求文字处理,审核、完成仍由真人发明确命令。运行中的任务优先恢复,新的需求会等活跃阶段结束后处理。响应时间取决于 heartbeat 实际轮询周期、对话是否可用和 Git 同步。
162
+ 需求不清时机器人会追问:只有一条待补充需求,可直接 @它回复;多条时带上对应 N-ID。澄清等待中不产生执行任务;状态问题使用回答,不创建新任务。真人可自然语言审核;`resolve_desktop_request(action=review)` 只转达当前请求中明确的本人决定,不允许模型自审。自然语言“完成”不替代管理员的完成命令。运行中的任务优先恢复,新的需求会等活跃阶段结束后处理。响应时间取决于 heartbeat 实际轮询周期、对话是否可用和 Git 同步。
163
+
164
+ ### 自然语言审核的版本与记录
165
+
166
+ Runner 收到已验证审核人的群消息时,把本群由该机器人负责审核的候选任务固定到请求的 `review_context`:任务 ID、任务 seq、标题、提交 SHA、阶段。MCP 返回这份候选和真人原文,绑定对话解释是通过还是退回;有多条候选时按原顺序列出选择,不自行挑选最新任务。澄清只使用同一个群、同一个真人的补充消息,不能给未来提交补授权。
167
+
168
+ 对话通过 `resolve_desktop_request` 的 `review` action 提交 `task_id`、冻结的 `expected_seq`、`decision`;退回还需要从真人消息中原文摘录具体意见 `summary`。服务检查当前身份、任务固定的审核策略、所属群、状态、版本和 SHA,再把任务 `reviewed` 事件与 inbox `reviewed` 结果在同一次 Git 提交中发布。重复处理同一消息返回原结果,不会再次推进阶段;原文、候选版本和决策摘要留在团队 Git 中。
169
+
170
+ 如果提交或任务状态在处理前变化,审核会被拒绝,需要真人查看最新结果后重新发一条消息。收到提交前说的“通过”不能批准未来结果。只有新请求会捕获新的候选,旧消息及其澄清不升级版本。审核只是按原工作流继续;最终通过不自动完成任务或合并 main。
156
171
 
157
172
  ## MCP 与运行约束
158
173
 
@@ -161,7 +176,7 @@ codex-collab start /absolute/private-peer.json
161
176
  | get_identity | 核对身份与模式 |
162
177
  | bind_desktop_thread / desktop_status | 本机绑定与检查;不创建 app automation |
163
178
  | poll_desktop_task | 恢复活跃任务;否则返回待理解 requests,或原子认领任务;两者都无时 notify=false |
164
- | resolve_desktop_request | 绑定对话按最新 expected_seq 创建结构化任务、追问或回答;不批准、完成或取消任务 |
179
+ | resolve_desktop_request | 绑定对话按请求 expected_seq 创建任务、追问、回答或转达真人审核;review 另带冻结的任务 expected_seq |
165
180
  | renew_desktop_task | 活跃执行时显式续租 |
166
181
  | finish_desktop_task | 验证已提交代码、跑固定测试、push、发布下一阶段或待审核 |
167
182
  | fail_desktop_task | 简洁脱敏失败;候补接力;保留工作树 |
@@ -1,6 +1,6 @@
1
1
  # 网页向导功能检查与测试记录
2
2
 
3
- 最近检查日期:2026-10-14。对象是工具仓库中的网页向导源码,初始记录对应 0.3.1 的发布前验证;后面的补充记录覆盖后续源码变更。
3
+ 最近检查日期:2026-10-22。对象是工具仓库中的网页向导源码,初始记录对应 0.3.1 的发布前验证;后面的补充记录覆盖后续源码变更。
4
4
 
5
5
  ## 测试范围
6
6
 
@@ -92,7 +92,7 @@ Skill 校验通过。`npm pack --dry-run` 确认发布包包含 `guided.mjs`、`
92
92
  | 更换 Bot ID、变更群 | 当前可在页面更新同一机器人的 Secret;更换 Bot ID 或群需移除接入,再使用管理员的新接入包 |
93
93
  | 撤销邀请、禁用/移除成员、轮换签名身份 | 当前没有产品化入口;需要补签名协议、权限与历史兼容,不能仅删本机文件 |
94
94
  | 修改既有成员职责、重排首选与候补 | 页面只展示已有分工;生成邀请时可以指定新成员优先级 |
95
- | 缺失 MCP/Skill 的一键修复 | 当前能识别缺失,但现有 Skill、受版本管理的配置等需要检查;不会自动覆盖 |
95
+ | 自定义 MCP/Skill 的自动合并 | 0.5.0 可恢复缺失的生成配置;自定义、受版本管理或符号链接文件继续要求人工检查 |
96
96
  | 确认 heartbeat 持续启用 | 页面可解除旧绑定并观察最近轮询;暂停旧 heartbeat、启用新 heartbeat 仍需在对应 Codex 对话完成 |
97
97
  | 页面内展示日志与详细 Git 诊断 | 当前只有安全错误提示、私有配置路径;详细日志仍需本机查看 |
98
98
  | 多管理员配置冲突的页面引导 | 自动刷新保留未保存编辑,提示策略版本已变化;后端拒绝过时保存,仍需刷新重新确认 |
@@ -105,3 +105,13 @@ Skill 校验通过。`npm pack --dry-run` 确认发布包包含 `guided.mjs`、`
105
105
  - 应用退出、电脑休眠、heartbeat 暂停与网络恢复后,候补接管与旧执行者的租约隔离。
106
106
 
107
107
  页面的“项目 MCP / Skill 已就绪”表示本机文件已配置,并非读取了 Codex 的实际加载状态。近期成功轮询是额外证据,仍不能保证未来自动化持续运行。Git 启动检查与任务执行中的 Git 失败属于不同时间点,初始化通过不代表未来的 push 永远成功。
108
+
109
+ ## 2026-10-22 · 0.5.0 接入简化与后台运行
110
+
111
+ 完整自动测试通过 178 项,0 失败。新增覆盖后台工作台脱离终端、重复打开复用、会话鉴权与私有文件权限、已启用连接恢复与退避、主动停止后保持离线、升级迁移与并发停止、无需路径的项目选择,以及缺失 MCP/Skill 的安全修复。新增诊断和修复接口沿用本机会话与 Origin 校验。
112
+
113
+ 使用真实本机 Runner、临时业务 Git 仓库且不配置外部机器人,实际启动 Runner,强制结束进程,再通过后台监管恢复至不同 PID;执行停止命令后再次检查,连接保持离线。单独注册临时 macOS LaunchAgent,实际启动工作台并强制结束,确认 launchd 恢复至不同 PID,随后移除临时注册。测试没有接管个人现有机器人。
114
+
115
+ 使用本机 Chrome 实际检查:首页双入口、当前项目与本机名称默认值、验证命令推荐、连接诊断与修复、邀请文件上传和密码输入后自动读取、推荐 clone 目录。桌面与 390px 手机视口检查通过,邀请信息展开后没有横向溢出,页面没有脚本错误。机器人认证仍使用隔离测试连接,未连接真实企业微信群或创建 Codex 自动化。
116
+
117
+ 实际 npm tarball 在独立目录安装,命令入口、后台启动、页面资源、带会话鉴权的 API 与服务状态检查均通过。发布包包含 74 个文件,新增运行模块齐全,不包含本机凭据、运行日志或测试目录。Linux 仅验证 systemd 配置生成和失败处理;Windows 登录自启尚不支持。尚未实际退出登录或重启电脑,也未完成跨机器企业微信、真实 Codex 自动化与睡眠恢复的联合验收。