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 +31 -5
- package/apps/onboarding/commands.mjs +46 -0
- package/apps/onboarding/guided.mjs +1 -0
- package/apps/onboarding/main.mjs +72 -19
- package/apps/onboarding/manager.mjs +57 -14
- package/apps/onboarding/reset.mjs +3 -1
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/startup.mjs +61 -0
- package/apps/onboarding/supervisor.mjs +41 -0
- package/apps/onboarding/web/app.js +41 -11
- package/apps/onboarding/web/index.html +14 -15
- package/apps/onboarding/web/style.css +3 -0
- package/apps/onboarding/workspace.mjs +36 -0
- package/apps/peer/desktop.mjs +2 -1
- package/apps/peer/intent.mjs +19 -0
- package/apps/peer/mcp.mjs +4 -2
- package/apps/peer/process.mjs +13 -0
- package/apps/peer/wecom.mjs +6 -4
- package/bin/codex-collab.mjs +21 -9
- package/docs/desktop-poll.md +20 -5
- package/docs/onboarding-audit.md +12 -2
- package/docs/onboarding.md +26 -10
- package/docs/peer.md +5 -3
- package/docs/releases/0.5.0.md +14 -0
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +5 -4
- package/packages/peer/inbox.mjs +11 -4
- package/packages/peer/natural-review.mjs +105 -0
- package/packages/peer/service.mjs +31 -9
- package/packages/peer/store.mjs +28 -3
- package/schemas/peer-inbox-event.schema.json +181 -3
- package/schemas/peer-inbox.schema.json +40 -1
- package/scripts/peer/cli.mjs +12 -3
- package/scripts/peer/refresh-project.mjs +23 -15
- package/scripts/peer/setup-desktop-project.mjs +1 -1
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global xuym-codex-collab
|
|
11
|
-
codex-collab
|
|
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
|
|
94
|
+
Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核提示。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
|
|
76
95
|
|
|
77
|
-
|
|
96
|
+
审核也支持自然语言:指定审核人 @ 自己的机器人说“通过,继续下一步”,或“先别通过,安装步骤再补一下”。只有一条待审核任务时无需复制任务 ID 和版本号;多条时机器人先请你选任务。只说“收到”、带条件的“如果测试通过就批准”不会触发通过;退回缺少具体意见时先追问。已有安装须由所有参与者一起更新工具、重启 Runner/MCP、刷新项目 Skill 和原对话的 heartbeat 提示词,步骤见 [已有项目升级](docs/desktop-poll.md#已有项目启用自然语言入口)。
|
|
78
97
|
|
|
79
98
|
代码保存在独立 `codex/*` 分支;任务和签名策略保存在 `agent-collab`,两者不自动合并到 `main`。最后阶段始终等待真人审核,审核通过后由管理员完成任务。
|
|
80
99
|
|
|
81
|
-
审核人在群里 @
|
|
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
|
}
|
package/apps/onboarding/main.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
36
|
+
if (!lock) throw new Error('无法取得本机工作台锁。');
|
|
37
|
+
let app, timer, stopping = false;
|
|
23
38
|
try {
|
|
24
|
-
|
|
25
|
-
await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
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
|
+
}
|