xuym-codex-collab 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +23 -2
  2. package/apps/onboarding/bundle.mjs +1 -6
  3. package/apps/onboarding/commands.mjs +46 -0
  4. package/apps/onboarding/guided.mjs +3 -2
  5. package/apps/onboarding/main.mjs +72 -19
  6. package/apps/onboarding/manager.mjs +116 -55
  7. package/apps/onboarding/reset.mjs +3 -1
  8. package/apps/onboarding/server.mjs +1 -1
  9. package/apps/onboarding/startup.mjs +61 -0
  10. package/apps/onboarding/supervisor.mjs +41 -0
  11. package/apps/onboarding/web/app.js +78 -13
  12. package/apps/onboarding/web/index.html +17 -17
  13. package/apps/onboarding/web/style.css +3 -0
  14. package/apps/onboarding/workspace.mjs +36 -0
  15. package/apps/peer/desktop.mjs +4 -3
  16. package/apps/peer/intent.mjs +19 -0
  17. package/apps/peer/mcp.mjs +1 -1
  18. package/apps/peer/process.mjs +13 -0
  19. package/apps/peer/runner.mjs +4 -3
  20. package/apps/peer/runtime.mjs +6 -1
  21. package/apps/peer/wecom.mjs +1 -1
  22. package/bin/codex-collab.mjs +21 -9
  23. package/docs/desktop-poll.md +2 -2
  24. package/docs/onboarding-audit.md +12 -2
  25. package/docs/onboarding.md +37 -9
  26. package/docs/peer.md +4 -2
  27. package/docs/releases/0.5.0.md +14 -0
  28. package/docs/team-registry.md +7 -1
  29. package/docs/terminal-onboarding.md +3 -3
  30. package/package.json +1 -1
  31. package/packages/codex-kit/templates/.agents/skills/team-collab/SKILL.md +2 -2
  32. package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +2 -2
  33. package/packages/peer/config.mjs +2 -0
  34. package/packages/peer/git.mjs +14 -0
  35. package/packages/peer/protocol.mjs +4 -2
  36. package/packages/peer/repository.mjs +67 -0
  37. package/packages/peer/service.mjs +11 -8
  38. package/packages/peer/store.mjs +16 -16
  39. package/schemas/peer-task.schema.json +15 -13
  40. package/schemas/team-policy.schema.json +12 -1
  41. package/scripts/peer/cli.mjs +14 -8
  42. package/scripts/peer/init-agent.mjs +6 -15
  43. package/scripts/peer/onboarding.mjs +26 -20
  44. package/scripts/peer/refresh-project.mjs +23 -15
@@ -5,10 +5,13 @@ import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
+ import { diagnosePeer } from './onboarding.mjs';
8
9
  import { loadRuntime, readPeerConfig } from '../../apps/peer/runtime.mjs';
9
- import { alive, readRunnerLock } from '../../apps/peer/process.mjs';
10
+ import { alive, readRunnerLock, acquireRunnerControl } from '../../apps/peer/process.mjs';
10
11
  import { git } from '../../packages/peer/store.mjs';
11
12
 
13
+ import { readRunnerIntent, writeRunnerIntent } from '../../apps/peer/intent.mjs';
14
+
12
15
  const root = fileURLToPath(new URL('../../', import.meta.url));
13
16
  const [command, configArg, ...args] = process.argv.slice(2);
14
17
  if (!command || !configArg) throw new Error('usage: node scripts/peer/cli.mjs install|doctor|probe|start|stop|status|list|read|review|complete|reopen|desktop-status|desktop-unbind /abs/peer.json [task-id] [decision/reason]');
@@ -20,6 +23,8 @@ async function running() {
20
23
  return state && alive(state.pid) ? state : null;
21
24
  }
22
25
 
