xuym-codex-collab 0.4.0 → 0.4.2
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 +27 -5
- package/apps/onboarding/discovery.mjs +4 -0
- package/apps/onboarding/guided.mjs +105 -0
- package/apps/onboarding/manager.mjs +151 -19
- package/apps/onboarding/reset.mjs +247 -0
- package/apps/onboarding/server.mjs +2 -1
- package/apps/onboarding/web/app.js +216 -38
- package/apps/onboarding/web/index.html +30 -7
- package/apps/onboarding/web/style.css +4 -0
- package/apps/peer/desktop.mjs +2 -0
- package/apps/peer/runtime.mjs +2 -1
- package/apps/peer/wecom.mjs +6 -4
- package/docs/desktop-poll.md +1 -1
- package/docs/onboarding-audit.md +27 -8
- package/docs/onboarding.md +49 -24
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +3 -2
- package/packages/peer/inbox.mjs +4 -2
- package/packages/peer/service.mjs +8 -4
- package/schemas/peer-inbox.schema.json +1 -1
- package/scripts/peer/refresh-project.mjs +1 -1
- package/scripts/peer/setup-desktop-project.mjs +3 -0
package/README.md
CHANGED
|
@@ -4,24 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
## 安装与入口
|
|
6
6
|
|
|
7
|
-
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push
|
|
7
|
+
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有 `main` 分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global xuym-codex-collab
|
|
11
11
|
codex-collab setup
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`setup`
|
|
14
|
+
`setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“初始化团队”,成员选择“加入团队”;群身份验证、等待审批和本机配置会自动衔接,页面显示当前进度。已在使用旧配置的成员选择“导入已有配置”。终端保持运行即可继续使用工作台,关闭工作台后已启动的机器人连接仍运行。
|
|
15
15
|
|
|
16
16
|
| 谁 | 在工作台做什么 | 接入后需要保留什么 |
|
|
17
17
|
| --- | --- | --- |
|
|
18
|
-
| 管理员 |
|
|
19
|
-
| 成员 |
|
|
18
|
+
| 管理员 | 选择或拉取项目,确认推荐的验证命令;在群里验证自己;批量分配机器人与职责;下载加密接入包并复制接入说明;确认自动出现的成员申请 | 本机身份、管理员配置和机器人连接 |
|
|
19
|
+
| 成员 | 导入接入包与密码,使用已有 clone 或让向导拉取;在群里发送验证码;审批通过后自动配置;将一条启用指令发送到选定 Codex 对话 | 本机机器人连接,以及该对话的轮询自动化 |
|
|
20
20
|
|
|
21
|
-
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret
|
|
21
|
+
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员;批量分配自动生成密码,可在“已分配的机器人”查看并单独复制。接入说明不含密码和 Secret。显示名称支持中文,职责可从前端、后端、全栈、测试等预设中选择;内部 ID 和自定义标签放在高级选项中。
|
|
22
22
|
|
|
23
23
|
同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
|
|
24
24
|
|
|
25
|
+
机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
|
|
26
|
+
|
|
27
|
+
本机配置完成后,向导只要求在目标 Codex 对话发送一条指令,例如:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
使用 $team-collab-desktop,启用当前项目的团队协作,将 bob 机器人绑定到当前对话。项目:项目绝对路径
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Skill 完成身份核对、对话绑定和 heartbeat 配置,页面自动观察实际轮询。管理员入队批准和选定对话启用仍由本人明确操作。群验证中刷新可恢复原进度;重启工作台后可从首页继续,失效验证码会重新连接。
|
|
34
|
+
|
|
25
35
|
完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
|
|
26
36
|
|
|
27
37
|
## 一次任务如何接力
|
|
@@ -87,6 +97,16 @@ Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代
|
|
|
87
97
|
|
|
88
98
|
停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
|
|
89
99
|
|
|
100
|
+
项目页面展开 **管理与重置**,可以先查看影响,再选择:
|
|
101
|
+
|
|
102
|
+
- **重新绑定对话**:先在原对话暂停协作 heartbeat,输入当前 Agent 名称确认。向导停止本机机器人连接、解除对话绑定,并提供启用指令;将指令发到目标对话,完成绑定后点击“启动机器人连接”。
|
|
103
|
+
- **更新机器人凭据**:填写同一个 Bot ID 的新 Secret,用原企业微信本人账号在原群完成验证码验证,确认后保存。保留成员身份、群和对话绑定,机器人保持停止,点击“启动机器人连接”恢复。
|
|
104
|
+
- **移除本机接入**:停止本机连接、解除绑定,移除当前 clone 生成的协作 MCP 和 Skill,停用旧私有配置。原成员身份和本机工作目录保留,项目出现在首页“已移除的接入”,可恢复原身份;此 clone 也可以导入其他 Agent 的新接入包。
|
|
105
|
+
|
|
106
|
+
有运行中任务时会阻止重置;无法同步 Git 确认任务状态时也会停止操作。自定义、已跟踪或符号链接配置需要手动处理。操作前写入本机私有恢复记录,项目代码、其他 MCP、模型配置与共享 `agent-collab` 记录不受影响。恢复后重新加载项目 MCP、绑定对话并启动连接;若 clone 已接入另一个身份,需先处理当前接入。
|
|
107
|
+
|
|
108
|
+
这里移除的是本机接入,企业微信平台机器人、团队成员和群中的历史消息仍保留。身份、Secret 的旧版本及恢复记录仅留在本机;不需要恢复时可手动清理工作台显示的私有配置与备份。更换平台机器人或真人所有者,使用管理员提供的新接入包重新验证。
|
|
109
|
+
|
|
90
110
|
## 开发与兼容
|
|
91
111
|
|
|
92
112
|
```sh
|
|
@@ -106,3 +126,5 @@ node bin/codex-collab.mjs setup
|
|
|
106
126
|
2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
|
|
107
127
|
|
|
108
128
|
2026-10-13 自然语言入口通过全量 **122 项自动测试**,覆盖签名需求、追问补充、并发去重、绑定对话 MCP、Alice/Bob 固定 SHA 接力和通知重试;Skill 与 npm 打包检查通过。测试用固定解读结果模拟 Codex 的理解,没有调用真实模型或连接真实企业微信群;本轮自然语言实群验收仍待进行。
|
|
129
|
+
|
|
130
|
+
2026-10-14 接入简化与管理重置通过全量 **154 项自动测试**、隔离仓库的实际浏览器接入流程、Skill 校验和 npm 打包检查。群验证后自动继续、成员批准后自动配置、批量机器人分配及刷新恢复均已验证;本轮使用模拟机器人,未新增真实企微或跨机器联合验收。具体范围见 [向导检查记录](docs/onboarding-audit.md)。
|
|
@@ -40,5 +40,9 @@ export class BotDiscovery {
|
|
|
40
40
|
this.sessions.delete(id); clearTimeout(state.timer); state.client.disconnect();
|
|
41
41
|
return { bot_id: state.bot_id, secret: state.secret, ...state.proof };
|
|
42
42
|
}
|
|
43
|
+
cancel(id) {
|
|
44
|
+
const state = this.sessions.get(id); if (!state) return;
|
|
45
|
+
this.sessions.delete(id); clearTimeout(state.timer); state.client.disconnect();
|
|
46
|
+
}
|
|
43
47
|
closeAll() { for (const state of this.sessions.values()) { clearTimeout(state.timer); state.client.disconnect(); } this.sessions.clear(); }
|
|
44
48
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { git } from '../../packages/peer/store.mjs';
|
|
6
|
+
import { remoteIdentity } from './bundle.mjs';
|
|
7
|
+
import { SetupError } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
export const responsibilities = {
|
|
10
|
+
frontend: ['frontend'], backend: ['backend'], fullstack: ['frontend', 'backend'],
|
|
11
|
+
testing: ['testing'], docs: ['docs'], general: ['general'],
|
|
12
|
+
};
|
|
13
|
+
const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
14
|
+
export function memberFields(input) {
|
|
15
|
+
if (input.display_name != null && typeof input.display_name !== 'string' || input.agent_id != null && typeof input.agent_id !== 'string') throw new SetupError('成员名称和内部 Agent ID 应是文字。');
|
|
16
|
+
const displayName = (input.display_name || input.agent_id || '').trim();
|
|
17
|
+
if (!displayName || displayName.length > 80 || /[\x00-\x1f\x7f]/.test(displayName)) throw new SetupError('请填写成员或机器人名称,最多 80 个字符。');
|
|
18
|
+
const agentId = input.agent_id?.trim() || displayName.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || `agent-${randomBytes(4).toString('hex')}`;
|
|
19
|
+
if (!idPattern.test(agentId)) throw new SetupError('内部 Agent ID 请使用英文、数字、下划线或连字符。');
|
|
20
|
+
const raw = input.capabilities || responsibilities[input.responsibility || 'general'];
|
|
21
|
+
const caps = typeof raw === 'string' ? raw.split(/[\s,,]+/).filter(Boolean) : raw;
|
|
22
|
+
if (!Array.isArray(caps) || !caps.length || caps.length > 16 || caps.some((cap) => typeof cap !== 'string' || !idPattern.test(cap))) throw new SetupError('自定义职责标签请使用英文、数字、下划线或连字符。');
|
|
23
|
+
return { agent_id: agentId, display_name: displayName, capabilities: [...new Set(caps)] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Convert a confirmed command to argv. Never add a shell or evaluate substitutions.
|
|
27
|
+
export function commandArgv(command) {
|
|
28
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 4000 || /[\x00\r\n]/.test(command)) throw new SetupError('请确认验证命令,例如 npm test 或 go test ./...。');
|
|
29
|
+
const argv = []; let word = '', quote = null, escaped = false, started = false;
|
|
30
|
+
for (const char of command.trim()) {
|
|
31
|
+
if (escaped) { word += char; escaped = false; started = true; continue; }
|
|
32
|
+
if (char === '\\' && quote !== "'") { escaped = true; started = true; continue; }
|
|
33
|
+
if (quote) { if (char === quote) quote = null; else word += char; continue; }
|
|
34
|
+
if (char === '"' || char === "'") { quote = char; started = true; continue; }
|
|
35
|
+
if (/\s/.test(char)) { if (started) { argv.push(word); word = ''; started = false; } continue; }
|
|
36
|
+
if (/[;&|<>`$()]/.test(char)) throw new SetupError('验证命令请填写一个程序及参数,不支持管道、重定向或命令拼接。多个检查请写进项目测试脚本。');
|
|
37
|
+
word += char; started = true;
|
|
38
|
+
}
|
|
39
|
+
if (quote || escaped) throw new SetupError('验证命令的引号或转义未闭合。');
|
|
40
|
+
if (started) argv.push(word);
|
|
41
|
+
if (!argv.length || argv.length > 32 || argv.some((part) => !part || part.length > 2000)) throw new SetupError('验证命令参数无效。');
|
|
42
|
+
return argv;
|
|
43
|
+
}
|
|
44
|
+
export function displayCommand(argv) {
|
|
45
|
+
return argv.map((part) => /^[\w./:@=-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`).join(' ');
|
|
46
|
+
}
|
|
47
|
+
export async function recommendTests(repo) {
|
|
48
|
+
const files = new Set(await readdir(repo)); const commands = [];
|
|
49
|
+
if (files.has('test.sh')) commands.push(['sh', 'test.sh']);
|
|
50
|
+
if (files.has('package.json')) {
|
|
51
|
+
try { const value = JSON.parse(await readFile(join(repo, 'package.json'), 'utf8')); if (typeof value.scripts?.test === 'string' && value.scripts.test.trim()) commands.push(['npm', 'test']); } catch { /* A suggestion never executes or repairs project files. */ }
|
|
52
|
+
}
|
|
53
|
+
if (files.has('go.mod')) commands.push(['go', 'test', './...']);
|
|
54
|
+
if (files.has('Cargo.toml')) commands.push(['cargo', 'test']);
|
|
55
|
+
return commands.map((argv) => ({ argv, command: displayCommand(argv) }));
|
|
56
|
+
}
|
|
57
|
+
export function suggestedRepo(remote, agentId) {
|
|
58
|
+
const name = remote.split(/[/:]/).at(-1).replace(/\.git$/, '').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80) || 'project';
|
|
59
|
+
return join(homedir(), 'codex-projects', `${name}-${agentId}`);
|
|
60
|
+
}
|
|
61
|
+
function validCloneRemote(remote) {
|
|
62
|
+
if (typeof remote !== 'string' || !remote || remote.length > 4000 || /[\x00-\x1f\x7f]/.test(remote) || remote.startsWith('-')) throw new SetupError('仓库地址无效。');
|
|
63
|
+
if (isAbsolute(remote)) return;
|
|
64
|
+
if (/\s/.test(remote)) throw new SetupError('仓库地址不能包含空白字符。'); // Also supports a team's local/offline Git mirror.
|
|
65
|
+
if (/^[\w.-]+@[\w.-]+:[\w./-]+$/.test(remote)) return;
|
|
66
|
+
let url; try { url = new URL(remote); } catch { throw new SetupError('请使用 HTTPS 或 SSH 仓库地址。'); }
|
|
67
|
+
if (!['https:', 'http:', 'ssh:'].includes(url.protocol) || !url.hostname || url.password || url.search || url.hash || url.protocol !== 'ssh:' && url.username) throw new SetupError('请使用 HTTPS 或 SSH 仓库地址,并通过本机 Git 登录;不要在地址中填写 Token。');
|
|
68
|
+
}
|
|
69
|
+
const inside = (parent, child) => { const path = relative(parent, child); return !path || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
|
|
70
|
+
export async function cloneRepository({ remote, destination, privateHome }) {
|
|
71
|
+
validCloneRemote(remote);
|
|
72
|
+
if (typeof destination !== 'string' || !isAbsolute(destination)) throw new SetupError('请选择新 clone 的本机绝对路径。');
|
|
73
|
+
const target = resolve(destination);
|
|
74
|
+
privateHome = await realpath(privateHome).catch((error) => { if (error.code === 'ENOENT') return resolve(privateHome); throw error; });
|
|
75
|
+
// Keep the private workspace outside the target repo and vice versa.
|
|
76
|
+
if (inside(target, privateHome) || inside(privateHome, target)) throw new SetupError('项目目录和本机私有配置目录不能相互包含。');
|
|
77
|
+
let existing; try { existing = await lstat(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (existing.isSymbolicLink()) throw new SetupError('拉取目录不能是符号链接,请选择独立目录。');
|
|
80
|
+
try {
|
|
81
|
+
const actual = await realpath(target);
|
|
82
|
+
if (await git(actual, 'rev-parse', '--show-toplevel') === actual && remoteIdentity(await git(actual, 'remote', 'get-url', 'origin')) === remoteIdentity(remote)) return actual;
|
|
83
|
+
} catch { /* Reject existing unrelated folders instead of overwriting them. */ }
|
|
84
|
+
throw new SetupError('目标目录已存在,请选择新的目录,或切换到使用已有项目。');
|
|
85
|
+
}
|
|
86
|
+
await mkdir(dirname(target), { recursive: true });
|
|
87
|
+
const parent = await realpath(dirname(target)); const actualTarget = join(parent, basename(target));
|
|
88
|
+
if (inside(actualTarget, privateHome) || inside(privateHome, actualTarget)) throw new SetupError('项目目录和本机私有配置目录不能相互包含。');
|
|
89
|
+
await mkdir(actualTarget, { mode: 0o700 });
|
|
90
|
+
const staging = join(parent, `.collab-clone-${randomUUID()}`);
|
|
91
|
+
try {
|
|
92
|
+
await git(parent, '-c', 'protocol.ext.allow=never', 'clone', '--', remote, staging);
|
|
93
|
+
if ((await readdir(actualTarget)).length) throw new SetupError('拉取目录出现其他文件,请选择新目录后重试。');
|
|
94
|
+
await rename(staging, actualTarget);
|
|
95
|
+
return actualTarget;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof SetupError) throw error;
|
|
98
|
+
const detail = `${error.stderr || ''}\n${error.message || ''}`.toLowerCase();
|
|
99
|
+
if (/permission denied|authentication failed|could not read username|repository not found|403|401/.test(detail)) throw new SetupError('拉取仓库失败,请先完成本机 Git 登录并确认仓库读取权限。');
|
|
100
|
+
throw new SetupError('拉取仓库未完成,请检查仓库地址、网络和本机 Git 权限后重试。');
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(staging, { recursive: true, force: true });
|
|
103
|
+
await rmdir(actualTarget).catch(() => {}); // Only remove our empty reservation, never project files.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
2
2
|
import { SetupError } from './errors.mjs';
|
|
3
|
-
import { createPublicKey, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
@@ -11,12 +11,14 @@ import { PeerGitStore, git } from '../../packages/peer/store.mjs';
|
|
|
11
11
|
import { approveEnrollment, enrollmentRequest, invitationFile, invitationRecord, policyVersion, recordHash, registryPath, requestFile, validateEnrollment, validateHistory, validateInvitation, versionFile, readRegistry } from '../../packages/peer/registry.mjs';
|
|
12
12
|
import { attachProject, buildTeamPolicy, createMemberConfig, diagnosePeer, getOrCreateIdentityFiles } from '../../scripts/peer/onboarding.mjs';
|
|
13
13
|
import { installedProjectConfig, refreshProject } from '../../scripts/peer/refresh-project.mjs';
|
|
14
|
-
import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
|
|
14
|
+
import { bindingPrompt, detailedBindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
|
|
15
15
|
import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
|
|
16
16
|
import { DesktopExecutor } from '../peer/desktop.mjs';
|
|
17
17
|
import { alive, readRunnerLock } from '../peer/process.mjs';
|
|
18
18
|
import { BotDiscovery } from './discovery.mjs';
|
|
19
|
+
import { ResetActions } from './reset.mjs';
|
|
19
20
|
import { encryptBundle, decryptBundle, remoteIdentity } from './bundle.mjs';
|
|
21
|
+
import { cloneRepository, commandArgv, memberFields, recommendTests, suggestedRepo } from './guided.mjs';
|
|
20
22
|
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
21
23
|
const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
22
24
|
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
@@ -110,7 +112,7 @@ function validateBundle(value) {
|
|
|
110
112
|
|
|
111
113
|
export class SetupManager {
|
|
112
114
|
constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
|
|
113
|
-
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve();
|
|
115
|
+
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve(); this.resets = new ResetActions(this);
|
|
114
116
|
this.runCli = runCli || ((command, config) => {
|
|
115
117
|
const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
|
|
116
118
|
if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
|
|
@@ -130,12 +132,18 @@ export class SetupManager {
|
|
|
130
132
|
async overview() {
|
|
131
133
|
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
132
134
|
const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
|
|
133
|
-
const profiles = [];
|
|
135
|
+
const profiles = [], removed_profiles = [];
|
|
134
136
|
for (const id of entries) {
|
|
135
137
|
const state = await this.state(id);
|
|
136
|
-
profiles.push({ id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
138
|
+
(state.phase === 'removed' ? removed_profiles : profiles).push({ id, agent_id: state.agent_id, role: state.role, display_name: state.display_name || state.agent_id, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
137
139
|
}
|
|
138
|
-
|
|
140
|
+
const drafts = [];
|
|
141
|
+
for (const file of await readdir(join(this.home, 'drafts')).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error))) {
|
|
142
|
+
if (!uuidPattern.test(file.replace(/\.json$/, ''))) continue;
|
|
143
|
+
const draft = await readPrivateJson(join(this.home, 'drafts', file));
|
|
144
|
+
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
|
+
}
|
|
146
|
+
return { profiles, removed_profiles, drafts };
|
|
139
147
|
}
|
|
140
148
|
async checkBotAvailable(botId) {
|
|
141
149
|
for (const profile of (await this.overview()).profiles) {
|
|
@@ -148,6 +156,7 @@ export class SetupManager {
|
|
|
148
156
|
let credentials = input;
|
|
149
157
|
if (input.profile_id) {
|
|
150
158
|
const state = await this.state(input.profile_id);
|
|
159
|
+
if (state.discovery_id && !input.reconnect) { try { const session = this.discovery.view(state.discovery_id); if (!['failed', 'expired', 'disconnected'].includes(session.status)) return session; } catch {} }
|
|
151
160
|
if (!state.bundle) throw new SetupError('此项目没有待验证的接入包。');
|
|
152
161
|
credentials = { ...state.bundle.wecom, chat_ids: state.bundle.invitation.chat_ids };
|
|
153
162
|
}
|
|
@@ -163,11 +172,57 @@ export class SetupManager {
|
|
|
163
172
|
} catch (error) { if (error instanceof SetupError) throw error; }
|
|
164
173
|
}
|
|
165
174
|
}
|
|
166
|
-
|
|
175
|
+
const session = this.discovery.begin(credentials);
|
|
176
|
+
if (input.profile_id) { const state = await this.state(input.profile_id); state.discovery_id = session.id; await this.save(state); }
|
|
177
|
+
return session;
|
|
178
|
+
}
|
|
179
|
+
async prepareProject(input) {
|
|
180
|
+
const path = input.repo_mode === 'clone'
|
|
181
|
+
? await cloneRepository({ remote: input.remote, destination: input.destination, privateHome: this.home }) : input.repo;
|
|
182
|
+
const selected = await ensureRepo(path);
|
|
183
|
+
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
184
|
+
return { ...selected, tests: await recommendTests(selected.repo) };
|
|
185
|
+
}
|
|
186
|
+
draftPath(id) { if (!uuidPattern.test(id || '')) throw new SetupError('无效创建记录。'); return join(this.home, 'drafts', `${id}.json`); }
|
|
187
|
+
async prepareAdmin(input) {
|
|
188
|
+
const fields = memberFields(input);
|
|
189
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret.trim()) throw new SetupError('请填写机器人 Bot ID 和 Secret。');
|
|
190
|
+
if (input.team_name != null && (typeof input.team_name !== 'string' || input.team_name.length > 80 || /[\x00-\x1f\x7f]/.test(input.team_name))) throw new SetupError('团队名称最多 80 个字符。');
|
|
191
|
+
const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv);
|
|
192
|
+
const selected = await this.prepareProject(input);
|
|
193
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
194
|
+
const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, test_argv: argv, team_name: input.team_name?.trim() || null,
|
|
195
|
+
bot_id: input.bot_id, secret: input.secret } };
|
|
196
|
+
await savePrivate(this.draftPath(draft.id), draft);
|
|
197
|
+
return this.adminDraft({ id: draft.id });
|
|
198
|
+
}
|
|
199
|
+
async adminDraft({ id, reconnect = false }) {
|
|
200
|
+
const draft = await readPrivateJson(this.draftPath(id));
|
|
201
|
+
if (draft.profile_id) return { profile: await this.detail(draft.profile_id, { localOnly: true }) };
|
|
202
|
+
let session;
|
|
203
|
+
if (draft.discovery_id && !reconnect) { try { session = this.discovery.view(draft.discovery_id); if (['failed', 'expired', 'disconnected'].includes(session.status)) session = null; } catch {} }
|
|
204
|
+
if (!session) {
|
|
205
|
+
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo });
|
|
206
|
+
draft.discovery_id = session.id; await savePrivate(this.draftPath(id), draft);
|
|
207
|
+
}
|
|
208
|
+
return { ...session, draft_id: id, agent_id: draft.input.agent_id, repo: draft.input.repo };
|
|
167
209
|
}
|
|
168
|
-
async
|
|
169
|
-
const
|
|
170
|
-
|
|
210
|
+
async completeAdmin({ draft_id, discovery_id }) {
|
|
211
|
+
const draft = await readPrivateJson(this.draftPath(draft_id));
|
|
212
|
+
if (draft.profile_id) return this.detail(draft.profile_id, { localOnly: true });
|
|
213
|
+
if (draft.discovery_id !== discovery_id) throw new SetupError('请使用当前创建流程的群验证码。');
|
|
214
|
+
return this.createAdmin({ ...draft.input, discovery_id }, { onSaved: async (id) => {
|
|
215
|
+
draft.profile_id = id; delete draft.input.secret; await savePrivate(this.draftPath(draft_id), draft);
|
|
216
|
+
} });
|
|
217
|
+
}
|
|
218
|
+
async progress({ id, sync = false }) {
|
|
219
|
+
const state = await this.state(id);
|
|
220
|
+
if (['initializing', 'verified', 'waiting', 'configuring'].includes(state.phase) && !state.last_error) return this.resume(id);
|
|
221
|
+
return this.detail(id, { localOnly: !sync || state.phase !== 'ready' });
|
|
222
|
+
}
|
|
223
|
+
async createAdmin(input, { onSaved } = {}) {
|
|
224
|
+
const fields = memberFields(input), agentId = fields.agent_id;
|
|
225
|
+
const caps = fields.capabilities; const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
171
226
|
const { repo, remote } = await ensureRepo(input.repo);
|
|
172
227
|
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
173
228
|
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
@@ -179,10 +234,11 @@ export class SetupManager {
|
|
|
179
234
|
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
180
235
|
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
181
236
|
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
182
|
-
const state = { id, role: 'admin', agent_id: agentId, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
237
|
+
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
183
238
|
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
184
239
|
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
185
240
|
await this.save(state);
|
|
241
|
+
if (onSaved) await onSaved(id);
|
|
186
242
|
return this.resume(id);
|
|
187
243
|
}
|
|
188
244
|
async importExisting({ config_path }) {
|
|
@@ -240,6 +296,7 @@ export class SetupManager {
|
|
|
240
296
|
}
|
|
241
297
|
async resume(id) {
|
|
242
298
|
const state = await this.state(id);
|
|
299
|
+
if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
|
|
243
300
|
try {
|
|
244
301
|
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
245
302
|
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
@@ -319,11 +376,27 @@ export class SetupManager {
|
|
|
319
376
|
throw new SetupError(state.last_error);
|
|
320
377
|
}
|
|
321
378
|
}
|
|
322
|
-
|
|
379
|
+
readBundle({ content, password }) {
|
|
323
380
|
if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
|
|
324
381
|
let envelope;
|
|
325
382
|
try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
|
|
326
|
-
return
|
|
383
|
+
return validateBundle(decryptBundle(envelope, password)).bundle;
|
|
384
|
+
}
|
|
385
|
+
async inspectBundle(input) {
|
|
386
|
+
const bundle = this.readBundle(input), invite = bundle.invitation;
|
|
387
|
+
return { agent_id: invite.agent_id, display_name: bundle.display_name || invite.agent_id, team_name: bundle.team_name || null,
|
|
388
|
+
remote: bundle.remote, capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
389
|
+
}
|
|
390
|
+
async importBundle(input) {
|
|
391
|
+
const bundle = this.readBundle(input);
|
|
392
|
+
// An interrupted import resumes its original profile without making another clone.
|
|
393
|
+
for (const profile of (await this.overview()).profiles) {
|
|
394
|
+
const state = await this.state(profile.id);
|
|
395
|
+
if (state.bundle?.invitation.invite_id === bundle.invitation.invite_id) return this.detail(profile.id, { localOnly: true });
|
|
396
|
+
}
|
|
397
|
+
const repo = input.repo_mode === 'clone'
|
|
398
|
+
? await cloneRepository({ remote: bundle.remote, destination: input.destination || suggestedRepo(bundle.remote, bundle.invitation.agent_id), privateHome: this.home }) : input.repo;
|
|
399
|
+
return this.importAssignment(bundle, repo);
|
|
327
400
|
}
|
|
328
401
|
async importAssignment(value, repo, ownAdminId) {
|
|
329
402
|
const { bundle, history } = validateBundle(value);
|
|
@@ -336,13 +409,13 @@ export class SetupManager {
|
|
|
336
409
|
if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
337
410
|
}
|
|
338
411
|
const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
339
|
-
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
412
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
340
413
|
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
|
|
341
414
|
...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
|
|
342
415
|
await this.save(state); return this.detail(id, { localOnly: true });
|
|
343
416
|
}
|
|
344
417
|
async verifyMember({ id, discovery_id }) {
|
|
345
|
-
const state = await this.state(id); if (state.phase !== 'imported')
|
|
418
|
+
const state = await this.state(id); if (state.phase !== 'imported') return this.detail(id, { localOnly: true });
|
|
346
419
|
const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
|
|
347
420
|
if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
|
|
348
421
|
if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
|
|
@@ -353,7 +426,60 @@ export class SetupManager {
|
|
|
353
426
|
default_workflow: [invite.capabilities[0]] };
|
|
354
427
|
state.phase = 'verified'; await this.save(state); return this.resume(id);
|
|
355
428
|
}
|
|
429
|
+
async issueBatch({ id, members }) {
|
|
430
|
+
const state = await this.state(id);
|
|
431
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
432
|
+
if (!Array.isArray(members) || !members.length || members.length > 25) throw new SetupError('每次请分配 1 到 25 个机器人。');
|
|
433
|
+
const store = await this.profileStore(state), identity = await readPrivateJson(state.identity_path);
|
|
434
|
+
const ids = new Set([state.agent_id]), bots = new Set([state.wecom.bot_id]);
|
|
435
|
+
const prepared = members.map((input) => {
|
|
436
|
+
const fields = memberFields(input);
|
|
437
|
+
if (ids.has(fields.agent_id) || store.team.agents[fields.agent_id]) throw new SetupError(`成员 ${fields.agent_id} 已存在或在本次分配中重复。`);
|
|
438
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret.trim()) throw new SetupError('请为每位成员填写独立机器人的 Bot ID 和 Secret。');
|
|
439
|
+
const botId = input.bot_id.trim();
|
|
440
|
+
if (bots.has(botId)) throw new SetupError('本次分配的机器人重复;每个 Agent 必须使用独立机器人。');
|
|
441
|
+
ids.add(fields.agent_id); bots.add(botId);
|
|
442
|
+
const password = input.password || randomBytes(18).toString('base64url');
|
|
443
|
+
encryptBundle({}, password); // Validate the whole batch before any invitation is published.
|
|
444
|
+
return { ...fields, bot_id: botId, secret: input.secret, commands_enabled: input.commands_enabled !== false,
|
|
445
|
+
prefer: input.prefer !== false, password, claim: generateIdentity(), invite_id: randomUUID() };
|
|
446
|
+
});
|
|
447
|
+
await store.registryTransaction(async (history) => {
|
|
448
|
+
for (const item of prepared) if (history.latest.team.agents[item.agent_id]) throw new SetupError(`成员 ${item.agent_id} 已存在,请刷新工作台。`);
|
|
449
|
+
const existing = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error));
|
|
450
|
+
for (const file of existing) {
|
|
451
|
+
const invite = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
|
|
452
|
+
validateInvitation(invite, history);
|
|
453
|
+
if (prepared.some((item) => (item.agent_id === invite.agent_id || item.bot_id === invite.bot_id) && item.invite_id !== invite.invite_id) &&
|
|
454
|
+
(Date.now() < Date.parse(invite.expires_at) || history.records.some((record) => record.enrollment?.invite_id === invite.invite_id))) throw new SetupError('成员或机器人已有有效接入包,请使用已生成的文件。');
|
|
455
|
+
}
|
|
456
|
+
const files = [];
|
|
457
|
+
for (const item of prepared) {
|
|
458
|
+
const invitation = invitationRecord({ invite_id: item.invite_id, agent_id: item.agent_id, capabilities: item.capabilities,
|
|
459
|
+
bot_id: item.bot_id, chat_ids: state.wecom.chat_ids, commands_enabled: item.commands_enabled, prefer: item.prefer,
|
|
460
|
+
claim_public_key: item.claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString() }, history.latest, state.agent_id, identity.private_key);
|
|
461
|
+
const bundle = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
|
|
462
|
+
history: history.records, invitation, claim_private_key: item.claim.private_key, display_name: item.display_name, team_name: state.team_name || null,
|
|
463
|
+
wecom: { bot_id: item.bot_id, secret: item.secret }, test_argv: state.test_argv };
|
|
464
|
+
await savePrivate(join(this.path(id), 'exports', `${item.invite_id}.json`), { invitation, display_name: item.display_name,
|
|
465
|
+
encrypted: encryptBundle(bundle, item.password), delivery_password: item.password });
|
|
466
|
+
files.push([invitationFile(item.invite_id), invitation]);
|
|
467
|
+
}
|
|
468
|
+
return { files, value: null };
|
|
469
|
+
});
|
|
470
|
+
return { assignments: prepared.map((item) => ({ invite_id: item.invite_id, agent_id: item.agent_id,
|
|
471
|
+
display_name: item.display_name, filename: `${item.agent_id}-collab-invite.json`, password: item.password })) };
|
|
472
|
+
}
|
|
473
|
+
async delivery({ id, invite_id }) {
|
|
474
|
+
const state = await this.state(id);
|
|
475
|
+
if (state.role !== 'admin' || state.phase !== 'ready' || !uuidPattern.test(invite_id || '')) throw new SetupError('只有管理员可查看已分配的接入说明。');
|
|
476
|
+
const saved = await this.publishAssignment(id, invite_id);
|
|
477
|
+
if (!saved.encrypted) throw new SetupError('本人机器人直接在本机接入,无需分发接入包。');
|
|
478
|
+
return { agent_id: saved.invitation.agent_id, display_name: saved.display_name || saved.invitation.agent_id, password: saved.delivery_password || null,
|
|
479
|
+
instructions: `已为你分配 ${saved.display_name || saved.invitation.agent_id} 协作机器人。\n1. 安装 Node.js 20 或以上版本及 Git。\n2. 执行 npx xuym-codex-collab@latest setup 打开接入向导。\n3. 导入我提供的加密接入包,使用单独发送的密码,选择已有项目或自动拉取。\n4. 按页面提示在群里确认身份;批准后自动完成配置。\n5. 在目标 Codex 项目对话发送页面提供的启用指令。` };
|
|
480
|
+
}
|
|
356
481
|
async issueBundle(input, { ownRepo } = {}) {
|
|
482
|
+
if (!ownRepo) return (await this.issueBatch({ id: input.id, members: [input] })).assignments[0];
|
|
357
483
|
const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
358
484
|
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
|
|
359
485
|
const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
|
|
@@ -478,7 +604,7 @@ export class SetupManager {
|
|
|
478
604
|
return this.detail(id);
|
|
479
605
|
}
|
|
480
606
|
async detail(id, { localOnly = false } = {}) {
|
|
481
|
-
const state = await this.state(id); const result = { id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
607
|
+
const state = await this.state(id); const result = { id, agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
482
608
|
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
483
609
|
expires_at: state.bundle.invitation.expires_at };
|
|
484
610
|
if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
|
|
@@ -506,11 +632,11 @@ export class SetupManager {
|
|
|
506
632
|
for (const file of exported) {
|
|
507
633
|
const value = await readPrivateJson(join(this.path(id), 'exports', file));
|
|
508
634
|
result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
|
|
509
|
-
filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
|
|
635
|
+
filename: `${value.invitation.agent_id}-collab-invite.json`, display_name: value.display_name || value.invitation.agent_id, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
|
|
510
636
|
}
|
|
511
637
|
}
|
|
512
638
|
if (state.phase === 'ready') {
|
|
513
|
-
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
|
|
639
|
+
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo); result.detailed_binding_prompt = detailedBindingPrompt(state.agent_id, state.repo);
|
|
514
640
|
const config = (await readPeerConfig(result.config_path)).config;
|
|
515
641
|
const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
|
|
516
642
|
const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
@@ -534,13 +660,19 @@ export class SetupManager {
|
|
|
534
660
|
async runner({ id, command }) {
|
|
535
661
|
const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
|
|
536
662
|
try {
|
|
663
|
+
if (command === 'start' && [...this.discovery.sessions.values()].some((session) => session.bot_id === state.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) throw new SetupError('机器人正在验证,请先取消验证,再启动连接。');
|
|
537
664
|
this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
|
|
538
665
|
state.last_error = null; await this.save(state);
|
|
539
|
-
} catch {
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (error instanceof SetupError) throw error;
|
|
540
668
|
state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
|
|
541
669
|
await this.save(state); throw new SetupError(state.last_error);
|
|
542
670
|
}
|
|
543
671
|
return this.detail(id, { localOnly: true });
|
|
544
672
|
}
|
|
673
|
+
resetPreview(input) { return this.resets.preview(input); }
|
|
674
|
+
resetDiscovery(input) { return this.resets.discover(input); }
|
|
675
|
+
resetCancel(input) { return this.resets.cancel(input); }
|
|
676
|
+
reset(input) { return this.resets.apply(input); }
|
|
545
677
|
close() { this.discovery.closeAll(); }
|
|
546
678
|
}
|