ai_co_discuss 0.3.90 → 0.3.93
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/docs/runner.md +1 -1
- package/package.json +1 -1
- package/runner/adapters/antigravity_cli.mjs +22 -7
- package/runner/discussion_setup.mjs +26 -7
- package/runner/discussion_skills.mjs +28 -25
- package/runner/discussion_tui.mjs +89 -0
- package/web/app.js +5 -3
- package/web/modern.js +1 -1
- package/workflows/story-serial/agents/critic.md +2 -0
- package/workflows/story-serial/agents/critic_people.md +2 -0
- package/workflows/story-serial/agents/recorder.md +2 -0
package/docs/runner.md
CHANGED
|
@@ -113,7 +113,7 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
|
|
|
113
113
|
|
|
114
114
|
工作流把一种多人协作的做法存下来,供别的房间复用:一份给主持人读的做法说明(SKILL.md),加一组 Agent 卡片(每张就是一个专家预设)。建房或普通邀请时在“工作流”一行选中它,只供查看和编辑,不添加成员,也不随建房启动。建房后,在输入框要求主持人启动(打 `/` 选工作流可以直接写好这句请求,权限见下文);主持人此后按说明组织工作。它只带来做法和成员,不保证工序,具体怎么做仍由主持人判断,你随时可以 @ 任何成员。
|
|
115
115
|
|
|
116
|
-
- **内置工作流**:安装包自带“小说剧本连载”(`story-serial`:编剧、写手、两位批评家与记录员逐集接力,附项目模板与状态检查脚本,脚本需要 Python 的 PyYAML
|
|
116
|
+
- **内置工作流**:安装包自带“小说剧本连载”(`story-serial`:编剧、写手、两位批评家与记录员逐集接力,附项目模板与状态检查脚本,脚本需要 Python 的 PyYAML)。默认不安装。在终端里运行 `codiscuss setup` 时,选完软件后有一步“安装配置”:先显示概览,按 → 展开,用空格勾选要装的工作流,回车继续;不勾就不装。也可以直接加 `--workflows` 安装全部(`install` 同样适用,非交互运行只认这个选项)。装的时候,已有同名的会覆盖为包里的最新版,原来那份先备份到服务目录的 `backups/workflows/`。选择只对这一次有效,不会记住,不选就不动工作流库。
|
|
117
117
|
- **保存**:在“工作流”一行点“添加”,选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先在编辑页预览,点保存才写入。选中一个工作流后,下方显示它的描述,可“查看说明”(做法说明与成员卡片)或“编辑”,删除在编辑页底部。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“主持人完整权限”。
|
|
118
118
|
- **让主持人自己建**:建房时(第 3 节“协作方式”)或在“成员与设置 → 协作设置”勾选“主持人完整权限”后,直接对主持人说要什么分工(或“把这个房间的做法存成工作流”),它会自己保存、在本房间启用,并邀请工作流里的成员,接着就按流程派活,不需要你导入或添加成员。之后让它改流程,改动从成员下一次执行起生效。打开这个开关后,也可以在输入框输入 `/` 选一个已保存的工作流,输入框里会写好“启用工作流「名称」(ID),”,补上要做的事发给主持人,它就启用这个工作流并带来成员(没打开时该项灰显)。主持人带来的成员项目权限最高为可写;只有本地软件担任的主持人能这样做,API 主持人不行。
|
|
119
119
|
- **主持人完整权限的其他控制**:同一个开关打开后,主持人会自己处理房间里的问题、持续推进:成员跑偏时插话、卡住时打断或重试、模型不可用时换模型、无法恢复时换人;你也可以直接说“停下写手”“全部停下”“下一个让批评家先说”“请一位 Codex 进来只读评审”“把记录员请出去”“开个 Goal 把剩下几集写完”“暂停讨论”。邀请只用本机已配置的软件,项目权限最高可写;它自己开的 Goal 用你的默认预算(不限时为 8 次续跑、90 分钟)。成员连续 10 分钟没有新输出时,系统会提醒主持人检查;主持人派出长任务时还可以给自己设定时检查(每 5–120 分钟一次,房间空闲后自动结束)。它也能撤回自己派出的活,正在循环的接力会立即停下。以下仍由你决定:对主持人自己操作或更换主持人、批准超出上限的轮数、高于可写的权限或放行工具、改项目目录、撤回你的消息、私聊。API 主持人没有命令行,不能这样做。
|
package/package.json
CHANGED
|
@@ -19,7 +19,8 @@ const AGY_MODEL_TAG = [0xe2, 0x01];
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* One operation a headless agy run refused, from the failed tool step, with the project grant that would allow it.
|
|
22
|
-
* agy names the check that failed: a sandboxed or unsandboxed command, or a file
|
|
22
|
+
* agy names the check that failed: a sandboxed or unsandboxed command, a file write, or (1.5.3+) a file read outside
|
|
23
|
+
* the project folders.
|
|
23
24
|
*/
|
|
24
25
|
export function agy_denial(step) {
|
|
25
26
|
const message = step.tool_info?.error?.message || '';
|
|
@@ -28,9 +29,9 @@ export function agy_denial(step) {
|
|
|
28
29
|
// Older agy did not name the check; a refused step with a command line was a command.
|
|
29
30
|
const kind = message.match(/permission check failed for (\w+)/)?.[1] || (parameters.CommandLine ? 'command' : '');
|
|
30
31
|
if (!kind) return null;
|
|
31
|
-
if (kind === 'write_file') {
|
|
32
|
-
const path = String(parameters.Path || parameters.TargetFile || message.match(
|
|
33
|
-
return { tool:
|
|
32
|
+
if (kind === 'write_file' || kind === 'read_file') {
|
|
33
|
+
const path = String(parameters.Path || parameters.TargetFile || message.match(new RegExp(`${kind}\\(([^)]+)\\)`))?.[1] || '');
|
|
34
|
+
return { tool: kind, detail: path.slice(0, DENIAL_FIELD), rule: path ? `${kind}(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
|
|
34
35
|
}
|
|
35
36
|
const command = String(parameters.CommandLine || '');
|
|
36
37
|
const rule = `${kind === 'unsandboxed' ? 'unsandboxed_command' : 'command'}(${command})`;
|
|
@@ -53,6 +54,18 @@ function check_agy_shell(user_home) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Skill folders agy loads by itself: its own folder and every entry in its global skills.json. agy shows the model these
|
|
59
|
+
* skills' paths, so a run must be able to read them (1.5.3 refuses reads outside the added directories); agy leaves ~/
|
|
60
|
+
* unexpanded on Windows.
|
|
61
|
+
*/
|
|
62
|
+
export function agy_skill_directories(user_home = homedir()) {
|
|
63
|
+
const entries = read_json(join(user_home, '.gemini', 'config', 'skills.json')).entries;
|
|
64
|
+
const listed = (Array.isArray(entries) ? entries : []).map(entry => entry?.path).filter(value => typeof value === 'string')
|
|
65
|
+
.map(value => process.platform !== 'win32' && value.startsWith('~/') ? join(user_home, value.slice(2)) : value);
|
|
66
|
+
return [join(user_home, '.gemini', 'config', 'skills'), ...listed].filter(value => isAbsolute(value) && existsSync(value));
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
/** Test directory containment on the current platform, including Windows drive boundaries. */
|
|
57
70
|
function within_directory(parent, child) {
|
|
58
71
|
const path = relative(resolve(parent), resolve(child));
|
|
@@ -94,7 +107,7 @@ function agy_project_grants(config, task, resources, user_home) {
|
|
|
94
107
|
const read_only = (task.extra_directories || []).filter(item => item.access === 'read').map(item => item.path);
|
|
95
108
|
const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)
|
|
96
109
|
&& !read_only.some(path => same_path(path, directory))))];
|
|
97
|
-
const protected_paths = [task.directory, ...task.skill_directories, join(user_home, '.gemini'),
|
|
110
|
+
const protected_paths = [task.directory, ...task.skill_directories, ...agy_skill_directories(user_home), join(user_home, '.gemini'),
|
|
98
111
|
...(config.project_access === 'read' ? projects : []), ...read_only];
|
|
99
112
|
if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
|
|
100
113
|
throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
|
|
@@ -156,7 +169,8 @@ export function remove_agy_projects(directory, user_home = homedir()) {
|
|
|
156
169
|
function invocation(config, binding, task, user_home) {
|
|
157
170
|
const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
|
|
158
171
|
const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
|
|
159
|
-
'--add-dir', task.draft_directory, ...[...task.skill_directories, ...(
|
|
172
|
+
'--add-dir', task.draft_directory, ...[...task.skill_directories, ...agy_skill_directories(user_home),
|
|
173
|
+
...(task.extra_directories || []).map(item => item.path)].flatMap(directory => ['--add-dir', directory])];
|
|
160
174
|
// Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
|
|
161
175
|
// offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
|
|
162
176
|
args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
|
|
@@ -176,7 +190,8 @@ function lifecycle(state, event, task, bind) {
|
|
|
176
190
|
const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
|
|
177
191
|
if (denied) {
|
|
178
192
|
if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
|
|
179
|
-
|
|
193
|
+
const action = { write_file: '写入了未授权的文件', read_file: '读取了未授权的文件' }[denied.tool] || '运行了未授权的命令';
|
|
194
|
+
state.denial = `${action}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
|
|
180
195
|
}
|
|
181
196
|
if (event.event === 'result') {
|
|
182
197
|
const result = event.result || {};
|
|
@@ -9,8 +9,8 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
import { atomic_json } from './files.mjs';
|
|
10
10
|
import { launch_settings, save_launch_settings } from './discussion_launch.mjs';
|
|
11
11
|
import { assert_shared_home, service_home } from './discussion_service.mjs';
|
|
12
|
-
import { SKILLS, install_skills, install_workflows, remove_legacy_skills } from './discussion_skills.mjs';
|
|
13
|
-
import { ask, checkbox_prompt, intro, outro, pad, short_path, spinner, step, styled, text_width, tone } from './discussion_tui.mjs';
|
|
12
|
+
import { SKILLS, install_skills, install_workflows, remove_legacy_skills, shipped_workflows } from './discussion_skills.mjs';
|
|
13
|
+
import { ask, checkbox_prompt, intro, outro, pad, short_path, spinner, step, styled, text_width, tone, tree_prompt } from './discussion_tui.mjs';
|
|
14
14
|
export { checkbox_prompt } from './discussion_tui.mjs';
|
|
15
15
|
|
|
16
16
|
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
@@ -34,7 +34,7 @@ const CLIENTS = {
|
|
|
34
34
|
const OPTIONS = { yes: { type: 'boolean' }, clients: { type: 'string' }, python: { type: 'string' },
|
|
35
35
|
codex: { type: 'string' }, claude: { type: 'string' }, antigravity: { type: 'string' }, cursor: { type: 'string' },
|
|
36
36
|
port: { type: 'string' }, prefix: { type: 'string' }, 'skills-home': { type: 'string' },
|
|
37
|
-
'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
|
|
37
|
+
'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, workflows: { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
|
|
38
38
|
const HELP = `用法:codiscuss install [选项] / codiscuss setup [选项]
|
|
39
39
|
install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置已有安装。
|
|
40
40
|
--yes 使用已检测且支持自动接入的软件,冲突保留并报告
|
|
@@ -45,6 +45,8 @@ install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置
|
|
|
45
45
|
--no-start / --no-open 只配置 / 启动但不打开浏览器
|
|
46
46
|
--prefix DIR npm 安装前缀(install)
|
|
47
47
|
--skills-home DIR 技能用户根目录(默认当前用户目录)
|
|
48
|
+
--workflows 安装全部随包工作流,已有同名的覆盖为最新版(原来的先备份到服务目录 backups/);
|
|
49
|
+
交互运行时也可在“安装配置”里逐个勾选;不选则不安装,选择不会记住
|
|
48
50
|
Antigravity(显示名 Gemini)使用 agy;Grok 使用 Cursor CLI(agent / cursor-agent)。仅检测 CLI。
|
|
49
51
|
Cursor 当前只安装技能,可手动参与;全自动接入尚未提供。已有软件设置保留。
|
|
50
52
|
显式软件路径可更新已支持的 CLI 配置。安装不会开始讨论。
|
|
@@ -162,6 +164,22 @@ async function selected_clients(options, detected) {
|
|
|
162
164
|
return requested_clients(choice, detected);
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Which shipped workflows to install on this run. Nothing by default: an interactive run offers them unchecked in the
|
|
169
|
+
* install settings, and a non-interactive one installs them only with --workflows. Never remembered between runs.
|
|
170
|
+
*/
|
|
171
|
+
async function selected_workflows(options) {
|
|
172
|
+
const shipped = shipped_workflows();
|
|
173
|
+
// The tree needs key-by-key input; a terminal that cannot give it keeps the default.
|
|
174
|
+
if (options.workflows !== undefined || options.yes || !process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function' || !shipped.length) {
|
|
175
|
+
return options.workflows ? shipped.map(item => item.name) : [];
|
|
176
|
+
}
|
|
177
|
+
const [picked] = await tree_prompt('安装配置', [{ label: '随包工作流',
|
|
178
|
+
description: '把安装包自带的工作流装进本机工作流库;已有同名的覆盖为最新版,原来那份先备份到服务目录 backups/workflows/',
|
|
179
|
+
items: shipped.map(item => ({ label: item.label, hint: item.name, description: item.description, checked: false })) }]);
|
|
180
|
+
return shipped.filter((_, index) => picked[index]).map(item => item.name);
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
/** Validate an explicit selection without prompting or changing files. */
|
|
166
184
|
function requested_clients(choice, detected) {
|
|
167
185
|
const available = detected.filter(item => item.executable);
|
|
@@ -347,12 +365,14 @@ async function setup(options) {
|
|
|
347
365
|
? detected.map(item => `${pad(item.name, width)} ${tone.dim(item.executable ? short_path(item.executable) : '未检测到 CLI')}`) : [];
|
|
348
366
|
scan.done(`检测到 ${detected.filter(item => item.executable).length} 个可接入的 CLI`, listed);
|
|
349
367
|
const selected = await selected_clients(options, detected);
|
|
368
|
+
const workflow_names = await selected_workflows(options);
|
|
350
369
|
const configured = await configure_clients(home, selected, options, launch_settings(home).participants);
|
|
351
370
|
// The remote entry is retired: a saved remote_port from an older version is dropped here.
|
|
352
371
|
const { remote_port: retired, ...kept } = previous;
|
|
353
372
|
atomic_json(join(home, 'local-settings.json'), { ...kept, python, port });
|
|
354
373
|
step('已写入软件配置与技能', configured.skills.map(group => `${pad(group.client, width)} ${tone.dim(short_path(dirname(group.results[0].path)))}`));
|
|
355
|
-
|
|
374
|
+
// Shipped workflows are installed only when asked for each time, so a copy the user or a host edited is never replaced unasked.
|
|
375
|
+
const workflows = workflow_names.length ? install_workflows(home, workflow_names) : [];
|
|
356
376
|
const result = { home, python, clients: selected.map(item => item.name), ...configured, workflows,
|
|
357
377
|
command: check_command(), ai_verified: false };
|
|
358
378
|
result.issues = setup_issues(result);
|
|
@@ -382,12 +402,11 @@ async function setup(options) {
|
|
|
382
402
|
return result;
|
|
383
403
|
}
|
|
384
404
|
|
|
385
|
-
|
|
386
|
-
const WORKFLOW_STATUS = { installed: '已安装', updated: '已更新', unchanged: '已是最新', kept: '有你的修改,保留原样未更新' };
|
|
405
|
+
const WORKFLOW_STATUS = { installed: '已安装', updated: '已覆盖为最新版,原来的备份在', unchanged: '已是最新' };
|
|
387
406
|
|
|
388
407
|
/** One line naming each shipped workflow and what setup did with it. */
|
|
389
408
|
function workflow_summary(workflows) {
|
|
390
|
-
return workflows.map(item => `${item.name} ${WORKFLOW_STATUS[item.status]}`).join(' · ');
|
|
409
|
+
return workflows.map(item => `${item.name} ${WORKFLOW_STATUS[item.status]}${item.backup ? `:${item.backup}` : ''}`).join(' · ');
|
|
391
410
|
}
|
|
392
411
|
|
|
393
412
|
/** Print a concise human receipt while keeping stdout machine-readable. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install complete skills
|
|
3
|
-
*
|
|
2
|
+
* Install complete skills without replacing an unmanaged or locally edited copy (hashes live in the service home), and,
|
|
3
|
+
* when asked, the workflows this package ships.
|
|
4
4
|
*/
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
@@ -8,14 +8,14 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import { atomic_json } from './files.mjs';
|
|
11
|
-
import { WORKFLOW_ID_PATTERN, install_workflow_folder, workflow_directory } from './workflows.mjs';
|
|
11
|
+
import { WORKFLOW_ID_PATTERN, install_workflow_folder, parse_markdown, workflow_directory } from './workflows.mjs';
|
|
12
12
|
|
|
13
13
|
const SOURCE = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
|
|
14
14
|
export const SKILLS = ['discuss-host', 'discuss-participant', 'discuss-report', 'workflow-creator'];
|
|
15
15
|
const LEGACY_MANIFEST = '.codiscuss-skills.json';
|
|
16
16
|
|
|
17
17
|
/** Hash names and contents, rejecting links instead of following them outside a skill. */
|
|
18
|
-
|
|
18
|
+
function tree_hash(directory) {
|
|
19
19
|
if (lstatSync(directory).isSymbolicLink()) throw new Error('技能目录包含符号链接');
|
|
20
20
|
const hash = createHash('sha256');
|
|
21
21
|
for (const name of readdirSync(directory).sort()) {
|
|
@@ -93,33 +93,36 @@ export function remove_legacy_skills(directory, record) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
const WORKFLOW_SOURCE = fileURLToPath(new URL('../workflows/', import.meta.url));
|
|
96
|
-
|
|
96
|
+
|
|
97
|
+
/** The workflows this package ships, with the name and description their SKILL.md gives, for setup to offer. */
|
|
98
|
+
export function shipped_workflows() {
|
|
99
|
+
if (!existsSync(WORKFLOW_SOURCE)) return [];
|
|
100
|
+
return readdirSync(WORKFLOW_SOURCE).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(name => {
|
|
101
|
+
const { fields } = parse_markdown(readFileSync(join(WORKFLOW_SOURCE, name, 'SKILL.md'), 'utf8'), `${name}/SKILL.md`);
|
|
102
|
+
return { name, label: fields.label || name, description: fields.description || '' };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
97
105
|
|
|
98
106
|
/**
|
|
99
|
-
* Install
|
|
100
|
-
*
|
|
107
|
+
* Install shipped workflows into the service home's library, replacing any of the same ID with the shipped version.
|
|
108
|
+
* Setup calls this only for the ones the user picks on that run; a copy that differs is backed up first.
|
|
101
109
|
* @param {string} home - the service home.
|
|
102
|
-
* @
|
|
110
|
+
* @param {string[]} [names] - which shipped workflows; all when left out.
|
|
111
|
+
* @returns {{name: string, status: 'installed' | 'updated' | 'unchanged', path: string, backup?: string}[]}
|
|
103
112
|
*/
|
|
104
|
-
export function install_workflows(home) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const previous = existsSync(record) ? JSON.parse(readFileSync(record, 'utf8')) : {};
|
|
108
|
-
const managed = { ...previous };
|
|
109
|
-
const results = readdirSync(WORKFLOW_SOURCE).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(name => {
|
|
113
|
+
export function install_workflows(home, names = shipped_workflows().map(item => item.name)) {
|
|
114
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
115
|
+
return shipped_workflows().map(item => item.name).filter(name => names.includes(name)).map(name => {
|
|
110
116
|
const source = join(WORKFLOW_SOURCE, name), destination = workflow_directory(home, name);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
try { current = tree_hash(destination); } catch { return { name, status: 'kept', path: destination }; }
|
|
115
|
-
if (current === hash) { managed[name] = hash; return { name, status: 'unchanged', path: destination }; }
|
|
116
|
-
if (current !== previous[name]) return { name, status: 'kept', path: destination };
|
|
117
|
+
if (!existsSync(destination)) {
|
|
118
|
+
install_workflow_folder(home, name, source);
|
|
119
|
+
return { name, status: 'installed', path: destination };
|
|
117
120
|
}
|
|
121
|
+
if (tree_hash(destination) === tree_hash(source)) return { name, status: 'unchanged', path: destination };
|
|
122
|
+
const backup = join(home, 'backups', 'workflows', `${name}-${stamp}`);
|
|
123
|
+
mkdirSync(dirname(backup), { recursive: true });
|
|
124
|
+
cpSync(destination, backup, { recursive: true });
|
|
118
125
|
install_workflow_folder(home, name, source);
|
|
119
|
-
|
|
120
|
-
return { name, status: previous[name] ? 'updated' : 'installed', path: destination };
|
|
126
|
+
return { name, status: 'updated', path: destination, backup };
|
|
121
127
|
});
|
|
122
|
-
mkdirSync(home, { recursive: true });
|
|
123
|
-
atomic_json(record, managed);
|
|
124
|
-
return results;
|
|
125
128
|
}
|
|
@@ -162,6 +162,95 @@ export function checkbox_prompt(message, items, input = process.stdin, output =
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** A group's mark from how many of its items are on: all, none or some. */
|
|
166
|
+
function group_mark(on, total) {
|
|
167
|
+
return on === total ? tone.green('[✓]') : on ? tone.yellow('[−]') : tone.dim('[ ]');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Optional settings as a folded tree, after the health-check menus: an overview first (→ to edit, enter to go on), then
|
|
172
|
+
* groups that unfold into items. Arrows move, space toggles an item or a whole group, → unfolds, ← folds or returns to
|
|
173
|
+
* the overview, enter confirms. Choices apply to this run only.
|
|
174
|
+
* @param {string} message - the step title.
|
|
175
|
+
* @param {{label: string, description: string, items: {label: string, hint: string, description: string, checked: boolean}[]}[]} groups
|
|
176
|
+
* @returns {Promise<boolean[][]>} The checked state of every item, by group.
|
|
177
|
+
*/
|
|
178
|
+
export function tree_prompt(message, groups, input = process.stdin, output = process.stderr) {
|
|
179
|
+
const checked = groups.map(group => group.items.map(item => Boolean(item.checked)));
|
|
180
|
+
const expanded = new Set();
|
|
181
|
+
const total = checked.flat().length;
|
|
182
|
+
const width = Math.max(...groups.flatMap(group => [text_width(group.label) + 2, ...group.items.map(item => text_width(item.label))]));
|
|
183
|
+
let editing = false, cursor = 0, drawn = 0;
|
|
184
|
+
const count = index => checked[index].filter(Boolean).length;
|
|
185
|
+
const enabled = () => checked.flat().filter(Boolean).length;
|
|
186
|
+
const rows = () => groups.flatMap((group, index) => [{ group: index },
|
|
187
|
+
...(expanded.has(index) ? group.items.map((_, item) => ({ group: index, item })) : [])]);
|
|
188
|
+
const draw = lines => {
|
|
189
|
+
if (drawn) output.write(`\x1b[${drawn}A`);
|
|
190
|
+
output.write(`${lines.map(line => `\x1b[2K${line}`).join('\n')}\n`);
|
|
191
|
+
if (lines.length < drawn) output.write('\x1b[J');
|
|
192
|
+
drawn = lines.length;
|
|
193
|
+
};
|
|
194
|
+
const row_text = ({ group, item }, focused) => {
|
|
195
|
+
const pointer = focused ? tone.cyan('›') : ' ';
|
|
196
|
+
if (item === undefined) {
|
|
197
|
+
const arrow = expanded.has(group) ? '▾' : '▸';
|
|
198
|
+
const label = pad(groups[group].label, width - 2);
|
|
199
|
+
return `${bar()} ${pointer} ${arrow} ${group_mark(count(group), checked[group].length)} ${focused ? tone.bold(label) : label} ${tone.dim(`${count(group)} / ${checked[group].length}`)}`;
|
|
200
|
+
}
|
|
201
|
+
const value = groups[group].items[item];
|
|
202
|
+
const box = checked[group][item] ? tone.green('[✓]') : tone.dim('[ ]');
|
|
203
|
+
return `${bar()} ${pointer} ${box} ${focused ? tone.bold(pad(value.label, width)) : pad(value.label, width)} ${tone.dim(value.hint)}`;
|
|
204
|
+
};
|
|
205
|
+
const overview = () => [`${tone.cyan('◆')} ${message}`, bar(),
|
|
206
|
+
...groups.map((group, index) => `${bar()} ${pad(group.label, width)} ${tone.dim(`${count(index)} / ${checked[index].length}`)}`),
|
|
207
|
+
bar(), `${bar()} ${tone.dim('▸ 修改安装配置(→ 展开)')}`, `${tone.gray('└')} ${tone.dim('回车 继续 · Ctrl+C 退出')}`];
|
|
208
|
+
const editor = () => {
|
|
209
|
+
const list = rows(), focus = list[cursor];
|
|
210
|
+
const detail = focus.item === undefined ? groups[focus.group].description : groups[focus.group].items[focus.item].description;
|
|
211
|
+
return [`${tone.cyan('◆')} ${message}`, bar(), ...list.map((row, index) => row_text(row, index === cursor)), bar(),
|
|
212
|
+
`${bar()} ${tone.dim(detail)}`, `${bar()} ${tone.cyan(`${enabled()} / ${total} 项启用 · 仅本次生效`)}`, bar(),
|
|
213
|
+
`${bar()} ${tone.dim('↑↓ 移动 · 空格 开关 · → 展开 · ← 收起 / 返回概览')}`, `${tone.gray('└')} ${tone.dim('回车 继续 · Ctrl+C 退出')}`];
|
|
214
|
+
};
|
|
215
|
+
return new Promise((done, fail) => {
|
|
216
|
+
const finish = error => {
|
|
217
|
+
input.off('keypress', on_key);
|
|
218
|
+
input.setRawMode(false);
|
|
219
|
+
input.pause();
|
|
220
|
+
const summary = groups.map((group, index) => `${group.label}:${group.items.filter((_, item) => checked[index][item]).map(item => item.label).join('、') || '不安装'}`);
|
|
221
|
+
draw(error ? [`${tone.red('■')} ${message}`, `${bar()} ${tone.dim('已取消')}`]
|
|
222
|
+
: [`${tone.green('◇')} ${message}`, ...summary.map(line => `${bar()} ${tone.dim(line)}`), bar()]);
|
|
223
|
+
output.write('\x1b[?25h');
|
|
224
|
+
if (error) fail(error); else done(checked);
|
|
225
|
+
};
|
|
226
|
+
const on_key = (_text, key = {}) => {
|
|
227
|
+
if (key.ctrl && key.name === 'c') return finish(new Error('已取消安装配置'));
|
|
228
|
+
if (key.name === 'return' || key.name === 'enter') return finish();
|
|
229
|
+
if (!editing) {
|
|
230
|
+
if (key.name === 'right') editing = true;
|
|
231
|
+
return draw(editing ? editor() : overview());
|
|
232
|
+
}
|
|
233
|
+
const list = rows(), row = list[cursor];
|
|
234
|
+
if (key.name === 'up' || key.name === 'down') cursor = (cursor + (key.name === 'down' ? 1 : list.length - 1)) % list.length;
|
|
235
|
+
else if (key.name === 'space') {
|
|
236
|
+
if (row.item === undefined) checked[row.group].fill(!checked[row.group].every(Boolean));
|
|
237
|
+
else checked[row.group][row.item] = !checked[row.group][row.item];
|
|
238
|
+
} else if (key.name === 'right') expanded.add(row.group);
|
|
239
|
+
else if (key.name === 'left') {
|
|
240
|
+
if (row.item === undefined && !expanded.has(row.group)) editing = false;
|
|
241
|
+
else { expanded.delete(row.group); cursor = rows().findIndex(item => item.group === row.group && item.item === undefined); }
|
|
242
|
+
}
|
|
243
|
+
draw(editing ? editor() : overview());
|
|
244
|
+
};
|
|
245
|
+
emitKeypressEvents(input);
|
|
246
|
+
input.setRawMode(true);
|
|
247
|
+
input.resume();
|
|
248
|
+
input.on('keypress', on_key);
|
|
249
|
+
output.write('\x1b[?25l');
|
|
250
|
+
draw(overview());
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
165
254
|
/** Close the gutter with a titled block of aligned rows, notes and a final line. */
|
|
166
255
|
export function outro(title, rows, notes, closing, mark = tone.green('◆')) {
|
|
167
256
|
const width = Math.max(0, ...rows.map(([label]) => text_width(label)));
|
package/web/app.js
CHANGED
|
@@ -1507,7 +1507,7 @@ async function set_member_adapter({ name, adapter }) {
|
|
|
1507
1507
|
}
|
|
1508
1508
|
|
|
1509
1509
|
/** Owner changes use the website; only automatic members need the local service to reconnect. */
|
|
1510
|
-
async function change_member({ member_id, name, action, role, latest_id }) {
|
|
1510
|
+
async function change_member({ member_id, name, action, role, latest_id, quiet = false }) {
|
|
1511
1511
|
const room_id = state.room.id;
|
|
1512
1512
|
if (action === "rejoin") {
|
|
1513
1513
|
const member = (state.room.free?.members || state.room.members).find(item => item.id === member_id);
|
|
@@ -1517,7 +1517,7 @@ async function change_member({ member_id, name, action, role, latest_id }) {
|
|
|
1517
1517
|
? await local_launch_request("room-member", { room_id, token, action: "rejoin", name, member_token: record.member.token })
|
|
1518
1518
|
: await request_json(`/api/rooms/${room_id}/members/${member_id}`, { action: "active", token: record.member.token });
|
|
1519
1519
|
finish_member_request(room_id, token, record, result);
|
|
1520
|
-
if (state.room?.id === room_id) notify("info", member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`);
|
|
1520
|
+
if (!quiet && state.room?.id === room_id) notify("info", member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`);
|
|
1521
1521
|
} else {
|
|
1522
1522
|
await request_json(`/api/rooms/${room_id}/members/${member_id}${action === "interrupt" ? "/interrupt" : ""}`,
|
|
1523
1523
|
action === "interrupt" ? {} : { action, ...(role ? { role } : {}), ...(latest_id !== undefined ? { latest_id } : {}) });
|
|
@@ -4131,7 +4131,9 @@ async function fork_room(message_id) {
|
|
|
4131
4131
|
const registered = new Set((local?.members || []).filter(member => member.status !== "failed").map(member => member.name));
|
|
4132
4132
|
for (const member of (state.room.free?.members || []).filter(item => item.status === "away")) {
|
|
4133
4133
|
if (member.access === "runner" && !registered.has(member.name)) continue;
|
|
4134
|
-
|
|
4134
|
+
// The fork's own notice below sums up who continues and who starts anew, so automatic members rejoin silently;
|
|
4135
|
+
// a manual member still says it must register again with its new start command.
|
|
4136
|
+
try { await change_member({ member_id: member.id, name: member.name, action: "rejoin", quiet: member.access === "runner" }); }
|
|
4135
4137
|
catch (error) { problems.push(`${member.name} 未能请回:${error.message}`); }
|
|
4136
4138
|
}
|
|
4137
4139
|
const fresh = (local?.members || []).filter(member => member.status === "fresh").map(member => member.name);
|