xuym-codex-collab 0.4.3 → 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
@@ -72,13 +91,20 @@ sequenceDiagram
72
91
 
73
92
  自然语言入口使用 `desktop_poll`,沿用本人身份和群权限。对话从团队路由选择职责,指定的同事不存在或分工不明确时需要追问;机器人显示名不决定调度。旧的 `协作 创建 ...` 命令仍兼容。
74
93
 
75
- Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
94
+ Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核提示。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
76
95
 
77
- 自然语言“通过”或“完成”不会批准任务;真人审核仍复制通知中的明确命令。已有安装须同时更新工具、项目 Skill、MCP 和原对话的 heartbeat 提示词,步骤见 [已有项目升级](docs/desktop-poll.md#已有项目启用自然语言入口)。
96
+ 审核也支持自然语言:指定审核人 @ 自己的机器人说“通过,继续下一步”,或“先别通过,安装步骤再补一下”。只有一条待审核任务时无需复制任务 ID 和版本号;多条时机器人先请你选任务。只说“收到”、带条件的“如果测试通过就批准”不会触发通过;退回缺少具体意见时先追问。已有安装须由所有参与者一起更新工具、重启 Runner/MCP、刷新项目 Skill 和原对话的 heartbeat 提示词,步骤见 [已有项目升级](docs/desktop-poll.md#已有项目启用自然语言入口)。
78
97
 
79
98
  代码保存在独立 `codex/*` 分支;任务和签名策略保存在 `agent-collab`,两者不自动合并到 `main`。最后阶段始终等待真人审核,审核通过后由管理员完成任务。
80
99
 
81
- 审核人在群里 @ 自己绑定的机器人,复制最新通知中的任务 ID 和 `R-版本号`:
100
+ 审核人在群里 @ 自己绑定的机器人直接表达决定:
101
+
102
+ ```text
103
+ @Alice 通过,交给 Bob 继续。
104
+ @Bob 先别通过,安装步骤再补一下。
105
+ ```
106
+
107
+ 自然语言审核按收到消息时的待审核任务与提交版本处理;任务已经变化时,需要看最新结果后重新确认。审核通过会按已有分工交接,不修改职责或跳过阶段。最终审核通过后仍由管理员明确完成任务。旧的精确命令也可继续使用:
82
108
 
83
109
  ```text
84
110
  协作 审核 T-实际任务ID R-7 通过
@@ -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
+ }