26
+ const releaseControl = ['start', 'recover', 'stop'].includes(command) ? await acquireRunnerControl(config.data_dir, configPath) : null;
27
+ try {
23
28
  switch (command) {
24
29
  case 'probe': {
25
30
  try {
@@ -51,14 +56,12 @@ switch (command) {
51
56
  break;
52
57
  }
53
58
  case 'doctor': {
54
- const origin = await git(config.repo, 'remote', 'get-url', 'origin');
55
- const main = await git(config.repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main');
56
- if (!main) throw new Error('shared origin needs a main branch');
57
- execFileSync('git', ['-C', config.repo, 'check-ref-format', 'refs/heads/agent-collab'], { stdio: 'ignore' });
58
- console.log(JSON.stringify({ identity: config.agent_id, auto_execute: config.auto_execute !== false, execution_session: config.execution_mode === 'desktop_poll' ? 'bound desktop heartbeat; adapter never launches SDK (check desktop-status and app heartbeat separately)' : 'new Runner-owned SDK thread; not an existing desktop conversation', config: 'valid; private key matches policy', git: 'shared origin and main readable', origin_configured: !!origin, wecom: config.wecom ? 'configured; live authentication not checked' : 'disabled', codex_provider: config.execution_mode === 'desktop_poll' ? 'uses the existing desktop conversation model; no SDK provider is launched by this adapter' : 'not tested; uses selected CODEX_HOME/provider configuration', test_argv: config.test_argv || ['sh', 'test.sh'] }, null, 2));
59
+ console.log(JSON.stringify(await diagnosePeer(configPath), null, 2));
59
60
  break;
60
61
  }
61
- case 'start': {
62
+ case 'start': case 'recover': {
63
+ if (command === 'recover') { if (!(await readRunnerIntent(config.data_dir))?.enabled) break; }
64
+ else await writeRunnerIntent(config.data_dir, true);
62
65
  const current = await running();
63
66
  if (current) { console.log(`Runner already online (PID ${current.pid})`); break; }
64
67
  await mkdir(config.data_dir, { recursive: true, mode: 0o700 });
@@ -81,10 +84,11 @@ switch (command) {
81
84
  if (health.pid === state.pid && new Date(health.updated_at).getTime() >= new Date(state.started_at).getTime() && ['connecting', 'authenticated', 'reconnecting', 'disconnected', 'failed'].includes(health.status)) wecom = health.status;
82
85
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
83
86
  }
84
- console.log(JSON.stringify({ identity: config.agent_id, execution_mode: config.execution_mode || 'legacy', auto_execute: config.auto_execute !== false, online: !!state, wecom, pid: state?.pid || null, log: join(config.data_dir, 'runner.log') }, null, 2));
87
+ console.log(JSON.stringify({ identity: config.agent_id, execution_mode: config.execution_mode || 'legacy', auto_execute: config.auto_execute !== false, online: !!state, connection_enabled: (await readRunnerIntent(config.data_dir))?.enabled ?? !!state, wecom, pid: state?.pid || null, log: join(config.data_dir, 'runner.log') }, null, 2));
85
88
  break;
86
89
  }
87
90
  case 'stop': {
91
+ await writeRunnerIntent(config.data_dir, false);
88
92
  const state = await running();
89
93
  if (!state) { console.log('Runner offline'); break; }
90
94
  if (state.config !== configPath) throw new Error('PID configuration mismatch; refusing to signal');
@@ -114,3 +118,5 @@ switch (command) {
114
118
  }
115
119
  default: throw new Error('unknown peer command');
116
120
  }
121
+
122
+ } finally { await releaseControl?.(); }
@@ -1,15 +1,6 @@
1
- import { createPublicKey } from 'node:crypto';
2
- import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
3
- import { dirname, isAbsolute, resolve } from 'node:path';
4
- import { readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
5
- const [teamPath, identityPath, repo, dataDir, out] = process.argv.slice(2);
6
- if (![teamPath, identityPath, repo, dataDir, out].every((v) => v && isAbsolute(v))) throw new Error('usage: node scripts/peer/init-agent.mjs /abs/team.json /abs/bob.private.json /abs/clone /abs/data /abs/bob-peer.json');
7
- const team = validateTeam(JSON.parse(await readFile(resolve(teamPath), 'utf8')));
8
- const identity = await readPrivateJson(resolve(identityPath));
9
- if (!team.agents[identity.agent_id]) throw new Error('identity is not in team policy');
10
- const publicKey = createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' });
11
- if (publicKey !== team.agents[identity.agent_id].public_key) throw new Error('private identity does not match the public key pinned in team policy');
12
- const config = { schema_version: 2, agent_id: identity.agent_id, repo: resolve(repo), data_dir: resolve(dataDir), team_file: resolve(teamPath), private_key: identity.private_key, test_argv: ['sh', 'test.sh'] };
13
- await mkdir(dirname(resolve(out)), { recursive: true, mode: 0o700 });
14
- await writeFile(resolve(out), JSON.stringify(config, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await chmod(resolve(out), 0o600);
15
- console.log(`private peer config written: ${resolve(out)} (keep it local)`);
1
+ import { isAbsolute } from 'node:path';
2
+ import { createMemberConfig } from './onboarding.mjs';
3
+ const [teamPath, identityPath, repoPath, dataDir, configPath] = process.argv.slice(2);
4
+ if (![teamPath, identityPath, repoPath, dataDir, configPath].every((v) => v && isAbsolute(v))) throw new Error('usage: node scripts/peer/init-agent.mjs /abs/team.json /abs/bob.private.json /abs/clone /abs/data /abs/bob-peer.json');
5
+ await createMemberConfig({ teamPath, identityPath, repoPath, dataDir, configPath });
6
+ console.log(`private peer config written: ${configPath} (keep it local)`);
@@ -1,3 +1,4 @@
1
+ import { inspectRepository, resolveRemote, teamBaseBranch } from '../../packages/peer/repository.mjs';
1
2
  import { createPublicKey } from 'node:crypto';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { emitKeypressEvents } from 'node:readline';
@@ -62,10 +63,11 @@ export async function getOrCreateIdentityFiles(agentId, directory) {
62
63
  return { agent_id: agentId, private_key_file: privatePath, public_key_file: publicPath };
63
64
  }
64
65
 
65
- export function buildTeamPolicy({ repo, reviewer, defaultRoute, agents, routes, policy = {} }) {
66
+ export function buildTeamPolicy({ repo, reviewer, defaultRoute, agents, routes, policy = {}, baseBranch }) {
66
67
  const team = validateTeam({
67
68
  schema_version: 2,
68
69
  repo,
70
+ ...(baseBranch !== undefined ? { base_branch: baseBranch } : {}),
69
71
  reviewer,
70
72
  default_route: defaultRoute,
71
73
  agents: Object.fromEntries(agents.map((agent) => [agent.id, {
@@ -95,7 +97,7 @@ export async function writeTeamPolicy(path, team) {
95
97
  return path;
96
98
  }
97
99
 
98
- export async function createMemberConfig({ teamPath, identityPath, repoPath, dataDir, configPath, testArgv = ['sh', 'test.sh'] }) {
100
+ export async function createMemberConfig({ teamPath, identityPath, repoPath, dataDir, configPath, testArgv = ['sh', 'test.sh'], remoteName, remoteUrl }) {
99
101
  for (const path of [teamPath, identityPath, repoPath, dataDir, configPath]) {
100
102
  if (!path || !isAbsolute(path)) throw new Error('team, identity, repository, data and config paths must be absolute');
101
103
  }
@@ -109,13 +111,13 @@ export async function createMemberConfig({ teamPath, identityPath, repoPath, dat
109
111
  if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new Error('select the root directory of a Git project');
110
112
  const privatePaths = [dataDir, configPath, identityPath];
111
113
  if (privatePaths.some((path) => isWithin(repoPath, path) || isWithin(repo, path))) throw new Error('private identity, data and config files must stay outside the code repository');
112
- const origin = await git(repo, 'remote', 'get-url', 'origin');
113
- const main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main');
114
- if (!main) throw new Error('the shared origin must have a readable main branch');
114
+ const selected = await inspectRepository(repo, { remoteName, expectedRemote: remoteUrl, baseBranch: teamBaseBranch(team) });
115
115
  const config = {
116
116
  schema_version: 2,
117
117
  agent_id: identity.agent_id,
118
118
  repo,
119
+ remote_name: selected.remote_name,
120
+ remote_url: selected.remote,
119
121
  data_dir: dataDir,
120
122
  team_file: teamPath,
121
123
  private_key: identity.private_key,
@@ -133,29 +135,32 @@ export async function createMemberConfig({ teamPath, identityPath, repoPath, dat
133
135
  await rm(configPath, { force: true });
134
136
  throw error;
135
137
  }
136
- return { config, team, origin };
138
+ return { config, team, origin: selected.remote };
137
139
  }
138
140
 
139
141
  export async function diagnosePeer(configPath, { testPush = false } = {}) {
140
- const { config, team } = await readPeerConfig(configPath);
141
- const origin = await git(config.repo, 'remote', 'get-url', 'origin');
142
- const main = await git(config.repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main');
143
- if (!main) throw new Error('shared origin is missing refs/heads/main');
144
- const ledger = await git(config.repo, 'ls-remote', '--heads', 'origin', 'refs/heads/agent-collab');
142
+ const { config, team: cachedTeam } = await readPeerConfig(configPath);
143
+ const remote = await resolveRemote(config.repo, { remoteName: config.remote_name, expectedRemote: config.remote_url, legacyOrigin: !config.remote_name && !config.remote_url });
144
+ const ledger = await git(config.repo, 'ls-remote', '--heads', remote.remote_name, 'refs/heads/agent-collab');
145
+ if (config.team_registry_root && !ledger) throw new Error('signed team ledger is missing; restore agent-collab before continuing');
146
+ const team = config.team_registry_root ? (await loadRuntime(configPath)).store.team : cachedTeam;
147
+ const selected = await inspectRepository(config.repo, { remoteName: remote.remote_name, expectedRemote: remote.remote, baseBranch: teamBaseBranch(team) });
145
148
  let writeAccess = 'not checked';
146
149
  if (testPush) {
147
150
  const probe = `refs/heads/codex-collab-doctor/${process.pid}-${Date.now()}`;
148
- await git(config.repo, 'push', '--dry-run', 'origin', `HEAD:${probe}`);
151
+ await git(config.repo, 'push', '--dry-run', selected.remote_name, `HEAD:${probe}`);
149
152
  writeAccess = 'push dry-run passed; no remote ref was created';
150
153
  }
151
154
  return {
152
155
  agent_id: config.agent_id,
153
156
  mode: config.execution_mode,
154
- origin,
157
+ origin: selected.remote,
158
+ remote_name: selected.remote_name,
159
+ base_branch: selected.base_branch,
155
160
  team_repo: team.repo,
156
161
  checks: {
157
162
  identity: 'valid; private key matches team public key',
158
- repository: 'Git root with readable origin/main',
163
+ repository: `Git root with readable ${selected.remote_name}/${selected.base_branch}`,
159
164
  ledger: ledger ? 'agent-collab exists' : 'not initialized; first start may create it',
160
165
  push_access: writeAccess,
161
166
  wecom: config.wecom ? 'configured; authentication requires starting the adapter' : 'not configured',
@@ -305,8 +310,9 @@ async function createAdminTeam(prompts, output) {
305
310
  const reviewer = await prompts.ask('人工审核管理员 agent ID', id);
306
311
  const defaultRoute = await prompts.ask('默认任务能力标签', capabilities[0]);
307
312
  const repo = await prompts.required('共享代码仓库标识(例如 owner/repo)');
313
+ const baseBranch = await prompts.ask('业务基准分支(供所有成员继承;网页向导可自动识别)', 'main');
308
314
  const teamPath = resolve(await prompts.ask('团队策略文件路径', join(homedir(), 'codex-collab-team.json')));
309
- const team = buildTeamPolicy({ repo, reviewer, defaultRoute, agents, routes });
315
+ const team = buildTeamPolicy({ repo, reviewer, defaultRoute, agents, routes, baseBranch });
310
316
  await writeTeamPolicy(teamPath, team);
311
317
  output.write(`\n团队策略已创建:${teamPath}\n只分发此 team.json;各成员仍须在自己的电脑运行 setup 并使用自己的私钥。\n`);
312
318
  return { teamPath, identityPath: identityPaths.private_key_file, agentId: id };
@@ -335,7 +341,7 @@ async function configureWeCom(prompts, configPath, config, team, output) {
335
341
  }
336
342
 
337
343
  async function joinTeam(prompts, output, initial = {}) {
338
- output.write('\n成员接入:私钥留在本机;共享仓库必须允许读取 main,并具备推送协作分支的权限。\n\n');
344
+ output.write('\n成员接入:私钥留在本机;共享仓库必须允许读取团队基准分支,并具备推送协作分支的权限。\n\n');
339
345
  const teamPath = resolve(initial.teamPath || await prompts.required('管理员提供的 team.json 路径'));
340
346
  const identityPath = resolve(initial.identityPath || await prompts.required('本机私有身份文件路径'));
341
347
  const identity = await readPrivateJson(identityPath);
@@ -347,10 +353,10 @@ async function joinTeam(prompts, output, initial = {}) {
347
353
  let parsedArgv;
348
354
  try { parsedArgv = JSON.parse(rawArgv); }
349
355
  catch { throw new Error('固定验证命令必须是 JSON 字符串数组,例如 ["npm", "test"]'); }
350
- const { config, team, origin } = await createMemberConfig({ teamPath, identityPath, repoPath, dataDir, configPath, testArgv: parsedArgv });
351
- output.write(`\n身份 ${agentId} 已验证;共享 origin:${origin}\n执行模式:desktop_poll(不启动后台 Codex SDK)\n`);
356
+ const { config, team } = await createMemberConfig({ teamPath, identityPath, repoPath, dataDir, configPath, testArgv: parsedArgv });
357
+ output.write(`\n身份 ${agentId} 已验证;共享远程:${config.remote_name};基准分支:${teamBaseBranch(team)}\n执行模式:desktop_poll(不启动后台 Codex SDK)\n`);
352
358
 
353
- if (await prompts.confirm('把本项目接入 Codex MCP + Skill?(只写本机 project config,不改 origin)', true)) {
359
+ if (await prompts.confirm('把本项目接入 Codex MCP + Skill?(只写本机 project config)', true)) {
354
360
  const attached = await attachProject({ configPath, repoPath: config.repo });
355
361
  output.write(`MCP 配置:${attached.mcp_config}\nSkill:${attached.skill}\n`);
356
362
  }
@@ -361,7 +367,7 @@ async function joinTeam(prompts, output, initial = {}) {
361
367
  const diagnosis = await diagnosePeer(configPath, { testPush: true });
362
368
  output.write('\n连接检查:\n');
363
369
  for (const [name, result] of Object.entries(diagnosis.checks)) output.write(`- ${name}: ${result}\n`);
364
- if (await prompts.confirm('现在初始化/同步共享 agent-collab 账本分支?首次初始化会向 origin 推送该分支。', true)) {
370
+ if (await prompts.confirm('现在初始化/同步共享 agent-collab 账本分支?首次初始化会向所选协作远程推送该分支。', true)) {
365
371
  await loadRuntime(configPath);
366
372
  output.write('共享账本分支已就绪。\n');
367
373
  }
@@ -6,14 +6,15 @@ import { readPeerConfig } from '../../apps/peer/runtime.mjs';
6
6
  import { git } from '../../packages/peer/store.mjs';
7
7
  import { bindingPrompt } from './setup-desktop-project.mjs';
8
8
  const root = fileURLToPath(new URL('../../', import.meta.url));
9
- const legacyHashes = new Set(['46543ca4fe00836f0ae0d7d7c12ea2756c9ab0220f01d29c391d608c5e405c53', '6e4d1ea6a644fb3078c541e40bcc9e8bf5069462994152f3ec2fc3aa5fedd5fa', 'f98ad425e2ea3c8ab6fb86febef7d9cb288029ef55bea6937dda6de8b467fece']);
9
+ const legacyHashes = new Set(['f0f5f51ec7f2e0952d15e079d37e9f00b958ac7fda0d819c0a20e4ed620abaff', '46543ca4fe00836f0ae0d7d7c12ea2756c9ab0220f01d29c391d608c5e405c53', '6e4d1ea6a644fb3078c541e40bcc9e8bf5069462994152f3ec2fc3aa5fedd5fa', 'f98ad425e2ea3c8ab6fb86febef7d9cb288029ef55bea6937dda6de8b467fece']);
10
10
  const hash = (value) => createHash('sha256').update(value).digest('hex');
11
11
  const inside = (parent, child) => { const path = relative(parent, child); return !path || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
12
- async function plainPath(base, path) {
12
+ async function plainPath(base, path, { allowMissing = false } = {}) {
13
13
  let current = base;
14
14
  for (const part of relative(base, path).split('/').filter(Boolean)) {
15
15
  current = join(current, part);
16
- if ((await lstat(current)).isSymbolicLink()) throw new Error('协作配置或 Skill 是符号链接,请先手动检查;未覆盖文件。');
16
+ let info; try { info = await lstat(current); } catch (error) { if (allowMissing && error.code === 'ENOENT') return; throw error; }
17
+ if (info.isSymbolicLink()) throw new Error('协作配置或 Skill 是符号链接,请先手动检查;未覆盖文件。');
17
18
  }
18
19
  }
19
20
  async function privateWrite(path, contents) {
@@ -22,14 +23,17 @@ async function privateWrite(path, contents) {
22
23
  finally { await rm(temporary, { force: true }); }
23
24
  }
24
25
  // Only recognize our generated MCP entry. Never replace another identity's entry.
25
- export async function installedProjectConfig({ repoPath, agentId }) {
26
+ export async function installedProjectConfig({ repoPath, agentId, allowMissing = false }) {
26
27
  const repo = await realpath(repoPath);
27
- const path = join(repo, '.codex/config.toml'); await plainPath(repo, path);
28
- const before = await readFile(path, 'utf8');
28
+ const path = join(repo, '.codex/config.toml'); await plainPath(repo, path, { allowMissing });
29
+ const before = await readFile(path, 'utf8').catch((error) => { if (allowMissing && error.code === 'ENOENT') return null; throw error; });
29
30
  const server = `team-collab-${agentId}`;
30
- const headers = [...before.matchAll(/^\s*\[([^\]\n]+)\][^\n]*$/gm)];
31
+ const headers = [...(before || '').matchAll(/^\s*\[([^\]\n]+)\][^\n]*$/gm)];
31
32
  if (headers.some((item) => item[1].startsWith(`mcp_servers.${server}.`))) throw new Error('协作 MCP 有自定义嵌套配置,请手动检查;未覆盖文件。');
32
33
  const matching = headers.filter((item) => item[1] === `mcp_servers.${server}`);
34
+ // A missing block can be rebuilt from the private profile, but ambiguous or
35
+ // other identities' collaboration settings always require manual inspection.
36
+ if (!matching.length && allowMissing && !(before || '').includes('team-collab-')) return { repo, path, before, missing: true };
33
37
  if (matching.length !== 1) throw new Error('未找到唯一的已安装协作 MCP,请先完成项目接入。');
34
38
  const header = matching[0]; const end = headers.find((item) => item.index > header.index)?.index ?? before.length;
35
39
  const section = before.slice(header.index, end);
@@ -46,17 +50,19 @@ export async function refreshProject({ configPath }) {
46
50
  if (!isAbsolute(configPath || '')) throw new Error('usage: codex-collab refresh-project /absolute/private-peer.json');
47
51
  const { config } = await readPeerConfig(configPath);
48
52
  if (config.execution_mode !== 'desktop_poll' || config.auto_execute !== false) throw new Error('refresh-project 需要 desktop_poll 配置且 SDK 已关闭。');
49
- const installed = await installedProjectConfig({ repoPath: config.repo, agentId: config.agent_id });
53
+ const installed = await installedProjectConfig({ repoPath: config.repo, agentId: config.agent_id, allowMissing: true });
50
54
  const { repo, path: configFile, before, section } = installed;
51
- if (await git(repo, 'rev-parse', '--show-toplevel') !== repo || await realpath(installed.config_path) !== await realpath(configPath)) throw new Error('项目 MCP 已绑定其他配置,请使用该 MCP 实际使用的个人配置路径;未更换身份。');
55
+ if (await git(repo, 'rev-parse', '--show-toplevel') !== repo || !installed.missing && await realpath(installed.config_path) !== await realpath(configPath)) throw new Error('项目 MCP 已绑定其他配置,请使用该 MCP 实际使用的个人配置路径;未更换身份。');
52
56
  const skillFile = join(repo, '.agents/skills/team-collab-desktop/SKILL.md');
53
- await plainPath(repo, skillFile);
57
+ await plainPath(repo, skillFile, { allowMissing: true });
54
58
  if (await git(repo, 'ls-files', '--', '.codex/config.toml', '.agents/skills/team-collab-desktop')) throw new Error('协作配置或 Skill 已提交到项目 Git,请手动合并;未覆盖共享文件。');
55
- const oldSkill = await readFile(skillFile, 'utf8');
59
+ const oldSkill = await readFile(skillFile, 'utf8').catch((error) => { if (error.code === 'ENOENT') return null; throw error; });
56
60
  const template = await readFile(join(root, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
57
- if (oldSkill !== template && !legacyHashes.has(hash(oldSkill))) throw new Error('协作 Skill 已自定义,请手动合并自然语言处理说明;未覆盖文件。');
58
- const nextSection = section.replace(/^command\s*=.*$/m, `command = ${JSON.stringify(process.execPath)}`).replace(/^args\s*=.*$/m, `args = ${JSON.stringify([join(root, 'apps/peer/mcp-main.mjs'), installed.config_path])}`);
59
- const next = before.replace(section, () => nextSection);
61
+ if (oldSkill !== null && oldSkill !== template && !legacyHashes.has(hash(oldSkill))) throw new Error('协作 Skill 已自定义,请手动合并自然语言处理说明;未覆盖文件。');
62
+ const command = `command = ${JSON.stringify(process.execPath)}`;
63
+ const args = `args = ${JSON.stringify([join(root, 'apps/peer/mcp-main.mjs'), installed.config_path || configPath])}`;
64
+ const nextSection = installed.missing ? `[mcp_servers.team-collab-${config.agent_id}]\n${command}\n${args}\n` : section.replace(/^command\s*=.*$/m, () => command).replace(/^args\s*=.*$/m, () => args);
65
+ const next = installed.missing ? `${before || ''}\n${nextSection}` : before.replace(section, () => nextSection);
60
66
  if (inside(repo, resolve(config.data_dir))) throw new Error('个人数据目录必须位于项目仓库之外。');
61
67
  await mkdir(config.data_dir, { recursive: true, mode: 0o700 });
62
68
  const dataDir = await realpath(config.data_dir);
@@ -67,8 +73,10 @@ export async function refreshProject({ configPath }) {
67
73
  if (changed) {
68
74
  backup = join(dataDir, `project-refresh-${randomUUID()}.json`);
69
75
  await writeFile(backup, JSON.stringify({ repo, config_toml: before, skill: oldSkill, created_at: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
76
+ await mkdir(dirname(skillFile), { recursive: true, mode: 0o700 });
77
+ await mkdir(dirname(configFile), { recursive: true, mode: 0o700 });
70
78
  try { await privateWrite(configFile, next); await privateWrite(skillFile, template); }
71
- catch (error) { await privateWrite(configFile, before); await privateWrite(skillFile, oldSkill); throw error; }
79
+ catch (error) { if (before === null) await rm(configFile, { force: true }); else await privateWrite(configFile, before); if (oldSkill === null) await rm(skillFile, { force: true }); else await privateWrite(skillFile, oldSkill); throw error; }
72
80
  }
73
81
  await privateWrite(prompt, bindingPrompt(config.agent_id, repo));
74
82
  return { agent_id: config.agent_id, repo, changed, mcp_config: configFile, skill: skillFile, backup, binding_prompt: prompt,