xuym-codex-collab 0.5.0 → 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 +3 -1
- package/apps/onboarding/bundle.mjs +1 -6
- package/apps/onboarding/guided.mjs +2 -2
- package/apps/onboarding/manager.mjs +61 -43
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/web/app.js +40 -5
- package/apps/onboarding/web/index.html +3 -2
- package/apps/peer/desktop.mjs +4 -3
- package/apps/peer/mcp.mjs +1 -1
- package/apps/peer/runner.mjs +4 -3
- package/apps/peer/runtime.mjs +6 -1
- package/apps/peer/wecom.mjs +1 -1
- package/docs/desktop-poll.md +2 -2
- package/docs/onboarding.md +15 -3
- package/docs/peer.md +4 -2
- 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 +2 -5
- package/scripts/peer/init-agent.mjs +6 -15
- package/scripts/peer/onboarding.mjs +26 -20
- package/scripts/peer/refresh-project.mjs +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ codex-collab
|
|
|
20
20
|
|
|
21
21
|
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员;批量分配自动生成密码,可在“已分配的机器人”查看并单独复制。接入说明不含密码和 Secret。显示名称支持中文,职责可从前端、后端、全栈、测试等预设中选择;内部 ID 和自定义标签放在高级选项中。
|
|
22
22
|
|
|
23
|
+
协作远程与业务基准分支自动识别;不要求 `origin/main`。有多个不同远程或默认分支不明确时,管理员选择一次。成员与新增机器人继承签名团队基准,各自可使用不同远程别名。每个新任务固定创建时的 commit,排队和重试期间不会漂移;管理员可在团队设置里为后续任务修改基准分支。
|
|
24
|
+
|
|
23
25
|
同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
|
|
24
26
|
|
|
25
27
|
机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
|
|
@@ -51,7 +53,7 @@ codex-collab autostart status
|
|
|
51
53
|
|
|
52
54
|
`doctor` 检查项目 MCP/Skill、机器人进程与认证、对话绑定、真实轮询以及 Git 读取和推送权限,并给出下一步。页面也提供“检查整条连接”和“修复本机配置”。修复更新已识别的生成配置,并恢复缺失的 MCP 配置和 Skill;自定义、符号链接或已提交的共享配置需要手动合并。修复后请在 Codex 重新加载项目 MCP,在原对话执行启用指令。
|
|
53
55
|
|
|
54
|
-
升级执行 `npm install -g xuym-codex-collab@latest`,然后重新打开工作台并修复项目配置。已经运行的 Node 进程需要重启才能加载新代码:停止并重新启动机器人;后台工作台可等下次登录,或终止 `codex-collab service-status` 显示的工作台 PID 后重新执行 `codex-collab`。0.5.0 迁移时只接管仍在运行的旧连接,已停止的旧连接需要你主动启动。
|
|
56
|
+
升级执行 `npm install -g xuym-codex-collab@latest`,然后重新打开工作台并修复项目配置。已经运行的 Node 进程需要重启才能加载新代码:停止并重新启动机器人;后台工作台可等下次登录,或终止 `codex-collab service-status` 显示的工作台 PID 后重新执行 `codex-collab`。0.6.0 增加任务 v3 规格:团队全员须先升级并重启工作台、Runner 与 MCP,再创建新任务;历史任务保持原规则。0.5.0 迁移时只接管仍在运行的旧连接,已停止的旧连接需要你主动启动。
|
|
55
57
|
|
|
56
58
|
`codex-collab autostart off` 关闭登录自启,`autostart on` 重新设置。`setup --no-startup` 仅跳过本次自启配置,`setup --foreground` 以前台模式运行,便于排查。Windows 支持独立后台工作台,暂未自动配置登录启动。电脑休眠、关机或退出系统期间不会执行群任务;Codex 对话的自动化仍需保持启用。
|
|
57
59
|
|
|
@@ -17,9 +17,4 @@ export function decryptBundle(value, password) {
|
|
|
17
17
|
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(value.ciphertext, 'base64')), decipher.final()]).toString('utf8'));
|
|
18
18
|
} catch { throw new SetupError('接入包或密码无效,请确认文件完整且密码正确。'); }
|
|
19
19
|
}
|
|
20
|
-
export
|
|
21
|
-
const scp = remote.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
22
|
-
try { const url = new URL(remote); if (['https:', 'http:', 'ssh:'].includes(url.protocol)) return `${url.hostname.toLowerCase()}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`; } catch {}
|
|
23
|
-
if (scp && !remote.startsWith('/')) return `${scp[1].toLowerCase()}/${scp[2].replace(/\/+$/g, '').replace(/\.git$/, '')}`;
|
|
24
|
-
return remote;
|
|
25
|
-
}
|
|
20
|
+
export { remoteIdentity } from '../../packages/peer/repository.mjs';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { resolveRemote } from '../../packages/peer/repository.mjs';
|
|
1
2
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
3
|
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
6
|
import { git } from '../../packages/peer/store.mjs';
|
|
6
|
-
import { remoteIdentity } from './bundle.mjs';
|
|
7
7
|
import { SetupError } from './errors.mjs';
|
|
8
8
|
|
|
9
9
|
export const responsibilities = {
|
|
@@ -80,7 +80,7 @@ export async function cloneRepository({ remote, destination, privateHome }) {
|
|
|
80
80
|
if (existing.isSymbolicLink()) throw new SetupError('拉取目录不能是符号链接,请选择独立目录。');
|
|
81
81
|
try {
|
|
82
82
|
const actual = await realpath(target);
|
|
83
|
-
if (await git(actual, 'rev-parse', '--show-toplevel') === actual &&
|
|
83
|
+
if (await git(actual, 'rev-parse', '--show-toplevel') === actual && !!await resolveRemote(actual, { expectedRemote: remote })) return actual;
|
|
84
84
|
} catch { /* Reject existing unrelated folders instead of overwriting them. */ }
|
|
85
85
|
throw new SetupError('目标目录已存在,请选择新的目录,或切换到使用已有项目。');
|
|
86
86
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectRepository, RepositoryError, resolveRemote, teamBaseBranch } from '../../packages/peer/repository.mjs';
|
|
1
2
|
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
2
3
|
import { SetupError } from './errors.mjs';
|
|
3
4
|
import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
|
|
@@ -53,9 +54,8 @@ function gitFailure(error) {
|
|
|
53
54
|
if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
|
|
54
55
|
return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
|
|
55
56
|
}
|
|
56
|
-
return new SetupError('Git
|
|
57
|
+
return new SetupError('Git 无法读取远程仓库,请检查所选远程地址、Git 权限和网络后重试。');
|
|
57
58
|
}
|
|
58
|
-
function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
|
|
59
59
|
function mcpSection(text, serverName) {
|
|
60
60
|
let active = false; const lines = [];
|
|
61
61
|
for (const line of String(text || '').split(/\r?\n/)) {
|
|
@@ -74,7 +74,7 @@ function projectMcpConfigured(projectConfig, configPath, config) {
|
|
|
74
74
|
const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
|
|
75
75
|
return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
|
|
76
76
|
}
|
|
77
|
-
async function ensureRepo(path) {
|
|
77
|
+
async function ensureRepo(path, options = {}) {
|
|
78
78
|
if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
|
|
79
79
|
let repo;
|
|
80
80
|
try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
|
|
@@ -84,25 +84,10 @@ async function ensureRepo(path) {
|
|
|
84
84
|
if (error instanceof SetupError) throw error;
|
|
85
85
|
throw new SetupError('请选择 Git 仓库根目录。');
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
catch (error) {
|
|
90
|
-
const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
|
|
91
|
-
if (/no such remote|does not appear to be a git repository/.test(detail)) throw new SetupError('该目录没有名为 origin 的远程仓库,请先配置 GitHub origin。');
|
|
92
|
-
throw gitFailure(error);
|
|
93
|
-
}
|
|
94
|
-
let main;
|
|
95
|
-
try { main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main'); }
|
|
96
|
-
catch (error) { throw gitFailure(error); }
|
|
97
|
-
if (!main) {
|
|
98
|
-
let heads;
|
|
99
|
-
try { heads = await git(repo, 'ls-remote', '--heads', 'origin'); }
|
|
100
|
-
catch (error) { throw gitFailure(error); }
|
|
101
|
-
if (!heads) throw new SetupError(`远程仓库为空,未找到 origin/main。请先在终端执行:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
102
|
-
throw new SetupError(`远程仓库可访问,但没有 main 分支。请先将本地 main 推送到 origin:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
103
|
-
}
|
|
104
|
-
return { repo, remote };
|
|
87
|
+
try { return { repo, ...await (options.remoteOnly ? resolveRemote(repo, options) : inspectRepository(repo, options)) }; }
|
|
88
|
+
catch (error) { if (error instanceof RepositoryError) throw new SetupError(error.message); throw gitFailure(error); }
|
|
105
89
|
}
|
|
90
|
+
|
|
106
91
|
function validateBundle(value) {
|
|
107
92
|
if (!value || value.schema_version !== 1 || value.kind !== 'codex-collab-invite' || typeof value.remote !== 'string' || !value.remote || !value.history || !value.invitation || !value.wecom || typeof value.wecom.secret !== 'string' || !value.wecom.secret) throw new SetupError('接入包格式无效。');
|
|
108
93
|
const history = validateHistory(value.history, value.root_hash);
|
|
@@ -130,7 +115,7 @@ export class SetupManager {
|
|
|
130
115
|
async profileStore(state, { enrollmentOnly = false } = {}) {
|
|
131
116
|
const identity = await readPrivateJson(state.identity_path);
|
|
132
117
|
const initial = state.bootstrap_team;
|
|
133
|
-
return new PeerGitStore({ repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
118
|
+
return new PeerGitStore({ remoteName: state.remote_name, remoteUrl: state.remote, repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
134
119
|
agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
|
|
135
120
|
}
|
|
136
121
|
async overview() {
|
|
@@ -167,7 +152,7 @@ export class SetupManager {
|
|
|
167
152
|
}
|
|
168
153
|
await this.checkBotAvailable(credentials.bot_id);
|
|
169
154
|
if (input.repo) {
|
|
170
|
-
const { repo } = await ensureRepo(input.repo);
|
|
155
|
+
const { repo } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, remoteOnly: true });
|
|
171
156
|
const toml = await readFile(join(repo, '.codex/config.toml'), 'utf8').catch(() => '');
|
|
172
157
|
for (const match of toml.matchAll(/"((?:[^"\\]|\\.)*\.json)"/g)) {
|
|
173
158
|
try {
|
|
@@ -184,7 +169,7 @@ export class SetupManager {
|
|
|
184
169
|
async prepareProject(input) {
|
|
185
170
|
const path = input.repo_mode === 'clone'
|
|
186
171
|
? await cloneRepository({ remote: input.remote, destination: input.destination || suggestedRepo(input.remote, memberFields({ ...input, display_name: input.display_name || input.agent_id || 'owner' }).agent_id), privateHome: this.home }) : input.repo;
|
|
187
|
-
const selected = await ensureRepo(path);
|
|
172
|
+
const selected = await ensureRepo(path, { remoteName: input.remote_name, baseBranch: input.base_branch, allowSelection: true });
|
|
188
173
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
189
174
|
return { ...selected, tests: await recommendTests(selected.repo) };
|
|
190
175
|
}
|
|
@@ -196,7 +181,8 @@ export class SetupManager {
|
|
|
196
181
|
const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv);
|
|
197
182
|
const selected = await this.prepareProject(input);
|
|
198
183
|
for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
199
|
-
|
|
184
|
+
if (selected.selection_required) throw new SetupError('请先检查项目并选择协作远程和业务基准分支。');
|
|
185
|
+
const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, remote_name: selected.remote_name, remote_url: selected.remote, base_branch: selected.base_branch, test_argv: argv, team_name: input.team_name?.trim() || null,
|
|
200
186
|
bot_id: input.bot_id, secret: input.secret } };
|
|
201
187
|
await savePrivate(this.draftPath(draft.id), draft);
|
|
202
188
|
return this.adminDraft({ id: draft.id });
|
|
@@ -207,7 +193,7 @@ export class SetupManager {
|
|
|
207
193
|
let session;
|
|
208
194
|
if (draft.discovery_id && !reconnect) { try { session = this.discovery.view(draft.discovery_id); if (['failed', 'expired', 'disconnected'].includes(session.status)) session = null; } catch {} }
|
|
209
195
|
if (!session) {
|
|
210
|
-
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo });
|
|
196
|
+
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo, remote_name: draft.input.remote_name, remote_url: draft.input.remote_url });
|
|
211
197
|
draft.discovery_id = session.id; await savePrivate(this.draftPath(id), draft);
|
|
212
198
|
}
|
|
213
199
|
return { ...session, draft_id: id, agent_id: draft.input.agent_id, repo: draft.input.repo };
|
|
@@ -228,18 +214,18 @@ export class SetupManager {
|
|
|
228
214
|
async createAdmin(input, { onSaved } = {}) {
|
|
229
215
|
const fields = memberFields(input), agentId = fields.agent_id;
|
|
230
216
|
const caps = fields.capabilities; const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
231
|
-
const { repo, remote } = await ensureRepo(input.repo);
|
|
217
|
+
const { repo, remote, remote_name, base_branch } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, baseBranch: input.base_branch });
|
|
232
218
|
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
233
219
|
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
234
220
|
const proof = this.discovery.consume(input.discovery_id);
|
|
235
221
|
const id = randomUUID(); const directory = this.path(id);
|
|
236
222
|
const identity = await getOrCreateIdentityFiles(agentId, join(this.home, 'identities'));
|
|
237
|
-
const team = buildTeamPolicy({ repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
223
|
+
const team = buildTeamPolicy({ baseBranch: base_branch, repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
238
224
|
agents: [{ id: agentId, role: 'admin', capabilities: caps, publicKey: await readFile(identity.public_key_file, 'utf8'), wecomUserid: proof.userid }],
|
|
239
225
|
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
240
226
|
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
241
227
|
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
242
|
-
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
228
|
+
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, remote_name, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
243
229
|
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
244
230
|
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
245
231
|
await this.save(state);
|
|
@@ -261,7 +247,7 @@ export class SetupManager {
|
|
|
261
247
|
const id = randomUUID(); const identityPath = join(this.path(id), 'identity.json');
|
|
262
248
|
await savePrivate(identityPath, identity);
|
|
263
249
|
let genesis, rootHash = config.team_registry_root;
|
|
264
|
-
const store = await new PeerGitStore({ repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
250
|
+
const store = await new PeerGitStore({ remoteName: config.remote_name, remoteUrl: config.remote_url, repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
265
251
|
if (!rootHash) {
|
|
266
252
|
const firstPath = join(store.checkout, '.agent-collab/team/versions/000001.json');
|
|
267
253
|
if (await exists(firstPath)) {
|
|
@@ -282,7 +268,7 @@ export class SetupManager {
|
|
|
282
268
|
}
|
|
283
269
|
}
|
|
284
270
|
const state = { id, role: team.agents[config.agent_id].role, agent_id: config.agent_id, repo: config.repo,
|
|
285
|
-
remote:
|
|
271
|
+
remote: store.remoteUrl, remote_name: store.remoteName, identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
|
|
286
272
|
test_argv: config.test_argv || ['sh', 'test.sh'], wecom: config.wecom, phase: 'ready', existing_config: resolve(config_path), data_dir: config.data_dir, attached: true };
|
|
287
273
|
await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
|
|
288
274
|
// Existing MCP processes keep using this path and the same private desktop state.
|
|
@@ -306,7 +292,7 @@ export class SetupManager {
|
|
|
306
292
|
if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
|
|
307
293
|
try {
|
|
308
294
|
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
309
|
-
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
295
|
+
const bootstrap = new PeerGitStore({ remoteName: state.remote_name, remoteUrl: state.remote, repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
310
296
|
privateKey: (await readPrivateJson(state.identity_path)).private_key });
|
|
311
297
|
await bootstrap.init();
|
|
312
298
|
await bootstrap.registryTransaction(async () => {
|
|
@@ -358,7 +344,7 @@ export class SetupManager {
|
|
|
358
344
|
const teamPath = join(this.path(id), 'team.json'); await savePrivate(teamPath, store.team);
|
|
359
345
|
const configPath = join(this.path(id), 'peer.json');
|
|
360
346
|
if (!await exists(configPath)) await createMemberConfig({ teamPath, identityPath: state.identity_path, repoPath: state.repo,
|
|
361
|
-
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv });
|
|
347
|
+
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv, remoteName: state.remote_name, remoteUrl: state.remote });
|
|
362
348
|
const config = await readPrivateJson(configPath);
|
|
363
349
|
await savePrivate(configPath, { ...config, team_registry_root: state.root_hash, wecom: state.wecom });
|
|
364
350
|
await diagnosePeer(configPath, { testPush: true });
|
|
@@ -397,7 +383,7 @@ export class SetupManager {
|
|
|
397
383
|
async inspectBundle(input) {
|
|
398
384
|
const bundle = this.readBundle(input), invite = bundle.invitation;
|
|
399
385
|
return { agent_id: invite.agent_id, display_name: bundle.display_name || invite.agent_id, team_name: bundle.team_name || null,
|
|
400
|
-
remote: bundle.remote, capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
386
|
+
remote: bundle.remote, base_branch: teamBaseBranch(validateHistory(bundle.history, bundle.root_hash).latest.team), capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
401
387
|
}
|
|
402
388
|
async importBundle(input) {
|
|
403
389
|
const bundle = this.readBundle(input);
|
|
@@ -412,9 +398,9 @@ export class SetupManager {
|
|
|
412
398
|
}
|
|
413
399
|
async importAssignment(value, repo, ownAdminId) {
|
|
414
400
|
const { bundle, history } = validateBundle(value);
|
|
415
|
-
const selected = await ensureRepo(repo);
|
|
401
|
+
const selected = await ensureRepo(repo, { expectedRemote: bundle.remote, remoteOnly: true });
|
|
416
402
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
417
|
-
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('
|
|
403
|
+
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的远程与邀请仓库不一致,请选择正确仓库;工具不会改写远程配置。');
|
|
418
404
|
let recovered;
|
|
419
405
|
for (const profile of (await this.overview()).profiles) {
|
|
420
406
|
const current = await this.state(profile.id);
|
|
@@ -433,7 +419,7 @@ export class SetupManager {
|
|
|
433
419
|
recovered = current;
|
|
434
420
|
}
|
|
435
421
|
const id = recovered?.id || randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
436
|
-
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
422
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, remote_name: selected.remote_name, identity_path: identity.private_key_file,
|
|
437
423
|
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
|
|
438
424
|
...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
|
|
439
425
|
await this.save(state); return this.detail(id, { localOnly: true });
|
|
@@ -631,7 +617,8 @@ export class SetupManager {
|
|
|
631
617
|
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
|
|
632
618
|
if (!idPattern.test(input.agent_id?.trim() || '')) throw new SetupError('请填写新 Agent 名称。');
|
|
633
619
|
const repo = input.repo_mode === 'clone' ? await cloneRepository({ remote: state.remote, destination: input.destination || suggestedRepo(state.remote, input.agent_id.trim()), privateHome: this.home }) : input.repo;
|
|
634
|
-
const
|
|
620
|
+
const team = (await this.profileStore(state)).team;
|
|
621
|
+
const selected = await ensureRepo(repo, { expectedRemote: state.remote, baseBranch: teamBaseBranch(team) });
|
|
635
622
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
636
623
|
if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
|
|
637
624
|
const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
@@ -674,6 +661,32 @@ export class SetupManager {
|
|
|
674
661
|
await savePrivate(config.team_file, store.team);
|
|
675
662
|
return this.detail(id);
|
|
676
663
|
}
|
|
664
|
+
async repositorySettings({ id }) {
|
|
665
|
+
const state = await this.state(id);
|
|
666
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
|
|
667
|
+
const store = await this.profileStore(state);
|
|
668
|
+
const selected = await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, allowSelection: true });
|
|
669
|
+
return { branches: selected.branches, base_branch: teamBaseBranch(store.team), default_branch: selected.base_branch, version: store.registry.latest.version };
|
|
670
|
+
}
|
|
671
|
+
async setRepositoryPolicy({ id, expected_version, base_branch }) {
|
|
672
|
+
const state = await this.state(id);
|
|
673
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
|
|
674
|
+
if (!Number.isSafeInteger(expected_version) || expected_version < 1 || typeof base_branch !== 'string' || !base_branch) throw new SetupError('请重新读取团队基准分支后再保存。');
|
|
675
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
676
|
+
await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, baseBranch: base_branch });
|
|
677
|
+
await store.registryTransaction(async (history) => {
|
|
678
|
+
if (history.latest.team.agents[state.agent_id]?.role !== 'admin') throw new SetupError('需要管理员权限。');
|
|
679
|
+
if (history.latest.version !== expected_version) throw new SetupError('团队规则已更新,请重新读取基准分支后再保存。');
|
|
680
|
+
const team = { ...structuredClone(history.latest.team), base_branch };
|
|
681
|
+
validateTeam(team);
|
|
682
|
+
if (canonical(team) === canonical(history.latest.team)) return { value: history.latest.version };
|
|
683
|
+
const version = policyVersion(team, history.latest, state.agent_id, identity.private_key);
|
|
684
|
+
return { files: [[versionFile(version), version]], value: version.version };
|
|
685
|
+
});
|
|
686
|
+
const { config } = await readPeerConfig(state.existing_config || join(this.path(id), 'peer.json'));
|
|
687
|
+
await savePrivate(config.team_file, store.team);
|
|
688
|
+
return this.detail(id);
|
|
689
|
+
}
|
|
677
690
|
async setReviewRules({ id, expected_version, review_rules }) {
|
|
678
691
|
const state = await this.state(id);
|
|
679
692
|
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置审核规则。');
|
|
@@ -697,8 +710,12 @@ export class SetupManager {
|
|
|
697
710
|
const state = await this.state(id); const result = { id, agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
698
711
|
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
699
712
|
expires_at: state.bundle.invitation.expires_at };
|
|
700
|
-
|
|
701
|
-
|
|
713
|
+
let synchronized;
|
|
714
|
+
if (state.phase === 'ready' && !localOnly) {
|
|
715
|
+
synchronized = await this.profileStore(state); result.base_branch = teamBaseBranch(synchronized.team); result.remote_name = synchronized.remoteName;
|
|
716
|
+
}
|
|
717
|
+
if (state.role === 'admin' && synchronized) {
|
|
718
|
+
const store = synchronized; const owners = new Map();
|
|
702
719
|
result.members = Object.entries(store.team.agents).map(([id, agent]) => {
|
|
703
720
|
if (agent.wecom_userid) {
|
|
704
721
|
const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
|
|
@@ -706,7 +723,7 @@ export class SetupManager {
|
|
|
706
723
|
return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
|
|
707
724
|
});
|
|
708
725
|
result.owners = [...owners].map(([userid, agent_ids]) => ({ agent_ids, is_you: userid === store.team.agents[state.agent_id]?.wecom_userid }));
|
|
709
|
-
result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
|
|
726
|
+
result.base_branch = teamBaseBranch(store.team); result.remote_name = store.remoteName; result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
|
|
710
727
|
const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
711
728
|
for (const file of files) {
|
|
712
729
|
try {
|
|
@@ -729,7 +746,8 @@ export class SetupManager {
|
|
|
729
746
|
}
|
|
730
747
|
if (state.phase === 'ready') {
|
|
731
748
|
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo); result.detailed_binding_prompt = detailedBindingPrompt(state.agent_id, state.repo);
|
|
732
|
-
const config =
|
|
749
|
+
const { config, team: cachedTeam } = await readPeerConfig(result.config_path);
|
|
750
|
+
result.base_branch ??= teamBaseBranch(cachedTeam); result.remote_name ??= config.remote_name || state.remote_name;
|
|
733
751
|
const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
|
|
734
752
|
const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
735
753
|
const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
@@ -779,7 +797,7 @@ export class SetupManager {
|
|
|
779
797
|
add('binding', '目标 Codex 对话', !!value.desktop.binding, '复制启用指令,发送到你选定的 Codex 对话。');
|
|
780
798
|
add('poll', '对话实际轮询', value.poll_recent, '在绑定对话中执行启用指令,并确认该对话的协作自动化已启用。');
|
|
781
799
|
try { await diagnosePeer(value.config_path, { testPush: true }); add('git', 'Git 读取与推送权限', true); }
|
|
782
|
-
catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git
|
|
800
|
+
catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git 登录,确认所选远程和团队基准分支可读,并具有协作分支推送权限。'); }
|
|
783
801
|
}
|
|
784
802
|
const startup = await startupStatus(this.home);
|
|
785
803
|
return { id, checks, ready: value.can_receive_tasks === true, startup, heartbeat_confirmed: false,
|
|
@@ -41,7 +41,7 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
|
|
|
41
41
|
const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
|
|
42
42
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
|
|
43
43
|
}
|
|
44
|
-
const actions = { diagnostics: () => manager.diagnostics(body), repair: () => manager.repair(body), 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), 'reissue-invite': () => manager.reissueBundle(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
|
|
44
|
+
const actions = { 'repository-settings': () => manager.repositorySettings(body), 'repository-policy': () => manager.setRepositoryPolicy(body), diagnostics: () => manager.diagnostics(body), repair: () => manager.repair(body), 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), 'reissue-invite': () => manager.reissueBundle(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
|
|
45
45
|
const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
|
|
46
46
|
if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
|
|
47
47
|
return json(404, { error: '操作不存在。' });
|
|
@@ -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 {} }
|
|
@@ -114,6 +115,7 @@ function watchProfile(id, epoch) {
|
|
|
114
115
|
timer = setTimeout(tick, 5000);
|
|
115
116
|
}
|
|
116
117
|
async function loadProfile(id) {
|
|
118
|
+
repositoryPolicy = null; $('repository-policy-form').hidden = true;
|
|
117
119
|
currentId = id; remember({ profile_id: id }); show('profile'); const epoch = viewEpoch; let value;
|
|
118
120
|
try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
|
|
119
121
|
if (epoch !== viewEpoch) return;
|
|
@@ -123,7 +125,7 @@ async function loadProfile(id) {
|
|
|
123
125
|
function renderProfile(value, automatic = false) {
|
|
124
126
|
const id = value.id; renderSteps(value);
|
|
125
127
|
$('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
|
|
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
|
|
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}` : ''}`;
|
|
127
129
|
$('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
|
|
128
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';
|
|
129
131
|
if (value.phase === 'ready') {
|
|
@@ -201,21 +203,54 @@ for (const button of document.querySelectorAll('[data-view]')) button.onclick =
|
|
|
201
203
|
function repoMode(prefix) {
|
|
202
204
|
const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
|
|
203
205
|
}
|
|
204
|
-
for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange = () =>
|
|
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
|
+
}
|
|
205
214
|
async function checkProject() {
|
|
206
215
|
const snapshot = values($('admin-form'));
|
|
207
216
|
const selected = await api('project-check', snapshot);
|
|
208
217
|
const current = values($('admin-form'));
|
|
209
|
-
if (['repo_mode', 'repo', 'remote', 'destination'].some((key) => current[key] !== snapshot[key])) return;
|
|
218
|
+
if (['repo_mode', 'repo', 'remote', 'destination', 'remote_name', 'base_branch'].some((key) => current[key] !== snapshot[key])) return;
|
|
210
219
|
const form = $('admin-form'); form.elements.repo.value = selected.repo; $('admin-repo-mode').value = 'existing'; repoMode('admin');
|
|
211
|
-
$('
|
|
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(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
|
|
212
231
|
if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
|
|
213
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;
|
|
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
|
+
}); };
|
|
214
246
|
$('check-project').onclick = () => action(checkProject);
|
|
215
247
|
for (const id of ['admin-repo', 'admin-remote', 'admin-destination']) $(id).onchange = () => {
|
|
216
248
|
const form = $('admin-form');
|
|
249
|
+
clearProjectSelection();
|
|
217
250
|
if ($('admin-repo-mode').value === 'clone' ? form.elements.remote.value && form.elements.destination.value : form.elements.repo.value) action(checkProject);
|
|
218
251
|
};
|
|
252
|
+
$('admin-remote-name').onchange = () => { $('admin-base-branch').value = ''; action(checkProject); };
|
|
253
|
+
$('admin-base-branch').onchange = () => action(checkProject);
|
|
219
254
|
$('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
220
255
|
const input = values(event.target);
|
|
221
256
|
const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
|
|
@@ -229,7 +264,7 @@ async function inspectInvite() {
|
|
|
229
264
|
const value = await api('inspect-invite', input);
|
|
230
265
|
if ($('join-form').elements.password.value !== input.password || $('join-form').elements.file.files?.[0] !== file) return;
|
|
231
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 : ''}`;
|
|
232
|
-
$('invite-remote').textContent = value.remote
|
|
267
|
+
$('invite-remote').textContent = `${value.remote}${value.base_branch ? ` · 基准分支:${value.base_branch}(自动继承)` : ''}`;
|
|
233
268
|
if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
|
|
234
269
|
}
|
|
235
270
|
$('inspect-invite').onclick = () => action(inspectInvite);
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
<fieldset><legend>选择业务项目</legend><label>项目来源<select id="admin-repo-mode" name="repo_mode"><option value="existing">使用本机已有项目</option><option value="clone">从 Git 地址拉取新项目</option></select></label>
|
|
9
9
|
<label id="admin-existing-repo">项目目录<input name="repo" id="admin-repo" placeholder="Git 仓库根目录的完整路径"></label>
|
|
10
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"
|
|
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>
|
|
12
13
|
<label>验证命令<input id="test-command" name="test_command" required placeholder="例如 npm test、go test ./...、sh test.sh"></label><p class="muted">请确认这个命令适合项目。工具使用固定程序和参数执行,不运行管道或拼接命令。</p></fieldset>
|
|
13
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>
|
|
14
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>
|
|
@@ -27,7 +28,7 @@
|
|
|
27
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>
|
|
28
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>
|
|
29
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>
|
|
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>
|
|
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>
|
|
31
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>
|
|
32
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>
|
|
33
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>
|
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;
|
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/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');
|