xuym-codex-collab 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  ```sh
10
10
  npm install --global xuym-codex-collab
11
- codex-collab setup
11
+ codex-collab
12
12
  ```
13
13
 
14
- `setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“初始化团队”,成员选择“加入团队”;群身份验证、等待审批和本机配置会自动衔接,页面显示当前进度。已在使用旧配置的成员选择“导入已有配置”。终端保持运行即可继续使用工作台,关闭工作台后已启动的机器人连接仍运行。
14
+ `setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“连接我的机器人”,成员选择“接受同事邀请”;群身份验证、等待审批和本机配置会自动衔接,页面显示当前进度。已在使用旧配置的成员选择“导入已有配置”。命令启动后台工作台后退出,可以关闭终端和浏览器。macOS 默认设置登录自启;Linux 尝试配置 systemd 用户服务,失败时明确提示。工作台每 15 秒检查一次已开启的机器人进程,意外退出时恢复并在失败后退避重试;手动停止的连接保持停止。
15
15
 
16
16
  | 谁 | 在工作台做什么 | 接入后需要保留什么 |
17
17
  | --- | --- | --- |
@@ -36,6 +36,25 @@ Skill 完成身份核对、对话绑定和 heartbeat 配置,页面自动观察
36
36
 
37
37
  若管理员工作台显示接入包密码为空,通常是旧版本未保存原密码:无法从密文恢复,只能在管理员项目的“已分配的机器人”中重新生成包和密码。详情见[密码遗失恢复步骤](docs/onboarding.md#接入包密码遗失)。
38
38
 
39
+ ## 日常使用与升级
40
+
41
+ 在业务项目目录运行以下命令;只有一个接入时,也可以在任意目录运行。多个接入可通过 `codex-collab projects` 查看,再加 `--profile <项目ID>` 或唯一的 Agent ID 选择。原有显式 `peer.json` 路径参数继续支持。
42
+
43
+ ```sh
44
+ codex-collab status
45
+ codex-collab doctor
46
+ codex-collab repair
47
+ codex-collab stop
48
+ codex-collab start
49
+ codex-collab autostart status
50
+ ```
51
+
52
+ `doctor` 检查项目 MCP/Skill、机器人进程与认证、对话绑定、真实轮询以及 Git 读取和推送权限,并给出下一步。页面也提供“检查整条连接”和“修复本机配置”。修复更新已识别的生成配置,并恢复缺失的 MCP 配置和 Skill;自定义、符号链接或已提交的共享配置需要手动合并。修复后请在 Codex 重新加载项目 MCP,在原对话执行启用指令。
53
+
54
+ 升级执行 `npm install -g xuym-codex-collab@latest`,然后重新打开工作台并修复项目配置。已经运行的 Node 进程需要重启才能加载新代码:停止并重新启动机器人;后台工作台可等下次登录,或终止 `codex-collab service-status` 显示的工作台 PID 后重新执行 `codex-collab`。0.5.0 迁移时只接管仍在运行的旧连接,已停止的旧连接需要你主动启动。
55
+
56
+ `codex-collab autostart off` 关闭登录自启,`autostart on` 重新设置。`setup --no-startup` 仅跳过本次自启配置,`setup --foreground` 以前台模式运行,便于排查。Windows 支持独立后台工作台,暂未自动配置登录启动。电脑休眠、关机或退出系统期间不会执行群任务;Codex 对话的自动化仍需保持启用。
57
+
39
58
  ## 一次任务如何接力
40
59
 
41
60
  ```mermaid
@@ -0,0 +1,46 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { SetupManager } from './manager.mjs';
3
+ import { selectProfile, workspaceHome, profiles } from './workspace.mjs';
4
+ import { configureStartup, startupStatus } from './startup.mjs';
5
+ import { serviceState } from './main.mjs';
6
+
7
+ // Preserve the original explicit peer.json interface for scripts and advanced users.
8
+ export async function resolveCommand(args, { home = workspaceHome(), cwd = process.cwd() } = {}) {
9
+ const [command, ...input] = args;
10
+ let selector; const rest = [];
11
+ for (let i = 0; i < input.length; i++) {
12
+ if (input[i] === '--profile') { if (!input[i + 1]) throw new Error('--profile 需要项目 ID 或 Agent ID。'); selector = input[++i]; }
13
+ else rest.push(input[i]);
14
+ }
15
+ if (rest[0] && (isAbsolute(rest[0]) || rest[0].endsWith('.json'))) return { command, configPath: resolve(rest.shift()), args: rest };
16
+ const profile = await selectProfile({ home, cwd, selector });
17
+ return { command, configPath: profile.config_path, profile, args: rest };
18
+ }
19
+ export async function workspaceCommand(args, { home = workspaceHome() } = {}) {
20
+ if (args[0] === 'projects') {
21
+ console.log(JSON.stringify((await profiles(home)).map(({ id, agent_id, repo, phase }) => ({ id, agent_id, repo, phase })), null, 2)); return true;
22
+ }
23
+ if (args[0] === 'autostart') {
24
+ if (!['on', 'off', 'status'].includes(args[1])) throw new Error('usage: codex-collab autostart on|off|status');
25
+ console.log(JSON.stringify(args[1] === 'status' ? await startupStatus(home) : await configureStartup({ home, enabled: args[1] === 'on' }), null, 2)); return true;
26
+ }
27
+ if (args[0] === 'service-status') {
28
+ const state = await serviceState(home);
29
+ console.log(JSON.stringify({ online: !!state, pid: state?.pid || null, startup: await startupStatus(home) }, null, 2)); return true;
30
+ }
31
+ return false;
32
+ }
33
+ export async function guidedCommand(resolved, { home = workspaceHome() } = {}) {
34
+ if (resolved.command === 'repair' && !resolved.profile) {
35
+ const { refreshProject } = await import('../../scripts/peer/refresh-project.mjs');
36
+ console.log(JSON.stringify(await refreshProject({ configPath: resolved.configPath }), null, 2)); return true;
37
+ }
38
+ if (!['doctor', 'repair'].includes(resolved.command) || !resolved.profile) return false;
39
+ const manager = new SetupManager({ home });
40
+ try {
41
+ const value = resolved.command === 'repair' ? await manager.repair({ id: resolved.profile.id }) : await manager.diagnostics({ id: resolved.profile.id });
42
+ console.log(JSON.stringify(value, null, 2));
43
+ if (resolved.command === 'doctor' && value.checks.some((check) => !check.ok)) process.exitCode = 1;
44
+ } finally { manager.close(); }
45
+ return true;
46
+ }
@@ -55,6 +55,7 @@ export async function recommendTests(repo) {
55
55
  return commands.map((argv) => ({ argv, command: displayCommand(argv) }));
56
56
  }
57
57
  export function suggestedRepo(remote, agentId) {
58
+ if (typeof remote !== 'string' || !remote.trim()) throw new SetupError('请填写业务仓库地址。');
58
59
  const name = remote.split(/[/:]/).at(-1).replace(/\.git$/, '').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80) || 'project';
59
60
  return join(homedir(), 'codex-projects', `${name}-${agentId}`);
60
61
  }
@@ -1,32 +1,85 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdir, readFile, rm } from 'node:fs/promises';
2
+ import { chmod, mkdir, open as openFile, readFile, rm } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
- import { homedir } from 'node:os';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { setTimeout as delay } from 'node:timers/promises';
5
6
  import { startSetupServer } from './server.mjs';
6
- import { SetupManager } from './manager.mjs';
7
+ import { SetupManager, savePrivate } from './manager.mjs';
8
+ import { RunnerSupervisor } from './supervisor.mjs';
9
+ import { workspaceHome } from './workspace.mjs';
10
+ import { configureStartup, startupStatus } from './startup.mjs';
7
11
  import { alive } from '../peer/process.mjs';
12
+ const entry = fileURLToPath(import.meta.url);
8
13
  function openBrowser(url) {
9
14
  const command = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
10
15
  const child = spawn(command[0], command.slice(1), { stdio: 'ignore' }); child.on('error', () => {}); child.unref();
11
16
  }
12
- export async function runLocalSetup({ open = true, home = join(homedir(), '.config/codex-collab/workspace') } = {}) {
13
- home = resolve(home); await mkdir(home, { recursive: true, mode: 0o700 });
17
+ export async function serviceState(home) {
18
+ for (let attempt = 0; attempt < 30; attempt++) {
19
+ try { const state = JSON.parse(await readFile(join(home, 'wizard.json'), 'utf8')); return alive(state.pid) ? state : null; }
20
+ catch (error) { if (error.code === 'ENOENT') return null; if (!(error instanceof SyntaxError)) throw error; }
21
+ await delay(50);
22
+ }
23
+ throw new Error('本机工作台正在初始化,请稍后重试。');
24
+ }
25
+ export async function runService({ home = workspaceHome() } = {}) {
26
+ process.umask(0o077); home = resolve(home); await mkdir(home, { recursive: true, mode: 0o700 });
14
27
  const path = join(home, 'wizard.json'); let lock;
15
- try { lock = await (await import('node:fs/promises')).open(path, 'wx', 0o600); }
16
- catch (error) {
17
- if (error.code !== 'EEXIST') throw error;
18
- let previous; try { previous = JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error('本机向导正在初始化,请稍后重试。'); }
19
- if (alive(previous.pid)) { console.log(`本机向导已运行:${previous.url}`); if (open) openBrowser(previous.url); return; }
20
- await rm(path, { force: true }); return runLocalSetup({ open, home });
28
+ for (let attempt = 0; attempt < 3; attempt++) {
29
+ try { lock = await openFile(path, 'wx', 0o600); break; }
30
+ catch (error) {
31
+ if (error.code !== 'EEXIST') throw error;
32
+ if (await serviceState(home)) return null;
33
+ await rm(path, { force: true });
34
+ }
21
35
  }
22
- let app;
36
+ if (!lock) throw new Error('无法取得本机工作台锁。');
37
+ let app, timer, stopping = false;
23
38
  try {
24
- app = await startSetupServer({ manager: new SetupManager({ home }) });
25
- await lock.writeFile(JSON.stringify({ pid: process.pid, url: app.url }) + '\n'); await lock.close();
26
- console.log(`Codex Collab 本机接入向导:${app.url}\n保持此终端运行。Ctrl-C 关闭向导;已启动的机器人 Runner 会继续运行。`);
27
- if (open) openBrowser(app.url);
28
- const stop = async () => { await app.close(); await rm(path, { force: true }); process.exit(0); };
29
- process.once('SIGINT', stop); process.once('SIGTERM', stop);
30
- return app;
39
+ const manager = new SetupManager({ home });
40
+ app = await startSetupServer({ manager });
41
+ await lock.writeFile(JSON.stringify({ pid: process.pid, url: app.url, started_at: new Date().toISOString() }) + '\n'); await lock.close();
42
+ const supervisor = new RunnerSupervisor(manager);
43
+ const tick = async () => {
44
+ try { await manager.exclusive(() => supervisor.tick()); } catch { /* Local diagnostics show missing/invalid profiles without logging secrets. */ }
45
+ if (!stopping) timer = setTimeout(tick, 15000);
46
+ };
47
+ timer = setTimeout(tick, 1000);
48
+ const stop = async () => {
49
+ if (stopping) return; stopping = true; clearTimeout(timer);
50
+ await app.close(); const state = await serviceState(home); if (state?.pid === process.pid) await rm(path, { force: true });
51
+ };
52
+ process.once('SIGINT', () => void stop()); process.once('SIGTERM', () => void stop());
53
+ return { ...app, stop };
31
54
  } catch (error) { await lock.close().catch(() => {}); await rm(path, { force: true }); if (app) await app.close(); throw error; }
32
55
  }
56
+ export async function runLocalSetup({ open = true, home = workspaceHome(), cwd = process.cwd(), foreground = false, startup = true } = {}) {
57
+ home = resolve(home); await savePrivate(join(home, 'context.json'), { cwd: resolve(cwd) });
58
+ let state = await serviceState(home);
59
+ if (foreground) {
60
+ if (state) { console.log(`本机工作台已运行:${state.url}`); if (open) openBrowser(state.url); return; }
61
+ const app = await runService({ home }); if (app) { console.log(`Codex Collab 工作台:${app.url}`); if (open) openBrowser(app.url); } return app;
62
+ }
63
+ const previous = await startupStatus(home);
64
+ let registered = startup ? previous : { enabled: false };
65
+ if (startup && (previous.enabled || previous.kind == null && previous.supported && !previous.error)) registered = await configureStartup({ home, start: !state });
66
+ if (!state) {
67
+ state = await serviceState(home);
68
+ if (!state && !registered.enabled) {
69
+ const log = await openFile(join(home, 'service.log'), 'a', 0o600); await chmod(join(home, 'service.log'), 0o600);
70
+ const child = spawn(process.execPath, [entry, '--home', home], { detached: true, stdio: ['ignore', log.fd, log.fd], env: process.env });
71
+ let error; child.on('error', () => { error = true; }); child.unref(); await log.close();
72
+ for (let i = 0; i < 100 && !state && !error; i++) { await delay(100); state = await serviceState(home); }
73
+ } else for (let i = 0; i < 100 && !state; i++) { await delay(100); state = await serviceState(home); }
74
+ }
75
+ if (!state) throw new Error(`本机后台工作台未启动,请检查 ${join(home, 'service.log')},或运行 codex-collab setup --foreground。`);
76
+ console.log(`Codex Collab 工作台:${state.url}\n后台服务已运行,可以关闭终端和浏览器。${registered.enabled ? '登录时将自动恢复已开启的连接。' : '登录自启未启用。'}`);
77
+ if (registered.error) console.log(registered.error);
78
+ if (open) openBrowser(state.url);
79
+ return state;
80
+ }
81
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
82
+ const args = process.argv.slice(2);
83
+ if (args.length !== 2 || args[0] !== '--home') throw new Error('Internal service requires --home.');
84
+ runService({ home: args[1] }).catch(() => { console.error('Codex Collab 后台服务启动失败,请运行 codex-collab setup --foreground 排查。'); process.exitCode = 1; });
85
+ }
@@ -2,9 +2,12 @@ import { defaultReviewRules } from '../../packages/peer/review.mjs';
2
2
  import { SetupError } from './errors.mjs';
3
3
  import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
4
4
  import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
5
- import { homedir } from 'node:os';
6
5
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
- import { spawnSync } from 'node:child_process';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { readRunnerIntent, writeRunnerIntent } from '../peer/intent.mjs';
9
+ import { localDefaults, workspaceHome } from './workspace.mjs';
10
+ import { startupStatus } from './startup.mjs';
8
11
  import { fileURLToPath } from 'node:url';
9
12
  import { canonical, digest, generateIdentity, readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
10
13
  import { PeerGitStore, git } from '../../packages/peer/store.mjs';
@@ -111,12 +114,13 @@ function validateBundle(value) {
111
114
  }
112
115
 
113
116
  export class SetupManager {
114
- constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
117
+ constructor({ home = workspaceHome(), discovery = new BotDiscovery(), runCli } = {}) {
115
118
  this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve(); this.resets = new ResetActions(this);
116
- this.runCli = runCli || ((command, config) => {
117
- const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
118
- if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
119
- return result.stdout.trim();
119
+ this.runCli = runCli || (async (command, config) => {
120
+ try {
121
+ const result = await promisify(execFile)(process.execPath, [join(packageRoot, 'scripts/peer/cli.mjs'), command, config], { timeout: 35000, maxBuffer: 200000 });
122
+ return result.stdout.trim();
123
+ } catch { throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`); }
120
124
  });
