xuym-codex-collab 0.5.0 → 0.7.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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 安装与入口
6
6
 
7
- 需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有 `main` 分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
7
+ 需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有至少一个远程业务分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
8
8
 
9
9
  ```sh
10
10
  npm install --global xuym-codex-collab
@@ -20,7 +20,11 @@ codex-collab
20
20
 
21
21
  管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员;批量分配自动生成密码,可在“已分配的机器人”查看并单独复制。接入说明不含密码和 Secret。显示名称支持中文,职责可从前端、后端、全栈、测试等预设中选择;内部 ID 和自定义标签放在高级选项中。
22
22
 
23
- 同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
23
+ 协作远程与业务基准分支自动识别;不要求 `origin/main`。有多个不同远程或默认分支不明确时,管理员选择一次。成员与新增机器人继承签名团队基准,各自可使用不同远程别名。每个新任务固定创建时的 commit,排队和重试期间不会漂移;管理员可在团队设置里为后续任务修改基准分支。
24
+
25
+ 工作台首页按项目汇总,同一仓库的多个本机机器人共用一张项目卡片。打开项目后,所有团队机器人展示在同一列表里,自己的标记“我的”;本机已接入的机器人可继续配置或管理,其他机器人的职责来自签名团队记录。界面使用轻量 CSS 动效,支持系统减少动态效果设置,页面进入后台时暂停动画。
26
+
27
+ 同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“添加我的机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
24
28
 
25
29
  机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
26
30
 
@@ -51,7 +55,7 @@ codex-collab autostart status
51
55
 
52
56
  `doctor` 检查项目 MCP/Skill、机器人进程与认证、对话绑定、真实轮询以及 Git 读取和推送权限,并给出下一步。页面也提供“检查整条连接”和“修复本机配置”。修复更新已识别的生成配置,并恢复缺失的 MCP 配置和 Skill;自定义、符号链接或已提交的共享配置需要手动合并。修复后请在 Codex 重新加载项目 MCP,在原对话执行启用指令。
53
57
 
54
- 升级执行 `npm install -g xuym-codex-collab@latest`,然后重新打开工作台并修复项目配置。已经运行的 Node 进程需要重启才能加载新代码:停止并重新启动机器人;后台工作台可等下次登录,或终止 `codex-collab service-status` 显示的工作台 PID 后重新执行 `codex-collab`。0.5.0 迁移时只接管仍在运行的旧连接,已停止的旧连接需要你主动启动。
58
+ 升级执行 `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
59
 
56
60
  `codex-collab autostart off` 关闭登录自启,`autostart on` 重新设置。`setup --no-startup` 仅跳过本次自启配置,`setup --foreground` 以前台模式运行,便于排查。Windows 支持独立后台工作台,暂未自动配置登录启动。电脑休眠、关机或退出系统期间不会执行群任务;Codex 对话的自动化仍需保持启用。
57
61
 
@@ -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 function remoteIdentity(remote) {
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 && remoteIdentity(await git(actual, 'remote', 'get-url', 'origin')) === remoteIdentity(remote)) return 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,5 +1,7 @@
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';
4
+ import { groupProjects, projectId, projectView } from './projects.mjs';
3
5
  import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
4
6
  import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
5
7
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
@@ -53,9 +55,8 @@ function gitFailure(error) {
53
55
  if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
54
56
  return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
55
57
  }
56
- return new SetupError('Git 无法读取远程仓库,请检查 origin 地址、Git 权限和网络后重试。');
58
+ return new SetupError('Git 无法读取远程仓库,请检查所选远程地址、Git 权限和网络后重试。');
57
59
  }
