xuym-codex-collab 0.4.4 → 0.6.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 -2
- package/apps/onboarding/bundle.mjs +1 -6
- package/apps/onboarding/commands.mjs +46 -0
- package/apps/onboarding/guided.mjs +3 -2
- package/apps/onboarding/main.mjs +72 -19
- package/apps/onboarding/manager.mjs +116 -55
- package/apps/onboarding/reset.mjs +3 -1
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/startup.mjs +61 -0
- package/apps/onboarding/supervisor.mjs +41 -0
- package/apps/onboarding/web/app.js +78 -13
- package/apps/onboarding/web/index.html +17 -17
- package/apps/onboarding/web/style.css +3 -0
- package/apps/onboarding/workspace.mjs +36 -0
- package/apps/peer/desktop.mjs +4 -3
- package/apps/peer/intent.mjs +19 -0
- package/apps/peer/mcp.mjs +1 -1
- package/apps/peer/process.mjs +13 -0
- package/apps/peer/runner.mjs +4 -3
- package/apps/peer/runtime.mjs +6 -1
- package/apps/peer/wecom.mjs +1 -1
- package/bin/codex-collab.mjs +21 -9
- package/docs/desktop-poll.md +2 -2
- package/docs/onboarding-audit.md +12 -2
- package/docs/onboarding.md +37 -9
- package/docs/peer.md +4 -2
- package/docs/releases/0.5.0.md +14 -0
- package/docs/team-registry.md +7 -1
- package/docs/terminal-onboarding.md +3 -3
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab/SKILL.md +2 -2
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +2 -2
- package/packages/peer/config.mjs +2 -0
- package/packages/peer/git.mjs +14 -0
- package/packages/peer/protocol.mjs +4 -2
- package/packages/peer/repository.mjs +67 -0
- package/packages/peer/service.mjs +11 -8
- package/packages/peer/store.mjs +16 -16
- package/schemas/peer-task.schema.json +15 -13
- package/schemas/team-policy.schema.json +12 -1
- package/scripts/peer/cli.mjs +14 -8
- package/scripts/peer/init-agent.mjs +6 -15
- package/scripts/peer/onboarding.mjs +26 -20
- package/scripts/peer/refresh-project.mjs +23 -15
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const $ = (id) => document.getElementById(id);
|
|
2
2
|
let currentId, pending, discovery, timer, busy = false, reviewVersion = null, reviewId, ownAdminId, resetPlan, resetDiscovery;
|
|
3
|
+
let repositoryPolicy;
|
|
3
4
|
let viewEpoch = 0, currentView = 'home', completing = false, deliveryTarget;
|
|
4
5
|
const storageKey = 'codex-collab-onboarding';
|
|
5
6
|
function remember(value) { try { if (value) sessionStorage.setItem(storageKey, JSON.stringify(value)); else sessionStorage.removeItem(storageKey); } catch {} }
|
|
@@ -18,7 +19,13 @@ const values = (form) => Object.fromEntries(new FormData(form));
|
|
|
18
19
|
function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
|
|
19
20
|
function button(label, fn) { const node = text('button', label); node.onclick = () => action(fn); return node; }
|
|
20
21
|
async function overview() {
|
|
21
|
-
const value = await api('overview');
|
|
22
|
+
const value = await api('overview');
|
|
23
|
+
if (value.defaults) {
|
|
24
|
+
if (!$('admin-form').elements.repo.value) $('admin-form').elements.repo.value = value.defaults.repo || '';
|
|
25
|
+
if (!$('admin-form').elements.display_name.value) $('admin-form').elements.display_name.value = value.defaults.display_name || '';
|
|
26
|
+
}
|
|
27
|
+
$('startup-status').textContent = value.startup?.enabled ? '后台服务已运行,登录时自动恢复已开启的连接。可关闭终端和浏览器。' : value.startup?.error || '后台工作台会保持连接;登录自启状态可用 codex-collab autostart status 查看。';
|
|
28
|
+
$('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
|
|
22
29
|
$('removed-projects').hidden = !(value.removed_profiles || []).length;
|
|
23
30
|
for (const profile of value.removed_profiles || []) {
|
|
24
31
|
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
@@ -29,7 +36,7 @@ async function overview() {
|
|
|
29
36
|
const card = document.createElement('div'); card.className = 'card';
|
|
30
37
|
card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
|
|
31
38
|
}
|
|
32
|
-
if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '
|
|
39
|
+
if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。选择“连接我的机器人”,或导入同事提供的接入包。'));
|
|
33
40
|
for (const profile of value.profiles) {
|
|
34
41
|
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
35
42
|
info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
|
|
@@ -108,6 +115,7 @@ function watchProfile(id, epoch) {
|
|
|
108
115
|
timer = setTimeout(tick, 5000);
|
|
109
116
|
}
|
|
110
117
|
async function loadProfile(id) {
|
|
118
|
+
repositoryPolicy = null; $('repository-policy-form').hidden = true;
|
|
111
119
|
currentId = id; remember({ profile_id: id }); show('profile'); const epoch = viewEpoch; let value;
|
|
112
120
|
try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
|
|
113
121
|
if (epoch !== viewEpoch) return;
|
|
@@ -117,9 +125,9 @@ async function loadProfile(id) {
|
|
|
117
125
|
function renderProfile(value, automatic = false) {
|
|
118
126
|
const id = value.id; renderSteps(value);
|
|
119
127
|
$('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
|
|
120
|
-
$('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
|
|
128
|
+
$('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}${value.base_branch ? ` · 基准:${value.remote_name || '远程'}/${value.base_branch}` : ''}`;
|
|
121
129
|
$('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';
|
|
130
|
+
$('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
131
|
if (value.phase === 'ready') {
|
|
124
132
|
$('binding-text').textContent = value.binding_prompt; $('binding-details').textContent = value.detailed_binding_prompt || value.binding_prompt;
|
|
125
133
|
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 +199,79 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
|
|
|
191
199
|
await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
|
|
192
200
|
await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
|
|
193
201
|
}); };
|
|
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(); });
|
|
202
|
+
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
203
|
function repoMode(prefix) {
|
|
196
204
|
const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
|
|
197
205
|
}
|
|
198
|
-
for (const prefix of ['admin', 'join']) $(`${prefix}-repo-mode`).onchange = () =>
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange = () => {
|
|
207
|
+
repoMode(prefix);
|
|
208
|
+
if (prefix === 'admin') clearProjectSelection();
|
|
209
|
+
};
|
|
210
|
+
function clearProjectSelection() {
|
|
211
|
+
$('admin-remote-name').value = ''; $('admin-base-branch').value = ''; $('repository-options').hidden = true;
|
|
212
|
+
$('project-recommendation').textContent = '';
|
|
213
|
+
}
|
|
214
|
+
async function checkProject() {
|
|
215
|
+
const snapshot = values($('admin-form'));
|
|
216
|
+
const selected = await api('project-check', snapshot);
|
|
217
|
+
const current = values($('admin-form'));
|
|
218
|
+
if (['repo_mode', 'repo', 'remote', 'destination', 'remote_name', 'base_branch'].some((key) => current[key] !== snapshot[key])) return;
|
|
201
219
|
const form = $('admin-form'); form.elements.repo.value = selected.repo; $('admin-repo-mode').value = 'existing'; repoMode('admin');
|
|
202
|
-
$('
|
|
220
|
+
$('repository-options').hidden = false;
|
|
221
|
+
for (const [id, options, chosen, placeholder] of [
|
|
222
|
+
['admin-remote-name', selected.remotes || [], selected.remote_name, '请选择协作远程'],
|
|
223
|
+
['admin-base-branch', selected.branches || [], selected.base_branch, '请选择业务基准分支'],
|
|
224
|
+
]) {
|
|
225
|
+
const select = $(id); select.replaceChildren();
|
|
226
|
+
const empty = text('option', placeholder); empty.value = ''; select.append(empty);
|
|
227
|
+
for (const value of options) { const option = text('option', value); option.value = value; select.append(option); }
|
|
228
|
+
select.value = chosen || '';
|
|
229
|
+
}
|
|
230
|
+
$('project-recommendation').textContent = selected.selection_required ? '请在下方选择协作远程或业务基准分支,再继续。' : `项目已检查:${selected.repo}。基准:${selected.remote_name}/${selected.base_branch}。${selected.tests.length ? '推荐:' + selected.tests.map((test) => test.command).join(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
|
|
203
231
|
if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
|
|
232
|
+
}
|
|
233
|
+
$('load-repository-settings').onclick = () => action(async () => {
|
|
234
|
+
const id = currentId, value = await api('repository-settings', { id });
|
|
235
|
+
if (id !== currentId || currentView !== 'profile') return;
|
|
236
|
+
const select = $('team-base-branch'); select.replaceChildren();
|
|
237
|
+
for (const branch of value.branches) { const option = text('option', branch); option.value = branch; select.append(option); }
|
|
238
|
+
select.value = value.branches.includes(value.base_branch) ? value.base_branch : value.default_branch || '';
|
|
239
|
+
repositoryPolicy = { id, expected_version: value.version }; $('repository-policy-form').hidden = false;
|
|
204
240
|
});
|
|
241
|
+
$('repository-policy-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
242
|
+
if (!repositoryPolicy || repositoryPolicy.id !== currentId) throw new Error('请重新读取团队基准分支。');
|
|
243
|
+
await api('repository-policy', { ...repositoryPolicy, base_branch: $('team-base-branch').value });
|
|
244
|
+
await loadProfile(currentId); notice('团队基准分支已保存,同事和新增机器人会自动继承;已有任务保留原起始提交。');
|
|
245
|
+
}); };
|
|
246
|
+
$('check-project').onclick = () => action(checkProject);
|
|
247
|
+
for (const id of ['admin-repo', 'admin-remote', 'admin-destination']) $(id).onchange = () => {
|
|
248
|
+
const form = $('admin-form');
|
|
249
|
+
clearProjectSelection();
|
|
250
|
+
if ($('admin-repo-mode').value === 'clone' ? form.elements.remote.value && form.elements.destination.value : form.elements.repo.value) action(checkProject);
|
|
251
|
+
};
|
|
252
|
+
$('admin-remote-name').onchange = () => { $('admin-base-branch').value = ''; action(checkProject); };
|
|
253
|
+
$('admin-base-branch').onchange = () => action(checkProject);
|
|
205
254
|
$('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
206
255
|
const input = values(event.target);
|
|
207
256
|
const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
|
|
208
257
|
pending = { type: 'admin', draft_id: session.draft_id }; remember({ draft_id: session.draft_id }); await verifyView(session);
|
|
209
258
|
}); };
|
|
210
259
|
async function invitationInput(form) { const input = values(form); input.content = await input.file.text(); delete input.file; return input; }
|
|
211
|
-
|
|
260
|
+
async function inspectInvite() {
|
|
212
261
|
$('invite-info').hidden = true; $('invite-summary').textContent = ''; $('invite-remote').textContent = '';
|
|
213
|
-
const
|
|
262
|
+
const file = $('join-form').elements.file.files?.[0];
|
|
263
|
+
const input = await invitationInput($('join-form'));
|
|
264
|
+
const value = await api('inspect-invite', input);
|
|
265
|
+
if ($('join-form').elements.password.value !== input.password || $('join-form').elements.file.files?.[0] !== file) return;
|
|
214
266
|
$('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
|
-
$('invite-remote').textContent = value.remote
|
|
267
|
+
$('invite-remote').textContent = `${value.remote}${value.base_branch ? ` · 基准分支:${value.base_branch}(自动继承)` : ''}`;
|
|
216
268
|
if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
|
|
217
|
-
}
|
|
269
|
+
}
|
|
270
|
+
$('inspect-invite').onclick = () => action(inspectInvite);
|
|
271
|
+
for (const id of ['invite-file', 'invite-password']) $(id).onchange = () => {
|
|
272
|
+
$('invite-info').hidden = true;
|
|
273
|
+
if ($('invite-file').files?.length && $('invite-password').value) action(inspectInvite);
|
|
274
|
+
};
|
|
218
275
|
$('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
219
276
|
const profile = await api('import', await invitationInput(event.target)); event.target.elements.password.value = ''; await connectMember(profile);
|
|
220
277
|
}); };
|
|
@@ -242,6 +299,14 @@ $('retry-code').onclick = () => action(async () => {
|
|
|
242
299
|
$('confirm-code').onclick = () => action(completeVerification);
|
|
243
300
|
$('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
|
|
244
301
|
$('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('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
|
|
302
|
+
$('run-diagnostics').onclick = () => action(async () => {
|
|
303
|
+
const value = await api('diagnostics', { id: currentId }); $('diagnostic-result').replaceChildren();
|
|
304
|
+
for (const check of value.checks) $('diagnostic-result').append(text('p', `${check.ok ? '✓' : '待处理'} ${check.label}${check.action ? ':' + check.action : ''}`));
|
|
305
|
+
$('diagnostic-result').append(text('p', value.note));
|
|
306
|
+
});
|
|
307
|
+
$('repair-project').onclick = () => action(async () => {
|
|
308
|
+
const value = await api('repair', { id: currentId }); await loadProfile(currentId); notice(value.next_step);
|
|
309
|
+
});
|
|
245
310
|
$('refresh').onclick = () => action(() => loadProfile(currentId));
|
|
246
311
|
$('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
|
|
247
312
|
$('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
|
|
@@ -1,34 +1,34 @@
|
|
|
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"
|
|
4
|
-
<nav><button data-view="home">我的项目</button><button data-view="admin"
|
|
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
|
|
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
|
|
12
|
-
<button id="check-project" type="button" class="secondary">检查项目并推荐验证命令</button><p id="project-recommendation" class="muted"
|
|
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>
|
|
11
|
+
<button id="check-project" type="button" class="secondary">检查项目并推荐验证命令</button><p id="project-recommendation" class="muted">使用本机已有的 Git 登录,自动识别远程仓库和默认分支;无法确定时只需选择一次。</p>
|
|
12
|
+
<div id="repository-options" hidden><label>协作远程<select name="remote_name" id="admin-remote-name"><option value="">自动识别</option></select></label><label>业务基准分支<select name="base_branch" id="admin-base-branch"><option value="">使用远程默认分支</option></select></label><p class="muted">同事和新增机器人会继承这个基准分支。每个新任务固定起始提交,后续分支更新不会改变已有任务。</p></div>
|
|
13
13
|
<label>验证命令<input id="test-command" name="test_command" required placeholder="例如 npm test、go test ./...、sh test.sh"></label><p class="muted">请确认这个命令适合项目。工具使用固定程序和参数执行,不运行管道或拼接命令。</p></fieldset>
|
|
14
14
|
<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
|
|
15
|
+
<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
16
|
<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"
|
|
17
|
+
<button type="submit">连接并进入群验证</button></form></section>
|
|
18
18
|
<section id="own" hidden><h2>给自己增加机器人</h2><p>同一个企业微信账号可以拥有多个 Agent。每个 Agent 使用独立机器人、独立 clone 和独立 Codex 对话。验证后自动完成本机接入,无需下载接入包。</p>
|
|
19
|
-
<form id="own-form"><label>另一个 clone 的本机目录<input name="repo"
|
|
20
|
-
<section id="join" hidden><h2
|
|
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"
|
|
19
|
+
<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>
|
|
20
|
+
<section id="join" hidden><h2>接受同事邀请</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
|
|
21
|
+
<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>
|
|
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>
|
|
23
23
|
<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="
|
|
24
|
+
<label id="join-clone-repo">新项目目录<input name="destination" placeholder="留空使用推荐目录,亦可填写完整路径"></label>
|
|
25
25
|
<label id="join-existing-repo" hidden>已有项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
|
|
26
26
|
<button type="submit">接入我的机器人</button><p class="muted">工具使用本机 Git 登录拉取代码,不会改写已有目录。导入后请按提示在群里确认一次身份,审批和配置会自动继续。</p></form></section>
|
|
27
27
|
<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
28
|
<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
|
|
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(已打开的项目请重新加载 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
30
|
<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
|
-
<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>
|
|
31
|
+
<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><button type="button" id="load-repository-settings" class="secondary">读取并调整基准分支</button><form id="repository-policy-form" hidden><label>业务基准分支<select id="team-base-branch" required></select></label><p class="muted">保存后所有成员的新任务使用此分支。已创建任务保留原来的分支、起始提交和审核规则。</p><button type="submit">保存团队基准分支</button></form><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
32
|
<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>
|
|
33
33
|
<div id="delivery-panel" hidden><h3 id="delivery-title"></h3><p>接入包与密码请分开传递。下面的接入说明不包含密码或 Secret。</p><pre id="delivery-instructions"></pre><button id="copy-delivery">复制接入说明</button><label id="delivery-password-label">接入包密码<input id="delivery-password" type="password" readonly></label><button id="copy-password" type="button" class="secondary">复制密码</button><p id="delivery-password-note" class="muted" hidden></p><a id="delivery-download" class="secondary">下载这份接入包</a><button id="reissue-invite" type="button" class="secondary" hidden>重新生成接入包与密码</button><form id="reissue-form" hidden><p>旧包和未批准的旧入队申请将失效。保持原机器人和职责;请输入这个机器人的 Secret,自动生成新的密码。</p><label>机器人 Bot ID<input id="reissue-bot-id" readonly></label><label>机器人 Secret<input id="reissue-secret" name="secret" type="password" required autocomplete="off"></label><button type="submit">生成新的接入包与密码</button><button id="reissue-cancel" type="button" class="secondary">取消</button></form><button id="hide-delivery" type="button" class="secondary">收起</button></div>
|
|
34
34
|
<details><summary>单独分配(兼容入口)</summary><form id="issue-form"><div class="row"><label>Agent ID<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>自定义密码(可选,留空自动生成)<input name="password" type="password" minlength="12" 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></details>
|
|
@@ -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
|
+
}
|
package/apps/peer/desktop.mjs
CHANGED
|
@@ -63,10 +63,11 @@ export class DesktopExecutor {
|
|
|
63
63
|
async prepare(job, task) {
|
|
64
64
|
const refs = task.state.checkpoint;
|
|
65
65
|
const fetched = `refs/collab-desktop/${this.service.agentId}/${task.spec.id}/${job.token.epoch}`;
|
|
66
|
-
await git(this.config.repo, 'fetch', '--no-tags', '--no-write-fetch-head',
|
|
66
|
+
await git(this.config.repo, 'fetch', '--no-tags', '--no-write-fetch-head', this.service.store.remoteName, `refs/heads/${refs?.branch || task.spec.base_branch}:${fetched}`);
|
|
67
67
|
const tip = await git(this.config.repo, 'rev-parse', '--verify', `${fetched}^{commit}`);
|
|
68
68
|
if (refs) await git(this.config.repo, 'merge-base', '--is-ancestor', refs.commit, tip);
|
|
69
|
-
const source = refs?.commit || tip;
|
|
69
|
+
const source = refs?.commit || task.spec.base_commit || tip;
|
|
70
|
+
if (!refs && task.spec.base_commit) await git(this.config.repo, 'merge-base', '--is-ancestor', source, tip);
|
|
70
71
|
await this.service.assertLease(task.spec.id, job.token);
|
|
71
72
|
const worktree = join(this.config.repo, '.codex-collab-worktrees', `${task.spec.id.toLowerCase()}-${this.service.agentId}-${job.token.epoch}`);
|
|
72
73
|
const branch = `codex/desktop-${task.spec.id.toLowerCase()}-${this.service.agentId}-${job.token.epoch}`;
|
|
@@ -195,7 +196,7 @@ export class DesktopExecutor {
|
|
|
195
196
|
}
|
|
196
197
|
await this.service.assertLease(taskId, job.token);
|
|
197
198
|
if (renewalFailed) throw new Conflict('execution lease lost; stop publishing');
|
|
198
|
-
await git(job.worktree, 'push',
|
|
199
|
+
await git(job.worktree, 'push', this.service.store.remoteName, `HEAD:refs/heads/${job.branch}`);
|
|
199
200
|
await this.service.assertLease(taskId, job.token);
|
|
200
201
|
clearInterval(timer); await pulse;
|
|
201
202
|
const more = task.state.stage + 1 < task.spec.workflow.length;
|
|
@@ -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
|
@@ -27,7 +27,7 @@ export function makePeerMcp(service, { config } = {}) {
|
|
|
27
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
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));
|
|
29
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));
|
|
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
|
|
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 the baseline branch, 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));
|
|
31
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));
|
|
32
32
|
}
|
|
33
33
|
// No standalone approval tool: review only relays a recorded authenticated
|
package/apps/peer/process.mjs
CHANGED
|
@@ -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
|
+
}
|
package/apps/peer/runner.mjs
CHANGED
|
@@ -46,10 +46,11 @@ export class PeerRunner {
|
|
|
46
46
|
await mkdir(join(this.config.data_dir, 'jobs', id), { recursive: true });
|
|
47
47
|
const refs = task.state.checkpoint;
|
|
48
48
|
const fetchedRef = `refs/collab-peer/${this.service.agentId}/${id}/${token.epoch}`;
|
|
49
|
-
await git(this.config.repo, 'fetch', '--no-tags', '--no-write-fetch-head',
|
|
49
|
+
await git(this.config.repo, 'fetch', '--no-tags', '--no-write-fetch-head', this.service.store.remoteName, `refs/heads/${refs?.branch || task.spec.base_branch}:${fetchedRef}`);
|
|
50
50
|
const tip = await git(this.config.repo, 'rev-parse', '--verify', `${fetchedRef}^{commit}`);
|
|
51
51
|
if (refs) await git(this.config.repo, 'merge-base', '--is-ancestor', refs.commit, tip);
|
|
52
|
-
const source = refs?.commit || tip;
|
|
52
|
+
const source = refs?.commit || task.spec.base_commit || tip;
|
|
53
|
+
if (!refs && task.spec.base_commit) await git(this.config.repo, 'merge-base', '--is-ancestor', source, tip);
|
|
53
54
|
await ensure();
|
|
54
55
|
await git(this.config.repo, 'worktree', 'add', '-b', branch, worktree, source); created = true;
|
|
55
56
|
const note = task.events.findLast((e) => ['handoff', 'stage_review_requested'].includes(e.type))?.summary || '';
|
|
@@ -75,7 +76,7 @@ export class PeerRunner {
|
|
|
75
76
|
}
|
|
76
77
|
if (await git(worktree, 'status', '--porcelain')) throw new Error('worktree not clean');
|
|
77
78
|
const commit = await git(worktree, 'rev-parse', 'HEAD'); if (commit === source) throw new Error('agent produced no code changes');
|
|
78
|
-
await ensure(); await git(worktree, 'push',
|
|
79
|
+
await ensure(); await git(worktree, 'push', this.service.store.remoteName, `HEAD:refs/heads/${branch}`); await ensure();
|
|
79
80
|
clearInterval(timer); await pulse; await ensure();
|
|
80
81
|
const more = task.state.stage + 1 < task.spec.workflow.length;
|
|
81
82
|
phase = 'protocol';
|
package/apps/peer/runtime.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveRemote } from '../../packages/peer/repository.mjs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
4
|
import { makeSigner, readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
|
|
@@ -8,6 +9,8 @@ export async function readPeerConfig(path, { allowDisabled = false } = {}) {
|
|
|
8
9
|
const config = await readPrivateJson(resolve(path));
|
|
9
10
|
if (config.local_disabled === true && !allowDisabled) throw new Error('此项目已移除本机接入,请通过向导恢复后重试。');
|
|
10
11
|
if (config.schema_version !== 2 || ![config.repo, config.data_dir, config.team_file].every((p) => typeof p === 'string' && isAbsolute(p)) || typeof config.agent_id !== 'string') throw new Error('peer config needs v2 and absolute repo/data_dir/team_file paths');
|
|
12
|
+
if (config.remote_name !== undefined && (typeof config.remote_name !== 'string' || !config.remote_name || config.remote_name.startsWith('-') || /[\s\x00-\x1f\x7f]/.test(config.remote_name))) throw new Error('invalid configured remote name');
|
|
13
|
+
if (config.remote_url !== undefined && (typeof config.remote_url !== 'string' || !config.remote_url || /[\x00-\x1f\x7f]/.test(config.remote_url))) throw new Error('invalid configured remote URL');
|
|
11
14
|
const team = validateTeam(JSON.parse(await readFile(config.team_file, 'utf8')));
|
|
12
15
|
if (config.codex_home !== undefined && (typeof config.codex_home !== 'string' || !isAbsolute(config.codex_home))) throw new Error('codex_home must be an absolute path');
|
|
13
16
|
if (config.auto_execute !== undefined && typeof config.auto_execute !== 'boolean') throw new Error('auto_execute must be a boolean');
|
|
@@ -25,6 +28,8 @@ export async function readPeerConfig(path, { allowDisabled = false } = {}) {
|
|
|
25
28
|
}
|
|
26
29
|
export async function loadRuntime(path, options = {}) {
|
|
27
30
|
const { config, team } = await readPeerConfig(path);
|
|
28
|
-
const
|
|
31
|
+
const selected = await resolveRemote(config.repo, { remoteName: config.remote_name, expectedRemote: config.remote_url, legacyOrigin: !config.remote_name && !config.remote_url });
|
|
32
|
+
config.remote_name = selected.remote_name;
|
|
33
|
+
const store = await new PeerGitStore({ remoteName: selected.remote_name, remoteUrl: selected.remote, registryRoot: config.team_registry_root, repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, ...options }).init();
|
|
29
34
|
return { config, team, store, service: new PeerService(store) };
|
|
30
35
|
}
|
package/apps/peer/wecom.mjs
CHANGED
|
@@ -137,7 +137,7 @@ export function formatPeerNotification(task, type, agentId, team) {
|
|
|
137
137
|
if (type === 'reviewed') {
|
|
138
138
|
if (event.decision === 'changes_requested') detail.push(`审核退回,${task.spec.workflow[task.state.stage]} 阶段重新执行。\n修改意见:${notificationText(task.state.review_feedback)}\n从提交 ${task.state.checkpoint.commit} 继续修改。`);
|
|
139
139
|
else if (task.state.status === 'waiting') detail.push('真人审核已通过。', next());
|
|
140
|
-
else detail.push(`最终审核已通过;请管理员 @ 自己的机器人发送:协作 完成 ${task.spec.id}。代码分支尚未合并
|
|
140
|
+
else detail.push(`最终审核已通过;请管理员 @ 自己的机器人发送:协作 完成 ${task.spec.id}。代码分支尚未合并 ${task.spec.base_branch}。`);
|
|
141
141
|
}
|
|
142
142
|
if (type === 'run_failed') detail.push('本阶段未成功,按 Git 路由交给剩余候选或等待人工处理。');
|
|
143
143
|
return [base, ...detail].join('\n');
|
package/bin/codex-collab.mjs
CHANGED
|
@@ -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
|
|
21
|
-
codex-collab
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/docs/desktop-poll.md
CHANGED
|
@@ -167,7 +167,7 @@ Runner 收到已验证审核人的群消息时,把本群由该机器人负责
|
|
|
167
167
|
|
|
168
168
|
对话通过 `resolve_desktop_request` 的 `review` action 提交 `task_id`、冻结的 `expected_seq`、`decision`;退回还需要从真人消息中原文摘录具体意见 `summary`。服务检查当前身份、任务固定的审核策略、所属群、状态、版本和 SHA,再把任务 `reviewed` 事件与 inbox `reviewed` 结果在同一次 Git 提交中发布。重复处理同一消息返回原结果,不会再次推进阶段;原文、候选版本和决策摘要留在团队 Git 中。
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
如果提交或任务状态在处理前变化,审核会被拒绝,需要真人查看最新结果后重新发一条消息。收到提交前说的“通过”不能批准未来结果。只有新请求会捕获新的候选,旧消息及其澄清不升级版本。审核只是按原工作流继续;最终通过不自动完成任务或合并团队基准分支。
|
|
171
171
|
|
|
172
172
|
## MCP 与运行约束
|
|
173
173
|
|
|
@@ -187,7 +187,7 @@ Runner 收到已验证审核人的群消息时,把本群由该机器人负责
|
|
|
187
187
|
- 编辑阶段没有无限续租定时器。定时 app heartbeat 只是唤起检查;必须由活跃对话显式调用续租。只有有超时的前台固定测试/发布调用会在调用期间续租。Bot 在线不是对话活着的证据。
|
|
188
188
|
- 首选不接单,3 分钟后候补可接;执行掉线,15 分钟 lease 过期加 clock skew 后可接管。旧进程/对话应停止,不改写已交接内容。没有签名成功 checkpoint 的未提交进度不会自动恢复给另一台机器。
|
|
189
189
|
- 本机绑定使用调用参数 thread_id 与私有绑定比对,是工具路由与防误操作措施,**不是由 Codex 宿主认证的调用者身份**。同一台机器上能访问同一私钥/配置的其他对话不构成安全隔离边界;真正成员授权靠独立签名私钥和团队策略。
|
|
190
|
-
- MCP
|
|
190
|
+
- MCP 不发完整对话,不自动审核,不合并团队基准分支;群通知失败不回滚 Git 事件,适配器按成功回执持久化游标重试。
|
|
191
191
|
|
|
192
192
|
## 停止、状态和重新绑定
|
|
193
193
|
|
package/docs/onboarding-audit.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 网页向导功能检查与测试记录
|
|
2
2
|
|
|
3
|
-
最近检查日期:2026-10-
|
|
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
|
-
|
|
|
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 自动化与睡眠恢复的联合验收。
|