121
125
  }
122
126
  async exclusive(fn) { const action = this.queue.then(fn); this.queue = action.catch(() => {}); return action; }
@@ -143,7 +147,8 @@ export class SetupManager {
143
147
  const draft = await readPrivateJson(join(this.home, 'drafts', file));
144
148
  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 });
145
149
  }
146
- return { profiles, removed_profiles, drafts };
150
+ 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) };
147
152
  }
148
153
  async checkBotAvailable(botId) {
149
154
  for (const profile of (await this.overview()).profiles) {
@@ -178,7 +183,7 @@ export class SetupManager {
178
183
  }
179
184
  async prepareProject(input) {
180
185
  const path = input.repo_mode === 'clone'
181
- ? await cloneRepository({ remote: input.remote, destination: input.destination, privateHome: this.home }) : input.repo;
186
+ ? 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;
182
187
  const selected = await ensureRepo(path);
183
188
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
184
189
  return { ...selected, tests: await recommendTests(selected.repo) };
@@ -282,11 +287,12 @@ export class SetupManager {
282
287
  await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
283
288
  // Existing MCP processes keep using this path and the same private desktop state.
284
289
  await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
285
- await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
290
+ await this.save(state); await this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
286
291
  }
287
292
  async refreshAttachment(state, configPath) {
288
293
  try {
289
- const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id });
294
+ const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id, allowMissing: true });
295
+ if (installed.missing) { await refreshProject({ configPath }); return; }
290
296
  const source = (await readPeerConfig(configPath)).config;
291
297
  const bound = (await readPeerConfig(installed.config_path)).config;
292
298
  if (bound.agent_id !== source.agent_id || bound.private_key !== source.private_key ||
@@ -296,6 +302,7 @@ export class SetupManager {
296
302
  }
297
303
  async resume(id) {
298
304
  const state = await this.state(id);
305
+ const wasReady = state.phase === 'ready';
299
306
  if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
300
307
  try {
301
308
  if (state.role === 'admin' && state.phase === 'initializing') {
@@ -367,7 +374,11 @@ export class SetupManager {
367
374
  if (state.phase === 'ready') {
368
375
  await this.refreshAttachment(state, state.existing_config || join(this.path(id), 'peer.json'));
369
376
  await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
370
- this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
377
+ const configPath = state.existing_config || join(this.path(id), 'peer.json');
378
+ const { config } = await readPeerConfig(configPath);
379
+ const intent = await readRunnerIntent(config.data_dir);
380
+ if (intent?.enabled) await this.runCli('recover', configPath);
381
+ else if (!intent && !wasReady) { await writeRunnerIntent(config.data_dir, true); await this.runCli('start', configPath); }
371
382
  }
372
383
  state.last_error = null; await this.save(state);
373
384
  return this.detail(id, { localOnly: true });
@@ -618,7 +629,9 @@ export class SetupManager {
618
629
  async createOwnAgent(input) {
619
630
  const state = await this.state(input.id);
620
631
  if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
621
- const selected = await ensureRepo(input.repo);
632
+ if (!idPattern.test(input.agent_id?.trim() || '')) throw new SetupError('请填写新 Agent 名称。');
633
+ 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);
622
635
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
623
636
  if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
624
637
  const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
@@ -728,6 +741,7 @@ export class SetupManager {
728
741
  }
729
742
  const desktop = new DesktopExecutor({ agentId: config.agent_id, store: { clock: Date.now } }, config);
730
743
  result.desktop = await desktop.status();
744
+ result.connection_enabled = (await readRunnerIntent(config.data_dir))?.enabled ?? result.runner.online;
731
745
  result.last_poll_at = result.desktop.last_poll_at || null;
732
746
  result.poll_observed = !!result.last_poll_at;
733
747
  result.poll_recent = !!result.last_poll_at && Date.now() - Date.parse(result.last_poll_at) < 180000;
@@ -740,7 +754,10 @@ export class SetupManager {
740
754
  const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
741
755
  try {
742
756
  if (command === 'start' && [...this.discovery.sessions.values()].some((session) => session.bot_id === state.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) throw new SetupError('机器人正在验证,请先取消验证,再启动连接。');
743
- this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
757
+ const configPath = state.existing_config || join(this.path(id), 'peer.json');
758
+ const { config } = await readPeerConfig(configPath);
759
+ await writeRunnerIntent(config.data_dir, command === 'start');
760
+ await this.runCli(command, configPath);
744
761
  state.last_error = null; await this.save(state);
745
762
  } catch (error) {
746
763
  if (error instanceof SetupError) throw error;
@@ -749,6 +766,32 @@ export class SetupManager {
749
766
  }
750
767
  return this.detail(id, { localOnly: true });
751
768
  }
769
+ async diagnostics({ id }) {
770
+ const value = await this.detail(id, { localOnly: true });
771
+ const checks = [];
772
+ const add = (key, label, ok, action) => checks.push({ key, label, ok: !!ok, action: ok ? null : action });
773
+ add('setup', '本机接入', value.phase === 'ready', '先继续完成群身份验证和管理员确认。');
774
+ if (value.phase === 'ready') {
775
+ add('mcp', '项目 MCP', value.project_setup.mcp_configured, '点击修复本机配置,然后在 Codex 中重新加载项目 MCP。');
776
+ add('skill', '接入 Skill', value.project_setup.skill_installed, '点击修复本机配置,恢复工具生成的 Skill。');
777
+ add('runner', '后台机器人进程', value.runner.online, value.connection_enabled ? '连接已启用,后台将尝试恢复;也可以点击启动机器人连接。' : '连接已停止。需要接任务时点击启动机器人连接。');
778
+ add('wecom', '企业微信认证', value.runner.wecom === 'authenticated', '等待认证;若持续失败,请检查网络或通过更新机器人凭据重新验证。');
779
+ add('binding', '目标 Codex 对话', !!value.desktop.binding, '复制启用指令,发送到你选定的 Codex 对话。');
780
+ add('poll', '对话实际轮询', value.poll_recent, '在绑定对话中执行启用指令,并确认该对话的协作自动化已启用。');
781
+ try { await diagnosePeer(value.config_path, { testPush: true }); add('git', 'Git 读取与推送权限', true); }
782
+ catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git 登录,确认 origin/main 可读并具有推送权限。'); }
783
+ }
784
+ const startup = await startupStatus(this.home);
785
+ return { id, checks, ready: value.can_receive_tasks === true, startup, heartbeat_confirmed: false,
786
+ note: '检查使用真实进程、认证和轮询记录,不创建测试任务。未来能否自动唤醒仍需在 Codex 中确认自动化。' };
787
+ }
788
+ async repair({ id }) {
789
+ const state = await this.state(id);
790
+ if (state.phase !== 'ready') throw new SetupError('请先完成本机接入。');
791
+ const configPath = state.existing_config || join(this.path(id), 'peer.json');
792
+ await this.refreshAttachment(state, configPath);
793
+ return { id, repaired: true, next_step: '本机生成配置已更新。请在 Codex 重新加载项目 MCP,并在原绑定对话中执行启用指令。' };
794
+ }
752
795
  resetPreview(input) { return this.resets.preview(input); }
753
796
  resetDiscovery(input) { return this.resets.discover(input); }
754
797
  resetCancel(input) { return this.resets.cancel(input); }
@@ -6,6 +6,7 @@ import { SetupError } from './errors.mjs';
6
6
  import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
7
7
  import { PrivateDesktopState } from '../peer/private-state.mjs';
8
8
  import { alive, readRunnerLock } from '../peer/process.mjs';
9
+ import { writeRunnerIntent } from '../peer/intent.mjs';
9
10
  import { installedProjectConfig } from '../../scripts/peer/refresh-project.mjs';
10
11
  import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
11
12
  import { digest, readPrivateJson } from '../../packages/peer/config.mjs';
@@ -152,8 +153,9 @@ export class ResetActions {
152
153
  if (!value || value.id !== input.id || value.kind !== input.kind || value.expires < Date.now() || value.fingerprint !== fingerprint) throw new SetupError('项目配置或绑定已改变,或预览已过期,请重新预览。');
153
154
  }
154
155
  async stop(context, plan) {
156
+ await writeRunnerIntent(context.dataDir, false);
155
157
  if (!plan.lock || !alive(plan.lock.pid)) return;
156
- try { this.manager.runCli('stop', plan.lock.config); }
158
+ try { await this.manager.runCli('stop', plan.lock.config); }
157
159
  catch { throw new SetupError('机器人停止失败,未修改配置;请检查 Runner 后重试。'); }
158
160
  const lock = await readRunnerLock(join(context.dataDir, 'runner.pid.json'));
159
161
  if (lock && alive(lock.pid)) throw new SetupError('机器人仍在停止,请稍后重试;未修改配置。');
@@ -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 = { '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 = { 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: '操作不存在。' });
@@ -0,0 +1,61 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { createHash } from 'node:crypto';
4
+ import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { fileURLToPath } from 'node:url';
8
+ const exec = promisify(execFile);
9
+ const entry = fileURLToPath(new URL('./main.mjs', import.meta.url));
10
+ const xml = (value) => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
11
+ const unitArg = (value, expand = true) => {
12
+ let text = String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%');
13
+ if (expand) text = text.replaceAll('$', () => '$$');
14
+ return `"${text}"`;
15
+ };
16
+ export function startupDefinition({ home, platform = process.platform, userHome = homedir(), node = process.execPath, path = process.env.PATH || '/usr/bin:/bin' }) {
17
+ if ([home, userHome, node, path].some((value) => typeof value !== 'string' || /[\x00\r\n]/.test(value))) throw new Error('自启路径不能包含换行或空字符。');
18
+ const suffix = createHash('sha256').update(home).digest('hex').slice(0, 12);
19
+ const label = `com.codex-collab.${suffix}`;
20
+ const log = join(home, 'service.log');
21
+ if (platform === 'darwin') return { kind: 'launchd', label, path: join(userHome, 'Library/LaunchAgents', `${label}.plist`), content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${[node, entry, '--home', home].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>15</integer><key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(path)}</string></dict><key>Umask</key><integer>63</integer><key>StandardOutPath</key><string>${xml(log)}</string><key>StandardErrorPath</key><string>${xml(log)}</string></dict></plist>\n` };
22
+ if (platform === 'linux') return { kind: 'systemd', label: `${label}.service`, path: join(userHome, '.config/systemd/user', `${label}.service`), content: `[Unit]\nDescription=Codex Collab local workspace\n[Service]\nType=simple\nExecStart=${[node, entry, '--home', home].map((arg) => unitArg(arg)).join(' ')}\nEnvironment=${unitArg(`PATH=${path}`, false)}\nRestart=on-failure\nRestartSec=15\nUMask=0077\n[Install]\nWantedBy=default.target\n` };
23
+ return null;
24
+ }
25
+ export async function configureStartup({ home, enabled = true, start = false, platform = process.platform, userHome = homedir(), execute = exec } = {}) {
26
+ const definition = startupDefinition({ home, platform, userHome });
27
+ const status = { enabled: false, supported: !!definition, kind: definition?.kind || null };
28
+ await mkdir(home, { recursive: true, mode: 0o700 });
29
+ const run = (command, args) => execute(command, args, { timeout: 10000, maxBuffer: 10000 });
30
+ if (definition) {
31
+ try {
32
+ if (enabled) {
33
+ const logPath = join(home, 'service.log');
34
+ const log = await open(logPath, 'a', 0o600); await log.close(); await chmod(logPath, 0o600);
35
+ await mkdir(dirname(definition.path), { recursive: true, mode: 0o700 });
36
+ await writeFile(definition.path, definition.content, { mode: 0o600 });
37
+ if (definition.kind === 'launchd') {
38
+ const domain = `gui/${process.getuid()}`;
39
+ if (start) {
40
+ try { await run('launchctl', ['bootstrap', domain, definition.path]); }
41
+ catch { await run('launchctl', ['kickstart', `${domain}/${definition.label}`]); }
42
+ }
43
+ } else {
44
+ await run('systemctl', ['--user', 'daemon-reload']);
45
+ await run('systemctl', ['--user', 'enable', ...(start ? ['--now'] : []), definition.label]);
46
+ }
47
+ status.enabled = true;
48
+ } else {
49
+ if (definition.kind === 'launchd') await run('launchctl', ['bootout', `gui/${process.getuid()}/${definition.label}`]).catch(() => {});
50
+ else await run('systemctl', ['--user', 'disable', definition.label]);
51
+ await rm(definition.path, { force: true });
52
+ }
53
+ } catch { status.error = '登录自启未完成。后台连接仍可使用,可运行 codex-collab autostart on 重试。'; }
54
+ } else if (enabled) status.error = '此系统暂不支持自动配置登录自启;本次后台服务已独立运行。';
55
+ await writeFile(join(home, 'startup.json'), JSON.stringify(status) + '\n', { mode: 0o600 });
56
+ return status;
57
+ }
58
+ export async function startupStatus(home) {
59
+ try { return JSON.parse(await readFile(join(home, 'startup.json'), 'utf8')); }
60
+ catch (error) { if (error.code === 'ENOENT') return { enabled: false, supported: ['darwin', 'linux'].includes(process.platform) }; throw error; }
61
+ }
@@ -0,0 +1,41 @@
1
+ import { join } from 'node:path';
2
+ import { profiles } from './workspace.mjs';
3
+ import { readPeerConfig } from '../peer/runtime.mjs';
4
+ import { alive, readRunnerLock, acquireRunnerControl } from '../peer/process.mjs';
5
+ import { readRunnerIntent, writeRunnerIntent } from '../peer/intent.mjs';
6
+ import { savePrivate } from './manager.mjs';
7
+
8
+ export class RunnerSupervisor {
9
+ constructor(manager, { clock = Date.now } = {}) { this.manager = manager; this.clock = clock; this.failures = new Map(); }
10
+ async tick() {
11
+ for (const profile of await profiles(this.manager.home)) {
12
+ if (profile.phase !== 'ready') continue;
13
+ const retry = this.failures.get(profile.id);
14
+ if (retry?.after > this.clock()) continue;
15
+ try {
16
+ const { config } = await readPeerConfig(profile.config_path);
17
+ const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
18
+ let intent = await readRunnerIntent(config.data_dir);
19
+ // Upgrade only adopts a live connection. Previously stopped installations stay stopped.
20
+ if (!intent) {
21
+ const release = await acquireRunnerControl(config.data_dir, profile.config_path);
22
+ try {
23
+ intent = await readRunnerIntent(config.data_dir);
24
+ if (!intent) {
25
+ const current = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
26
+ intent = await writeRunnerIntent(config.data_dir, !!current && alive(current.pid));
27
+ }
28
+ } finally { await release(); }
29
+ }
30
+ if (!intent.enabled || lock && alive(lock.pid)) { this.failures.delete(profile.id); continue; }
31
+ if ([...this.manager.discovery.sessions.values()].some((session) => session.bot_id === config.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) continue;
32
+ await this.manager.runCli('recover', profile.config_path);
33
+ this.failures.delete(profile.id);
34
+ } catch {
35
+ const attempts = (retry?.attempts || 0) + 1;
36
+ this.failures.set(profile.id, { attempts, after: this.clock() + Math.min(300000, 15000 * 2 ** Math.min(attempts - 1, 5)) });
37
+ }
38
+ }
39
+ await savePrivate(join(this.manager.home, 'supervisor.json'), { checked_at: new Date(this.clock()).toISOString(), retrying: [...this.failures].map(([id, retry]) => ({ id, next_retry_at: new Date(retry.after).toISOString() })) });
40
+ }
41
+ }
@@ -18,7 +18,13 @@ const values = (form) => Object.fromEntries(new FormData(form));
18
18
  function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
19
19
  function button(label, fn) { const node = text('button', label); node.onclick = () => action(fn); return node; }
20
20
  async function overview() {
21
- const value = await api('overview'); $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
21
+ const value = await api('overview');
22
+ if (value.defaults) {
23
+ if (!$('admin-form').elements.repo.value) $('admin-form').elements.repo.value = value.defaults.repo || '';
24
+ if (!$('admin-form').elements.display_name.value) $('admin-form').elements.display_name.value = value.defaults.display_name || '';
25
+ }
26
+ $('startup-status').textContent = value.startup?.enabled ? '后台服务已运行,登录时自动恢复已开启的连接。可关闭终端和浏览器。' : value.startup?.error || '后台工作台会保持连接;登录自启状态可用 codex-collab autostart status 查看。';
27
+ $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
22
28
  $('removed-projects').hidden = !(value.removed_profiles || []).length;
23
29
  for (const profile of value.removed_profiles || []) {
24
30
  const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
@@ -29,7 +35,7 @@ async function overview() {
29
35
  const card = document.createElement('div'); card.className = 'card';
30
36
  card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
31
37
  }
32
- if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。管理员选择“初始化团队”,成员选择“加入团队”。'));
38
+ if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。选择“连接我的机器人”,或导入同事提供的接入包。'));
33
39
  for (const profile of value.profiles) {
34
40
  const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
35
41
  info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
@@ -119,7 +125,7 @@ function renderProfile(value, automatic = false) {
119
125
  $('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
120
126
  $('profile-title').textContent = `${value.display_name && value.display_name !== value.agent_id ? `${value.display_name}(${value.agent_id})` : value.agent_id} · ${value.role === 'admin' ? '管理员' : '成员'}`; $('profile-repo').textContent = value.repo;
121
127
  $('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
122
- $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
128
+ $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('diagnostic-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
123
129
  if (value.phase === 'ready') {
124
130
  $('binding-text').textContent = value.binding_prompt; $('binding-details').textContent = value.detailed_binding_prompt || value.binding_prompt;
125
131
  for (const [label, ok, wait] of [['项目 MCP', value.project_setup?.mcp_configured, '待配置'], ['项目 Skill', value.project_setup?.skill_installed, '待配置'], ['企业微信机器人', value.runner?.wecom === 'authenticated', value.runner?.online ? '连接中' : '已停止'], ['目标 Codex 对话', !!value.desktop?.binding, '待绑定'], ['对话实际轮询', value.poll_recent, value.poll_observed ? '最近未观察到轮询' : '待观察'], ['可接收任务', value.can_receive_tasks, '尚未满足条件']]) {
@@ -191,30 +197,46 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
191
197
  await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
192
198
  await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
193
199
  }); };
194
- for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; } remember(null); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
200
+ for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; } remember(null); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); if (button.dataset.view === 'admin' && $('admin-form').elements.repo.value) await checkProject(); });
195
201
  function repoMode(prefix) {
196
202
  const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
197
203
  }
198
- for (const prefix of ['admin', 'join']) $(`${prefix}-repo-mode`).onchange = () => repoMode(prefix);
199
- $('check-project').onclick = () => action(async () => {
200
- const selected = await api('project-check', values($('admin-form')));
204
+ for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange = () => repoMode(prefix);
205
+ async function checkProject() {
206
+ const snapshot = values($('admin-form'));
207
+ const selected = await api('project-check', snapshot);
208
+ const current = values($('admin-form'));
209
+ if (['repo_mode', 'repo', 'remote', 'destination'].some((key) => current[key] !== snapshot[key])) return;
201
210
  const form = $('admin-form'); form.elements.repo.value = selected.repo; $('admin-repo-mode').value = 'existing'; repoMode('admin');
202
211
  $('project-recommendation').textContent = `项目已检查:${selected.repo}。${selected.tests.length ? '推荐:' + selected.tests.map((test) => test.command).join(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
203
212
  if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
204
- });
213
+ }
214
+ $('check-project').onclick = () => action(checkProject);
215
+ for (const id of ['admin-repo', 'admin-remote', 'admin-destination']) $(id).onchange = () => {
216
+ const form = $('admin-form');
217
+ if ($('admin-repo-mode').value === 'clone' ? form.elements.remote.value && form.elements.destination.value : form.elements.repo.value) action(checkProject);
218
+ };
205
219
  $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
206
220
  const input = values(event.target);
207
221
  const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
208
222
  pending = { type: 'admin', draft_id: session.draft_id }; remember({ draft_id: session.draft_id }); await verifyView(session);
209
223
  }); };
210
224
  async function invitationInput(form) { const input = values(form); input.content = await input.file.text(); delete input.file; return input; }
211
- $('inspect-invite').onclick = () => action(async () => {
225
+ async function inspectInvite() {
212
226
  $('invite-info').hidden = true; $('invite-summary').textContent = ''; $('invite-remote').textContent = '';
213
- const value = await api('inspect-invite', await invitationInput($('join-form')));
227
+ const file = $('join-form').elements.file.files?.[0];
228
+ const input = await invitationInput($('join-form'));
229
+ const value = await api('inspect-invite', input);
230
+ if ($('join-form').elements.password.value !== input.password || $('join-form').elements.file.files?.[0] !== file) return;
214
231
  $('invite-info').hidden = false; $('invite-summary').textContent = `${value.display_name || value.agent_id}(${value.agent_id})· 职责:${value.capabilities.join('、')}${value.team_name ? ' · ' + value.team_name : ''}`;
215
232
  $('invite-remote').textContent = value.remote;
216
233
  if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
217
- });
234
+ }
235
+ $('inspect-invite').onclick = () => action(inspectInvite);
236
+ for (const id of ['invite-file', 'invite-password']) $(id).onchange = () => {
237
+ $('invite-info').hidden = true;
238
+ if ($('invite-file').files?.length && $('invite-password').value) action(inspectInvite);
239
+ };
218
240
  $('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
219
241
  const profile = await api('import', await invitationInput(event.target)); event.target.elements.password.value = ''; await connectMember(profile);
220
242
  }); };
@@ -242,6 +264,14 @@ $('retry-code').onclick = () => action(async () => {
242
264
  $('confirm-code').onclick = () => action(completeVerification);
243
265
  $('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
244
266
  $('issue-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.id = currentId; input.prefer = event.target.elements.prefer.checked; input.commands_enabled = event.target.elements.commands_enabled.checked; const result = await api('issue', input); event.target.reset(); await loadProfile(currentId); await showDelivery(currentId, result.invite_id); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
267
+ $('run-diagnostics').onclick = () => action(async () => {
268
+ const value = await api('diagnostics', { id: currentId }); $('diagnostic-result').replaceChildren();
269
+ for (const check of value.checks) $('diagnostic-result').append(text('p', `${check.ok ? '✓' : '待处理'} ${check.label}${check.action ? ':' + check.action : ''}`));
270
+ $('diagnostic-result').append(text('p', value.note));
271
+ });
272
+ $('repair-project').onclick = () => action(async () => {
273
+ const value = await api('repair', { id: currentId }); await loadProfile(currentId); notice(value.next_step);
274
+ });
245
275
  $('refresh').onclick = () => action(() => loadProfile(currentId));
246
276
  $('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
247
277
  $('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
@@ -1,32 +1,31 @@
1
1
  <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Collab · 团队接入</title><link rel="stylesheet" href="/style.css"></head><body>
2
2
  <header><span class="brand">Codex Collab</span><span>本机团队工作台</span></header>
3
- <main><h1>把群里的任务,交给你的 Codex 对话</h1><p class="intro">管理员分配机器人,成员导入接入包。项目配置与连接检查集中在这里。</p><div id="notice" role="status" hidden></div>
4
- <nav><button data-view="home">我的项目</button><button data-view="admin">初始化团队</button><button data-view="join">加入团队</button><button data-view="existing">导入已有配置</button></nav>
5
- <section id="home"><h2>我的项目</h2><div id="profiles"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
6
- <section id="admin" hidden><h2>管理员初始化</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
3
+ <main><h1>把群里的任务,交给你的 Codex 对话</h1><p class="intro">选一种接入方式,按提示完成群验证,再把启用指令发给你的 Codex 对话。</p><div id="notice" role="status" hidden></div>
4
+ <nav><button data-view="home">我的项目</button><button data-view="admin">连接我的机器人</button><button data-view="join">接受同事邀请</button><button data-view="existing">导入已有配置</button></nav>
5
+ <section id="home"><div class="entry-options"><button data-view="admin">连接我的机器人<small>首次接入,或为团队建立协作项目</small></button><button data-view="join">接受同事邀请<small>已有接入包,自动带入机器人和项目信息</small></button></div><p id="startup-status" class="muted"></p><h2>我的项目</h2><div id="profiles"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
6
+ <section id="admin" hidden><h2>连接我的机器人</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
7
7
  <form id="admin-form">
8
- <label>团队名称<input name="team_name" placeholder="例如:产品研发团队" maxlength="80"></label>
9
8
  <fieldset><legend>选择业务项目</legend><label>项目来源<select id="admin-repo-mode" name="repo_mode"><option value="existing">使用本机已有项目</option><option value="clone">从 Git 地址拉取新项目</option></select></label>
10
- <label id="admin-existing-repo">项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
11
- <div id="admin-clone-repo" hidden><label>Git 仓库地址<input name="remote" placeholder="HTTPS 或 SSH 地址,不含 Token"></label><label>保存到新目录<input name="destination" placeholder="新目录的完整路径"></label></div>
9
+ <label id="admin-existing-repo">项目目录<input name="repo" id="admin-repo" placeholder="Git 仓库根目录的完整路径"></label>
10
+ <div id="admin-clone-repo" hidden><label>Git 仓库地址<input name="remote" id="admin-remote" placeholder="HTTPS 或 SSH 地址,不含 Token"></label><label>保存到新目录(可选)<input name="destination" id="admin-destination" placeholder="留空按项目与名称生成独立目录"></label></div>
12
11
  <button id="check-project" type="button" class="secondary">检查项目并推荐验证命令</button><p id="project-recommendation" class="muted">自动拉取使用你本机已有的 Git 登录。仓库需要已有 main 分支。</p>
13
12
  <label>验证命令<input id="test-command" name="test_command" required placeholder="例如 npm test、go test ./...、sh test.sh"></label><p class="muted">请确认这个命令适合项目。工具使用固定程序和参数执行,不运行管道或拼接命令。</p></fieldset>
14
13
  <div class="row"><label>你的显示名称<input name="display_name" required placeholder="例如 Alice 或张三" maxlength="80"></label><label>主要职责<select name="responsibility"><option value="fullstack">全栈开发</option><option value="frontend">前端开发</option><option value="backend">后端开发</option><option value="testing">测试</option><option value="docs">文档</option><option value="general">通用开发</option></select></label></div>
15
- <details><summary>自定义内部身份与职责</summary><label>内部 Agent ID(可选)<input name="agent_id" pattern="[a-zA-Z0-9_-]{1,64}" placeholder="留空自动生成"></label><label>自定义职责标签(可选)<input name="capabilities" placeholder="英文标签,以逗号分隔"></label></details>
14
+ <details><summary>更多设置:团队名称、内部身份与职责</summary><label>团队名称(可选)<input name="team_name" placeholder="留空使用项目名称" maxlength="80"></label><label>内部 Agent ID(可选)<input name="agent_id" pattern="[a-zA-Z0-9_-]{1,64}" placeholder="留空自动生成"></label><label>自定义职责标签(可选)<input name="capabilities" placeholder="英文标签,以逗号分隔"></label></details>
16
15
  <div class="row"><label>你的机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div>
17
- <button type="submit">创建团队并连接机器人</button></form></section>
16
+ <button type="submit">连接并进入群验证</button></form></section>
18
17
  <section id="own" hidden><h2>给自己增加机器人</h2><p>同一个企业微信账号可以拥有多个 Agent。每个 Agent 使用独立机器人、独立 clone 和独立 Codex 对话。验证后自动完成本机接入,无需下载接入包。</p>
19
- <form id="own-form"><label>另一个 clone 的本机目录<input name="repo" required placeholder="同一业务仓库的另一个独立 clone"></label><div class="row"><label>新的 Agent 名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>新机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>新机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label class="checkbox"><input name="prefer" type="checkbox" checked>该 Agent 为这些职责的首选执行者</label><button type="submit">连接我的新机器人</button><button id="own-cancel" type="button" class="secondary">返回原项目</button></form></section>
20
- <section id="join" hidden><h2>加入团队</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
21
- <form id="join-form"><label>接入包文件<input name="file" type="file" accept=".json" required></label><label>接入包密码<input name="password" type="password" required autocomplete="off"></label>
22
- <button id="inspect-invite" type="button" class="secondary">读取接入信息</button><div id="invite-info" hidden><p id="invite-summary"></p><p id="invite-remote" class="muted"></p></div>
18
+ <form id="own-form"><label>准备项目<select id="own-repo-mode" name="repo_mode"><option value="clone">自动拉取独立项目</option><option value="existing">使用已有的独立 clone</option></select></label><label id="own-clone-repo">新项目目录(可选)<input name="destination" placeholder="留空按项目和 Agent 名称创建"></label><label id="own-existing-repo" hidden>另一个 clone 的本机目录<input name="repo" placeholder="同一业务仓库的另一个独立 clone"></label><div class="row"><label>新的 Agent 名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>新机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>新机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label class="checkbox"><input name="prefer" type="checkbox" checked>该 Agent 为这些职责的首选执行者</label><button type="submit">连接我的新机器人</button><button id="own-cancel" type="button" class="secondary">返回原项目</button></form></section>
19
+ <section id="join" hidden><h2>接受同事邀请</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
20
+ <form id="join-form"><label>接入包文件<input name="file" id="invite-file" type="file" accept=".json" required></label><label>接入包密码<input name="password" id="invite-password" type="password" required autocomplete="off"></label>
21
+ <button id="inspect-invite" type="button" class="secondary">重新读取接入信息</button><div id="invite-info" hidden><p id="invite-summary"></p><p id="invite-remote" class="muted"></p></div>
23
22
  <label>准备项目<select id="join-repo-mode" name="repo_mode"><option value="clone">帮我拉取到新目录</option><option value="existing">使用本机已有项目</option></select></label>
24
- <label id="join-clone-repo">新项目目录<input name="destination" placeholder="读取接入包后自动建议,亦可填写完整路径"></label>
23
+ <label id="join-clone-repo">新项目目录<input name="destination" placeholder="留空使用推荐目录,亦可填写完整路径"></label>
25
24
  <label id="join-existing-repo" hidden>已有项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
26
25
  <button type="submit">接入我的机器人</button><p class="muted">工具使用本机 Git 登录拉取代码,不会改写已有目录。导入后请按提示在群里确认一次身份,审批和配置会自动继续。</p></form></section>
27
26
  <section id="existing" hidden><h2>导入已有协作项目</h2><p>先停止原 Runner。导入会保留原成员身份、任务和对话绑定,并重新启动机器人。旧团队由管理员先导入。</p><form id="existing-form"><label>已有私有 peer.json 路径<input name="config_path" required placeholder="完整路径"></label><button type="submit">导入已有项目</button></form></section>
28
27
  <section id="verification" hidden><h2>在群里确认身份</h2><p id="connection" role="status"></p><p class="muted">发送消息后页面会自动继续,无需回来确认。刷新页面可恢复当前接入进度。</p><p>请用自己的企业微信账号,<strong>@ 刚刚分配的机器人</strong>,发送下面这条消息:</p><pre id="code"></pre><button id="copy-code">复制消息</button><button id="confirm-code">立即检查</button><button id="retry-code" class="secondary" hidden>重新连接</button></section>
29
- <section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh">刷新项目状态</button></div><p id="profile-repo"></p><details id="profile-diagnostics" hidden><summary>查看本机配置路径</summary><pre id="config-path"></pre><button id="copy-config-path">复制配置路径</button></details><p id="next-step" class="next-step" role="status"></p><ol id="setup-steps" class="setup-steps"></ol><details><summary>连接检查详情</summary><div id="checks"></div></details><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP,将下面的指令发到要绑定的对话。Skill 会完成身份检查、对话绑定和轮询启用。完成后本页自动更新实际连接状态。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button><details><summary>无法识别 Skill 时使用详细指令</summary><pre id="binding-details"></pre><button id="copy-binding-details">复制详细指令</button></details></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
28
+ <section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh">刷新项目状态</button></div><p id="profile-repo"></p><details id="profile-diagnostics" hidden><summary>查看本机配置路径</summary><pre id="config-path"></pre><button id="copy-config-path">复制配置路径</button></details><p id="next-step" class="next-step" role="status"></p><ol id="setup-steps" class="setup-steps"></ol><details><summary>连接检查详情</summary><div id="checks"></div></details><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP(已打开的项目请重新加载 MCP),将下面的指令发到要绑定的对话。Skill 会完成身份检查、对话绑定和轮询启用。完成后本页自动更新实际连接状态。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button><details><summary>无法识别 Skill 时使用详细指令</summary><pre id="binding-details"></pre><button id="copy-binding-details">复制详细指令</button></details></div><div id="diagnostic-actions" hidden><button id="run-diagnostics" class="secondary">检查整条连接</button><button id="repair-project" class="secondary">修复本机配置</button><div id="diagnostic-result" role="status"></div></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
30
29
  <details id="manage-project"><summary>管理与重置</summary><p>选择要调整的配置,先查看影响,再确认。每次只操作当前 Agent 和 clone。</p><div class="manage-actions"><button id="reset-conversation" class="secondary">重新绑定对话</button><button id="reset-robot" class="secondary">更新机器人凭据</button><button id="reset-project" class="danger">移除本机接入</button></div></details>
31
30
  <div id="admin-tools" hidden><details><summary>团队分工、审核与通知设置</summary><h3>团队 Agent 与分工</h3><div id="members"></div><div id="owners"></div><button id="add-own-agent" class="secondary">给自己增加机器人</button><div id="routes"></div><h3>真人审核与通知</h3><p>按职责设置:阶段结果提交后由谁审核、通知谁。关闭中间审核可直接接力;最终提交仍需真人审核,并由管理员完成任务。关闭审核的职责作为最后阶段时由管理员审核。</p><p id="review-policy-note" class="muted"></p><form id="review-form" hidden><div id="review-rules"></div><button type="submit">保存审核规则</button></form><p class="muted">规则保存后用于新任务。现有任务继续使用创建时的规则。审核通知附带分支、提交、测试和可复制的群命令;企业微信原生 @ 的显示效果需要在实际群中确认。</p></details><h3>批量分配机器人给同事</h3><p>每行分配一个独立机器人。同一个同事可以领取多行,分别绑定独立项目和对话。接入包密码自动生成。</p>
32
31
  <form id="batch-form"><div id="assignment-rows"></div><div class="row"><button id="add-assignment" type="button" class="secondary">增加一位成员/机器人</button><button type="submit">生成全部接入包</button></div></form>
@@ -5,3 +5,6 @@ form[hidden]{display:none}fieldset{border:1px solid #dbe6df;border-radius:9px;pa
5
5
  button[hidden],details[hidden],label[hidden]{display:none}details{margin:24px 0}summary{cursor:pointer;font-weight:600;line-height:1.7}.manage-actions{display:flex;gap:12px;flex-wrap:wrap}.danger{background:#fff;border:1px solid #d9aca4;color:#943c30}#reset-effects{line-height:1.9;padding-left:24px}#reset-target{overflow-wrap:anywhere}#reset-verification{padding:16px;background:#f0f5f2;border-radius:8px}
6
6
 
7
7
  #setup-steps{display:flex;flex-wrap:wrap;gap:10px;list-style:none;padding:0;margin:22px 0}#setup-steps li{font-size:13px;border:1px solid #e0e7e2;border-radius:7px;padding:9px 12px;color:#738379}#setup-steps .done{color:#176b4a;background:#eaf5ed;border-color:#cbe4d3}#setup-steps .active{color:#845d26;background:#fff5e2;border-color:#ebd29f}#next-step{padding:14px 16px;background:#edf6f0;border-radius:8px}#delivery-panel{padding:18px;background:#f6f9f7;border:1px solid #dbe6df;border-radius:9px;margin-top:20px}#delivery-panel h3{margin-top:0}#delivery-panel button{margin:6px 6px 6px 0}.assignment details{margin:0}.assignment>button{justify-self:start;background:#fff;color:#657970;border-color:#c9dacf}#assignment-rows{display:grid;gap:14px}#assignment-rows fieldset{margin:0}@media(max-width:640px){#setup-steps{gap:7px}#setup-steps li{flex:1 1 42%}.card>a{display:inline-block}.card{gap:14px}}
8
+
9
+ .entry-options{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:12px 0 24px}.entry-options button{text-align:left;padding:24px;font-size:19px}.entry-options small{display:block;margin-top:10px;font-size:13px;font-weight:normal;line-height:1.6}#diagnostic-result p{line-height:1.7}#diagnostic-actions{margin:18px 0}
10
+ form,label,fieldset,details,input,select{min-width:0}#invite-remote{overflow-wrap:anywhere}
@@ -0,0 +1,36 @@
1
+ import { homedir, userInfo } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { readFile, readdir, realpath } from 'node:fs/promises';
4
+ import { git } from '../../packages/peer/store.mjs';
5
+
6
+ export const workspaceHome = () => resolve(process.env.CODEX_COLLAB_HOME || join(homedir(), '.config/codex-collab/workspace'));
7
+ export async function localDefaults(cwd = process.cwd()) {
8
+ let repo = '';
9
+ try { repo = await git(cwd, 'rev-parse', '--show-toplevel'); } catch { /* Opening outside a project is supported. */ }
10
+ return { repo, display_name: userInfo().username };
11
+ }
12
+ export async function profiles(home = workspaceHome()) {
13
+ const root = join(home, 'profiles'); const result = [];
14
+ const entries = await readdir(root).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
15
+ for (const id of entries) {
16
+ if (!/^[a-f0-9-]{36}$/.test(id)) continue;
17
+ const state = JSON.parse(await readFile(join(root, id, 'setup.json'), 'utf8'));
18
+ if (state.phase !== 'removed') result.push({ ...state, config_path: state.existing_config || join(root, id, 'peer.json') });
19
+ }
20
+ return result;
21
+ }
22
+ export async function selectProfile({ home = workspaceHome(), selector, cwd = process.cwd() } = {}) {
23
+ let choices = (await profiles(home)).filter((state) => state.phase === 'ready');
24
+ if (selector) choices = choices.filter((state) => state.id === selector || state.agent_id === selector);
25
+ else {
26
+ const { repo } = await localDefaults(cwd);
27
+ if (repo) {
28
+ const matches = [];
29
+ for (const state of choices) if (await realpath(state.repo).catch(() => '') === repo) matches.push(state);
30
+ if (matches.length) choices = matches;
31
+ }
32
+ }
33
+ if (choices.length === 1) return choices[0];
34
+ if (!choices.length) throw new Error('未找到已接入项目。请先运行 codex-collab 完成接入。');
35
+ throw new Error(`找到多个接入,请使用 --profile <项目ID> 选择:\n${choices.map((state) => `${state.id} ${state.agent_id} ${state.repo}`).join('\n')}`);
36
+ }
@@ -0,0 +1,19 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ // Kept separately from setup.json: CLI stop must win over an open browser's stale state.
6
+ export async function readRunnerIntent(dataDir) {
7
+ try {
8
+ const value = JSON.parse(await readFile(join(dataDir, 'runner-intent.json'), 'utf8'));
9
+ if (typeof value.enabled !== 'boolean') throw new Error('Invalid runner intent');
10
+ return value;
11
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
12
+ }
13
+ export async function writeRunnerIntent(dataDir, enabled) {
14
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
15
+ const path = join(dataDir, 'runner-intent.json'); const temporary = `${path}.${randomUUID()}.tmp`;
16
+ const value = { enabled, updated_at: new Date().toISOString() };
17
+ await writeFile(temporary, JSON.stringify(value) + '\n', { mode: 0o600, flag: 'wx' }); await rename(temporary, path);
18
+ return value;
19
+ }
@@ -45,3 +45,16 @@ export async function acquireRunnerLock(dataDir, configPath) {
45
45
  }
46
46
  throw new Error('cannot acquire local Runner lock');
47
47
  }
48
+
49
+ // Serialize CLI/supervisor start and stop across processes. The runner's own lock
50
+ // still prevents duplicate bot connections; this control lock orders user intent.
51
+ export async function acquireRunnerControl(dataDir, configPath) {
52
+ const deadline = Date.now() + 35000;
53
+ while (true) {
54
+ try { return await acquireRunnerLock(join(dataDir, 'runner-control'), configPath); }
55
+ catch (error) {
56
+ if (!error.message.startsWith('Runner already active') || Date.now() > deadline) throw error;
57
+ await delay(100);
58
+ }
59
+ }
60
+ }
@@ -12,15 +12,20 @@ function usage() {
12
12
  console.log(`Codex Collab
13
13
 
14
14
  Usage:
15
- codex-collab setup [--no-open]
15
+ codex-collab setup [--no-open] [--foreground] [--no-startup]
16
16
  codex-collab setup --terminal [--role admin|member]
17
17
  codex-collab
18
18
  codex-collab identity create <agent-id> [--dir /absolute/private/directory]
19
19
  codex-collab wecom discover
20
- codex-collab refresh-project /absolute/private-peer.json
21
- codex-collab <doctor|start|stop|status|list|read|review|complete|reopen|...> /abs/private-peer.json [args]
20
+ codex-collab <status|start|stop|doctor|repair> [--profile <project-id|agent-id>]
21
+ codex-collab projects
22
+ codex-collab autostart on|off|status
23
+ codex-collab service-status
24
+ codex-collab refresh-project [--profile <project-id|agent-id>]
25
+ codex-collab <list|read|review|complete|reopen|...> [--profile <id>] [args]
26
+ Advanced: commands also accept /absolute/private-peer.json before args.
22
27
 
23
- setup opens the local web wizard. --terminal keeps the legacy terminal workflow. Never pass a bot Secret as a command argument.`);
28
+ setup opens the local workspace and runs its service in the background. It restores enabled connections at login on macOS and Linux with a user service. --terminal keeps the legacy terminal workflow. Never pass a bot Secret as a command argument.`);
24
29
  }
25
30
 
26
31
  async function discoverWeCom() {
@@ -70,9 +75,10 @@ async function main() {
70
75
  }
71
76
 
72
77
  if (args[0] === 'refresh-project') {
73
- if (args.length !== 2) throw new Error('usage: codex-collab refresh-project /absolute/private-peer.json');
78
+ const { resolveCommand } = await import('../apps/onboarding/commands.mjs');
79
+ const { configPath } = await resolveCommand(args);
74
80
  const { refreshProject } = await import('../scripts/peer/refresh-project.mjs');
75
- console.log(JSON.stringify(await refreshProject({ configPath: args[1] }), null, 2));
81
+ console.log(JSON.stringify(await refreshProject({ configPath }), null, 2));
76
82
  return;
77
83
  }
78
84
 
@@ -84,19 +90,25 @@ async function main() {
84
90
 
85
91
  if (args[0] === 'setup' || args.length === 0) {
86
92
  const options = args[0] === 'setup' ? args.slice(1) : [];
87
- let role, terminal = false, open = true;
93
+ let role, terminal = false, open = true, foreground = false, startup = true;
88
94
  for (let i = 0; i < options.length; i++) {
89
95
  if (options[i] === '--role' && ['admin', 'member'].includes(options[i + 1])) { role = options[++i]; terminal = true; }
90
96
  else if (options[i] === '--terminal') terminal = true;
91
97
  else if (options[i] === '--no-open') open = false;
98
+ else if (options[i] === '--foreground') foreground = true;
99
+ else if (options[i] === '--no-startup') startup = false;
92
100
  else throw new Error('usage: codex-collab setup [--no-open] or setup --terminal [--role admin|member]');
93
101
  }
94
102
  if (terminal) { const { runSetupWizard } = await import('../scripts/peer/onboarding.mjs'); await runSetupWizard({ role }); }
95
- else { const { runLocalSetup } = await import('../apps/onboarding/main.mjs'); await runLocalSetup({ open }); }
103
+ else { const { runLocalSetup } = await import('../apps/onboarding/main.mjs'); await runLocalSetup({ open, foreground, startup }); }
96
104
  return;
97
105
  }
98
106
 
99
- process.argv = [process.execPath, join(packageRoot, 'scripts/peer/cli.mjs'), ...args];
107
+ const { workspaceCommand, resolveCommand, guidedCommand } = await import('../apps/onboarding/commands.mjs');
108
+ if (await workspaceCommand(args)) return;
109
+ const resolved = await resolveCommand(args);
110
+ if (await guidedCommand(resolved)) return;
111
+ process.argv = [process.execPath, join(packageRoot, 'scripts/peer/cli.mjs'), resolved.command, resolved.configPath, ...resolved.args];
100
112
  await import('../scripts/peer/cli.mjs');
101
113
  }
102
114
 
@@ -1,6 +1,6 @@
1
1
  # 网页向导功能检查与测试记录
2
2
 
3
- 最近检查日期:2026-10-14。对象是工具仓库中的网页向导源码,初始记录对应 0.3.1 的发布前验证;后面的补充记录覆盖后续源码变更。
3
+ 最近检查日期:2026-10-22。对象是工具仓库中的网页向导源码,初始记录对应 0.3.1 的发布前验证;后面的补充记录覆盖后续源码变更。
4
4
 
5
5
  ## 测试范围
6
6
 
@@ -92,7 +92,7 @@ Skill 校验通过。`npm pack --dry-run` 确认发布包包含 `guided.mjs`、`
92
92
  | 更换 Bot ID、变更群 | 当前可在页面更新同一机器人的 Secret;更换 Bot ID 或群需移除接入,再使用管理员的新接入包 |
93
93
  | 撤销邀请、禁用/移除成员、轮换签名身份 | 当前没有产品化入口;需要补签名协议、权限与历史兼容,不能仅删本机文件 |
94
94
  | 修改既有成员职责、重排首选与候补 | 页面只展示已有分工;生成邀请时可以指定新成员优先级 |
95
- | 缺失 MCP/Skill 的一键修复 | 当前能识别缺失,但现有 Skill、受版本管理的配置等需要检查;不会自动覆盖 |
95
+ | 自定义 MCP/Skill 的自动合并 | 0.5.0 可恢复缺失的生成配置;自定义、受版本管理或符号链接文件继续要求人工检查 |
96
96
  | 确认 heartbeat 持续启用 | 页面可解除旧绑定并观察最近轮询;暂停旧 heartbeat、启用新 heartbeat 仍需在对应 Codex 对话完成 |
97
97
  | 页面内展示日志与详细 Git 诊断 | 当前只有安全错误提示、私有配置路径;详细日志仍需本机查看 |
98
98
  | 多管理员配置冲突的页面引导 | 自动刷新保留未保存编辑,提示策略版本已变化;后端拒绝过时保存,仍需刷新重新确认 |
@@ -105,3 +105,13 @@ Skill 校验通过。`npm pack --dry-run` 确认发布包包含 `guided.mjs`、`
105
105
  - 应用退出、电脑休眠、heartbeat 暂停与网络恢复后,候补接管与旧执行者的租约隔离。
106
106
 
107
107
  页面的“项目 MCP / Skill 已就绪”表示本机文件已配置,并非读取了 Codex 的实际加载状态。近期成功轮询是额外证据,仍不能保证未来自动化持续运行。Git 启动检查与任务执行中的 Git 失败属于不同时间点,初始化通过不代表未来的 push 永远成功。
108
+
109
+ ## 2026-10-22 · 0.5.0 接入简化与后台运行
110
+
111
+ 完整自动测试通过 178 项,0 失败。新增覆盖后台工作台脱离终端、重复打开复用、会话鉴权与私有文件权限、已启用连接恢复与退避、主动停止后保持离线、升级迁移与并发停止、无需路径的项目选择,以及缺失 MCP/Skill 的安全修复。新增诊断和修复接口沿用本机会话与 Origin 校验。
112
+
113
+ 使用真实本机 Runner、临时业务 Git 仓库且不配置外部机器人,实际启动 Runner,强制结束进程,再通过后台监管恢复至不同 PID;执行停止命令后再次检查,连接保持离线。单独注册临时 macOS LaunchAgent,实际启动工作台并强制结束,确认 launchd 恢复至不同 PID,随后移除临时注册。测试没有接管个人现有机器人。
114
+
115
+ 使用本机 Chrome 实际检查:首页双入口、当前项目与本机名称默认值、验证命令推荐、连接诊断与修复、邀请文件上传和密码输入后自动读取、推荐 clone 目录。桌面与 390px 手机视口检查通过,邀请信息展开后没有横向溢出,页面没有脚本错误。机器人认证仍使用隔离测试连接,未连接真实企业微信群或创建 Codex 自动化。
116
+
117
+ 实际 npm tarball 在独立目录安装,命令入口、后台启动、页面资源、带会话鉴权的 API 与服务状态检查均通过。发布包包含 74 个文件,新增运行模块齐全,不包含本机凭据、运行日志或测试目录。Linux 仅验证 systemd 配置生成和失败处理;Windows 登录自启尚不支持。尚未实际退出登录或重启电脑,也未完成跨机器企业微信、真实 Codex 自动化与睡眠恢复的联合验收。
@@ -10,18 +10,18 @@
10
10
 
11
11
  ```sh
12
12
  npm install --global xuym-codex-collab
13
- codex-collab setup
13
+ codex-collab
14
14
  ```
15
15
 
16
- 网页自动打开;若未打开,执行 `codex-collab setup --no-open`,复制终端打印的本机链接。保留此终端以使用工作台。团队成员无需 clone 工具仓库。也可执行 `npx xuym-codex-collab@latest setup` 直接打开向导。
16
+ 网页自动打开;若未打开,执行 `codex-collab setup --no-open`,复制终端打印的本机链接。工作台已在后台运行,可以关闭终端和浏览器。macOS 默认设置登录自启,Linux 尝试设置 systemd 用户服务;未配置成功时页面和终端会明确提示。手动停止的连接不会在登录或后台检查时重新启动。团队成员无需 clone 工具仓库。也可执行 `npx xuym-codex-collab@latest setup` 临时打开向导;长期运行推荐全局安装,以便登录服务使用稳定安装路径。
17
17
 
18
18
  同一台电脑模拟两位成员时,使用同一业务仓库的两个独立 clone、两个机器人、两个 Codex 对话;同一个 clone 只能接入一个身份。同一个企业微信账号可以拥有多个 Agent,每个 Agent 的机器人、签名身份、clone 和对话独立。真正同事使用自己的企业微信账号和 Git 凭据。
19
19
 
20
20
  ## 管理员操作
21
21
 
22
22
  1. 在企业微信创建独立智能机器人,获取 Bot ID 和 Secret,拉进协作群。为每位成员分别创建机器人并加入同一个群;真人同事也加入群。
23
- 2. 工作台选择“初始化团队”。填写团队名称,选择本机项目目录或 Git 地址与新目录。点击“检查项目并推荐验证命令”,确认或修改推荐命令,例如 `npm test`、`go test ./...` 或 `sh test.sh`。检查只读取常见测试入口,不执行项目脚本;实际任务使用确认后的固定程序与参数,不支持管道或命令拼接。
24
- 3. 填写自己的显示名称、选择主要职责,输入自己的 Bot ID/Secret。名称可以是 `Alice` 或“张三”;内部 Agent ID 自动生成,自定义身份和标签可在折叠选项中调整。点击“创建团队并连接机器人”。
23
+ 2. 工作台选择“连接我的机器人”。当前目录属于 Git 项目时会自动带入项目根目录,并带入本机用户名。确认项目,或选择 Git 地址拉取新目录(目录可留空自动生成)。已有项目自动检查常见测试入口,也可点击“检查项目并推荐验证命令”;确认或修改推荐命令,例如 `npm test`、`go test ./...` 或 `sh test.sh`。检查只读取常见测试入口,不执行项目脚本;实际任务使用确认后的固定程序与参数,不支持管道或命令拼接。
24
+ 3. 填写自己的显示名称、选择主要职责,输入自己的 Bot ID/Secret。名称可以是 `Alice` 或“张三”;内部 Agent ID 自动生成,自定义身份和标签可在折叠选项中调整。点击“连接并进入群验证”。
25
25
  4. 用自己的企业微信账号 @ 此机器人,发送页面显示的 `协作 接入 六位数字`。页面自动发现群 `chatid` 与本人 `userid`,创建身份、签名账本,配置项目 MCP/Skill 并启动本机机器人 Runner;无需再点击继续。群验证中刷新页面可以恢复进度。
26
26
  5. 项目页面“批量分配机器人给同事”:每行填写显示名称、选择职责、填写独立机器人的 Bot ID/Secret,点击“增加一位成员/机器人”添加更多行。默认设为该职责的首选执行者并允许所有者群命令;候补与自定义 ID/职责在折叠选项中调整。一次最多 25 行。
27
27
  6. 点击“生成全部接入包”。密码自动生成;在“已分配的机器人”逐个下载加密 JSON 文件、查看并复制接入说明。把文件和说明交给指定成员,密码通过另一条可信渠道单独发送。说明不包含密码或 Secret。包默认 7 天有效,同一成员或机器人不会同时分发多个有效邀请;重复 ID/Bot 的批次会在发布前拒绝。
@@ -38,7 +38,7 @@ codex-collab setup
38
38
  2. 在管理员项目的“已分配的机器人”中,打开对应成员的“查看接入说明与密码”。如果显示密码缺失,点击“重新生成接入包与密码”。
39
39
  3. 页面会预填原 Bot ID;管理员在本机输入该机器人的 Secret。工具保留原 Agent 与职责,生成新加密文件和新密码,并在 Git 签名版本链中撤销旧邀请。Secret 不会发到聊天或写入 Git。
40
40
  4. 通过可信渠道分别交给成员新接入包和新密码。旧文件以及基于旧包创建、尚未批准的申请都不能再使用。
41
- 5. 成员在“加入团队”选择 Bob 原来的本机 clone,上传新包并输入新密码,点击“读取接入信息”后再接入。工具会在确认签名替换关系后复用该 clone 的本地身份记录,清除旧申请并要求重新验证群身份;管理员随后确认新申请。
41
+ 5. 成员在“接受同事邀请”选择 Bob 原来的本机 clone,上传新包并输入新密码,选中文件并填完密码后,信息会自动读取;也可点击“重新读取接入信息”后再接入。工具会在确认签名替换关系后复用该 clone 的本地身份记录,清除旧申请并要求重新验证群身份;管理员随后确认新申请。
42
42
 
43
43
  该恢复入口仅用于未批准的普通成员邀请。已获批准的成员不能重签,应继续使用已有本机配置;管理员自己的机器人也不使用分发包恢复流程。若 Git 发布中断,半成品不会显示为可下载包;回到原邀请重试生成即可复用同一恢复记录。
44
44
 
@@ -50,8 +50,8 @@ codex-collab setup
50
50
 
51
51
  ## 成员操作
52
52
 
53
- 1. 从管理员取得自己的加密接入包、接入说明和单独发送的密码,打开 `codex-collab setup` 或 `npx xuym-codex-collab@latest setup`,选择“加入团队”。
54
- 2. 选择接入包文件、输入密码。可以点击“读取接入信息”查看团队、Agent、职责、仓库和建议目录。默认“帮我拉取到新目录”,可修改保存路径;也可切换“使用本机已有项目”。点击“接入我的机器人”。工具先验证接入包再 clone,使用本机 Git 登录;已有项目必须属于包内同一 remote,支持同一仓库的 SSH/HTTPS 表达。
53
+ 1. 从管理员取得自己的加密接入包、接入说明和单独发送的密码,打开 `codex-collab setup` 或 `npx xuym-codex-collab@latest setup`,选择“接受同事邀请”。
54
+ 2. 选择接入包文件、输入密码。填完后信息会自动读取;也可点击“重新读取接入信息”查看团队、Agent、职责、仓库和建议目录。默认“帮我拉取到新目录”,可修改保存路径;也可切换“使用本机已有项目”。点击“接入我的机器人”。工具先验证接入包再 clone,使用本机 Git 登录;已有项目必须属于包内同一 remote,支持同一仓库的 SSH/HTTPS 表达。
55
55
  3. 用**自己的企业微信账号** @ 分配给你的机器人,发送页面验证码。页面自动生成签名身份,把带公钥的入队申请写进协作分支,并进入等待批准;无需返回点击确认。刷新页面可以恢复当前验证或申请进度。
56
56
  4. 管理员明确批准后,页面自动检查 Git 权限、写入项目本地 MCP 和 Skill、启动机器人连接。正常流程无需点击“继续接入”;Git/配置异常时会停在可恢复的步骤,处理后再明确重试。
57
57
  5. 在 Codex 打开页面上的业务项目,信任项目并加载 MCP/Skill。点击“复制启用指令”,粘贴到要代表你的**已有对话**,例如:
@@ -171,3 +171,19 @@ Secret 若曾发到聊天或 Git,应在企业微信重新生成后更新本机
171
171
  重置预览有效期为十分钟。绑定、任务或文件变化后需重新预览;空闲轮询不会影响新 Secret 的群验证。当前 Agent 有有效任务租约时会拒绝重置,需先结束本阶段或在原对话报告失败;Git 无法访问时也不强行清理。自定义 Skill、自定义 MCP、Git 已跟踪或符号链接文件会阻止自动移除。
172
172
 
173
173
  操作前的私有备份位于向导配置目录的 `reset-backups/`,完成提示会显示完整路径。它可能包含私钥、旧 Secret 与本机任务信息,权限为 `0600`;目录权限为 `0700`,不要分享或提交。业务代码、共享任务分支、未提交改动和任务 worktree 保留。页面“移除”提供可恢复的解绑,并非清除所有私人数据;不再需要恢复时按本机配置路径自行清理备份与旧身份。
174
+
175
+ ## 0.5.0 后台连接与排查
176
+
177
+ 正常入口是 `codex-collab`;`setup` 保留为同一入口。首次在项目目录运行会带入项目路径与本机显示名称。团队名称、内部身份与职责标签收在更多设置中,仍需确认适合项目的验证命令。
178
+
179
+ 邀请文件和密码填好后自动展示成员、职责与项目。默认拉取到推荐目录;可以留空目录直接接入,也可以选择已有独立 clone。给自己增加机器人同样支持自动拉取独立目录。
180
+
181
+ 后台工作台与浏览器生命周期独立。macOS 使用 LaunchAgent;Linux 使用 systemd 用户服务。服务保留启动时的 Node 路径和 PATH,升级或更换 Node 安装位置后可运行 `codex-collab autostart on` 更新。自启注册失败会显示说明,当前会话仍尝试使用独立后台进程。Windows 暂时只支持独立后台进程。
182
+
183
+ 每 15 秒检查已开启的机器人进程。恢复失败按 15、30、60 秒等间隔退避,最长五分钟;机器人 SDK 本身处理连接重试。主动停止、移除、重置后的接入不会被恢复。0.4.x 的离线连接不会被迁移为自动启动。
184
+
185
+ 在业务项目目录执行 `codex-collab doctor`,或在页面点击“检查整条连接”,可以看到每个环节与处理办法。此检查读取真实状态并测试 Git 推送权限,不创建或执行业务测试任务;它不能证明未来的 Codex 自动化一定唤醒对话。`codex-collab repair` 更新受管理的项目 MCP 与 Skill,恢复缺失 MCP 配置和 Skill;自定义或共享文件会拒绝覆盖。之后仍需重新加载 MCP。
186
+
187
+ `codex-collab status|start|stop|doctor|repair` 不再需要私有配置路径。多个接入时用 `codex-collab projects` 查看,再加 `--profile <项目ID>`;Agent ID 在本机唯一时也可使用。旧显式配置路径命令保留。`CODEX_COLLAB_HOME` 可指定独立私有工作台目录,用于隔离环境;不要放进业务仓库。
188
+
189
+ `stop` 断开机器人并持久化停止意图;它不暂停 Codex 对话内的自动化。需要暂停整个协作时,还应在对应对话中暂停自动化。
@@ -0,0 +1,14 @@
1
+ # 0.5.0 · 简化接入和后台运行
2
+
3
+ 日期:2026-10-22。
4
+
5
+ - 首页提供“连接我的机器人”和“接受同事邀请”两个入口;带入当前 Git 项目和本机名称,自动推荐验证命令;团队名称与内部 ID 收进更多设置。
6
+ - 输入邀请文件与密码后自动读取信息;成员默认自动拉取推荐目录,给自己增加机器人也支持自动创建独立 clone。
7
+ - 普通启动命令退出后工作台留在后台。macOS 默认设置 LaunchAgent,Linux 尝试设置 systemd 用户服务;不支持或注册失败时给出明确状态。保留前台排查与跳过自启选项。
8
+ - 后台只恢复已启用的机器人进程,失败后退避。持久化停止意图,串行处理跨进程启停,防止后台检查撤销用户的停止操作。迁移只接管仍运行的旧连接。
9
+ - 增加无需配置路径的 status/start/stop/doctor/repair、项目选择、自启管理与后台服务状态命令;旧显式路径兼容。
10
+ - 页面增加完整连接诊断与保守修复:真实进程、企微认证、绑定和轮询、Git 读写检查;恢复缺失的 MCP 配置和生成 Skill,自定义文件拒绝覆盖。
11
+
12
+ 企业微信机器人创建、管理员入队批准、选择真实 Codex 对话并启用自动化仍需要本人操作。此版本不引入云端中转、不自动创建业务任务作连接测试。
13
+
14
+ 验证详情见 `docs/onboarding-audit.md` 的 2026-10-22 条目。Windows 登录自启、真实重启登录、跨机器企业微信和 Codex 自动化联合验收仍需分别验证。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xuym-codex-collab",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Git-backed collaboration tools for Codex teams, with MCP, WeCom, and local runners.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,9 +6,11 @@ import { join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { loadRuntime, readPeerConfig } from '../../apps/peer/runtime.mjs';
9
- import { alive, readRunnerLock } from '../../apps/peer/process.mjs';
9
+ import { alive, readRunnerLock, acquireRunnerControl } from '../../apps/peer/process.mjs';
10
10
  import { git } from '../../packages/peer/store.mjs';
11
11
 
12
+ import { readRunnerIntent, writeRunnerIntent } from '../../apps/peer/intent.mjs';
13
+
12
14
  const root = fileURLToPath(new URL('../../', import.meta.url));
13
15
  const [command, configArg, ...args] = process.argv.slice(2);
14
16
  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 +22,8 @@ async function running() {
20
22
  return state && alive(state.pid) ? state : null;
21
23
  }
22
24
 
25
+ const releaseControl = ['start', 'recover', 'stop'].includes(command) ? await acquireRunnerControl(config.data_dir, configPath) : null;
26
+ try {
23
27
  switch (command) {
24
28
  case 'probe': {
25
29
  try {
@@ -58,7 +62,9 @@ switch (command) {
58
62
  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
63
  break;
60
64
  }
61
- case 'start': {
65
+ case 'start': case 'recover': {
66
+ if (command === 'recover') { if (!(await readRunnerIntent(config.data_dir))?.enabled) break; }
67
+ else await writeRunnerIntent(config.data_dir, true);
62
68
  const current = await running();
63
69
  if (current) { console.log(`Runner already online (PID ${current.pid})`); break; }
64
70
  await mkdir(config.data_dir, { recursive: true, mode: 0o700 });
@@ -81,10 +87,11 @@ switch (command) {
81
87
  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
88
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
83
89
  }
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));
90
+ 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
91
  break;
86
92
  }
87
93
  case 'stop': {
94
+ await writeRunnerIntent(config.data_dir, false);
88
95
  const state = await running();
89
96
  if (!state) { console.log('Runner offline'); break; }
90
97
  if (state.config !== configPath) throw new Error('PID configuration mismatch; refusing to signal');
@@ -114,3 +121,5 @@ switch (command) {
114
121
  }
115
122
  default: throw new Error('unknown peer command');
116
123
  }
124
+
125
+ } finally { await releaseControl?.(); }
@@ -9,11 +9,12 @@ const root = fileURLToPath(new URL('../../', import.meta.url));
9
9
  const legacyHashes = new Set(['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,