58
- function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
59
60
  function mcpSection(text, serverName) {
60
61
  let active = false; const lines = [];
61
62
  for (const line of String(text || '').split(/\r?\n/)) {
@@ -74,7 +75,7 @@ function projectMcpConfigured(projectConfig, configPath, config) {
74
75
  const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
75
76
  return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
76
77
  }
77
- async function ensureRepo(path) {
78
+ async function ensureRepo(path, options = {}) {
78
79
  if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
79
80
  let repo;
80
81
  try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
@@ -84,25 +85,10 @@ async function ensureRepo(path) {
84
85
  if (error instanceof SetupError) throw error;
85
86
  throw new SetupError('请选择 Git 仓库根目录。');
86
87
  }
87
- let remote;
88
- try { remote = await git(repo, 'remote', 'get-url', 'origin'); }
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 };
88
+ try { return { repo, ...await (options.remoteOnly ? resolveRemote(repo, options) : inspectRepository(repo, options)) }; }
89
+ catch (error) { if (error instanceof RepositoryError) throw new SetupError(error.message); throw gitFailure(error); }
105
90
  }
91
+
106
92
  function validateBundle(value) {
107
93
  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
94
  const history = validateHistory(value.history, value.root_hash);
@@ -130,15 +116,21 @@ export class SetupManager {
130
116
  async profileStore(state, { enrollmentOnly = false } = {}) {
131
117
  const identity = await readPrivateJson(state.identity_path);
132
118
  const initial = state.bootstrap_team;
133
- return new PeerGitStore({ repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
119
+ 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
120
  agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
135
121
  }
122
+ async localStates() {
123
+ await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
124
+ const ids = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id)).sort();
125
+ return Promise.all(ids.map((id) => this.state(id)));
126
+ }
127
+ project(id, options) { return projectView(this, id, options); }
136
128
  async overview() {
137
129
  await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
138
- const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
130
+ const states = await this.localStates();
139
131
  const profiles = [], removed_profiles = [];
140
- for (const id of entries) {
141
- const state = await this.state(id);
132
+ for (const state of states) {
133
+ const id = state.id;
142
134
  (state.phase === 'removed' ? removed_profiles : profiles).push({ id, agent_id: state.agent_id, role: state.role, display_name: state.display_name || state.agent_id, repo: state.repo, phase: state.phase, error: state.last_error || null });
143
135
  }
144
136
  const drafts = [];
@@ -148,7 +140,7 @@ export class SetupManager {
148
140
  if (!draft.profile_id) drafts.push({ id: draft.id, agent_id: draft.input.agent_id, display_name: draft.input.display_name, repo: draft.input.repo });
149
141
  }
150
142
  let context = {}; try { context = await readPrivateJson(join(this.home, 'context.json')); } catch {}
151
- return { profiles, removed_profiles, drafts, defaults: await localDefaults(context.cwd || this.home), startup: await startupStatus(this.home) };
143
+ return { projects: groupProjects(states), profiles, removed_profiles, drafts, defaults: await localDefaults(context.cwd || this.home), startup: await startupStatus(this.home) };
152
144
  }
153
145
  async checkBotAvailable(botId) {
154
146
  for (const profile of (await this.overview()).profiles) {
@@ -167,7 +159,7 @@ export class SetupManager {
167
159
  }
168
160
  await this.checkBotAvailable(credentials.bot_id);
169
161
  if (input.repo) {
170
- const { repo } = await ensureRepo(input.repo);
162
+ const { repo } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, remoteOnly: true });
171
163
  const toml = await readFile(join(repo, '.codex/config.toml'), 'utf8').catch(() => '');
172
164
  for (const match of toml.matchAll(/"((?:[^"\\]|\\.)*\.json)"/g)) {
173
165
  try {
@@ -184,7 +176,7 @@ export class SetupManager {
184
176
  async prepareProject(input) {
185
177
  const path = input.repo_mode === 'clone'
186
178
  ? 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);
179
+ const selected = await ensureRepo(path, { remoteName: input.remote_name, baseBranch: input.base_branch, allowSelection: true });
188
180
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
189
181
  return { ...selected, tests: await recommendTests(selected.repo) };
190
182
  }
@@ -196,7 +188,8 @@ export class SetupManager {
196
188
  const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv);
197
189
  const selected = await this.prepareProject(input);
198
190
  for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
199
- const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, test_argv: argv, team_name: input.team_name?.trim() || null,
191
+ if (selected.selection_required) throw new SetupError('请先检查项目并选择协作远程和业务基准分支。');
192
+ 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
193
  bot_id: input.bot_id, secret: input.secret } };
201
194
  await savePrivate(this.draftPath(draft.id), draft);
202
195
  return this.adminDraft({ id: draft.id });
@@ -207,7 +200,7 @@ export class SetupManager {
207
200
  let session;
208
201
  if (draft.discovery_id && !reconnect) { try { session = this.discovery.view(draft.discovery_id); if (['failed', 'expired', 'disconnected'].includes(session.status)) session = null; } catch {} }
209
202
  if (!session) {
210
- session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo });
203
+ 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
204
  draft.discovery_id = session.id; await savePrivate(this.draftPath(id), draft);
212
205
  }
213
206
  return { ...session, draft_id: id, agent_id: draft.input.agent_id, repo: draft.input.repo };
@@ -228,18 +221,18 @@ export class SetupManager {
228
221
  async createAdmin(input, { onSaved } = {}) {
229
222
  const fields = memberFields(input), agentId = fields.agent_id;
230
223
  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);
224
+ const { repo, remote, remote_name, base_branch } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, baseBranch: input.base_branch });
232
225
  if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
233
226
  for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
234
227
  const proof = this.discovery.consume(input.discovery_id);
235
228
  const id = randomUUID(); const directory = this.path(id);
236
229
  const identity = await getOrCreateIdentityFiles(agentId, join(this.home, 'identities'));
237
- const team = buildTeamPolicy({ repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
230
+ const team = buildTeamPolicy({ baseBranch: base_branch, repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
238
231
  agents: [{ id: agentId, role: 'admin', capabilities: caps, publicKey: await readFile(identity.public_key_file, 'utf8'), wecomUserid: proof.userid }],
239
232
  routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
240
233
  team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
241
234
  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,
235
+ 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
236
  root_hash: recordHash(record), genesis: record, test_argv: argv,
244
237
  wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
245
238
  await this.save(state);
@@ -261,7 +254,7 @@ export class SetupManager {
261
254
  const id = randomUUID(); const identityPath = join(this.path(id), 'identity.json');
262
255
  await savePrivate(identityPath, identity);
263
256
  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();
257
+ 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
258
  if (!rootHash) {
266
259
  const firstPath = join(store.checkout, '.agent-collab/team/versions/000001.json');
267
260
  if (await exists(firstPath)) {
@@ -282,7 +275,7 @@ export class SetupManager {
282
275
  }
283
276
  }
284
277
  const state = { id, role: team.agents[config.agent_id].role, agent_id: config.agent_id, repo: config.repo,
285
- remote: await git(config.repo, 'remote', 'get-url', 'origin'), identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
278
+ remote: store.remoteUrl, remote_name: store.remoteName, identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
286
279
  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
280
  await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
288
281
  // Existing MCP processes keep using this path and the same private desktop state.
@@ -306,7 +299,7 @@ export class SetupManager {
306
299
  if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
307
300
  try {
308
301
  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,
302
+ 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
303
  privateKey: (await readPrivateJson(state.identity_path)).private_key });
311
304
  await bootstrap.init();
312
305
  await bootstrap.registryTransaction(async () => {
@@ -358,7 +351,7 @@ export class SetupManager {
358
351
  const teamPath = join(this.path(id), 'team.json'); await savePrivate(teamPath, store.team);
359
352
  const configPath = join(this.path(id), 'peer.json');
360
353
  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 });
354
+ dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv, remoteName: state.remote_name, remoteUrl: state.remote });
362
355
  const config = await readPrivateJson(configPath);
363
356
  await savePrivate(configPath, { ...config, team_registry_root: state.root_hash, wecom: state.wecom });
364
357
  await diagnosePeer(configPath, { testPush: true });
@@ -397,7 +390,7 @@ export class SetupManager {
397
390
  async inspectBundle(input) {
398
391
  const bundle = this.readBundle(input), invite = bundle.invitation;
399
392
  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) };
393
+ 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
394
  }
402
395
  async importBundle(input) {
403
396
  const bundle = this.readBundle(input);
@@ -412,9 +405,9 @@ export class SetupManager {
412
405
  }
413
406
  async importAssignment(value, repo, ownAdminId) {
414
407
  const { bundle, history } = validateBundle(value);
415
- const selected = await ensureRepo(repo);
408
+ const selected = await ensureRepo(repo, { expectedRemote: bundle.remote, remoteOnly: true });
416
409
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
417
- if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
410
+ if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的远程与邀请仓库不一致,请选择正确仓库;工具不会改写远程配置。');
418
411
  let recovered;
419
412
  for (const profile of (await this.overview()).profiles) {
420
413
  const current = await this.state(profile.id);
@@ -433,7 +426,7 @@ export class SetupManager {
433
426
  recovered = current;
434
427
  }
435
428
  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,
429
+ 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
430
  bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
438
431
  ...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
439
432
  await this.save(state); return this.detail(id, { localOnly: true });
@@ -631,7 +624,8 @@ export class SetupManager {
631
624
  if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
632
625
  if (!idPattern.test(input.agent_id?.trim() || '')) throw new SetupError('请填写新 Agent 名称。');
633
626
  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 selected = await ensureRepo(repo);
627
+ const team = (await this.profileStore(state)).team;
628
+ const selected = await ensureRepo(repo, { expectedRemote: state.remote, baseBranch: teamBaseBranch(team) });
635
629
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
636
630
  if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
637
631
  const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
@@ -674,6 +668,32 @@ export class SetupManager {
674
668
  await savePrivate(config.team_file, store.team);
675
669
  return this.detail(id);
676
670
  }
671
+ async repositorySettings({ id }) {
672
+ const state = await this.state(id);
673
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
674
+ const store = await this.profileStore(state);
675
+ const selected = await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, allowSelection: true });
676
+ return { branches: selected.branches, base_branch: teamBaseBranch(store.team), default_branch: selected.base_branch, version: store.registry.latest.version };
677
+ }
678
+ async setRepositoryPolicy({ id, expected_version, base_branch }) {
679
+ const state = await this.state(id);
680
+ if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
681
+ if (!Number.isSafeInteger(expected_version) || expected_version < 1 || typeof base_branch !== 'string' || !base_branch) throw new SetupError('请重新读取团队基准分支后再保存。');
682
+ const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
683
+ await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, baseBranch: base_branch });
684
+ await store.registryTransaction(async (history) => {
685
+ if (history.latest.team.agents[state.agent_id]?.role !== 'admin') throw new SetupError('需要管理员权限。');
686
+ if (history.latest.version !== expected_version) throw new SetupError('团队规则已更新,请重新读取基准分支后再保存。');
687
+ const team = { ...structuredClone(history.latest.team), base_branch };
688
+ validateTeam(team);
689
+ if (canonical(team) === canonical(history.latest.team)) return { value: history.latest.version };
690
+ const version = policyVersion(team, history.latest, state.agent_id, identity.private_key);
691
+ return { files: [[versionFile(version), version]], value: version.version };
692
+ });
693
+ const { config } = await readPeerConfig(state.existing_config || join(this.path(id), 'peer.json'));
694
+ await savePrivate(config.team_file, store.team);
695
+ return this.detail(id);
696
+ }
677
697
  async setReviewRules({ id, expected_version, review_rules }) {
678
698
  const state = await this.state(id);
679
699
  if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置审核规则。');
@@ -694,11 +714,15 @@ export class SetupManager {
694
714
  return this.detail(id);
695
715
  }
696
716
  async detail(id, { localOnly = false } = {}) {
697
- 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 };
717
+ const state = await this.state(id); const result = { id, project_id: projectId(state), 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
718
  if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
699
719
  expires_at: state.bundle.invitation.expires_at };
700
- if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
701
- const store = await this.profileStore(state); const owners = new Map();
720
+ let synchronized;
721
+ if (state.phase === 'ready' && !localOnly) {
722
+ synchronized = await this.profileStore(state); result.base_branch = teamBaseBranch(synchronized.team); result.remote_name = synchronized.remoteName;
723
+ }
724
+ if (state.role === 'admin' && synchronized) {
725
+ const store = synchronized; const owners = new Map();
702
726
  result.members = Object.entries(store.team.agents).map(([id, agent]) => {
703
727
  if (agent.wecom_userid) {
704
728
  const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
@@ -706,7 +730,7 @@ export class SetupManager {
706
730
  return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
707
731
  });
708
732
  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 = [];
733
+ 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
734
  const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
711
735
  for (const file of files) {
712
736
  try {
@@ -729,7 +753,8 @@ export class SetupManager {
729
753
  }
730
754
  if (state.phase === 'ready') {
731
755
  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 = (await readPeerConfig(result.config_path)).config;
756
+ const { config, team: cachedTeam } = await readPeerConfig(result.config_path);
757
+ result.base_branch ??= teamBaseBranch(cachedTeam); result.remote_name ??= config.remote_name || state.remote_name;
733
758
  const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
734
759
  const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
735
760
  const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
@@ -779,7 +804,7 @@ export class SetupManager {
779
804
  add('binding', '目标 Codex 对话', !!value.desktop.binding, '复制启用指令,发送到你选定的 Codex 对话。');
780
805
  add('poll', '对话实际轮询', value.poll_recent, '在绑定对话中执行启用指令,并确认该对话的协作自动化已启用。');
781
806
  try { await diagnosePeer(value.config_path, { testPush: true }); add('git', 'Git 读取与推送权限', true); }
782
- catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git 登录,确认 origin/main 可读并具有推送权限。'); }
807
+ catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git 登录,确认所选远程和团队基准分支可读,并具有协作分支推送权限。'); }
783
808
  }
784
809
  const startup = await startupStatus(this.home);
785
810
  return { id, checks, ready: value.can_receive_tasks === true, startup, heartbeat_confirmed: false,
@@ -0,0 +1,99 @@
1
+ import { createPublicKey } from 'node:crypto';
2
+ import { basename, join } from 'node:path';
3
+ import { digest, readPrivateJson } from '../../packages/peer/config.mjs';
4
+ import { remoteIdentity, teamBaseBranch } from '../../packages/peer/repository.mjs';
5
+ import { readRegistry, validateHistory } from '../../packages/peer/registry.mjs';
6
+ import { SetupError } from './errors.mjs';
7
+
8
+ export function projectId(state) {
9
+ return digest(state.remote && state.root_hash
10
+ ? { repository: remoteIdentity(state.remote), root: state.root_hash }
11
+ : { profile: state.id });
12
+ }
13
+ export function groupProjects(states) {
14
+ const groups = new Map();
15
+ for (const state of states) {
16
+ if (state.phase === 'removed') continue;
17
+ const id = projectId(state);
18
+ if (!groups.has(id)) {
19
+ const repository = state.remote ? remoteIdentity(state.remote) : '';
20
+ groups.set(id, { id, name: basename(repository || state.repo || '').replace(/\.git$/, '') || '协作项目', repository, profile_ids: [], local_agent_count: 0 });
21
+ }
22
+ groups.get(id).profile_ids.push(state.id);
23
+ }
24
+ for (const group of groups.values()) {
25
+ group.profile_ids.sort();
26
+ group.local_agent_count = new Set(states.filter((state) => group.profile_ids.includes(state.id)).map((state) => state.agent_id)).size;
27
+ }
28
+ return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
29
+ }
30
+
31
+ // Project navigation aggregates local connections. It never creates a new team or
32
+ // changes the authorization of the existing per-agent operations.
33
+ export async function projectView(manager, id, { localOnly = false } = {}) {
34
+ const states = await manager.localStates();
35
+ const project = groupProjects(states).find((value) => value.id === id);
36
+ if (!project) throw new SetupError('未找到这个项目,请从“我的项目”重新打开。');
37
+ const local = states.filter((state) => project.profile_ids.includes(state.id));
38
+ const candidates = local.filter((state) => state.phase === 'ready').sort((a, b) => (a.role === 'admin' ? 0 : 1) - (b.role === 'admin' ? 0 : 1));
39
+ let team, synced = false;
40
+ if (!localOnly) {
41
+ for (const state of candidates) {
42
+ try { team = (await manager.profileStore(state)).team; synced = true; break; }
43
+ catch { /* Try another local connection without exposing Git/SDK errors. */ }
44
+ }
45
+ }
46
+ if (!team) {
47
+ let latest;
48
+ const use = (history) => { if (history && (!latest || history.latest.version > latest.version)) latest = history.latest; };
49
+ for (const state of [...candidates, ...local.filter((value) => value.phase !== 'ready')]) {
50
+ try {
51
+ const registry = await readRegistry(join(state.data_dir || join(manager.path(state.id), 'data'), 'peer-ledger'), state.root_hash);
52
+ use(registry);
53
+ } catch { /* The next source must also pass signature verification. */ }
54
+ try {
55
+ const history = state.bundle?.history || (state.genesis ? [state.genesis] : null);
56
+ if (history) use(validateHistory(history, state.root_hash));
57
+ } catch { /* Unverified membership never reaches the project roster. */ }
58
+ }
59
+ team = latest?.team;
60
+ }
61
+ const identities = new Map();
62
+ for (const state of local) {
63
+ try {
64
+ const identity = await readPrivateJson(state.identity_path);
65
+ identities.set(state.id, createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }));
66
+ } catch { /* Missing local identity is visible as a connection problem. */ }
67
+ }
68
+ const matching = (state, agent) => identities.get(state.id) && identities.get(state.id) === agent.public_key;
69
+ const owners = new Set(local.flatMap((state) => {
70
+ const agent = team?.agents[state.agent_id];
71
+ return agent && matching(state, agent) && agent.wecom_userid ? [agent.wecom_userid] : [];
72
+ }));
73
+ const connections = new Map();
74
+ for (const state of local) {
75
+ let detail;
76
+ try { detail = await manager.detail(state.id, { localOnly: true }); } catch { /* Keep broken local connections recoverable. */ }
77
+ connections.set(state.id, { id: state.id, repo: state.repo, phase: state.phase, display_name: state.display_name || state.agent_id,
78
+ has_error: !!state.last_error || state.phase === 'ready' && !detail,
79
+ can_receive_tasks: detail?.can_receive_tasks === true, runner_online: detail?.runner?.online === true,
80
+ bound: !!detail?.desktop?.binding, poll_recent: detail?.poll_recent === true });
81
+ }
82
+ const included = new Set();
83
+ const agents = Object.entries(team?.agents || {}).map(([agentId, agent]) => {
84
+ const own = local.filter((state) => state.agent_id === agentId && matching(state, agent));
85
+ for (const state of own) included.add(state.id);
86
+ return { agent_id: agentId, role: agent.role, capabilities: agent.capabilities, mine: own.length > 0 || !!agent.wecom_userid && owners.has(agent.wecom_userid),
87
+ registered: true, connections: own.map((state) => connections.get(state.id)) };
88
+ });
89
+ for (const state of local) {
90
+ if (included.has(state.id)) continue;
91
+ agents.push({ agent_id: state.agent_id, role: state.role, capabilities: [], mine: true, registered: false,
92
+ identity_conflict: !!team?.agents[state.agent_id], connections: [connections.get(state.id)] });
93
+ }
94
+ agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id));
95
+ const admin = candidates.find((state) => team?.agents[state.agent_id]?.role === 'admin' && matching(state, team.agents[state.agent_id]));
96
+ return { ...project, agents, admin_profile_id: admin?.id || null, base_branch: team ? teamBaseBranch(team) : null,
97
+ sync_status: synced ? 'synced' : team ? 'cached' : 'pending',
98
+ sync_error: !localOnly && !synced && candidates.length > 0 ? '团队同步暂未成功,正在显示本机已验证的记录。请检查 Git 连接后刷新。' : null };
99
+ }
@@ -36,12 +36,13 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
36
36
  if (req.method === 'GET' && url.pathname === '/api/overview') return json(200, await manager.overview());
37
37
  if (req.method === 'GET' && url.pathname === '/api/admin-draft') return json(200, await manager.exclusive(() => manager.adminDraft({ id: url.searchParams.get('id') })));
38
38
  if (req.method === 'GET' && url.pathname === '/api/discovery') return json(200, manager.discovery.view(url.searchParams.get('id')));
39
+ if (req.method === 'GET' && url.pathname === '/api/project') return json(200, await manager.exclusive(() => manager.project(url.searchParams.get('id'), { localOnly: url.searchParams.get('local') === '1' })));
39
40
  if (req.method === 'GET' && url.pathname === '/api/profile') return json(200, await manager.exclusive(() => manager.detail(url.searchParams.get('id'), { localOnly: url.searchParams.get('local') === '1' })));
40
41
  if (req.method === 'GET' && url.pathname === '/api/export') {
41
42
  const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
42
43
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
43
44
  }
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) };
45
+ 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
46
  const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
46
47
  if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
47
48
  return json(404, { error: '操作不存在。' });