ai_co_discuss 0.3.39 → 0.3.40

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.
@@ -1,1351 +1,1437 @@
1
- /** Shared launch configuration for an existing AI conversation and the local website. */
2
- import { spawn } from 'node:child_process';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
- import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
7
- import { DatabaseSync } from 'node:sqlite';
8
- import { setTimeout as sleep } from 'node:timers/promises';
9
- import { pathToFileURL } from 'node:url';
10
- import { parseArgs } from 'node:util';
11
-
12
- import { atomic_json } from './files.mjs';
13
- import { read_project_files } from './project_files.mjs';
14
- import { setup_sidecar } from './adapters/antigravity_app.mjs';
15
- import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
16
- import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
17
- import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
18
- import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
19
- import { save_tts_settings, tts_settings, tts_speak } from './tts.mjs';
20
- import { stt_part } from './stt.mjs';
21
- import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
22
- import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
23
- import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
24
- import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
25
-
26
- // A platform is a type; `Claude-review` is another Claude instance with its own session, model and effort. An ID is
27
- // English letters, digits and underscores only; a Chinese name is a display label.
28
- const INSTANCE_PATTERN = /^(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_]{1,16})?$/;
29
-
30
- /** The platform of an AI instance name, or null. */
31
- export function platform_of(name) { return typeof name === 'string' ? INSTANCE_PATTERN.exec(name)?.[1] ?? null : null; }
32
-
33
- /** The first unused instance name of a platform: the platform itself, then platform-2, platform-3, …. */
34
- export function next_instance(platform, taken) {
35
- if (!taken.has(platform)) return platform;
36
- for (let number = 2; ; number++) if (!taken.has(`${platform}-${number}`)) return `${platform}-${number}`;
37
- }
38
-
39
- /** A free name from a used one, as the website numbers copies: Codex → Codex-2, Codex-review → Codex-review2. */
40
- function free_instance(name, taken) {
41
- if (!name.includes('-')) return next_instance(name, taken);
42
- for (let number = 2; ; number++) if (!taken.has(`${name}${number}`)) return `${name}${number}`;
43
- }
44
-
45
- /** Supported local clients of an instance's platform. */
46
- function clients_of(name) { return platform_adapters(platform_of(name)); }
47
- const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id', 'base_url', 'api_protocol', 'api_key_ref', 'system_prompt'];
48
- const MAX_MINUTES = 1440;
49
- const SESSION_ID_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
50
- const LOCAL_MEMBERS_FORMAT = 'ai-codiscuss-local-members';
51
- // Reading the inbox proves the owner credential without transferring any messages.
52
- const CONFIG_APPLY_TIMEOUT_MS = 6000;
53
- const CONFIG_APPLY_POLL_MS = 100;
54
- const HELP = `用法:codiscuss launch settings [--file FILE]
55
- codiscuss launch start --file FILE [--dry-run]
56
- codiscuss launch room-config --file FILE
57
- codiscuss launch room-models --file FILE
58
- codiscuss launch room-files --file FILE
59
- codiscuss launch models --file FILE
60
- codiscuss launch api-key --file FILE
61
- codiscuss launch api-check --file FILE
62
- codiscuss launch scheduler-check --file FILE
63
- codiscuss launch tts-settings [--file FILE]
64
- codiscuss launch tts --file FILE
65
- codiscuss launch stt --file FILE
66
- codiscuss launch room-member --file FILE
67
- codiscuss launch room-member-access --file FILE
68
- codiscuss launch room-member-allow --file FILE
69
- codiscuss launch room-member-model --file FILE
70
- codiscuss launch room-member-adapter --file FILE
71
- codiscuss launch room-profiles --file FILE
72
- codiscuss launch room-restore --file FILE
73
- codiscuss launch room-fork --file FILE
74
- codiscuss launch pick-directory
75
- codiscuss launch projects [--file FILE]
76
- codiscuss launch presets [--file FILE]
77
- codiscuss launch machine-link --file FILE
78
- codiscuss launch admin-account --file FILE
79
-
80
- settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
81
- start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
82
- room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
83
- room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论;quick 为 true 时不查询 agy 的默认模型(较慢),该成员标 defaults_pending。
84
- room-files 只读房间已保存的项目目录,须提供 site、room_id、token,可选相对 path 和目录分页 offset;文件返回最多前 128 KiB 文本。
85
- models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
86
- api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
87
- api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
88
- scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
89
- tts-settings 读取朗读设置;内容非空时保存(音色 speaker、语速 speech_rate、语气提示 context、api_key_ref)。
90
- tts 用朗读设置把 text(不超过 300 字)合成为 MP3,返回 base64。
91
- stt 按序接收录音片段(upload_id、index、base64 PCM 16 kHz 单声道、final);最后一段时识别整段录音并返回 text。
92
- pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
93
- projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
94
- presets 读取本机的专家预设(含内置预设)与各平台默认描述;提供 JSON(presets 列表和/或 description_templates)时替换对应部分。
95
- machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
96
- admin-account 把在指定 site 注册的管理员用户名与密码保存到服务目录的 admin-accounts.json;须提供 site、username、password。
97
- room-member 为房间邀请、请回一位成员;须提供 site、room_id、token、action(add/rejoin)、name。
98
- room-member-access 修改一位本机唤醒成员的项目权限(project_access 为 read/write/auto/full),下次投递起生效、保留原会话;须提供 site、room_id、token、name、project_access。
99
- room-member-allow 把被拒绝的操作加入一位本机唤醒的 Claude Code 或 agy 成员的 allowed_tools,下次投递起生效;须提供 site、room_id、token、name、rules。
100
- room-member-model 修改一位本机唤醒 CLI 成员的模型与思考强度(空值恢复软件默认),下次投递起生效、保留原会话;须提供 site、room_id、token、name、model、effort。
101
- room-member-adapter 把自由房间里空闲的 Codex 成员在 App(codex)与 CLI(codex_cli)间切换,保留原会话;须提供 site、room_id、token、name、adapter。
102
- room-profiles 导出房间的本机成员软件配置与实际会话 ID(不含凭据),供备份附带;须提供 site、room_id、token。
103
- room-restore 把导入房间的本机成员配置登记到本机并核对原会话存在,Claude Code 成员保持手动;须提供 site、room_id、token、local_members。
104
- room-fork 把从原房间分支出的新房间登记到本机,各本机成员从原会话分叉、不含分支点之后的执行,原会话不变;须提供 site、room_id、token、source_room_id、source_token、executions({成员名: [排除的执行编号]})。
105
- JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
106
- 或 room_id(已有房间)、participants(本次软件覆盖配置)。
107
- conversation 还须 current_session_id,当前会话接手首个主持任务。
108
- 已有受保护房间使用 access_file。路径、凭据只保存在本机。
109
- 详见 docs/runner.md 的“两种讨论入口与启动配置”。
110
- `;
111
-
112
- // Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
113
- // shown (transparent) as a topmost window; the dialog it owns then stays above the browser that asked for it.
114
- // Keep the owner on-screen because the Common Item Dialog positions itself relative to its owner.
115
- // windowsHide turns the process's first Show into a hide, so the owner is shown a second time to really appear.
116
- // Under the local service the whole process chain has no console and no foreground rights: Windows then silently ignores
117
- // SetWindowPos(HWND_TOPMOST) on the dialog (it returns success). So once the dialog exists, a timer in its message loop
118
- // briefly attaches to the foreground window's input thread (the browser that asked) to borrow its right to raise the dialog.
119
- // The C# source must not contain single quotes: it is embedded in a single-quoted PowerShell string.
120
- const PICKER_RAISE_SOURCE = [
121
- 'using System; using System.Runtime.InteropServices;',
122
- 'public static class PickerRaise {',
123
- ' delegate bool EnumProc(IntPtr handle, IntPtr arg);',
124
- ' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc proc, IntPtr arg);',
125
- ' [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr handle, uint command);',
126
- ' [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr handle, IntPtr after, int x, int y, int width, int height, uint flags);',
127
- ' [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();',
128
- ' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr handle, IntPtr process);',
129
- ' [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();',
130
- ' [DllImport("user32.dll")] static extern bool AttachThreadInput(uint attach, uint to, bool on);',
131
- ' [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr handle);',
132
- ' [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr handle);',
133
- ' public static bool OwnedBy(IntPtr owner) {',
134
- ' IntPtr dialog = IntPtr.Zero;',
135
- ' EnumWindows((handle, arg) => { if (GetWindow(handle, 4) == owner) dialog = handle; return dialog == IntPtr.Zero; }, IntPtr.Zero);',
136
- ' if (dialog == IntPtr.Zero) return false;',
137
- ' uint current = GetCurrentThreadId(), foreground = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);',
138
- ' bool attached = foreground != 0 && foreground != current && AttachThreadInput(current, foreground, true);',
139
- ' SetWindowPos(dialog, (IntPtr)(-1), 0, 0, 0, 0, 0x13); BringWindowToTop(dialog); SetForegroundWindow(dialog);',
140
- ' if (attached) AttachThreadInput(current, foreground, false);',
141
- ' return true;',
142
- ' }',
143
- '}',
144
- ].join(' ');
145
- // Use the Common Item Dialog: unlike Framework WinForms, it has an editable address bar.
146
- // COM declarations retain the native vtable order through the last method used.
147
- const FOLDER_PICKER_SOURCE = `
148
- public static class FolderPicker {
149
- const uint FOS_PICKFOLDERS = 0x20, FOS_FORCEFILESYSTEM = 0x40, FOS_PATHMUSTEXIST = 0x800;
150
- const uint SIGDN_FILESYSPATH = 0x80058000;
151
- const int CANCELLED = unchecked((int)0x800704C7);
152
- [ComImport, Guid("42F85136-DB7E-439C-85F1-E4075D135FC8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
153
- interface IFileDialog {
154
- [PreserveSig] int Show(IntPtr owner);
155
- void SetFileTypes(uint count, IntPtr filters);
156
- void SetFileTypeIndex(uint index);
157
- void GetFileTypeIndex(out uint index);
158
- void Advise(IntPtr events, out uint cookie);
159
- void Unadvise(uint cookie);
160
- void SetOptions(uint options);
161
- void GetOptions(out uint options);
162
- void SetDefaultFolder(IShellItem folder);
163
- void SetFolder(IShellItem folder);
164
- void GetFolder(out IShellItem folder);
165
- void GetCurrentSelection(out IShellItem item);
166
- void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
167
- void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
168
- void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
169
- void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
170
- void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
171
- void GetResult(out IShellItem item);
172
- }
173
- [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
174
- interface IShellItem {
175
- void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, out IntPtr result);
176
- void GetParent(out IShellItem parent);
177
- void GetDisplayName(uint format, [MarshalAs(UnmanagedType.LPWStr)] out string name);
178
- }
179
- /// <summary>Choose an existing filesystem folder, or return null when cancelled.</summary>
180
- public static string Show(IntPtr owner) {
181
- var dialog = (IFileDialog)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")));
182
- IShellItem item = null;
183
- try {
184
- uint options;
185
- dialog.GetOptions(out options);
186
- dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
187
- dialog.SetTitle("选择项目目录");
188
- int result = dialog.Show(owner);
189
- if (result == CANCELLED) return null;
190
- Marshal.ThrowExceptionForHR(result);
191
- dialog.GetResult(out item);
192
- string path;
193
- item.GetDisplayName(SIGDN_FILESYSPATH, out path);
194
- return path;
195
- } finally {
196
- if (item != null) Marshal.ReleaseComObject(item);
197
- Marshal.ReleaseComObject(dialog);
198
- }
199
- }
200
- }
201
- `;
202
- const WINDOWS_PICKER = [
203
- '$ErrorActionPreference = "Stop"',
204
- 'Add-Type -AssemblyName System.Windows.Forms',
205
- '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
206
- '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; Opacity = 0; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "CenterScreen"; Size = New-Object System.Drawing.Size(1, 1) }',
207
- `Add-Type -TypeDefinition '${PICKER_RAISE_SOURCE} ${FOLDER_PICKER_SOURCE}'`,
208
- '$owner.Show(); $owner.Hide(); $owner.Show(); $owner.Activate()',
209
- '$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ if ([PickerRaise]::OwnedBy($owner.Handle)) { $timer.Stop() } }); $timer.Start()',
210
- 'try { [FolderPicker]::Show($owner.Handle) } finally { $timer.Stop(); $timer.Dispose(); $owner.Close(); $owner.Dispose() }',
211
- ].join('; ');
212
- const PICKERS = {
213
- win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
214
- darwin: ['osascript', ['-e', 'POSIX path of (choose folder with prompt "选择项目目录")']],
215
- linux: ['zenity', ['--file-selection', '--directory', '--title=选择项目目录']],
216
- };
217
-
218
- /** Show the platform's own folder dialog on this computer; cancelling resolves without a path. */
219
- export function pick_directory({ platform = process.platform, spawn_process = spawn } = {}) {
220
- const picker = PICKERS[platform];
221
- if (!picker) return Promise.reject(new Error('此系统不支持目录选择窗口,请直接输入完整路径'));
222
- return new Promise((resolve_result, reject) => {
223
- const child = spawn_process(picker[0], picker[1], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
224
- let output = '';
225
- child.stdout.setEncoding('utf8');
226
- child.stdout.on('data', chunk => { output += chunk; });
227
- child.once('error', () => reject(new Error('无法打开目录选择窗口,请直接输入完整路径')));
228
- child.once('close', code => {
229
- const path = output.trim();
230
- // Judge the path by the picker's platform, not the host's: a Windows path is not absolute on POSIX.
231
- const absolute = platform === 'win32' ? win32.isAbsolute(path) : posix.isAbsolute(path);
232
- resolve_result(code === 0 && absolute ? { path } : { cancelled: true });
233
- });
234
- });
235
- }
236
-
237
- /** Some CLIs take seconds to list models; reuse a good list for a day unless the executable changes or the user retries. */
238
- const MODEL_CACHE_MS = 24 * 60 * 60 * 1000;
239
-
240
- /** Return the software catalog, served from the private service home when still valid. */
241
- export async function cached_software_models(home, profile, { refresh = false, query = software_models, query_effort = software_effort, query_defaults = software_defaults } = {}) {
242
- const path = join(home, 'model-catalogs.json');
243
- let stamp = '';
244
- try { const file = statSync(profile.executable); stamp = `${file.size}:${file.mtimeMs}`; } catch {}
245
- const key = JSON.stringify([profile.adapter, profile.executable || '', stamp]);
246
- const effort_key = JSON.stringify([profile.adapter, profile.executable || '', stamp, 'effort', profile.model || '']);
247
- let cache = {};
248
- try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
249
- const cached = entry => !refresh && entry && Date.now() - entry.at < MODEL_CACHE_MS ? entry.result : null;
250
- // Defaults are read on every request, so a changed software setting shows without waiting out the cache.
251
- const [result, effort, defaults] = await Promise.all([cached(cache[key]) || query(profile),
252
- adapter_profile(profile.adapter)?.efforts_per_model ? cached(cache[effort_key]) || query_effort(profile) : null, query_defaults(profile)]);
253
- const updates = {};
254
- if (!cached(cache[key]) && !result.error && result.models.length) {
255
- updates[key] = { at: Date.now(), result };
256
- }
257
- if (effort && !effort.error && !cached(cache[effort_key])) {
258
- updates[effort_key] = { at: Date.now(), result: effort };
259
- }
260
- if (Object.keys(updates).length) {
261
- try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
262
- const prefix = JSON.stringify([profile.adapter, profile.executable || '']).slice(0, -1);
263
- for (const old of Object.keys(cache)) if (old.startsWith(prefix) && !old.startsWith(key.slice(0, -1))) delete cache[old];
264
- atomic_json(path, { ...cache, ...updates });
265
- }
266
- return { ...result, defaults, ...(effort ? { efforts: effort.levels, ...(effort.error ? { effort_error: effort.error } : {}) } : {}) };
267
- }
268
-
269
- /** Read private defaults without selecting a global room or identity. */
270
- export function launch_settings(home) {
271
- const path = join(home, 'launch-settings.json');
272
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { participants: {} };
273
- }
274
-
275
- /**
276
- * Read the registered room's model choices, what each software selects for empty ones, and its smart scheduler, never connection secrets.
277
- * `actual_model` / `actual_effort` (present only when known) are what each member's session last ran with, read from the software's own session log.
278
- * With `quick`, agy's default model is not queried and its entry is marked `defaults_pending`.
279
- */
280
- export async function room_models(home, raw, { read_defaults = software_defaults, read_session = session_settings } = {}) {
281
- const input = launch_input({ ...raw, entry: 'web' });
282
- if (!input.room_id) throw new Error('读取本场模型须提供 room_id');
283
- const paths = read_registry(home).filter(path => {
284
- const identity = registered_identity(path);
285
- return identity?.site === input.site && identity.room === input.room_id;
286
- });
287
- if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
288
- const config = paths.length ? configuration_snapshot(paths[0]).config : {};
289
- const profiles = config.participants || {};
290
- let sessions = {};
291
- try { if (paths.length) sessions = bound_sessions(config, paths[0]); } catch { /* No readable runtime yet: fall back to borrowed sessions. */ }
292
- const participants = Object.fromEntries(await Promise.all(Object.entries(profiles).map(async ([name, profile]) => {
293
- const facts = adapter_profile(profile.adapter);
294
- // An App chooses its model itself; a CLI or API member's card sets it.
295
- const card_model = facts?.kind !== 'app';
296
- // A software read by running it to name its default model takes seconds; a quick read leaves that to a later full read.
297
- const pending = input.quick === true && Boolean(facts?.defaults_by_query);
298
- const defaults = pending ? { model: '', effort: '' } : await read_defaults(profile);
299
- const actual = read_session(profile.adapter, sessions[name] || profile.session_id);
300
- return [name, { ...(pending ? { defaults_pending: true } : {}), adapter: profile.adapter, model: card_model ? profile.model || '' : '',
301
- effort: facts?.efforts ? profile.effort || '' : '', default_model: defaults.model, default_effort: defaults.effort,
302
- ...(actual.model ? { actual_model: actual.model } : {}), ...(actual.effort ? { actual_effort: actual.effort } : {}),
303
- project_access: !facts?.capabilities.project ? 'read' : profile.project_access || config.project_access || 'read',
304
- // A member bound to its own conversation may keep that conversation's project, so this tool cannot set its access.
305
- own_project: Boolean(facts?.own_project_when_bound && profile.session_id) }];
306
- })));
307
- // Smart scheduling is a local choice; only which model decides is shown, never its key.
308
- const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
309
- return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler, project_directory: config.cwd || '' };
310
- }
311
-
312
- /** Read the owner's saved room project, never a directory supplied by the request. */
313
- export async function room_files(home, raw) {
314
- const { input, config } = await member_room_context(home, raw);
315
- return { room_id: input.room_id, ...read_project_files(config.cwd, input.path, input.offset) };
316
- }
317
-
318
- /** Save only reusable client settings; room credentials and session bindings are never defaults. */
319
- export function save_launch_settings(home, input) {
320
- const participants = {};
321
- for (const [name, profile] of Object.entries(input.participants || {})) {
322
- if (platform_of(name) !== name) throw new Error(`本机默认配置按平台保存;${name} 的模型与强度只用于本场讨论`);
323
- validate_profile(name, profile);
324
- participants[name] = profile;
325
- }
326
- const settings = { participants };
327
- mkdirSync(home, { recursive: true, mode: 0o700 });
328
- atomic_json(join(home, 'launch-settings.json'), settings);
329
- return settings;
330
- }
331
-
332
- const PROJECT_NAME_LIMIT = 80;
333
- const PROJECT_ID_PATTERN = /^[0-9a-f]{8,32}$/;
334
-
335
- /** Read the named project directories saved on this computer, offered as launch cwd choices. */
336
- export function launch_projects(home) {
337
- const path = join(home, 'launch-projects.json');
338
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { projects: [] };
339
- }
340
-
341
- /** Replace the project list; every path must be an existing folder on this computer. */
342
- // The id outlives renames and moves: other devices choose a project by id and never see its path.
343
- export function save_launch_projects(home, input) {
344
- if (!Array.isArray(input.projects)) throw new Error('projects 必须为列表');
345
- const projects = input.projects.map(item => ({ id: PROJECT_ID_PATTERN.test(item?.id) ? item.id : randomUUID().replaceAll('-', '').slice(0, 16),
346
- name: String(item?.name || '').trim(), path: String(item?.path || '').trim() }));
347
- const names = new Set();
348
- for (const { name, path } of projects) {
349
- if (!name || name.length > PROJECT_NAME_LIMIT) throw new Error(`项目名称不能为空,且不超过 ${PROJECT_NAME_LIMIT} 字`);
350
- if (names.has(name)) throw new Error(`项目名称重复:${name}`);
351
- names.add(name);
352
- if (!isAbsolute(path)) throw new Error(`${name}:路径必须为完整绝对路径`);
353
- if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${name}:这台电脑上不存在此文件夹:${path}`);
354
- }
355
- mkdirSync(home, { recursive: true, mode: 0o700 });
356
- atomic_json(join(home, 'launch-projects.json'), { projects });
357
- return { projects };
358
- }
359
-
360
- const MAX_EXPERT_PRESETS = 30;
361
- const MAX_PRESET_DESCRIPTION = 500;
362
- const MAX_PRESET_FIELD = 200;
363
- // name is the instance a preset adds (platform-suffix); label is the free name shown for it, on the add row and in the room.
364
- const PRESET_FIELDS = ['name', 'label', 'description', 'system_prompt', 'project_access', 'model', 'effort'];
365
- const MAX_PRESET_LABEL = 32;
366
-
367
- /**
368
- * Presets saved while IDs could hold Chinese: such an ID becomes `platform-presetN` and its old name the label, so the
369
- * add row still shows it. The next save keeps the new form.
370
- */
371
- function english_preset_ids(presets) {
372
- const taken = new Set(presets.map(preset => preset.name));
373
- return presets.map(preset => {
374
- const platform = /^(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-|$)/.exec(preset.name || '')?.[1];
375
- if (!platform || platform_of(preset.name)) return preset;
376
- let number = 1;
377
- while (taken.has(`${platform}-preset${number}`)) number++;
378
- const name = `${platform}-preset${number}`;
379
- taken.add(name);
380
- return { ...preset, name, label: preset.label || preset.name };
381
- });
382
- }
383
-
384
- /** The user's expert presets and per-platform default descriptions saved on this computer, plus the built-in presets. */
385
- export function expert_presets(home) {
386
- const path = join(home, 'expert-presets.json');
387
- const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
388
- return { presets: english_preset_ids(saved.presets || []), description_templates: saved.description_templates || {}, builtin_presets: BUILTIN_EXPERT_PRESETS };
389
- }
390
-
391
- /** Replace the user's presets or default descriptions (whichever the input carries); every field is checked first. */
392
- export function save_expert_presets(home, input) {
393
- const current = expert_presets(home);
394
- const presets = input.presets === undefined ? current.presets : input.presets;
395
- const templates = input.description_templates === undefined ? current.description_templates : input.description_templates;
396
- if (!Array.isArray(presets) || presets.length > MAX_EXPERT_PRESETS) throw new Error(`presets 必须为列表,最多 ${MAX_EXPERT_PRESETS} 个`);
397
- const names = new Set();
398
- for (const preset of presets) {
399
- if (!preset || typeof preset !== 'object' || Object.keys(preset).some(key => !PRESET_FIELDS.includes(key))) throw new Error('预设只能包含名称、显示名、描述、提示词、项目权限、模型与强度');
400
- if (!platform_of(preset.name) || !clients_of(preset.name)) throw new Error(`预设 ID 须为“平台名”或“平台名-后缀”,后缀只用英文、数字和下划线:${preset.name}`);
401
- if (names.has(preset.name)) throw new Error(`预设名称重复:${preset.name}`);
402
- names.add(preset.name);
403
- if (Object.values(preset).some(value => typeof value !== 'string')) throw new Error(`${preset.name} 的字段必须为文本`);
404
- if ((preset.label || '').trim().length > MAX_PRESET_LABEL) throw new Error(`${preset.name} 的显示名最多 ${MAX_PRESET_LABEL} 字`);
405
- if ((preset.description || '').length > MAX_PRESET_DESCRIPTION) throw new Error(`${preset.name} 的描述最多 ${MAX_PRESET_DESCRIPTION} 字`);
406
- if ((preset.system_prompt || '').length > MAX_EXPERT_PROMPT) throw new Error(`${preset.name} 的提示词最多 ${MAX_EXPERT_PROMPT} 字`);
407
- if (preset.project_access && !PROJECT_ACCESS.includes(preset.project_access)) throw new Error(`${preset.name} 的项目权限须为 read、write、auto 或 full`);
408
- if ([preset.model, preset.effort].some(value => (value || '').length > MAX_PRESET_FIELD)) throw new Error(`${preset.name} 的模型或强度过长`);
409
- }
410
- if (!templates || typeof templates !== 'object' || Array.isArray(templates) || Object.entries(templates).some(([platform, text]) =>
411
- platform_of(platform) !== platform || typeof text !== 'string' || !text.trim() || text.length > MAX_PRESET_DESCRIPTION)) {
412
- throw new Error(`description_templates 须为 {平台名: 描述},每段 1–${MAX_PRESET_DESCRIPTION} 字`);
413
- }
414
- mkdirSync(home, { recursive: true, mode: 0o700 });
415
- atomic_json(join(home, 'expert-presets.json'), { presets, description_templates: templates });
416
- return expert_presets(home);
417
- }
418
-
419
- /** Reject malformed client fields before saving defaults or creating any discussion. */
420
- function validate_profile(name, profile, binding = false) {
421
- if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
422
- if (profile.adapter && !clients_of(name).includes(profile.adapter)) throw new Error(`${name} 的客户端不匹配`);
423
- // Session bindings and project write access apply to one launch only, never to machine defaults.
424
- const fields = binding ? [...PROFILE_FIELDS, 'session_id', 'project_access'] : PROFILE_FIELDS;
425
- if (Object.keys(profile).some(key => !fields.includes(key))) throw new Error('软件配置包含不支持的字段;默认配置不能保存房间或会话绑定');
426
- for (const [key, value] of Object.entries(profile)) {
427
- if (key === 'allowed_tools') {
428
- if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${name}.allowed_tools 必须为工具名称数组`);
429
- } else if (typeof value !== 'string') throw new Error(`${name}.${key} 必须为文本`);
430
- if (['executable', 'cwd', 'ipc_directory'].includes(key) && value && !isAbsolute(value)) throw new Error(`${name}.${key} 必须为完整绝对路径`);
431
- // The model is passed as one CLI argument, so it must not look like a flag or contain spaces.
432
- if (key === 'model' && value && !/^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(value)) throw new Error(`${name}.model 只能是模型名称,例如 gpt-6-astra、opus 或 gemini-4-argon-high`);
433
- if (key === 'system_prompt' && value.length > MAX_EXPERT_PROMPT) throw new Error(`${name} 的专属提示词最多 ${MAX_EXPERT_PROMPT} 字`);
434
- }
435
- const facts = adapter_profile(profile.adapter);
436
- if (facts?.kind === 'api') validate_api_profile(profile);
437
- // The UI discovers each model's supported subset; omitted values keep the CLI's own default.
438
- if (profile.effort && facts?.kind !== 'api' && !facts?.efforts?.includes(profile.effort)) {
439
- throw new Error(facts?.efforts ? `${name}.effort 只能是 ${facts.efforts.join(' / ')}` : `${name} 的思考档位在原软件中选择`);
440
- }
441
- }
442
-
443
- /** Send one request without following redirects that could disclose the room credential. */
444
- async function site_request(site, path, token, body) {
445
- const response = await fetch(`${site}${path}`, { method: body === undefined ? 'GET' : 'POST',
446
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
447
- body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
448
- const result = await response.json();
449
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
450
- return result;
451
- }
452
-
453
- /** Require an explicit website, launch entry and stable creation identity. */
454
- function launch_input(input) {
455
- const value = structuredClone(input);
456
- const url = new URL(value.site);
457
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('site 必须为 HTTP(S) 根地址');
458
- value.site = url.origin;
459
- if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
460
- if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
461
- if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
462
- if (value.project_access && !PROJECT_ACCESS.includes(value.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
463
- if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
464
- if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
465
- if (value.room?.mode === 'free') {
466
- // P1: a free room is launched once, at creation, from the web page.
467
- if (value.entry !== 'web' || value.room_id) throw new Error('自由协作房间目前只能在网页创建时启动');
468
- const members = value.room.members;
469
- const names = members?.map(member => typeof member === 'string' ? member : member.name);
470
- const woken = Array.isArray(members) ? members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name) : [];
471
- if ((!woken.length && !value.scheduler && !value.cwd) || !names?.length || new Set(names).size !== names.length || woken.some(name => !clients_of(name))) {
472
- throw new Error('自由协作房间须选择至少 1 位支持自动接入的 AI 成员(或开启智能调度、指定项目目录),且不能重复');
473
- }
474
- }
475
- if (value.access_file) {
476
- const access = JSON.parse(readFileSync(resolve(value.access_file), 'utf8'));
477
- if (access.site?.replace(/\/$/, '') !== value.site || access.room !== value.room_id) throw new Error('access_file 与本次站点或房间不符');
478
- value.token = access.token;
479
- }
480
- return value;
481
- }
482
-
483
- /** Discussion experts the member list marks as manual: they answer with their own credential and need no local software. */
484
- function manual_experts(room) {
485
- return new Set((room.members || []).filter(member => member && typeof member === 'object' && member.access === 'manual').map(member => member.name));
486
- }
487
-
488
- /** Merge machine defaults and explicit room overrides, preserving the selected participants. */
489
- function launch_config(home, input, room, directory, options) {
490
- // In a creation input, mode='free' means no immediate discussion; omitted mode still requests a hosted discussion.
491
- if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
492
- if (!clients_of(room.host)) throw new Error('请选择支持全自动讨论的 AI 主持人');
493
- if (!room.experts || room.experts[room.host] !== 'cli') throw new Error('请将 AI 主持人的参与方式设为本地读写');
494
- const other = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
495
- if (other.length) throw new Error(`全自动讨论的参与方式须为本地读写:${other.join('、')}`);
496
- const defaults = options.defaults || launch_settings(home).participants;
497
- const manual = manual_experts(room);
498
- if (manual.has(room.host)) throw new Error('AI 主持人由本机服务续接,不能选择手动接入');
499
- const participants = {};
500
- for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
501
- if (!manual.has(name)) participants[name] = participant_profile(name, defaults, input, name === room.host);
502
- }
503
- const members = launch_members(input, Object.keys(participants), options);
504
- return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
505
- scheduler: input.scheduler || undefined,
506
- cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
507
- data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
508
- }
509
-
510
- /** A free-room member the local service wakes; manual members join without local configuration. */
511
- function woken_member(member) { return typeof member === 'string' || !member.access || member.access === 'runner'; }
512
-
513
- /** Free rooms add one private credential per woken member, kept with the room configuration for retries. */
514
- function free_launch_config(home, input, room, directory, options) {
515
- const defaults = options.defaults || launch_settings(home).participants;
516
- const names = room.members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name);
517
- const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
518
- const members = launch_members(input, names, options);
519
- return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
520
- scheduler: input.scheduler || undefined, cwd: input.cwd, project_access: input.project_access || undefined,
521
- data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
522
- }
523
-
524
- /** Preserve caller-generated member credentials; journal newly generated ones before creation. */
525
- function launch_members(input, names, options) {
526
- return Object.fromEntries(names.map(name => {
527
- const supplied = input.room?.members?.find(member => member?.name === name);
528
- return [name, { token: supplied?.token || options.members?.[name]?.token || `${randomUUID()}${randomUUID()}` }];
529
- }));
530
- }
531
-
532
- /** Merge one participant's machine defaults with this launch's overrides and check its software. */
533
- function participant_profile(name, defaults, input, current_host) {
534
- const platform = platform_of(name);
535
- // An extra instance starts from its platform's settings but never from another instance's bound session.
536
- const { session_id: ignored_session, ...inherited } = name === platform ? {} : input.participants?.[platform] || {};
537
- const profile = { ...defaults[platform], ...(name === platform ? {} : defaults[name]), ...inherited, ...input.participants?.[name] };
538
- for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
539
- validate_profile(name, profile, true);
540
- if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
541
- const facts = adapter_profile(profile.adapter);
542
- if (facts?.kind === 'api') {
543
- if (input.entry === 'conversation' && current_host) throw new Error('API 主持人请从网页启动;当前软件会话主持须选择对应 CLI / App');
544
- return profile;
545
- }
546
- if (facts?.location === 'executable' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
547
- || (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
548
- const cwd = profile.cwd || input.cwd;
549
- if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
550
- if (input.entry === 'conversation' && current_host) profile.session_id = input.current_session_id;
551
- // A new agy conversation always gets this tool's discussion project; only a manual agy expert the owner chose to host (input.adopt) keeps its own.
552
- if (!facts?.host_existing && profile.session_id && !input.adopt) throw new Error(`${name}:${facts?.label || profile.adapter} 不能绑定已有会话或从当前对话主持全自动讨论;请从网页或其他软件发起,它会由讨论服务新建会话`);
553
- return profile;
554
- }
555
-
556
- /** List every file below a directory, or nothing when the software has never stored a session there. */
557
- function session_files(directory) {
558
- return existsSync(directory) ? readdirSync(directory, { recursive: true }).map(path => join(directory, String(path))) : [];
559
- }
560
-
561
- /** Compare directories as the operating system resolves them (Windows short names and case). */
562
- function same_directory(left, right) {
563
- const real = path => { try { return realpathSync.native(path); } catch { return resolve(path); } };
564
- const [a, b] = [real(left), real(right)];
565
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
566
- }
567
-
568
- /**
569
- * Prove an invited existing conversation is on this machine before it can hold a turn, so a mistyped ID
570
- * fails here instead of pausing the discussion later. Claude stores sessions per directory and resumes only
571
- * from the original one, so its cwd follows the session; a different room project is refused.
572
- */
573
- export function borrowed_session(name, profile, room_cwd, env = process.env) {
574
- const id = profile.session_id;
575
- if (!SESSION_ID_PATTERN.test(id)) throw new Error(`${name}:会话 ID 格式不正确`);
576
- const adapter = adapter_of(profile.adapter);
577
- if (!adapter?.adopt || !adapter.session) {
578
- const adoptable = ADAPTER_IDS.filter(item => ADAPTER_PROFILES[item].adopt).map(item => ADAPTER_PROFILES[item].label);
579
- throw new Error(`${name}:只有 ${adoptable.join('、')} 可以接入已有会话`);
580
- }
581
- if (!adapter.session.exists(id, { env })) throw new Error(`${name}:本机找不到 ${adapter.label} 会话 ${id},请核对会话 ID`);
582
- if (adapter.adopt !== 'own_cwd') return profile;
583
- const cwd = adapter.session.cwd(id, { env });
584
- if (!cwd) throw new Error(`${name}:${adapter.label} 会话 ${id} 没有记录原工作目录,无法续接`);
585
- const wanted = profile.cwd || room_cwd;
586
- if (wanted && !same_directory(wanted, cwd)) throw new Error(`${name}:这个 Claude 会话属于 ${cwd},只能在原目录续接;请让项目目录与它一致,或留空会话 ID 新建会话`);
587
- return { ...profile, cwd: wanted || cwd };
588
- }
589
-
590
- /** Find the one registered configuration of a room after proving the owner's credential. */
591
- async function member_room_context(home, raw) {
592
- const input = launch_input({ ...raw, entry: 'web' });
593
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('管理成员须提供房间编号和房间控制凭据');
594
- const paths = read_registry(home).filter(path => {
595
- const identity = registered_identity(path);
596
- return identity?.site === input.site && identity.room === input.room_id;
597
- });
598
- if (paths.length !== 1) throw new Error('未找到本房间唯一的已登记配置,请核对本机接入服务');
599
- const config = configuration_snapshot(paths[0]).config;
600
- // Both reads need the owner credential; the inbox transfers no messages.
601
- await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
602
- return { input, path: paths[0], config };
603
- }
604
-
605
- /** Write a manual member's private credential next to the room configuration, never into a receipt. */
606
- function manual_access(path, input, name, token) {
607
- const access_path = join(dirname(path), `manual-${name}.access.json`);
608
- atomic_json(access_path, { site: input.site, room: input.room_id, member: name, token });
609
- return access_path;
610
- }
611
-
612
- /**
613
- * Invite (add) or bring back (rejoin) one member of a room.
614
- * The local configuration is written before the website call, so a retry reuses the same credential.
615
- * A manual member answers with its own credential and is never woken.
616
- */
617
- export async function room_member(home, raw, locked = false) {
618
- const { input, path } = await member_room_context(home, raw);
619
- const { action, name } = input;
620
- const access = input.access || 'runner';
621
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
622
- if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !['runner', 'manual'].includes(access)) {
623
- throw new Error('action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual');
624
- }
625
- const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
626
- try {
627
- const config = configuration_snapshot(path).config;
628
- config.api_home = home;
629
- const roster = room.free.members;
630
- if (action === 'add' && input.new_instance) {
631
- // A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
632
- const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
633
- return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true);
634
- }
635
- const current = roster.find(member => member.name === name);
636
- if (input.member_token !== undefined && (typeof input.member_token !== 'string' || input.member_token.length < 32 || input.member_token.length > 256)) throw new Error('member_token 须为 32–256 字符');
637
- if (current?.status === 'active' && input.member_token && config.members?.[name]?.token === input.member_token) {
638
- const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token });
639
- return { room_id: input.room_id, member: name, access: current.access, free: view, access_file: null };
640
- }
641
- if (action === 'add' && !current && config.participants[name] && !locked) {
642
- // A removed member's name keeps its bound session locally, so the new one joins under the next free name; a retry
643
- // finds the name its own credential was saved under.
644
- const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
645
- const retried = Object.keys(config.members || {}).find(key => key !== name && input.member_token && config.members[key].token === input.member_token
646
- && !roster.some(member => member.name === key));
647
- const renamed = retried || free_instance(name, taken);
648
- const participants = raw.participants?.[name] ? { participants: { ...raw.participants, [renamed]: raw.participants[name] } } : {};
649
- return room_member(home, { ...raw, name: renamed, ...participants }, true);
650
- }
651
- if (action === 'rejoin' && current?.status !== 'away') throw new Error(`${name} 不是离开状态,无需请回`);
652
- if (action === 'add' && current) throw new Error(`${name} 已是本房间成员;离开的成员请用请回`);
653
- const manual = action === 'rejoin' ? current.access === 'manual' : access === 'manual';
654
- const pending = input.retry_token && config.members?.[name]?.pending === input.retry_token;
655
- const token = input.member_token || (pending ? config.members[name].token : `${randomUUID()}${randomUUID()}`);
656
- let profile = config.participants[name];
657
- if (!manual) {
658
- if (action === 'add') {
659
- profile = participant_profile(name, launch_settings(home).participants, { ...input, cwd: input.cwd || config.cwd }, false);
660
- if (profile.session_id) profile = borrowed_session(name, profile, input.cwd || config.cwd);
661
- }
662
- if (!profile) throw new Error(`${name} 没有本机接入配置;请移除后重新邀请`);
663
- const next = { ...config, participants: { ...config.participants, [name]: profile },
664
- members: { ...config.members, [name]: { token, pending: input.retry_token || '' } } };
665
- validate_config(next, dirname(path));
666
- atomic_json(path, next);
667
- }
668
- const access_file = manual ? manual_access(path, input, name, token) : null;
669
- const body = action === 'add'
670
- ? { name, adapter: manual ? '' : profile.adapter, token, access, session_mode: profile?.session_id ? 'borrowed' : 'new' }
671
- : { action: 'active', token };
672
- const view = await site_request(input.site, action === 'add' ? `/api/rooms/${input.room_id}/members` : `/api/rooms/${input.room_id}/members/${current.id}`,
673
- input.token, body);
674
- return { room_id: input.room_id, member: name, access: manual ? 'manual' : 'runner', free: view, access_file };
675
- } finally { release(); }
676
- }
677
-
678
-
679
- /**
680
- * Switch one woken member's project files between read-only and writable after the room is created. Every run builds its
681
- * permissions from the configuration, so the change applies from the member's next delivery and keeps its session.
682
- */
683
- export async function room_member_access(home, raw) {
684
- const { input, path } = await member_room_context(home, raw);
685
- const { name, project_access } = input;
686
- if (!PROJECT_ACCESS.includes(project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
687
- const release = acquire_lock(join(dirname(path), 'configuration-edit'));
688
- try {
689
- const config = configuration_snapshot(path).config;
690
- const profile = config.participants?.[name];
691
- if (!profile) throw new Error(`${name} 不由本机唤醒,项目权限由它自己的会话决定`);
692
- if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,权限沿用它自己会话的软件设置`);
693
- const facts = adapter_profile(profile.adapter);
694
- // A CLI's project access is set per run; an App follows its own settings and an API member only reads.
695
- if (!facts?.access_levels) throw new Error(!facts?.capabilities.project ? `${name} 没有本机文件工具,项目固定只读` : `${name} 的项目权限在 App 内设置`);
696
- if (!facts.access_levels.includes(project_access)) throw new Error(facts.unsupported_hints?.[project_access] || `${facts.label} 不支持此档位`);
697
- if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,项目权限沿用该对话所在项目,请在原软件中设置`);
698
- if (project_access === 'write' && !(profile.cwd || config.cwd)) throw new Error('本房间没有指定项目目录,可写没有作用;请在新建房间时选择项目目录');
699
- const next = { ...config, participants: { ...config.participants, [name]: { ...profile, project_access } } };
700
- validate_config(next, dirname(path));
701
- atomic_json(path, next);
702
- return { room_id: input.room_id, member: name, project_access };
703
- } finally { release(); }
704
- }
705
-
706
- /**
707
- * Change a woken CLI member's model and thinking effort after the room is created. Each run passes them to the CLI, and
708
- * neither is part of the member's session context, so the change applies from its next delivery and keeps its session.
709
- * An empty value goes back to the software's default.
710
- */
711
- export async function room_member_model(home, raw) {
712
- const { input, path } = await member_room_context(home, raw);
713
- const { name } = input;
714
- const model = typeof input.model === 'string' ? input.model.trim() : '';
715
- const effort = typeof input.effort === 'string' ? input.effort.trim() : '';
716
- const release = acquire_lock(join(dirname(path), 'configuration-edit'));
717
- try {
718
- const config = configuration_snapshot(path).config;
719
- const profile = config.participants?.[name];
720
- if (!profile) throw new Error(`${name} 不由本机唤醒,模型由它自己的会话决定`);
721
- if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,模型与强度沿用它自己会话的设置`);
722
- const facts = adapter_profile(profile.adapter);
723
- if (!facts?.models) throw new Error(`${name} 的模型在 ${facts?.label || '它的软件'} 内设置`);
724
- if (effort && !facts.efforts?.includes(effort)) throw new Error(`${facts.label} 不支持思考强度 ${effort}`);
725
- const next = { ...profile, model, effort };
726
- if (!model) delete next.model;
727
- if (!effort) delete next.effort;
728
- const updated = { ...config, participants: { ...config.participants, [name]: next } };
729
- validate_config(updated, dirname(path));
730
- atomic_json(path, updated);
731
- return { room_id: input.room_id, member: name, model, effort };
732
- } finally { release(); }
733
- }
734
-
735
- const MAX_ALLOWED_TOOLS = 50;
736
-
737
- /**
738
- * Allow operations a woken member's software refused, by adding the rules the runner reported with them to its
739
- * `allowed_tools`: Claude passes them to `--allowedTools`, agy adds them to its discussion project's grants. Like the
740
- * project access, they apply from the member's next delivery and keep its session.
741
- */
742
- export async function room_member_allow(home, raw) {
743
- const { input, path } = await member_room_context(home, raw);
744
- const { name, rules } = input;
745
- if (!Array.isArray(rules) || !rules.length || rules.some(rule => typeof rule !== 'string' || !rule.trim() || rule.length > 300)) {
746
- throw new Error('rules 须为要放行的规则列表');
747
- }
748
- const release = acquire_lock(join(dirname(path), 'configuration-edit'));
749
- try {
750
- const config = configuration_snapshot(path).config;
751
- const profile = config.participants?.[name];
752
- if (!profile) throw new Error(`${name} 不由本机唤醒,权限由它自己的会话决定`);
753
- const facts = adapter_profile(profile.adapter);
754
- if (!facts?.per_tool_allow) throw new Error(`${name} 的软件不支持逐条放行,请改用更高的权限档位`);
755
- if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,权限沿用该对话所在项目,请在原软件中设置`);
756
- const allowed_tools = [...new Set([...(profile.allowed_tools || []), ...rules.map(rule => rule.trim())])];
757
- if (allowed_tools.length > MAX_ALLOWED_TOOLS) throw new Error(`每位成员最多放行 ${MAX_ALLOWED_TOOLS} 条规则`);
758
- const next = { ...config, participants: { ...config.participants, [name]: { ...profile, allowed_tools } } };
759
- validate_config(next, dirname(path));
760
- atomic_json(path, next);
761
- return { room_id: input.room_id, member: name, allowed_tools };
762
- } finally { release(); }
763
- }
764
-
765
- /**
766
- * Switch an idle free-room Codex member between the App and the CLI, keeping its conversation: the App resumes the
767
- * bound chat by its session, and the CLI resumes it only after the App has released it. Model and effort are chosen
768
- * in the App, so switching to it drops them.
769
- */
770
- export async function room_member_adapter(home, raw) {
771
- const { input, path } = await member_room_context(home, raw);
772
- const { name, adapter } = input;
773
- if (!ADAPTER_IDS.some(other => same_session_family(other, adapter))) throw new Error('只能在共用会话的 App 与 CLI 接入之间切换');
774
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
775
- const member = room.free.members.find(item => item.name === name);
776
- if (member?.pending > 0 || ['dispatched', 'started'].includes(member?.execution?.status)) throw new Error(`${name} 正在处理请求,请等它完成或先停止`);
777
- const release = acquire_lock(join(dirname(path), 'configuration-edit'));
778
- try {
779
- const config = configuration_snapshot(path).config;
780
- const profile = config.participants?.[name];
781
- if (profile?.adapter !== adapter && !same_session_family(profile?.adapter, adapter)) throw new Error(`${name} 不是本机唤醒、能切换到该接入方式的成员`);
782
- if (profile.adapter === adapter) return { room_id: input.room_id, member: name, adapter };
783
- const context = { config, path };
784
- const next = { ...profile };
785
- switch_codex_adapter(context, name, next, adapter);
786
- // An App chooses its own model and effort.
787
- if (!adapter_profile(adapter).models) delete next.model;
788
- if (!adapter_profile(adapter).efforts) delete next.effort;
789
- const updated = { ...config, participants: { ...config.participants, [name]: next } };
790
- validate_config(updated, dirname(path));
791
- atomic_json(path, updated);
792
- return { room_id: input.room_id, member: name, adapter };
793
- } finally { release(); }
794
- }
795
-
796
- /**
797
- * Register a manual Claude, Codex or agy expert's own session as a hosted participant, so the service resumes it for later turns.
798
- * Returns the adapter used. Throws when the local software is not configured or the session is not found on this machine; the member then stays manual.
799
- */
800
- export function adopt_member_profile(home, path, name, session_id, client, member_token) {
801
- const release = acquire_lock(join(dirname(path), 'configuration-edit'));
802
- try {
803
- const config = configuration_snapshot(path).config;
804
- // The adapter whose store holds the session: a desktop chat is resumed through its App, others through the CLI.
805
- const adapter = adoption_adapter(client, session_id);
806
- if (!adapter) throw new Error(`${name}:本机不能接管 ${client} 的会话`);
807
- // An App conversation is reached through this room's own Sidecar, which the App starts as soon as it is configured.
808
- const sidecar = adapter_profile(adapter).location === 'ipc_directory' ? { ipc_directory: join(dirname(path), 'agy-app-sidecar') } : {};
809
- // A manually started member keeps its own session's permissions; the room's 只读 / 可写 does not apply to it.
810
- const input = { cwd: config.cwd, adopt: true, participants: { [name]: { adapter, session_id, project_access: 'own', ...sidecar } } };
811
- // Its model, effort, prompt and tools stay as the user set them in that session; machine defaults never override them.
812
- const { model, effort, allowed_tools, system_prompt, ...own } = participant_profile(name, launch_settings(home).participants, input, false);
813
- const profile = borrowed_session(name, own, config.cwd);
814
- if (sidecar.ipc_directory) setup_sidecar(sidecar);
815
- // A room delivers with each member's own credential; the browser-made one is not kept locally, so the owner issues a new one.
816
- const next = { ...config, participants: { ...config.participants, [name]: profile },
817
- members: { ...config.members, [name]: { token: member_token, pending: '' } } };
818
- validate_config(next, dirname(path));
819
- atomic_json(path, next);
820
- return adapter;
821
- } finally { release(); }
822
- }
823
-
824
- /** The one registered configuration of a room on this machine, or null when it was never registered here. */
825
- function registered_room_path(home, site, room_id) {
826
- const paths = read_registry(home).filter(path => {
827
- const identity = registered_identity(path);
828
- return identity?.site === site && identity.room === room_id;
829
- });
830
- if (paths.length > 1) throw new Error('本房间存在多份登记配置,无法确定成员会话');
831
- return paths[0] || null;
832
- }
833
-
834
- /** The session each participant actually resumes, read without writing to a runtime the service may be using. */
835
- function bound_sessions(config, path) {
836
- const file = join(resolve(config.data_directory || join(dirname(path), `discussion-${config.room}`)), 'runtime.sqlite3');
837
- if (!existsSync(file)) return {};
838
- const db = new DatabaseSync(file, { readOnly: true });
839
- try { return Object.fromEntries(db.prepare('SELECT participant,session_id FROM bindings').all().map(row => [row.participant, row.session_id])); }
840
- finally { db.close(); }
841
- }
842
-
843
- /**
844
- * Export a room's local member software and the sessions they hold, so a backup imported on this machine resumes them.
845
- * Requires the owner's credential; member credentials and API keys are never included (only the key reference).
846
- * Returns `local_members: null` when the room is not registered here.
847
- */
848
- export async function room_profiles(home, raw) {
849
- const input = launch_input({ ...raw, entry: 'web' });
850
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('导出本机成员配置须提供房间编号和房间控制凭据');
851
- const path = registered_room_path(home, input.site, input.room_id);
852
- if (!path) return { room_id: input.room_id, local_members: null };
853
- const config = configuration_snapshot(path).config;
854
- await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
855
- const sessions = bound_sessions(config, path);
856
- const participants = Object.fromEntries(Object.entries(config.participants).map(([name, profile]) => {
857
- const kept = Object.fromEntries(Object.entries(profile).filter(([key]) => [...PROFILE_FIELDS, 'project_access'].includes(key)));
858
- const session_id = sessions[name] || profile.session_id;
859
- return [name, session_id ? { ...kept, session_id } : kept];
860
- }));
861
- return { room_id: input.room_id, local_members: { format: LOCAL_MEMBERS_FORMAT, version: 1, mode: 'free',
862
- cwd: config.cwd, discussion_project: config.discussion_project, project_access: config.project_access, scheduler: config.scheduler, participants } };
863
- }
864
-
865
- /**
866
- * Register an imported free room on this machine with its exported member software, each member resuming its original session.
867
- * A session or software missing here fails that member instead of starting a new conversation. Claude Code members stay manual,
868
- * and are never taken over, because a background resume forks the session away from its original window.
869
- * Returns `{room_id, registered, members: [{name, status: resumed|manual|failed, session_id?, cwd?, error?}]}`.
870
- */
871
- export async function restore_room(home, raw) {
872
- const input = launch_input({ ...raw, entry: 'web' });
873
- const local = input.local_members;
874
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('恢复本机成员须提供房间编号和房间控制凭据');
875
- if (local?.format !== LOCAL_MEMBERS_FORMAT || local.version !== 1 || !local.participants || typeof local.participants !== 'object') throw new Error('本机成员配置格式无效');
876
- await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
877
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
878
- if (registered_room_path(home, input.site, input.room_id)) throw new Error('本房间已在本机登记,不重复恢复');
879
- if (local.cwd && (!isAbsolute(local.cwd) || !existsSync(local.cwd) || !statSync(local.cwd).isDirectory())) throw new Error(`房间项目目录 ${local.cwd} 在本机不存在`);
880
- const keep_manual = room.free.members.filter(member => member.access === 'manual' && member.status !== 'removed'
881
- && platform_adapters(platform_of(member.name)).some(id => ADAPTER_PROFILES[id].restore_manual)).map(member => member.name);
882
- const participants = {}, members = [];
883
- for (const [name, exported] of Object.entries(local.participants)) {
884
- try {
885
- const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
886
- // A member without a software session store (an API member) kept its binding in the old runtime directory, so it starts fresh.
887
- if (!adapter_of(profile.adapter)?.session) delete profile.session_id;
888
- const resumed = profile.session_id ? borrowed_session(name, profile, local.cwd) : profile;
889
- if (adapter_profile(profile.adapter)?.restore_manual) {
890
- keep_manual.push(name);
891
- const cwd = resumed.cwd || local.cwd || '';
892
- const command = resumed.session_id && adapter_profile(profile.adapter).resume_command?.(resumed.session_id);
893
- members.push({ name, status: 'manual', session_id: resumed.session_id || '', cwd,
894
- resume: command ? { command, cwd } : null });
895
- } else {
896
- participants[name] = resumed;
897
- members.push({ name, status: 'resumed', session_id: resumed.session_id || '' });
898
- }
899
- } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
900
- }
901
- if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
902
- register_copied_room(home, input, local, participants, keep_manual);
903
- return { room_id: input.room_id, registered: true, members };
904
- }
905
-
906
- /**
907
- * Register an imported or forked free room's local members under a fresh launch directory; `seed` writes the new
908
- * runtime's bindings before the service can see the room. Returns the runtime directory.
909
- */
910
- function register_copied_room(home, input, local, participants, keep_manual, seed = () => {}) {
911
- const key = createHash('sha256').update(`${input.site}/${input.room_id}`).digest('hex');
912
- // A leftover directory of a deleted room with the same ID would carry its old bindings, so a restore never reuses one.
913
- const base = join(home, 'launches', key), directory = existsSync(base) ? `${base}-${Date.now()}` : base;
914
- // Members arrive away; these placeholders are replaced by the credential issued when the owner brings each one back.
915
- const config = { site: input.site, room: input.room_id, token: input.token, mode: 'free', participants, keep_manual, api_home: home,
916
- members: Object.fromEntries(Object.keys(participants).map(name => [name, { token: `${randomUUID()}${randomUUID()}`, pending: '' }])),
917
- scheduler: local.scheduler || undefined, cwd: local.cwd || undefined, discussion_project: local.discussion_project || undefined,
918
- project_access: local.project_access || undefined, data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) };
919
- validate_config(config, directory);
920
- mkdirSync(directory, { recursive: true });
921
- atomic_json(join(directory, 'config.json'), config);
922
- seed(config.data_directory);
923
- register_room(home, join(directory, 'config.json'));
924
- return config.data_directory;
925
- }
926
-
927
- // Software whose session a fork branches; other local members start a new conversation in the forked room.
928
-
929
- /**
930
- * Register a room forked from another on this machine: each local member continues from its own session as it stood
931
- * at the fork point, without the executions the fork leaves out. The source room's sessions are never changed.
932
- * Requires both rooms' owner credentials; members the new room keeps manual are not registered here.
933
- * Returns `{room_id, registered, members: [{name, status: forked|fresh|failed, error?}]}`.
934
- */
935
- export async function fork_room(home, raw) {
936
- const input = launch_input({ ...raw, entry: 'web' });
937
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim() || typeof raw.source_room_id !== 'string' || typeof raw.source_token !== 'string') {
938
- throw new Error('分支须提供新房间与原房间的编号和控制凭据');
939
- }
940
- const local = (await room_profiles(home, { site: input.site, room_id: raw.source_room_id, token: raw.source_token })).local_members;
941
- if (!local) return { room_id: input.room_id, registered: false, members: [] };
942
- await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
943
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
944
- if (registered_room_path(home, input.site, input.room_id)) throw new Error('分支房间已在本机登记');
945
- const runners = new Set(room.free.members.filter(member => member.access === 'runner' && member.status !== 'removed').map(member => member.name));
946
- const left_out = raw.executions && typeof raw.executions === 'object' ? raw.executions : {};
947
- const participants = {}, members = [], bindings = {}, forks = {};
948
- for (const [name, exported] of Object.entries(local.participants)) {
949
- if (!runners.has(name)) continue;
950
- try {
951
- const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
952
- if (!profile.session_id || !BRANCHABLE_ADAPTERS.includes(profile.adapter)) {
953
- delete profile.session_id;
954
- participants[name] = profile;
955
- members.push({ name, status: 'fresh' });
956
- continue;
957
- }
958
- // Claude resumes a session only in its original directory, so the forked member works there too.
959
- const located = adapter_profile(profile.adapter)?.adopt === 'own_cwd' ? borrowed_session(name, profile, local.cwd) : profile;
960
- const branch = await branch_session(located, profile.session_id, (left_out[name] || []).filter(Number.isInteger), { always: true });
961
- const { session_id, ...forked } = located;
962
- participants[name] = forked;
963
- bindings[name] = branch.session_id;
964
- if (branch.fork) forks[name] = branch.fork;
965
- members.push({ name, status: branch.session_id ? 'forked' : 'fresh' });
966
- } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
967
- }
968
- if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
969
- register_copied_room(home, input, local, participants, [], data_directory => {
970
- const store = new DiscussionStore(data_directory);
971
- try {
972
- for (const [name, session_id] of Object.entries(bindings)) store.bind(name, session_id);
973
- // A Claude fork happens on the member's next turn; the runner keeps it pending until that session exists.
974
- for (const [name, fork] of Object.entries(forks)) store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
975
- .run(`session-fork:${name}`, JSON.stringify(fork));
976
- } finally { store.close(); }
977
- });
978
- return { room_id: input.room_id, registered: true, members };
979
- }
980
-
981
- /** Stable signatures make changed retry requests explicit without putting credentials in receipts. */
982
- function signature(value) {
983
- if (Array.isArray(value)) return `[${value.map(signature).join(',')}]`;
984
- if (value && typeof value === 'object') return `{${Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${signature(value[key])}`).join(',')}}`;
985
- return JSON.stringify(value);
986
- }
987
-
988
- /** Inherit the topic duration unless overridden; zero leaves the run without a time limit. */
989
- function launch_minutes(input, room) {
990
- const minutes = Number(input.minutes ?? room.limits?.max_minutes ?? 0);
991
- if (!Number.isFinite(minutes) || minutes < 0 || minutes > MAX_MINUTES) throw new Error('讨论总时长或 minutes 须在 [0,1440],0 表示不限时长');
992
- return minutes;
993
- }
994
-
995
- /** Refuse a second registry owner before changing the website runtime. */
996
- function check_registration(home, config, path) {
997
- for (const registered of read_registry(home)) {
998
- if (resolve(registered) === path || !existsSync(registered)) continue;
999
- const other = registered_identity(registered);
1000
- if (other?.site === config.site && other.room === config.room) throw new Error('房间已使用另一份接入配置登记;请沿用原配置,不重复接入');
1001
- }
1002
- }
1003
-
1004
- /** Start a session once, reconciling a lost response from the durable start intent. */
1005
- async function start_session(config, record, persist, room, minutes) {
1006
- const state = await api(config, '/discussion');
1007
- if (record.session_id && state.session?.id === record.session_id && !record.start_pending) {
1008
- if (record.start_submission_id && state.session.start_submission_id !== record.start_submission_id) {
1009
- throw new Error('房间当前讨论的启动编号与本次记录不同');
1010
- }
1011
- if (state.session.status !== 'active') throw new Error('讨论已暂停或结束;请在房间核对状态后再操作');
1012
- return state.session;
1013
- }
1014
- if (record.session_id && state.session?.id !== record.session_id) throw new Error('房间当前讨论与本次启动记录不同');
1015
- if (state.session && !record.start_pending) {
1016
- if (state.session.status !== 'active') throw new Error('讨论已暂停或结束;请在房间核对状态后再操作');
1017
- throw new Error('房间已有进行中的讨论;请沿用原启动记录');
1018
- }
1019
- if (room.session?.status === 'concluded' && !record.start_pending) throw new Error('房间中的讨论已结束;请从房间明确开启新讨论');
1020
- if (!record.start_submission_id) record.start_submission_id = randomUUID();
1021
- if (!record.start_request) {
1022
- if (record.start_revision && record.start_revision !== state.revision) throw new Error('开启期间讨论状态已变化,请重新读取房间状态');
1023
- const topic_minutes = Number(room.limits?.max_minutes || 0);
1024
- const session_minutes = topic_minutes > 0 ? (minutes > 0 ? Math.min(topic_minutes, minutes) : topic_minutes) : minutes;
1025
- record.start_revision = state.revision;
1026
- record.start_request = { action: 'start', revision: state.revision, submission_id: record.start_submission_id,
1027
- goal: room.goal || room.title, named_followup: Boolean(room.named_followup),
1028
- limits: { max_rounds: room.limits?.max_rounds ?? 0, max_minutes: session_minutes,
1029
- execution_minutes: room.limits?.execution_minutes ?? 0 } };
1030
- record.start_pending = true;
1031
- persist();
1032
- }
1033
- const result = await api(config, '/discussion', record.start_request);
1034
- if (!result.session || result.session.start_submission_id !== record.start_submission_id) throw new Error('网站返回的讨论会话与本次启动编号不符');
1035
- record.session_id = result.session.id;
1036
- record.start_pending = false;
1037
- persist();
1038
- if (result.session.status !== 'active') throw new Error('本次讨论已暂停或结束;请在房间核对状态后再操作');
1039
- return result.session;
1040
- }
1041
-
1042
- /** Reserve the first host task for the calling conversation without sending it a duplicate prompt. */
1043
- async function current_host_task(config, record, persist, room) {
1044
- if (record.current_task) return record.current_task;
1045
- const discussion = await api(config, '/discussion');
1046
- if (discussion.session?.status !== 'active' || discussion.session.id !== record.session_id) throw new Error('本次讨论会话已暂停、结束或变化;请核对原房间');
1047
- const page = await api(config, '/inbox');
1048
- const member = page.members.find(item => item.name === room.host && item.role === 'host');
1049
- const request = member?.pending.find(item => item.purpose === 'discuss_host' && item.session_id === record.session_id);
1050
- if (!member || (!request && !record.execution_client_id && !record.execution_id)) throw new Error('没有可由当前会话接手的主持请求;请核对原房间');
1051
- record.execution_client_id ||= randomUUID();
1052
- persist();
1053
- const execution = record.execution_id ? await api(config, `/executions/${record.execution_id}`)
1054
- : await api(config, '/executions', { member_id: member.id, client_id: record.execution_client_id, cwd: '' });
1055
- if (!['dispatched', 'started'].includes(execution.status)) throw new Error('主持请求已不再等待执行;请核对原房间');
1056
- if (!execution.requests?.some(item => item.purpose === 'discuss_host' && item.session_id === record.session_id)) {
1057
- throw new Error('预约到的执行不属于本次讨论主持请求');
1058
- }
1059
- record.execution_id = execution.id;
1060
- persist();
1061
- const store = new DiscussionStore(config.directory);
1062
- try {
1063
- store.context(config);
1064
- store.scope(config);
1065
- const session_id = config.participants[room.host].session_id;
1066
- store.bind(room.host, session_id);
1067
- const task = free_task_files(config, member, execution, { session_id }, { session: discussion.session, process_access: false });
1068
- new FreeRoomRunner(config, store).save(room.host, { client_id: record.execution_client_id, execution_id: execution.id,
1069
- status: 'running', attempts: 0, started: Date.now(), last_renew: Date.now() });
1070
- record.current_task = { execution_id: execution.id, access_file: task.access_path, task_file: join(config.directory, `${task.id}.task.json`) };
1071
- persist();
1072
- return record.current_task;
1073
- } finally { store.close(); }
1074
- }
1075
-
1076
- /** Return launch evidence and paths, never raw control or execution tokens. */
1077
- async function launch_receipt(config, record, config_path) {
1078
- const room = await api(config, '');
1079
- const discussion = record.session_id ? await api(config, '/discussion') : null;
1080
- return { entry: record.entry, room, url: `${config.site}/?room=${config.room}`, config_path,
1081
- status: discussion?.session?.status || (record.session_id ? 'concluded' : 'free'), current_task: record.current_task || null };
1082
- }
1083
-
1084
- /** Read a saved launch without creating state during validation or preview. */
1085
- function read_launch(directory) {
1086
- const path = join(directory, 'launch.json');
1087
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
1088
- }
1089
-
1090
- /** Find the registered room only after proving the caller's original control credential. */
1091
- async function room_config_context(home, raw, options = {}) {
1092
- const input = launch_input({ ...raw, entry: 'web' });
1093
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('读取本场配置须提供房间编号和原控制凭据');
1094
- const room = options.local_guard ? { id: input.room_id, archived: false }
1095
- : await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
1096
- const config = { site: input.site, room: input.room_id, token: input.token };
1097
- if (!options.local_guard) await verify_control(config, room);
1098
- const discussion = options.local_guard
1099
- ? { session: { status: 'paused' }, revision: raw.session_revision ?? raw.runtime_revision }
1100
- : await api(config, '/discussion');
1101
- if (room.archived || discussion.session?.status !== 'paused') throw new Error('请先暂停本场讨论,再修正软件路径');
1102
- const paths = read_registry(home).filter(path => {
1103
- const identity = registered_identity(path);
1104
- return identity?.site === input.site && identity.room === input.room_id;
1105
- });
1106
- if (paths.length !== 1) throw new Error('未找到本场唯一的已登记配置,请核对原接入服务');
1107
- const path = paths[0];
1108
- const journal = read_launch(dirname(path));
1109
- if (journal && !journal.finished) throw new Error('本次启动尚未完成,请先核对原启动待办');
1110
- return { input, room, discussion, path, ...configuration_snapshot(path) };
1111
- }
1112
-
1113
- /** Expose only editable connection paths, never credentials or unrelated room settings. */
1114
- function connection_view(context) {
1115
- return { room_id: context.room.id, session_revision: context.discussion.revision, runtime_revision: context.discussion.revision, version: context.version,
1116
- participants: Object.fromEntries(Object.entries(context.config.participants).map(([name, profile]) =>
1117
- [name, Object.fromEntries(['adapter', ...CONNECTION_FIELDS].filter(field => profile[field] !== undefined).map(field => [field, profile[field]]))])) };
1118
- }
1119
-
1120
- /** Switch only Codex's execution entrypoint, keeping its actual original conversation. */
1121
- function switch_codex_adapter(context, name, profile, adapter) {
1122
- if (!same_session_family(profile.adapter, adapter)) throw new Error('本场仅支持共用会话的 App / CLI 接入之间切换');
1123
- const current = validate_config(context.config, dirname(context.path));
1124
- let session = profile.session_id;
1125
- if (existsSync(join(current.directory, 'runtime.sqlite3'))) {
1126
- const store = new DiscussionStore(current.directory);
1127
- try {
1128
- const binding = store.binding(name).session_id;
1129
- if (session && binding && session !== binding) throw new Error('原会话绑定不一致,请先核对原会话');
1130
- session = binding || session;
1131
- } finally { store.close(); }
1132
- }
1133
- if (!session) throw new Error('尚无实际 Codex 会话,不能切换;请先完成首次接入');
1134
- profile.adapter = adapter;
1135
- profile.session_id = session;
1136
- }
1137
-
1138
- /** Validate paused-room paths and Codex entrypoints without replacing the discussion or session. */
1139
- function corrected_configuration(context) {
1140
- const config = structuredClone(context.config);
1141
- for (const [name, patch] of Object.entries(context.input.participants)) {
1142
- const profile = config.participants[name];
1143
- const field = adapter_profile(profile?.adapter)?.location || 'executable';
1144
- const fields = ADAPTER_IDS.some(other => same_session_family(other, profile?.adapter)) ? [field, 'adapter'] : [field];
1145
- if (!profile || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(key => !fields.includes(key))) throw new Error('本场修正仅支持软件路径、Sidecar 目录及 Codex App / CLI;参与者、项目及会话保持不变');
1146
- validate_profile(name, patch);
1147
- if (patch.adapter && patch.adapter !== profile.adapter) switch_codex_adapter(context, name, profile, patch.adapter);
1148
- const path = patch[field] ?? profile[field];
1149
- if (typeof path !== 'string' || !path || !existsSync(path)
1150
- || (field === 'executable' ? !statSync(path).isFile() || (process.platform === 'win32' && !/\.exe$/i.test(path)) : !statSync(path).isDirectory())) throw new Error(`${name}:请提供有效的软件文件或连接目录`);
1151
- profile[field] = path;
1152
- }
1153
- validate_config(config, dirname(context.path));
1154
- return config;
1155
- }
1156
-
1157
- /** Wait for the resident worker to read this version before suggesting a manual restart. */
1158
- async function configuration_applied(home, path, version) {
1159
- for (let waited = 0; waited < CONFIG_APPLY_TIMEOUT_MS; waited += CONFIG_APPLY_POLL_MS) {
1160
- const pid = service_pid(home);
1161
- if (!pid) return false;
1162
- const status_path = join(home, 'status.json');
1163
- const status = existsSync(status_path) ? JSON.parse(readFileSync(status_path, 'utf8')) : {};
1164
- const worker = status.rooms?.find(item => resolve(item.config) === resolve(path));
1165
- if (status.pid === pid && worker?.config_version === version && worker.status !== 'error') return true;
1166
- await sleep(CONFIG_APPLY_POLL_MS);
1167
- }
1168
- return false;
1169
- }
1170
-
1171
- /** Read or save a paused room's connection paths without changing its runtime or global defaults. */
1172
- export async function room_configuration(home, raw, options = {}) {
1173
- let context = await room_config_context(home, raw, options);
1174
- if (raw.participants !== undefined && !options.local_guard) {
1175
- const saved = await save_room_connection(raw);
1176
- return { ...saved, applied: await configuration_applied(home, context.path, saved.version) };
1177
- }
1178
- if (raw.participants === undefined) return connection_view(context);
1179
- if (!raw.participants || typeof raw.participants !== 'object' || Array.isArray(raw.participants)) throw new Error('participants 须为本场软件路径对象');
1180
- const release = acquire_lock(join(dirname(context.path), 'configuration-edit'));
1181
- try {
1182
- context = await room_config_context(home, raw, options);
1183
- const config = corrected_configuration(context);
1184
- if (raw.version !== context.version || (raw.session_revision ?? raw.runtime_revision) !== context.discussion.revision) throw new Error('房间状态或配置已变化,请重新读取本场配置后核对');
1185
- atomic_json(context.path, config);
1186
- const saved = { ...context, ...configuration_snapshot(context.path) };
1187
- // The local website holds its database write lock here; the worker can acknowledge only after that lock is released.
1188
- return { ...connection_view(saved), applied: false };
1189
- } finally { release(); }
1190
- }
1191
-
1192
- /** Serialize file replacement with runtime changes through the local website's database guard. */
1193
- async function save_room_connection(raw) {
1194
- const input = launch_input({ ...raw, entry: 'web' });
1195
- const url = new URL(input.site);
1196
- if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('本场路径修正须通过已启用本机启动的回环网站');
1197
- const response = await fetch(`${input.site}/api/local/room-config`, { method: 'POST',
1198
- headers: { 'Content-Type': 'application/json', 'X-CoDiscuss-Local': '1' },
1199
- body: JSON.stringify(input), redirect: 'error', signal: AbortSignal.timeout(15000) });
1200
- const result = await response.json();
1201
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '本场配置保存失败'}`);
1202
- return result;
1203
- }
1204
-
1205
- /** Prove dispatcher access before a protected room is started or a corrected credential is saved. */
1206
- async function verify_control(config, room) {
1207
- await api({ ...config, room: room.id }, '/inbox');
1208
- }
1209
-
1210
- /** Pin resolved defaults, while comparing retries against only the caller's explicit request. */
1211
- async function prepare_launch(home, input, directory, options = {}) {
1212
- const previous = read_launch(directory);
1213
- if (options.browser_request && (typeof input.token !== 'string' || !input.token.trim())) throw new Error('网页启动须携带本次原始控制凭据,不能认领其他启动记录');
1214
- const { token: ignored_token, access_file: ignored_access, ...request } = input;
1215
- const request_signature = signature(request);
1216
- if (previous?.request_signature && previous.request_signature !== request_signature) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
1217
- const config_path = join(directory, 'config.json');
1218
- const saved = previous?.config || (previous && existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : null);
1219
- const token = input.token || previous?.token || randomUUID();
1220
- const room_id = input.room_id || previous?.room_id || (saved?.room !== 'preview' ? saved?.room : null);
1221
- if (options.browser_request && previous && token !== previous.token && !room_id) throw new Error('尚未确认原房间,不能替换本次控制凭据');
1222
- const room = room_id ? await site_request(input.site, `/api/rooms/${room_id}`, token) : input.room;
1223
- const config = previous?.request_signature ? { ...structuredClone(saved), token }
1224
- : launch_config(home, input, room, directory, { token, defaults: saved?.participants, members: saved?.members });
1225
- config.draft_directory = service_drafts(home, config.directory);
1226
- if (previous?.request_signature && !previous.finished) validate_config(config, directory);
1227
- const minutes = previous?.minutes ?? launch_minutes(input, room);
1228
- const draft = signature({ entry: input.entry, room_id: input.room_id, room: input.room, participants: config.participants, cwd: input.cwd, discussion_project: input.discussion_project, minutes });
1229
- if (room.id) await verify_control(config, room);
1230
- return { input, config, room, minutes, draft, request_signature, directory, previous };
1231
- }
1232
-
1233
- /** Launch a room through either entry, using a durable journal for safe retries. */
1234
- export async function launch_discussion(home, raw, options = {}) {
1235
- assert_shared_home(home);
1236
- const input = launch_input(raw);
1237
- const key = createHash('sha256').update(`${input.site}/${input.room_id || input.room.submission_id}`).digest('hex');
1238
- const directory = join(home, 'launches', key);
1239
- let release;
1240
- try {
1241
- const preview = await prepare_launch(home, input, directory, options);
1242
- if (options.dry_run) return { dry_run: true, entry: input.entry, site: input.site, room_id: input.room_id || null,
1243
- host: preview.room.host, participants: preview.config.participants, cwd: preview.config.cwd || null,
1244
- discussion_project: preview.config.discussion_project || null, minutes: preview.minutes };
1245
- release = acquire_lock(directory);
1246
- return await perform_launch(home, await prepare_launch(home, input, directory, options), options);
1247
- } catch (error) {
1248
- error.launch ||= { stage: existsSync(join(directory, 'launch.json')) ? 'unconfirmed' : 'not_started' };
1249
- throw error;
1250
- } finally { release?.(); }
1251
- }
1252
-
1253
- /** Persist preparation before writes, hand off the current host when needed, then register once. */
1254
- async function perform_launch(home, context, options) {
1255
- const { input, config, minutes, draft, request_signature, directory, previous } = context;
1256
- // This is the creation input's start-discussion choice, not the persisted room model (always free).
1257
- const discussion = previous?.discussion ?? (input.room ? input.room.mode !== 'free' : Boolean(context.room.session && context.room.session.status !== 'concluded'));
1258
- const record = previous || { token: config.token, signature: draft, request_signature, config, minutes, entry: input.entry, discussion };
1259
- record.discussion = discussion;
1260
- const persist = () => atomic_json(join(directory, 'launch.json'), record);
1261
- if (!previous) persist();
1262
- const progress = { stage: 'unconfirmed' };
1263
- try {
1264
- let room = context.room;
1265
- if (!input.room_id) room = await site_request(input.site, '/api/rooms', config.token, {
1266
- ...input.room,
1267
- members: [...Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
1268
- token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
1269
- // Manual members keep the credentials the browser generated; nothing local is configured for them.
1270
- ...(input.room.members || []).filter(member => record.discussion ? member?.access === 'manual' : !woken_member(member))
1271
- .map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' }))],
1272
- });
1273
- config.room = room.id;
1274
- await verify_control(config, room);
1275
- Object.assign(progress, { stage: 'created', room_id: room.id });
1276
- Object.assign(record, { token: config.token, request_signature, config, minutes, room_id: room.id });
1277
- persist();
1278
- const config_path = join(directory, 'config.json');
1279
- check_registration(home, config, config_path);
1280
- if (record.finished) {
1281
- const saved = existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : config;
1282
- if (saved.token !== config.token || !existsSync(config_path)) atomic_json(config_path, { ...saved, token: config.token });
1283
- return launch_receipt(config, record, config_path);
1284
- }
1285
- atomic_json(config_path, config);
1286
- const service = await (options.start_service || start_service)(home);
1287
- if (!service?.running) throw new Error('讨论服务未就绪,未开始本次讨论');
1288
- if (record.discussion) await start_session(config, record, persist, room, minutes);
1289
- progress.stage = 'started';
1290
- if (record.discussion && input.entry === 'conversation') record.current_task = await current_host_task(config, record, persist, room);
1291
- register_room(home, config_path);
1292
- record.finished = true;
1293
- persist();
1294
- return launch_receipt(config, record, config_path);
1295
- } catch (error) { error.launch = progress; throw error; }
1296
- }
1297
-
1298
- /** CLI and local HTTP bridge use the same commands and configuration validation. */
1299
- export async function launch_main(args = process.argv.slice(2), input_text) {
1300
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
1301
- file: { type: 'string' }, 'dry-run': { type: 'boolean' }, browser: { type: 'boolean' },
1302
- 'configuration-locked': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
1303
- } });
1304
- if (values.help || !positionals.length) return process.stdout.write(HELP);
1305
- const input = input_text === undefined ? (values.file ? JSON.parse(readFileSync(values.file, 'utf8').replace(/^\uFEFF/, '')) : null) : JSON.parse(input_text);
1306
- const home = service_home();
1307
- assert_shared_home(home);
1308
- let result;
1309
- if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
1310
- scheduler_providers: SCHEDULER_PROVIDERS, scheduler_modes: SCHEDULER_MODES, scheduler_default_mode: DEFAULT_SCHEDULER_MODE, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
1311
- else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
1312
- else if (positionals[0] === 'presets') result = input ? save_expert_presets(home, input) : expert_presets(home);
1313
- else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
1314
- else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
1315
- else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
1316
- else if (positionals[0] === 'tts-settings') result = input && Object.keys(input).length ? save_tts_settings(home, input) : tts_settings(home);
1317
- else if (positionals[0] === 'tts' && input) result = await tts_speak(home, input);
1318
- else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
1319
- else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
1320
- else if (positionals[0] === 'room-files' && input) result = await room_files(home, input);
1321
- else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
1322
- else if (positionals[0] === 'models' && input) {
1323
- const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
1324
- result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable,
1325
- ...(input.model ? { model: input.model } : {}) }, { refresh: input.refresh === true });
1326
- }
1327
- else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
1328
- else if (positionals[0] === 'room-member-access' && input) result = await room_member_access(home, input);
1329
- else if (positionals[0] === 'room-member-allow' && input) result = await room_member_allow(home, input);
1330
- else if (positionals[0] === 'room-member-adapter' && input) result = await room_member_adapter(home, input);
1331
- else if (positionals[0] === 'room-member-model' && input) result = await room_member_model(home, input);
1332
- else if (positionals[0] === 'room-profiles' && input) result = await room_profiles(home, input);
1333
- else if (positionals[0] === 'room-restore' && input) result = await restore_room(home, input);
1334
- else if (positionals[0] === 'room-fork' && input) result = await fork_room(home, input);
1335
- else if (positionals[0] === 'pick-directory') result = await pick_directory();
1336
- else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
1337
- else if (positionals[0] === 'admin-account' && input) result = (await import('./admin_account.mjs')).save_admin_account(home, input);
1338
- else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
1339
- else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
1340
- else throw new Error(HELP);
1341
- process.stdout.write(`${JSON.stringify(result)}\n`);
1342
- }
1343
-
1344
- if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
1345
- const args = process.argv.slice(2);
1346
- const stdin = args.includes('--stdin') ? readFileSync(0, 'utf8') : undefined;
1347
- launch_main(args.filter(arg => arg !== '--stdin'), stdin).catch(error => {
1348
- const failure = args.includes('--browser') ? JSON.stringify({ error: error.message, launch: error.launch || { stage: 'not_started' } }) : error.message;
1349
- process.stderr.write(`${failure}\n`); process.exitCode = 1;
1350
- });
1351
- }
1
+ /** Shared launch configuration for an existing AI conversation and the local website. */
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { parseArgs } from 'node:util';
11
+
12
+ import { atomic_json } from './files.mjs';
13
+ import { read_project_files } from './project_files.mjs';
14
+ import { setup_sidecar } from './adapters/antigravity_app.mjs';
15
+ import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
16
+ import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
17
+ import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
18
+ import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
19
+ import { save_tts_settings, tts_settings, tts_speak } from './tts.mjs';
20
+ import { stt_part } from './stt.mjs';
21
+ import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
22
+ import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
23
+ import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
24
+ import { bind_room_workflow, delete_workflow, get_workflow, list_workflows, load_room_workflow, preview_workflow, save_workflow } from './workflows.mjs';
25
+ import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
26
+
27
+ // A platform is a type; `Claude-review` is another Claude instance with its own session, model and effort. An ID is
28
+ // English letters, digits and underscores only; a Chinese name is a display label.
29
+ const INSTANCE_PATTERN = /^(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_]{1,16})?$/;
30
+
31
+ /** The platform of an AI instance name, or null. */
32
+ export function platform_of(name) { return typeof name === 'string' ? INSTANCE_PATTERN.exec(name)?.[1] ?? null : null; }
33
+
34
+ /** The first unused instance name of a platform: the platform itself, then platform-2, platform-3, …. */
35
+ export function next_instance(platform, taken) {
36
+ if (!taken.has(platform)) return platform;
37
+ for (let number = 2; ; number++) if (!taken.has(`${platform}-${number}`)) return `${platform}-${number}`;
38
+ }
39
+
40
+ /** A free name from a used one, as the website numbers copies: Codex → Codex-2, Codex-review → Codex-review2. */
41
+ function free_instance(name, taken) {
42
+ if (!name.includes('-')) return next_instance(name, taken);
43
+ for (let number = 2; ; number++) if (!taken.has(`${name}${number}`)) return `${name}${number}`;
44
+ }
45
+
46
+ /** Supported local clients of an instance's platform. */
47
+ function clients_of(name) { return platform_adapters(platform_of(name)); }
48
+ const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id', 'base_url', 'api_protocol', 'api_key_ref', 'system_prompt'];
49
+ const MAX_MINUTES = 1440;
50
+ const SESSION_ID_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
51
+ const LOCAL_MEMBERS_FORMAT = 'ai-codiscuss-local-members';
52
+ // Reading the inbox proves the owner credential without transferring any messages.
53
+ const CONFIG_APPLY_TIMEOUT_MS = 6000;
54
+ const CONFIG_APPLY_POLL_MS = 100;
55
+ const HELP = `用法:codiscuss launch settings [--file FILE]
56
+ codiscuss launch start --file FILE [--dry-run]
57
+ codiscuss launch room-config --file FILE
58
+ codiscuss launch room-models --file FILE
59
+ codiscuss launch room-files --file FILE
60
+ codiscuss launch models --file FILE
61
+ codiscuss launch api-key --file FILE
62
+ codiscuss launch api-check --file FILE
63
+ codiscuss launch scheduler-check --file FILE
64
+ codiscuss launch tts-settings [--file FILE]
65
+ codiscuss launch tts --file FILE
66
+ codiscuss launch stt --file FILE
67
+ codiscuss launch room-member --file FILE
68
+ codiscuss launch room-member-access --file FILE
69
+ codiscuss launch room-member-allow --file FILE
70
+ codiscuss launch room-member-model --file FILE
71
+ codiscuss launch room-member-adapter --file FILE
72
+ codiscuss launch room-profiles --file FILE
73
+ codiscuss launch room-restore --file FILE
74
+ codiscuss launch room-fork --file FILE
75
+ codiscuss launch pick-directory
76
+ codiscuss launch projects [--file FILE]
77
+ codiscuss launch presets [--file FILE]
78
+ codiscuss launch machine-link --file FILE
79
+ codiscuss launch admin-account --file FILE
80
+
81
+ settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
82
+ start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
83
+ room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
84
+ room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论;quick 为 true 时不查询 agy 的默认模型(较慢),该成员标 defaults_pending。
85
+ room-files 只读房间已保存的项目目录,须提供 site、room_id、token,可选相对 path 和目录分页 offset;文件返回最多前 128 KiB 文本。
86
+ models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
87
+ api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
88
+ api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
89
+ scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
90
+ tts-settings 读取朗读设置;内容非空时保存(音色 speaker、语速 speech_rate、语气提示 context、api_key_ref)。
91
+ tts 用朗读设置把 text(不超过 300 字)合成为 MP3,返回 base64。
92
+ stt 按序接收录音片段(upload_id、index、base64 PCM 16 kHz 单声道、final);最后一段时识别整段录音并返回 text。
93
+ pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
94
+ projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
95
+ presets 读取本机的专家预设(含内置预设)与各平台默认描述;提供 JSON(presets 列表和/或 description_templates)时替换对应部分。
96
+ machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
97
+ admin-account 把在指定 site 注册的管理员用户名与密码保存到服务目录的 admin-accounts.json;须提供 site、username、password。
98
+ room-member 为房间邀请、请回一位成员;须提供 site、room_id、token、action(add/rejoin)、name。
99
+ room-member-access 修改一位本机唤醒成员的项目权限(project_access 为 read/write/auto/full),下次投递起生效、保留原会话;须提供 site、room_id、token、name、project_access。
100
+ room-member-allow 把被拒绝的操作加入一位本机唤醒的 Claude Code 或 agy 成员的 allowed_tools,下次投递起生效;须提供 site、room_id、token、name、rules。
101
+ room-member-model 修改一位本机唤醒 CLI 成员的模型与思考强度(空值恢复软件默认),下次投递起生效、保留原会话;须提供 site、room_id、token、name、model、effort。
102
+ room-member-adapter 把自由房间里空闲的 Codex 成员在 App(codex)与 CLI(codex_cli)间切换,保留原会话;须提供 site、room_id、token、name、adapter。
103
+ room-profiles 导出房间的本机成员软件配置与实际会话 ID(不含凭据),供备份附带;须提供 site、room_id、token。
104
+ room-restore 把导入房间的本机成员配置登记到本机并核对原会话存在,Claude Code 成员保持手动;须提供 site、room_id、token、local_members。
105
+ room-fork 把从原房间分支出的新房间登记到本机,各本机成员从原会话分叉、不含分支点之后的执行,原会话不变;须提供 site、room_id、token、source_room_id、source_token、executions({成员名: [排除的执行编号]})。
106
+ JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
107
+ 或 room_id(已有房间)、participants(本次软件覆盖配置)。
108
+ conversation 还须 current_session_id,当前会话接手首个主持任务。
109
+ 已有受保护房间使用 access_file。路径、凭据只保存在本机。
110
+ 详见 docs/runner.md 的“两种讨论入口与启动配置”。
111
+ `;
112
+
113
+ // Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
114
+ // shown (transparent) as a topmost window; the dialog it owns then stays above the browser that asked for it.
115
+ // Keep the owner on-screen because the Common Item Dialog positions itself relative to its owner.
116
+ // windowsHide turns the process's first Show into a hide, so the owner is shown a second time to really appear.
117
+ // Under the local service the whole process chain has no console and no foreground rights: Windows then silently ignores
118
+ // SetWindowPos(HWND_TOPMOST) on the dialog (it returns success). So once the dialog exists, a timer in its message loop
119
+ // briefly attaches to the foreground window's input thread (the browser that asked) to borrow its right to raise the dialog.
120
+ // The C# source must not contain single quotes: it is embedded in a single-quoted PowerShell string.
121
+ const PICKER_RAISE_SOURCE = [
122
+ 'using System; using System.Runtime.InteropServices;',
123
+ 'public static class PickerRaise {',
124
+ ' delegate bool EnumProc(IntPtr handle, IntPtr arg);',
125
+ ' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc proc, IntPtr arg);',
126
+ ' [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr handle, uint command);',
127
+ ' [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr handle, IntPtr after, int x, int y, int width, int height, uint flags);',
128
+ ' [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();',
129
+ ' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr handle, IntPtr process);',
130
+ ' [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();',
131
+ ' [DllImport("user32.dll")] static extern bool AttachThreadInput(uint attach, uint to, bool on);',
132
+ ' [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr handle);',
133
+ ' [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr handle);',
134
+ ' public static bool OwnedBy(IntPtr owner) {',
135
+ ' IntPtr dialog = IntPtr.Zero;',
136
+ ' EnumWindows((handle, arg) => { if (GetWindow(handle, 4) == owner) dialog = handle; return dialog == IntPtr.Zero; }, IntPtr.Zero);',
137
+ ' if (dialog == IntPtr.Zero) return false;',
138
+ ' uint current = GetCurrentThreadId(), foreground = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);',
139
+ ' bool attached = foreground != 0 && foreground != current && AttachThreadInput(current, foreground, true);',
140
+ ' SetWindowPos(dialog, (IntPtr)(-1), 0, 0, 0, 0, 0x13); BringWindowToTop(dialog); SetForegroundWindow(dialog);',
141
+ ' if (attached) AttachThreadInput(current, foreground, false);',
142
+ ' return true;',
143
+ ' }',
144
+ '}',
145
+ ].join(' ');
146
+ // Use the Common Item Dialog: unlike Framework WinForms, it has an editable address bar.
147
+ // COM declarations retain the native vtable order through the last method used.
148
+ const FOLDER_PICKER_SOURCE = `
149
+ public static class FolderPicker {
150
+ const uint FOS_PICKFOLDERS = 0x20, FOS_FORCEFILESYSTEM = 0x40, FOS_PATHMUSTEXIST = 0x800;
151
+ const uint SIGDN_FILESYSPATH = 0x80058000;
152
+ const int CANCELLED = unchecked((int)0x800704C7);
153
+ [ComImport, Guid("42F85136-DB7E-439C-85F1-E4075D135FC8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
154
+ interface IFileDialog {
155
+ [PreserveSig] int Show(IntPtr owner);
156
+ void SetFileTypes(uint count, IntPtr filters);
157
+ void SetFileTypeIndex(uint index);
158
+ void GetFileTypeIndex(out uint index);
159
+ void Advise(IntPtr events, out uint cookie);
160
+ void Unadvise(uint cookie);
161
+ void SetOptions(uint options);
162
+ void GetOptions(out uint options);
163
+ void SetDefaultFolder(IShellItem folder);
164
+ void SetFolder(IShellItem folder);
165
+ void GetFolder(out IShellItem folder);
166
+ void GetCurrentSelection(out IShellItem item);
167
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
168
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
169
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
170
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
171
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
172
+ void GetResult(out IShellItem item);
173
+ }
174
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
175
+ interface IShellItem {
176
+ void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, out IntPtr result);
177
+ void GetParent(out IShellItem parent);
178
+ void GetDisplayName(uint format, [MarshalAs(UnmanagedType.LPWStr)] out string name);
179
+ }
180
+ /// <summary>Choose an existing filesystem folder, or return null when cancelled.</summary>
181
+ public static string Show(IntPtr owner) {
182
+ var dialog = (IFileDialog)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")));
183
+ IShellItem item = null;
184
+ try {
185
+ uint options;
186
+ dialog.GetOptions(out options);
187
+ dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
188
+ dialog.SetTitle("选择项目目录");
189
+ int result = dialog.Show(owner);
190
+ if (result == CANCELLED) return null;
191
+ Marshal.ThrowExceptionForHR(result);
192
+ dialog.GetResult(out item);
193
+ string path;
194
+ item.GetDisplayName(SIGDN_FILESYSPATH, out path);
195
+ return path;
196
+ } finally {
197
+ if (item != null) Marshal.ReleaseComObject(item);
198
+ Marshal.ReleaseComObject(dialog);
199
+ }
200
+ }
201
+ }
202
+ `;
203
+ const WINDOWS_PICKER = [
204
+ '$ErrorActionPreference = "Stop"',
205
+ 'Add-Type -AssemblyName System.Windows.Forms',
206
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
207
+ '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; Opacity = 0; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "CenterScreen"; Size = New-Object System.Drawing.Size(1, 1) }',
208
+ `Add-Type -TypeDefinition '${PICKER_RAISE_SOURCE} ${FOLDER_PICKER_SOURCE}'`,
209
+ '$owner.Show(); $owner.Hide(); $owner.Show(); $owner.Activate()',
210
+ '$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ if ([PickerRaise]::OwnedBy($owner.Handle)) { $timer.Stop() } }); $timer.Start()',
211
+ 'try { [FolderPicker]::Show($owner.Handle) } finally { $timer.Stop(); $timer.Dispose(); $owner.Close(); $owner.Dispose() }',
212
+ ].join('; ');
213
+ const PICKERS = {
214
+ win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
215
+ darwin: ['osascript', ['-e', 'POSIX path of (choose folder with prompt "选择项目目录")']],
216
+ linux: ['zenity', ['--file-selection', '--directory', '--title=选择项目目录']],
217
+ };
218
+
219
+ /** Show the platform's own folder dialog on this computer; cancelling resolves without a path. */
220
+ export function pick_directory({ platform = process.platform, spawn_process = spawn } = {}) {
221
+ const picker = PICKERS[platform];
222
+ if (!picker) return Promise.reject(new Error('此系统不支持目录选择窗口,请直接输入完整路径'));
223
+ return new Promise((resolve_result, reject) => {
224
+ const child = spawn_process(picker[0], picker[1], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
225
+ let output = '';
226
+ child.stdout.setEncoding('utf8');
227
+ child.stdout.on('data', chunk => { output += chunk; });
228
+ child.once('error', () => reject(new Error('无法打开目录选择窗口,请直接输入完整路径')));
229
+ child.once('close', code => {
230
+ const path = output.trim();
231
+ // Judge the path by the picker's platform, not the host's: a Windows path is not absolute on POSIX.
232
+ const absolute = platform === 'win32' ? win32.isAbsolute(path) : posix.isAbsolute(path);
233
+ resolve_result(code === 0 && absolute ? { path } : { cancelled: true });
234
+ });
235
+ });
236
+ }
237
+
238
+ /** Some CLIs take seconds to list models; reuse a good list for a day unless the executable changes or the user retries. */
239
+ const MODEL_CACHE_MS = 24 * 60 * 60 * 1000;
240
+
241
+ /** Return the software catalog, served from the private service home when still valid. */
242
+ export async function cached_software_models(home, profile, { refresh = false, query = software_models, query_effort = software_effort, query_defaults = software_defaults } = {}) {
243
+ const path = join(home, 'model-catalogs.json');
244
+ let stamp = '';
245
+ try { const file = statSync(profile.executable); stamp = `${file.size}:${file.mtimeMs}`; } catch {}
246
+ const key = JSON.stringify([profile.adapter, profile.executable || '', stamp]);
247
+ const effort_key = JSON.stringify([profile.adapter, profile.executable || '', stamp, 'effort', profile.model || '']);
248
+ let cache = {};
249
+ try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
250
+ const cached = entry => !refresh && entry && Date.now() - entry.at < MODEL_CACHE_MS ? entry.result : null;
251
+ // Defaults are read on every request, so a changed software setting shows without waiting out the cache.
252
+ const [result, effort, defaults] = await Promise.all([cached(cache[key]) || query(profile),
253
+ adapter_profile(profile.adapter)?.efforts_per_model ? cached(cache[effort_key]) || query_effort(profile) : null, query_defaults(profile)]);
254
+ const updates = {};
255
+ if (!cached(cache[key]) && !result.error && result.models.length) {
256
+ updates[key] = { at: Date.now(), result };
257
+ }
258
+ if (effort && !effort.error && !cached(cache[effort_key])) {
259
+ updates[effort_key] = { at: Date.now(), result: effort };
260
+ }
261
+ if (Object.keys(updates).length) {
262
+ try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
263
+ const prefix = JSON.stringify([profile.adapter, profile.executable || '']).slice(0, -1);
264
+ for (const old of Object.keys(cache)) if (old.startsWith(prefix) && !old.startsWith(key.slice(0, -1))) delete cache[old];
265
+ atomic_json(path, { ...cache, ...updates });
266
+ }
267
+ return { ...result, defaults, ...(effort ? { efforts: effort.levels, ...(effort.error ? { effort_error: effort.error } : {}) } : {}) };
268
+ }
269
+
270
+ /** Read private defaults without selecting a global room or identity. */
271
+ export function launch_settings(home) {
272
+ const path = join(home, 'launch-settings.json');
273
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { participants: {} };
274
+ }
275
+
276
+ /**
277
+ * Read the registered room's model choices, what each software selects for empty ones, and its smart scheduler, never connection secrets.
278
+ * `actual_model` / `actual_effort` (present only when known) are what each member's session last ran with, read from the software's own session log.
279
+ * With `quick`, agy's default model is not queried and its entry is marked `defaults_pending`.
280
+ */
281
+ export async function room_models(home, raw, { read_defaults = software_defaults, read_session = session_settings } = {}) {
282
+ const input = launch_input({ ...raw, entry: 'web' });
283
+ if (!input.room_id) throw new Error('读取本场模型须提供 room_id');
284
+ const paths = read_registry(home).filter(path => {
285
+ const identity = registered_identity(path);
286
+ return identity?.site === input.site && identity.room === input.room_id;
287
+ });
288
+ if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
289
+ const config = paths.length ? configuration_snapshot(paths[0]).config : {};
290
+ const profiles = config.participants || {};
291
+ let sessions = {};
292
+ try { if (paths.length) sessions = bound_sessions(config, paths[0]); } catch { /* No readable runtime yet: fall back to borrowed sessions. */ }
293
+ const participants = Object.fromEntries(await Promise.all(Object.entries(profiles).map(async ([name, profile]) => {
294
+ const facts = adapter_profile(profile.adapter);
295
+ // An App chooses its model itself; a CLI or API member's card sets it.
296
+ const card_model = facts?.kind !== 'app';
297
+ // A software read by running it to name its default model takes seconds; a quick read leaves that to a later full read.
298
+ const pending = input.quick === true && Boolean(facts?.defaults_by_query);
299
+ const defaults = pending ? { model: '', effort: '' } : await read_defaults(profile);
300
+ const actual = read_session(profile.adapter, sessions[name] || profile.session_id);
301
+ return [name, { ...(pending ? { defaults_pending: true } : {}), adapter: profile.adapter, model: card_model ? profile.model || '' : '',
302
+ effort: facts?.efforts ? profile.effort || '' : '', default_model: defaults.model, default_effort: defaults.effort,
303
+ ...(actual.model ? { actual_model: actual.model } : {}), ...(actual.effort ? { actual_effort: actual.effort } : {}),
304
+ project_access: !facts?.capabilities.project ? 'read' : profile.project_access || config.project_access || 'read',
305
+ // A member bound to its own conversation may keep that conversation's project, so this tool cannot set its access.
306
+ own_project: Boolean(facts?.own_project_when_bound && profile.session_id) }];
307
+ })));
308
+ // Smart scheduling is a local choice; only which model decides is shown, never its key.
309
+ const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
310
+ return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler, project_directory: config.cwd || '',
311
+ workflow: room_workflow_status(config, home) };
312
+ }
313
+
314
+ /** The room's workflow as the right column shows it; `available` is false when its folder is gone or broken. */
315
+ function room_workflow_status(config, home) {
316
+ if (!config.workflow?.id) return null;
317
+ const loaded = load_room_workflow({ ...config, api_home: config.api_home || home, workflow: { ...config.workflow, enabled: true } });
318
+ return { id: config.workflow.id, label: loaded.label || config.workflow.id, enabled: Boolean(config.workflow.enabled),
319
+ available: !loaded.error, members: config.workflow.members || {} };
320
+ }
321
+
322
+ /** Read the owner's saved room project, never a directory supplied by the request. */
323
+ export async function room_files(home, raw) {
324
+ const { input, config } = await member_room_context(home, raw);
325
+ return { room_id: input.room_id, ...read_project_files(config.cwd, input.path, input.offset) };
326
+ }
327
+
328
+ /** Save only reusable client settings; room credentials and session bindings are never defaults. */
329
+ export function save_launch_settings(home, input) {
330
+ const participants = {};
331
+ for (const [name, profile] of Object.entries(input.participants || {})) {
332
+ if (platform_of(name) !== name) throw new Error(`本机默认配置按平台保存;${name} 的模型与强度只用于本场讨论`);
333
+ validate_profile(name, profile);
334
+ participants[name] = profile;
335
+ }
336
+ const settings = { participants };
337
+ mkdirSync(home, { recursive: true, mode: 0o700 });
338
+ atomic_json(join(home, 'launch-settings.json'), settings);
339
+ return settings;
340
+ }
341
+
342
+ const PROJECT_NAME_LIMIT = 80;
343
+ const PROJECT_ID_PATTERN = /^[0-9a-f]{8,32}$/;
344
+
345
+ /** Read the named project directories saved on this computer, offered as launch cwd choices. */
346
+ export function launch_projects(home) {
347
+ const path = join(home, 'launch-projects.json');
348
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { projects: [] };
349
+ }
350
+
351
+ /** Replace the project list; every path must be an existing folder on this computer. */
352
+ // The id outlives renames and moves: other devices choose a project by id and never see its path.
353
+ export function save_launch_projects(home, input) {
354
+ if (!Array.isArray(input.projects)) throw new Error('projects 必须为列表');
355
+ const projects = input.projects.map(item => ({ id: PROJECT_ID_PATTERN.test(item?.id) ? item.id : randomUUID().replaceAll('-', '').slice(0, 16),
356
+ name: String(item?.name || '').trim(), path: String(item?.path || '').trim() }));
357
+ const names = new Set();
358
+ for (const { name, path } of projects) {
359
+ if (!name || name.length > PROJECT_NAME_LIMIT) throw new Error(`项目名称不能为空,且不超过 ${PROJECT_NAME_LIMIT} 字`);
360
+ if (names.has(name)) throw new Error(`项目名称重复:${name}`);
361
+ names.add(name);
362
+ if (!isAbsolute(path)) throw new Error(`${name}:路径必须为完整绝对路径`);
363
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${name}:这台电脑上不存在此文件夹:${path}`);
364
+ }
365
+ mkdirSync(home, { recursive: true, mode: 0o700 });
366
+ atomic_json(join(home, 'launch-projects.json'), { projects });
367
+ return { projects };
368
+ }
369
+
370
+ const MAX_EXPERT_PRESETS = 30;
371
+ const MAX_PRESET_DESCRIPTION = 500;
372
+ const MAX_PRESET_FIELD = 200;
373
+ // name is the instance a preset adds (platform-suffix); label is the free name shown for it, on the add row and in the room.
374
+ const PRESET_FIELDS = ['name', 'label', 'description', 'system_prompt', 'project_access', 'model', 'effort'];
375
+ const MAX_PRESET_LABEL = 32;
376
+
377
+ /**
378
+ * Presets saved while IDs could hold Chinese: such an ID becomes `platform-presetN` and its old name the label, so the
379
+ * add row still shows it. The next save keeps the new form.
380
+ */
381
+ function english_preset_ids(presets) {
382
+ const taken = new Set(presets.map(preset => preset.name));
383
+ return presets.map(preset => {
384
+ const platform = /^(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-|$)/.exec(preset.name || '')?.[1];
385
+ if (!platform || platform_of(preset.name)) return preset;
386
+ let number = 1;
387
+ while (taken.has(`${platform}-preset${number}`)) number++;
388
+ const name = `${platform}-preset${number}`;
389
+ taken.add(name);
390
+ return { ...preset, name, label: preset.label || preset.name };
391
+ });
392
+ }
393
+
394
+ /** The user's expert presets and per-platform default descriptions saved on this computer, plus the built-in presets. */
395
+ export function expert_presets(home) {
396
+ const path = join(home, 'expert-presets.json');
397
+ const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
398
+ return { presets: english_preset_ids(saved.presets || []), description_templates: saved.description_templates || {}, builtin_presets: BUILTIN_EXPERT_PRESETS };
399
+ }
400
+
401
+ /** Check one expert preset; a workflow's agent card is a preset and obeys the same rule. */
402
+ export function validate_preset(preset) {
403
+ if (!preset || typeof preset !== 'object' || Object.keys(preset).some(key => !PRESET_FIELDS.includes(key))) throw new Error('预设只能包含名称、显示名、描述、提示词、项目权限、模型与强度');
404
+ if (!platform_of(preset.name) || !clients_of(preset.name)) throw new Error(`预设 ID 须为“平台名”或“平台名-后缀”,后缀只用英文、数字和下划线:${preset.name}`);
405
+ if (Object.values(preset).some(value => typeof value !== 'string')) throw new Error(`${preset.name} 的字段必须为文本`);
406
+ if ((preset.label || '').trim().length > MAX_PRESET_LABEL) throw new Error(`${preset.name} 的显示名最多 ${MAX_PRESET_LABEL} 字`);
407
+ if ((preset.description || '').length > MAX_PRESET_DESCRIPTION) throw new Error(`${preset.name} 的描述最多 ${MAX_PRESET_DESCRIPTION} 字`);
408
+ if ((preset.system_prompt || '').length > MAX_EXPERT_PROMPT) throw new Error(`${preset.name} 的提示词最多 ${MAX_EXPERT_PROMPT} 字`);
409
+ if (preset.project_access && !PROJECT_ACCESS.includes(preset.project_access)) throw new Error(`${preset.name} 的项目权限须为 read、write、auto 或 full`);
410
+ if ([preset.model, preset.effort].some(value => (value || '').length > MAX_PRESET_FIELD)) throw new Error(`${preset.name} 的模型或强度过长`);
411
+ }
412
+
413
+ /** Replace the user's presets or default descriptions (whichever the input carries); every field is checked first. */
414
+ export function save_expert_presets(home, input) {
415
+ const current = expert_presets(home);
416
+ const presets = input.presets === undefined ? current.presets : input.presets;
417
+ const templates = input.description_templates === undefined ? current.description_templates : input.description_templates;
418
+ if (!Array.isArray(presets) || presets.length > MAX_EXPERT_PRESETS) throw new Error(`presets 必须为列表,最多 ${MAX_EXPERT_PRESETS} 个`);
419
+ const names = new Set();
420
+ for (const preset of presets) {
421
+ validate_preset(preset);
422
+ if (names.has(preset.name)) throw new Error(`预设名称重复:${preset.name}`);
423
+ names.add(preset.name);
424
+ }
425
+ if (!templates || typeof templates !== 'object' || Array.isArray(templates) || Object.entries(templates).some(([platform, text]) =>
426
+ platform_of(platform) !== platform || typeof text !== 'string' || !text.trim() || text.length > MAX_PRESET_DESCRIPTION)) {
427
+ throw new Error(`description_templates 须为 {平台名: 描述},每段 1–${MAX_PRESET_DESCRIPTION} 字`);
428
+ }
429
+ mkdirSync(home, { recursive: true, mode: 0o700 });
430
+ atomic_json(join(home, 'expert-presets.json'), { presets, description_templates: templates });
431
+ return expert_presets(home);
432
+ }
433
+
434
+ /** Reject malformed client fields before saving defaults or creating any discussion. */
435
+ function validate_profile(name, profile, binding = false) {
436
+ if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
437
+ if (profile.adapter && !clients_of(name).includes(profile.adapter)) throw new Error(`${name} 的客户端不匹配`);
438
+ // Session bindings and project write access apply to one launch only, never to machine defaults.
439
+ const fields = binding ? [...PROFILE_FIELDS, 'session_id', 'project_access'] : PROFILE_FIELDS;
440
+ if (Object.keys(profile).some(key => !fields.includes(key))) throw new Error('软件配置包含不支持的字段;默认配置不能保存房间或会话绑定');
441
+ for (const [key, value] of Object.entries(profile)) {
442
+ if (key === 'allowed_tools') {
443
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${name}.allowed_tools 必须为工具名称数组`);
444
+ } else if (typeof value !== 'string') throw new Error(`${name}.${key} 必须为文本`);
445
+ if (['executable', 'cwd', 'ipc_directory'].includes(key) && value && !isAbsolute(value)) throw new Error(`${name}.${key} 必须为完整绝对路径`);
446
+ // The model is passed as one CLI argument, so it must not look like a flag or contain spaces.
447
+ if (key === 'model' && value && !/^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(value)) throw new Error(`${name}.model 只能是模型名称,例如 gpt-6-astra、opus 或 gemini-4-argon-high`);
448
+ if (key === 'system_prompt' && value.length > MAX_EXPERT_PROMPT) throw new Error(`${name} 的专属提示词最多 ${MAX_EXPERT_PROMPT} 字`);
449
+ }
450
+ const facts = adapter_profile(profile.adapter);
451
+ if (facts?.kind === 'api') validate_api_profile(profile);
452
+ // The UI discovers each model's supported subset; omitted values keep the CLI's own default.
453
+ if (profile.effort && facts?.kind !== 'api' && !facts?.efforts?.includes(profile.effort)) {
454
+ throw new Error(facts?.efforts ? `${name}.effort 只能是 ${facts.efforts.join(' / ')}` : `${name} 的思考档位在原软件中选择`);
455
+ }
456
+ }
457
+
458
+ /** Send one request without following redirects that could disclose the room credential. */
459
+ async function site_request(site, path, token, body) {
460
+ const response = await fetch(`${site}${path}`, { method: body === undefined ? 'GET' : 'POST',
461
+ headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
462
+ body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
463
+ const result = await response.json();
464
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
465
+ return result;
466
+ }
467
+
468
+ /** Require an explicit website, launch entry and stable creation identity. */
469
+ function launch_input(input) {
470
+ const value = structuredClone(input);
471
+ const url = new URL(value.site);
472
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('site 必须为 HTTP(S) 根地址');
473
+ value.site = url.origin;
474
+ if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
475
+ if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
476
+ if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
477
+ if (value.project_access && !PROJECT_ACCESS.includes(value.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
478
+ if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
479
+ if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
480
+ if (value.room?.mode === 'free') {
481
+ // P1: a free room is launched once, at creation, from the web page.
482
+ if (value.entry !== 'web' || value.room_id) throw new Error('自由协作房间目前只能在网页创建时启动');
483
+ const members = value.room.members;
484
+ const names = members?.map(member => typeof member === 'string' ? member : member.name);
485
+ const woken = Array.isArray(members) ? members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name) : [];
486
+ if ((!woken.length && !value.scheduler && !value.cwd) || !names?.length || new Set(names).size !== names.length || woken.some(name => !clients_of(name))) {
487
+ throw new Error('自由协作房间须选择至少 1 位支持自动接入的 AI 成员(或开启智能调度、指定项目目录),且不能重复');
488
+ }
489
+ }
490
+ if (value.access_file) {
491
+ const access = JSON.parse(readFileSync(resolve(value.access_file), 'utf8'));
492
+ if (access.site?.replace(/\/$/, '') !== value.site || access.room !== value.room_id) throw new Error('access_file 与本次站点或房间不符');
493
+ value.token = access.token;
494
+ }
495
+ return value;
496
+ }
497
+
498
+ /** Discussion experts the member list marks as manual: they answer with their own credential and need no local software. */
499
+ function manual_experts(room) {
500
+ return new Set((room.members || []).filter(member => member && typeof member === 'object' && member.access === 'manual').map(member => member.name));
501
+ }
502
+
503
+ /** Merge machine defaults and explicit room overrides, preserving the selected participants. */
504
+ function launch_config(home, input, room, directory, options) {
505
+ // In a creation input, mode='free' means no immediate discussion; omitted mode still requests a hosted discussion.
506
+ if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
507
+ if (!clients_of(room.host)) throw new Error('请选择支持全自动讨论的 AI 主持人');
508
+ if (!room.experts || room.experts[room.host] !== 'cli') throw new Error('请将 AI 主持人的参与方式设为本地读写');
509
+ const other = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
510
+ if (other.length) throw new Error(`全自动讨论的参与方式须为本地读写:${other.join('、')}`);
511
+ const defaults = options.defaults || launch_settings(home).participants;
512
+ const manual = manual_experts(room);
513
+ if (manual.has(room.host)) throw new Error('AI 主持人由本机服务续接,不能选择手动接入');
514
+ const participants = {};
515
+ for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
516
+ if (!manual.has(name)) participants[name] = participant_profile(name, defaults, input, name === room.host);
517
+ }
518
+ const members = launch_members(input, Object.keys(participants), options);
519
+ return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
520
+ scheduler: input.scheduler || undefined,
521
+ cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
522
+ data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
523
+ }
524
+
525
+ /** A free-room member the local service wakes; manual members join without local configuration. */
526
+ function woken_member(member) { return typeof member === 'string' || !member.access || member.access === 'runner'; }
527
+
528
+ /** Free rooms add one private credential per woken member, kept with the room configuration for retries. */
529
+ function free_launch_config(home, input, room, directory, options) {
530
+ const defaults = options.defaults || launch_settings(home).participants;
531
+ const names = room.members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name);
532
+ const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
533
+ const members = launch_members(input, names, options);
534
+ // A workflow chosen at creation is written with the members, so the first wake already carries it.
535
+ const workflow = input.workflow ? bind_room_workflow({}, { ...input.workflow, enabled: true }).workflow : undefined;
536
+ if (workflow) get_workflow(home, workflow.id);
537
+ if (workflow && Object.values(workflow.members).some(name => !names.includes(name))) throw new Error('工作流成员须在本次邀请的成员中');
538
+ return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
539
+ scheduler: input.scheduler || undefined, cwd: input.cwd, project_access: input.project_access || undefined, workflow,
540
+ data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
541
+ }
542
+
543
+ /** Preserve caller-generated member credentials; journal newly generated ones before creation. */
544
+ function launch_members(input, names, options) {
545
+ return Object.fromEntries(names.map(name => {
546
+ const supplied = input.room?.members?.find(member => member?.name === name);
547
+ return [name, { token: supplied?.token || options.members?.[name]?.token || `${randomUUID()}${randomUUID()}` }];
548
+ }));
549
+ }
550
+
551
+ /** Merge one participant's machine defaults with this launch's overrides and check its software. */
552
+ function participant_profile(name, defaults, input, current_host) {
553
+ const platform = platform_of(name);
554
+ // An extra instance starts from its platform's settings but never from another instance's bound session.
555
+ const { session_id: ignored_session, ...inherited } = name === platform ? {} : input.participants?.[platform] || {};
556
+ const profile = { ...defaults[platform], ...(name === platform ? {} : defaults[name]), ...inherited, ...input.participants?.[name] };
557
+ for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
558
+ validate_profile(name, profile, true);
559
+ if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
560
+ const facts = adapter_profile(profile.adapter);
561
+ if (facts?.kind === 'api') {
562
+ if (input.entry === 'conversation' && current_host) throw new Error('API 主持人请从网页启动;当前软件会话主持须选择对应 CLI / App');
563
+ return profile;
564
+ }
565
+ if (facts?.location === 'executable' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
566
+ || (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
567
+ const cwd = profile.cwd || input.cwd;
568
+ if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
569
+ if (input.entry === 'conversation' && current_host) profile.session_id = input.current_session_id;
570
+ // A new agy conversation always gets this tool's discussion project; only a manual agy expert the owner chose to host (input.adopt) keeps its own.
571
+ if (!facts?.host_existing && profile.session_id && !input.adopt) throw new Error(`${name}:${facts?.label || profile.adapter} 不能绑定已有会话或从当前对话主持全自动讨论;请从网页或其他软件发起,它会由讨论服务新建会话`);
572
+ return profile;
573
+ }
574
+
575
+ /** List every file below a directory, or nothing when the software has never stored a session there. */
576
+ function session_files(directory) {
577
+ return existsSync(directory) ? readdirSync(directory, { recursive: true }).map(path => join(directory, String(path))) : [];
578
+ }
579
+
580
+ /** Compare directories as the operating system resolves them (Windows short names and case). */
581
+ function same_directory(left, right) {
582
+ const real = path => { try { return realpathSync.native(path); } catch { return resolve(path); } };
583
+ const [a, b] = [real(left), real(right)];
584
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
585
+ }
586
+
587
+ /**
588
+ * Prove an invited existing conversation is on this machine before it can hold a turn, so a mistyped ID
589
+ * fails here instead of pausing the discussion later. Claude stores sessions per directory and resumes only
590
+ * from the original one, so its cwd follows the session; a different room project is refused.
591
+ */
592
+ export function borrowed_session(name, profile, room_cwd, env = process.env) {
593
+ const id = profile.session_id;
594
+ if (!SESSION_ID_PATTERN.test(id)) throw new Error(`${name}:会话 ID 格式不正确`);
595
+ const adapter = adapter_of(profile.adapter);
596
+ if (!adapter?.adopt || !adapter.session) {
597
+ const adoptable = ADAPTER_IDS.filter(item => ADAPTER_PROFILES[item].adopt).map(item => ADAPTER_PROFILES[item].label);
598
+ throw new Error(`${name}:只有 ${adoptable.join('、')} 可以接入已有会话`);
599
+ }
600
+ if (!adapter.session.exists(id, { env })) throw new Error(`${name}:本机找不到 ${adapter.label} 会话 ${id},请核对会话 ID`);
601
+ if (adapter.adopt !== 'own_cwd') return profile;
602
+ const cwd = adapter.session.cwd(id, { env });
603
+ if (!cwd) throw new Error(`${name}:${adapter.label} 会话 ${id} 没有记录原工作目录,无法续接`);
604
+ const wanted = profile.cwd || room_cwd;
605
+ if (wanted && !same_directory(wanted, cwd)) throw new Error(`${name}:这个 Claude 会话属于 ${cwd},只能在原目录续接;请让项目目录与它一致,或留空会话 ID 新建会话`);
606
+ return { ...profile, cwd: wanted || cwd };
607
+ }
608
+
609
+ /** Find the one registered configuration of a room after proving the owner's credential. */
610
+ async function member_room_context(home, raw) {
611
+ const input = launch_input({ ...raw, entry: 'web' });
612
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('管理成员须提供房间编号和房间控制凭据');
613
+ const paths = read_registry(home).filter(path => {
614
+ const identity = registered_identity(path);
615
+ return identity?.site === input.site && identity.room === input.room_id;
616
+ });
617
+ if (paths.length !== 1) throw new Error('未找到本房间唯一的已登记配置,请核对本机接入服务');
618
+ const config = configuration_snapshot(paths[0]).config;
619
+ // Both reads need the owner credential; the inbox transfers no messages.
620
+ await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
621
+ return { input, path: paths[0], config };
622
+ }
623
+
624
+ /** Write a manual member's private credential next to the room configuration, never into a receipt. */
625
+ function manual_access(path, input, name, token) {
626
+ const access_path = join(dirname(path), `manual-${name}.access.json`);
627
+ atomic_json(access_path, { site: input.site, room: input.room_id, member: name, token });
628
+ return access_path;
629
+ }
630
+
631
+ /**
632
+ * Invite (add) or bring back (rejoin) one member of a room.
633
+ * The local configuration is written before the website call, so a retry reuses the same credential.
634
+ * A manual member answers with its own credential and is never woken.
635
+ */
636
+ export async function room_member(home, raw, locked = false) {
637
+ const { input, path } = await member_room_context(home, raw);
638
+ const { action, name } = input;
639
+ const access = input.access || 'runner';
640
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
641
+ if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !['runner', 'manual'].includes(access)) {
642
+ throw new Error('action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual');
643
+ }
644
+ const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
645
+ try {
646
+ const config = configuration_snapshot(path).config;
647
+ config.api_home = home;
648
+ const roster = room.free.members;
649
+ if (action === 'add' && input.new_instance) {
650
+ // A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
651
+ const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
652
+ return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true);
653
+ }
654
+ const current = roster.find(member => member.name === name);
655
+ if (input.member_token !== undefined && (typeof input.member_token !== 'string' || input.member_token.length < 32 || input.member_token.length > 256)) throw new Error('member_token 须为 32–256 字符');
656
+ if (current?.status === 'active' && input.member_token && config.members?.[name]?.token === input.member_token) {
657
+ const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token });
658
+ return { room_id: input.room_id, member: name, access: current.access, free: view, access_file: null };
659
+ }
660
+ if (action === 'add' && !current && config.participants[name] && !locked) {
661
+ // A removed member's name keeps its bound session locally, so the new one joins under the next free name; a retry
662
+ // finds the name its own credential was saved under.
663
+ const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
664
+ const retried = Object.keys(config.members || {}).find(key => key !== name && input.member_token && config.members[key].token === input.member_token
665
+ && !roster.some(member => member.name === key));
666
+ const renamed = retried || free_instance(name, taken);
667
+ const participants = raw.participants?.[name] ? { participants: { ...raw.participants, [renamed]: raw.participants[name] } } : {};
668
+ return room_member(home, { ...raw, name: renamed, ...participants }, true);
669
+ }
670
+ if (action === 'rejoin' && current?.status !== 'away') throw new Error(`${name} 不是离开状态,无需请回`);
671
+ if (action === 'add' && current) throw new Error(`${name} 已是本房间成员;离开的成员请用请回`);
672
+ const manual = action === 'rejoin' ? current.access === 'manual' : access === 'manual';
673
+ const pending = input.retry_token && config.members?.[name]?.pending === input.retry_token;
674
+ const token = input.member_token || (pending ? config.members[name].token : `${randomUUID()}${randomUUID()}`);
675
+ let profile = config.participants[name];
676
+ if (!manual) {
677
+ if (action === 'add') {
678
+ profile = participant_profile(name, launch_settings(home).participants, { ...input, cwd: input.cwd || config.cwd }, false);
679
+ if (profile.session_id) profile = borrowed_session(name, profile, input.cwd || config.cwd);
680
+ }
681
+ if (!profile) throw new Error(`${name} 没有本机接入配置;请移除后重新邀请`);
682
+ const next = { ...config, participants: { ...config.participants, [name]: profile },
683
+ members: { ...config.members, [name]: { token, pending: input.retry_token || '' } } };
684
+ validate_config(next, dirname(path));
685
+ atomic_json(path, next);
686
+ }
687
+ const access_file = manual ? manual_access(path, input, name, token) : null;
688
+ const body = action === 'add'
689
+ ? { name, adapter: manual ? '' : profile.adapter, token, access, session_mode: profile?.session_id ? 'borrowed' : 'new' }
690
+ : { action: 'active', token };
691
+ const view = await site_request(input.site, action === 'add' ? `/api/rooms/${input.room_id}/members` : `/api/rooms/${input.room_id}/members/${current.id}`,
692
+ input.token, body);
693
+ return { room_id: input.room_id, member: name, access: manual ? 'manual' : 'runner', free: view, access_file };
694
+ } finally { release(); }
695
+ }
696
+
697
+
698
+ /**
699
+ * Switch one woken member's project files between read-only and writable after the room is created. Every run builds its
700
+ * permissions from the configuration, so the change applies from the member's next delivery and keeps its session.
701
+ */
702
+ export async function room_member_access(home, raw) {
703
+ const { input, path } = await member_room_context(home, raw);
704
+ const { name, project_access } = input;
705
+ if (!PROJECT_ACCESS.includes(project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
706
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
707
+ try {
708
+ const config = configuration_snapshot(path).config;
709
+ const profile = config.participants?.[name];
710
+ if (!profile) throw new Error(`${name} 不由本机唤醒,项目权限由它自己的会话决定`);
711
+ if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,权限沿用它自己会话的软件设置`);
712
+ const facts = adapter_profile(profile.adapter);
713
+ // A CLI's project access is set per run; an App follows its own settings and an API member only reads.
714
+ if (!facts?.access_levels) throw new Error(!facts?.capabilities.project ? `${name} 没有本机文件工具,项目固定只读` : `${name} 的项目权限在 App 内设置`);
715
+ if (!facts.access_levels.includes(project_access)) throw new Error(facts.unsupported_hints?.[project_access] || `${facts.label} 不支持此档位`);
716
+ if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,项目权限沿用该对话所在项目,请在原软件中设置`);
717
+ if (project_access === 'write' && !(profile.cwd || config.cwd)) throw new Error('本房间没有指定项目目录,可写没有作用;请在新建房间时选择项目目录');
718
+ const next = { ...config, participants: { ...config.participants, [name]: { ...profile, project_access } } };
719
+ validate_config(next, dirname(path));
720
+ atomic_json(path, next);
721
+ return { room_id: input.room_id, member: name, project_access };
722
+ } finally { release(); }
723
+ }
724
+
725
+ /**
726
+ * Change a woken CLI member's model and thinking effort after the room is created. Each run passes them to the CLI, and
727
+ * neither is part of the member's session context, so the change applies from its next delivery and keeps its session.
728
+ * An empty value goes back to the software's default.
729
+ */
730
+ export async function room_member_model(home, raw) {
731
+ const { input, path } = await member_room_context(home, raw);
732
+ const { name } = input;
733
+ const model = typeof input.model === 'string' ? input.model.trim() : '';
734
+ const effort = typeof input.effort === 'string' ? input.effort.trim() : '';
735
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
736
+ try {
737
+ const config = configuration_snapshot(path).config;
738
+ const profile = config.participants?.[name];
739
+ if (!profile) throw new Error(`${name} 不由本机唤醒,模型由它自己的会话决定`);
740
+ if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,模型与强度沿用它自己会话的设置`);
741
+ const facts = adapter_profile(profile.adapter);
742
+ if (!facts?.models) throw new Error(`${name} 的模型在 ${facts?.label || '它的软件'} 内设置`);
743
+ if (effort && !facts.efforts?.includes(effort)) throw new Error(`${facts.label} 不支持思考强度 ${effort}`);
744
+ const next = { ...profile, model, effort };
745
+ if (!model) delete next.model;
746
+ if (!effort) delete next.effort;
747
+ const updated = { ...config, participants: { ...config.participants, [name]: next } };
748
+ validate_config(updated, dirname(path));
749
+ atomic_json(path, updated);
750
+ return { room_id: input.room_id, member: name, model, effort };
751
+ } finally { release(); }
752
+ }
753
+
754
+ const MAX_ALLOWED_TOOLS = 50;
755
+
756
+ /**
757
+ * Allow operations a woken member's software refused, by adding the rules the runner reported with them to its
758
+ * `allowed_tools`: Claude passes them to `--allowedTools`, agy adds them to its discussion project's grants. Like the
759
+ * project access, they apply from the member's next delivery and keep its session.
760
+ */
761
+ export async function room_member_allow(home, raw) {
762
+ const { input, path } = await member_room_context(home, raw);
763
+ const { name, rules } = input;
764
+ if (!Array.isArray(rules) || !rules.length || rules.some(rule => typeof rule !== 'string' || !rule.trim() || rule.length > 300)) {
765
+ throw new Error('rules 须为要放行的规则列表');
766
+ }
767
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
768
+ try {
769
+ const config = configuration_snapshot(path).config;
770
+ const profile = config.participants?.[name];
771
+ if (!profile) throw new Error(`${name} 不由本机唤醒,权限由它自己的会话决定`);
772
+ const facts = adapter_profile(profile.adapter);
773
+ if (!facts?.per_tool_allow) throw new Error(`${name} 的软件不支持逐条放行,请改用更高的权限档位`);
774
+ if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,权限沿用该对话所在项目,请在原软件中设置`);
775
+ const allowed_tools = [...new Set([...(profile.allowed_tools || []), ...rules.map(rule => rule.trim())])];
776
+ if (allowed_tools.length > MAX_ALLOWED_TOOLS) throw new Error(`每位成员最多放行 ${MAX_ALLOWED_TOOLS} 条规则`);
777
+ const next = { ...config, participants: { ...config.participants, [name]: { ...profile, allowed_tools } } };
778
+ validate_config(next, dirname(path));
779
+ atomic_json(path, next);
780
+ return { room_id: input.room_id, member: name, allowed_tools };
781
+ } finally { release(); }
782
+ }
783
+
784
+ /**
785
+ * Switch an idle free-room Codex member between the App and the CLI, keeping its conversation: the App resumes the
786
+ * bound chat by its session, and the CLI resumes it only after the App has released it. Model and effort are chosen
787
+ * in the App, so switching to it drops them.
788
+ */
789
+ export async function room_member_adapter(home, raw) {
790
+ const { input, path } = await member_room_context(home, raw);
791
+ const { name, adapter } = input;
792
+ if (!ADAPTER_IDS.some(other => same_session_family(other, adapter))) throw new Error('只能在共用会话的 App 与 CLI 接入之间切换');
793
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
794
+ const member = room.free.members.find(item => item.name === name);
795
+ if (member?.pending > 0 || ['dispatched', 'started'].includes(member?.execution?.status)) throw new Error(`${name} 正在处理请求,请等它完成或先停止`);
796
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
797
+ try {
798
+ const config = configuration_snapshot(path).config;
799
+ const profile = config.participants?.[name];
800
+ if (profile?.adapter !== adapter && !same_session_family(profile?.adapter, adapter)) throw new Error(`${name} 不是本机唤醒、能切换到该接入方式的成员`);
801
+ if (profile.adapter === adapter) return { room_id: input.room_id, member: name, adapter };
802
+ const context = { config, path };
803
+ const next = { ...profile };
804
+ switch_codex_adapter(context, name, next, adapter);
805
+ // An App chooses its own model and effort.
806
+ if (!adapter_profile(adapter).models) delete next.model;
807
+ if (!adapter_profile(adapter).efforts) delete next.effort;
808
+ const updated = { ...config, participants: { ...config.participants, [name]: next } };
809
+ validate_config(updated, dirname(path));
810
+ atomic_json(path, updated);
811
+ return { room_id: input.room_id, member: name, adapter };
812
+ } finally { release(); }
813
+ }
814
+
815
+ /**
816
+ * Register a manual Claude, Codex or agy expert's own session as a hosted participant, so the service resumes it for later turns.
817
+ * Returns the adapter used. Throws when the local software is not configured or the session is not found on this machine; the member then stays manual.
818
+ */
819
+ export function adopt_member_profile(home, path, name, session_id, client, member_token) {
820
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
821
+ try {
822
+ const config = configuration_snapshot(path).config;
823
+ // The adapter whose store holds the session: a desktop chat is resumed through its App, others through the CLI.
824
+ const adapter = adoption_adapter(client, session_id);
825
+ if (!adapter) throw new Error(`${name}:本机不能接管 ${client} 的会话`);
826
+ // An App conversation is reached through this room's own Sidecar, which the App starts as soon as it is configured.
827
+ const sidecar = adapter_profile(adapter).location === 'ipc_directory' ? { ipc_directory: join(dirname(path), 'agy-app-sidecar') } : {};
828
+ // A manually started member keeps its own session's permissions; the room's 只读 / 可写 does not apply to it.
829
+ const input = { cwd: config.cwd, adopt: true, participants: { [name]: { adapter, session_id, project_access: 'own', ...sidecar } } };
830
+ // Its model, effort, prompt and tools stay as the user set them in that session; machine defaults never override them.
831
+ const { model, effort, allowed_tools, system_prompt, ...own } = participant_profile(name, launch_settings(home).participants, input, false);
832
+ const profile = borrowed_session(name, own, config.cwd);
833
+ if (sidecar.ipc_directory) setup_sidecar(sidecar);
834
+ // A room delivers with each member's own credential; the browser-made one is not kept locally, so the owner issues a new one.
835
+ const next = { ...config, participants: { ...config.participants, [name]: profile },
836
+ members: { ...config.members, [name]: { token: member_token, pending: '' } } };
837
+ validate_config(next, dirname(path));
838
+ atomic_json(path, next);
839
+ return adapter;
840
+ } finally { release(); }
841
+ }
842
+
843
+ /** The one registered configuration of a room on this machine, or null when it was never registered here. */
844
+ function registered_room_path(home, site, room_id) {
845
+ const paths = read_registry(home).filter(path => {
846
+ const identity = registered_identity(path);
847
+ return identity?.site === site && identity.room === room_id;
848
+ });
849
+ if (paths.length > 1) throw new Error('本房间存在多份登记配置,无法确定成员会话');
850
+ return paths[0] || null;
851
+ }
852
+
853
+ /** The session each participant actually resumes, read without writing to a runtime the service may be using. */
854
+ function bound_sessions(config, path) {
855
+ const file = join(resolve(config.data_directory || join(dirname(path), `discussion-${config.room}`)), 'runtime.sqlite3');
856
+ if (!existsSync(file)) return {};
857
+ const db = new DatabaseSync(file, { readOnly: true });
858
+ try { return Object.fromEntries(db.prepare('SELECT participant,session_id FROM bindings').all().map(row => [row.participant, row.session_id])); }
859
+ finally { db.close(); }
860
+ }
861
+
862
+ /**
863
+ * Export a room's local member software and the sessions they hold, so a backup imported on this machine resumes them.
864
+ * Requires the owner's credential; member credentials and API keys are never included (only the key reference).
865
+ * Returns `local_members: null` when the room is not registered here.
866
+ */
867
+ export async function room_profiles(home, raw) {
868
+ const input = launch_input({ ...raw, entry: 'web' });
869
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('导出本机成员配置须提供房间编号和房间控制凭据');
870
+ const path = registered_room_path(home, input.site, input.room_id);
871
+ if (!path) return { room_id: input.room_id, local_members: null };
872
+ const config = configuration_snapshot(path).config;
873
+ await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
874
+ const sessions = bound_sessions(config, path);
875
+ const participants = Object.fromEntries(Object.entries(config.participants).map(([name, profile]) => {
876
+ const kept = Object.fromEntries(Object.entries(profile).filter(([key]) => [...PROFILE_FIELDS, 'project_access'].includes(key)));
877
+ const session_id = sessions[name] || profile.session_id;
878
+ return [name, session_id ? { ...kept, session_id } : kept];
879
+ }));
880
+ return { room_id: input.room_id, local_members: { format: LOCAL_MEMBERS_FORMAT, version: 1, mode: 'free',
881
+ cwd: config.cwd, discussion_project: config.discussion_project, project_access: config.project_access, scheduler: config.scheduler, participants } };
882
+ }
883
+
884
+ /**
885
+ * Register an imported free room on this machine with its exported member software, each member resuming its original session.
886
+ * A session or software missing here fails that member instead of starting a new conversation. Claude Code members stay manual,
887
+ * and are never taken over, because a background resume forks the session away from its original window.
888
+ * Returns `{room_id, registered, members: [{name, status: resumed|manual|failed, session_id?, cwd?, error?}]}`.
889
+ */
890
+ export async function restore_room(home, raw) {
891
+ const input = launch_input({ ...raw, entry: 'web' });
892
+ const local = input.local_members;
893
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('恢复本机成员须提供房间编号和房间控制凭据');
894
+ if (local?.format !== LOCAL_MEMBERS_FORMAT || local.version !== 1 || !local.participants || typeof local.participants !== 'object') throw new Error('本机成员配置格式无效');
895
+ await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
896
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
897
+ if (registered_room_path(home, input.site, input.room_id)) throw new Error('本房间已在本机登记,不重复恢复');
898
+ if (local.cwd && (!isAbsolute(local.cwd) || !existsSync(local.cwd) || !statSync(local.cwd).isDirectory())) throw new Error(`房间项目目录 ${local.cwd} 在本机不存在`);
899
+ const keep_manual = room.free.members.filter(member => member.access === 'manual' && member.status !== 'removed'
900
+ && platform_adapters(platform_of(member.name)).some(id => ADAPTER_PROFILES[id].restore_manual)).map(member => member.name);
901
+ const participants = {}, members = [];
902
+ for (const [name, exported] of Object.entries(local.participants)) {
903
+ try {
904
+ const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
905
+ // A member without a software session store (an API member) kept its binding in the old runtime directory, so it starts fresh.
906
+ if (!adapter_of(profile.adapter)?.session) delete profile.session_id;
907
+ const resumed = profile.session_id ? borrowed_session(name, profile, local.cwd) : profile;
908
+ if (adapter_profile(profile.adapter)?.restore_manual) {
909
+ keep_manual.push(name);
910
+ const cwd = resumed.cwd || local.cwd || '';
911
+ const command = resumed.session_id && adapter_profile(profile.adapter).resume_command?.(resumed.session_id);
912
+ members.push({ name, status: 'manual', session_id: resumed.session_id || '', cwd,
913
+ resume: command ? { command, cwd } : null });
914
+ } else {
915
+ participants[name] = resumed;
916
+ members.push({ name, status: 'resumed', session_id: resumed.session_id || '' });
917
+ }
918
+ } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
919
+ }
920
+ if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
921
+ register_copied_room(home, input, local, participants, keep_manual);
922
+ return { room_id: input.room_id, registered: true, members };
923
+ }
924
+
925
+ /**
926
+ * Register an imported or forked free room's local members under a fresh launch directory; `seed` writes the new
927
+ * runtime's bindings before the service can see the room. Returns the runtime directory.
928
+ */
929
+ function register_copied_room(home, input, local, participants, keep_manual, seed = () => {}) {
930
+ const key = createHash('sha256').update(`${input.site}/${input.room_id}`).digest('hex');
931
+ // A leftover directory of a deleted room with the same ID would carry its old bindings, so a restore never reuses one.
932
+ const base = join(home, 'launches', key), directory = existsSync(base) ? `${base}-${Date.now()}` : base;
933
+ // Members arrive away; these placeholders are replaced by the credential issued when the owner brings each one back.
934
+ const config = { site: input.site, room: input.room_id, token: input.token, mode: 'free', participants, keep_manual, api_home: home,
935
+ members: Object.fromEntries(Object.keys(participants).map(name => [name, { token: `${randomUUID()}${randomUUID()}`, pending: '' }])),
936
+ scheduler: local.scheduler || undefined, cwd: local.cwd || undefined, discussion_project: local.discussion_project || undefined,
937
+ project_access: local.project_access || undefined, data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) };
938
+ validate_config(config, directory);
939
+ mkdirSync(directory, { recursive: true });
940
+ atomic_json(join(directory, 'config.json'), config);
941
+ seed(config.data_directory);
942
+ register_room(home, join(directory, 'config.json'));
943
+ return config.data_directory;
944
+ }
945
+
946
+ // Software whose session a fork branches; other local members start a new conversation in the forked room.
947
+
948
+ /**
949
+ * Register a room forked from another on this machine: each local member continues from its own session as it stood
950
+ * at the fork point, without the executions the fork leaves out. The source room's sessions are never changed.
951
+ * Requires both rooms' owner credentials; members the new room keeps manual are not registered here.
952
+ * Returns `{room_id, registered, members: [{name, status: forked|fresh|failed, error?}]}`.
953
+ */
954
+ export async function fork_room(home, raw) {
955
+ const input = launch_input({ ...raw, entry: 'web' });
956
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim() || typeof raw.source_room_id !== 'string' || typeof raw.source_token !== 'string') {
957
+ throw new Error('分支须提供新房间与原房间的编号和控制凭据');
958
+ }
959
+ const local = (await room_profiles(home, { site: input.site, room_id: raw.source_room_id, token: raw.source_token })).local_members;
960
+ if (!local) return { room_id: input.room_id, registered: false, members: [] };
961
+ await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
962
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
963
+ if (registered_room_path(home, input.site, input.room_id)) throw new Error('分支房间已在本机登记');
964
+ const runners = new Set(room.free.members.filter(member => member.access === 'runner' && member.status !== 'removed').map(member => member.name));
965
+ const left_out = raw.executions && typeof raw.executions === 'object' ? raw.executions : {};
966
+ const participants = {}, members = [], bindings = {}, forks = {};
967
+ for (const [name, exported] of Object.entries(local.participants)) {
968
+ if (!runners.has(name)) continue;
969
+ try {
970
+ const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
971
+ if (!profile.session_id || !BRANCHABLE_ADAPTERS.includes(profile.adapter)) {
972
+ delete profile.session_id;
973
+ participants[name] = profile;
974
+ members.push({ name, status: 'fresh' });
975
+ continue;
976
+ }
977
+ // Claude resumes a session only in its original directory, so the forked member works there too.
978
+ const located = adapter_profile(profile.adapter)?.adopt === 'own_cwd' ? borrowed_session(name, profile, local.cwd) : profile;
979
+ const branch = await branch_session(located, profile.session_id, (left_out[name] || []).filter(Number.isInteger), { always: true });
980
+ const { session_id, ...forked } = located;
981
+ participants[name] = forked;
982
+ bindings[name] = branch.session_id;
983
+ if (branch.fork) forks[name] = branch.fork;
984
+ members.push({ name, status: branch.session_id ? 'forked' : 'fresh' });
985
+ } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
986
+ }
987
+ if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
988
+ register_copied_room(home, input, local, participants, [], data_directory => {
989
+ const store = new DiscussionStore(data_directory);
990
+ try {
991
+ for (const [name, session_id] of Object.entries(bindings)) store.bind(name, session_id);
992
+ // A Claude fork happens on the member's next turn; the runner keeps it pending until that session exists.
993
+ for (const [name, fork] of Object.entries(forks)) store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
994
+ .run(`session-fork:${name}`, JSON.stringify(fork));
995
+ } finally { store.close(); }
996
+ });
997
+ return { room_id: input.room_id, registered: true, members };
998
+ }
999
+
1000
+ /** Stable signatures make changed retry requests explicit without putting credentials in receipts. */
1001
+ function signature(value) {
1002
+ if (Array.isArray(value)) return `[${value.map(signature).join(',')}]`;
1003
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${signature(value[key])}`).join(',')}}`;
1004
+ return JSON.stringify(value);
1005
+ }
1006
+
1007
+ /** Inherit the topic duration unless overridden; zero leaves the run without a time limit. */
1008
+ function launch_minutes(input, room) {
1009
+ const minutes = Number(input.minutes ?? room.limits?.max_minutes ?? 0);
1010
+ if (!Number.isFinite(minutes) || minutes < 0 || minutes > MAX_MINUTES) throw new Error('讨论总时长或 minutes 须在 [0,1440],0 表示不限时长');
1011
+ return minutes;
1012
+ }
1013
+
1014
+ /** Refuse a second registry owner before changing the website runtime. */
1015
+ function check_registration(home, config, path) {
1016
+ for (const registered of read_registry(home)) {
1017
+ if (resolve(registered) === path || !existsSync(registered)) continue;
1018
+ const other = registered_identity(registered);
1019
+ if (other?.site === config.site && other.room === config.room) throw new Error('房间已使用另一份接入配置登记;请沿用原配置,不重复接入');
1020
+ }
1021
+ }
1022
+
1023
+ /** Start a session once, reconciling a lost response from the durable start intent. */
1024
+ async function start_session(config, record, persist, room, minutes) {
1025
+ const state = await api(config, '/discussion');
1026
+ if (record.session_id && state.session?.id === record.session_id && !record.start_pending) {
1027
+ if (record.start_submission_id && state.session.start_submission_id !== record.start_submission_id) {
1028
+ throw new Error('房间当前讨论的启动编号与本次记录不同');
1029
+ }
1030
+ if (state.session.status !== 'active') throw new Error('讨论已暂停或结束;请在房间核对状态后再操作');
1031
+ return state.session;
1032
+ }
1033
+ if (record.session_id && state.session?.id !== record.session_id) throw new Error('房间当前讨论与本次启动记录不同');
1034
+ if (state.session && !record.start_pending) {
1035
+ if (state.session.status !== 'active') throw new Error('讨论已暂停或结束;请在房间核对状态后再操作');
1036
+ throw new Error('房间已有进行中的讨论;请沿用原启动记录');
1037
+ }
1038
+ if (room.session?.status === 'concluded' && !record.start_pending) throw new Error('房间中的讨论已结束;请从房间明确开启新讨论');
1039
+ if (!record.start_submission_id) record.start_submission_id = randomUUID();
1040
+ if (!record.start_request) {
1041
+ if (record.start_revision && record.start_revision !== state.revision) throw new Error('开启期间讨论状态已变化,请重新读取房间状态');
1042
+ const topic_minutes = Number(room.limits?.max_minutes || 0);
1043
+ const session_minutes = topic_minutes > 0 ? (minutes > 0 ? Math.min(topic_minutes, minutes) : topic_minutes) : minutes;
1044
+ record.start_revision = state.revision;
1045
+ record.start_request = { action: 'start', revision: state.revision, submission_id: record.start_submission_id,
1046
+ goal: room.goal || room.title, named_followup: Boolean(room.named_followup),
1047
+ limits: { max_rounds: room.limits?.max_rounds ?? 0, max_minutes: session_minutes,
1048
+ execution_minutes: room.limits?.execution_minutes ?? 0 } };
1049
+ record.start_pending = true;
1050
+ persist();
1051
+ }
1052
+ const result = await api(config, '/discussion', record.start_request);
1053
+ if (!result.session || result.session.start_submission_id !== record.start_submission_id) throw new Error('网站返回的讨论会话与本次启动编号不符');
1054
+ record.session_id = result.session.id;
1055
+ record.start_pending = false;
1056
+ persist();
1057
+ if (result.session.status !== 'active') throw new Error('本次讨论已暂停或结束;请在房间核对状态后再操作');
1058
+ return result.session;
1059
+ }
1060
+
1061
+ /** Reserve the first host task for the calling conversation without sending it a duplicate prompt. */
1062
+ async function current_host_task(config, record, persist, room) {
1063
+ if (record.current_task) return record.current_task;
1064
+ const discussion = await api(config, '/discussion');
1065
+ if (discussion.session?.status !== 'active' || discussion.session.id !== record.session_id) throw new Error('本次讨论会话已暂停、结束或变化;请核对原房间');
1066
+ const page = await api(config, '/inbox');
1067
+ const member = page.members.find(item => item.name === room.host && item.role === 'host');
1068
+ const request = member?.pending.find(item => item.purpose === 'discuss_host' && item.session_id === record.session_id);
1069
+ if (!member || (!request && !record.execution_client_id && !record.execution_id)) throw new Error('没有可由当前会话接手的主持请求;请核对原房间');
1070
+ record.execution_client_id ||= randomUUID();
1071
+ persist();
1072
+ const execution = record.execution_id ? await api(config, `/executions/${record.execution_id}`)
1073
+ : await api(config, '/executions', { member_id: member.id, client_id: record.execution_client_id, cwd: '' });
1074
+ if (!['dispatched', 'started'].includes(execution.status)) throw new Error('主持请求已不再等待执行;请核对原房间');
1075
+ if (!execution.requests?.some(item => item.purpose === 'discuss_host' && item.session_id === record.session_id)) {
1076
+ throw new Error('预约到的执行不属于本次讨论主持请求');
1077
+ }
1078
+ record.execution_id = execution.id;
1079
+ persist();
1080
+ const store = new DiscussionStore(config.directory);
1081
+ try {
1082
+ store.context(config);
1083
+ store.scope(config);
1084
+ const session_id = config.participants[room.host].session_id;
1085
+ store.bind(room.host, session_id);
1086
+ const task = free_task_files(config, member, execution, { session_id }, { session: discussion.session, process_access: false });
1087
+ new FreeRoomRunner(config, store).save(room.host, { client_id: record.execution_client_id, execution_id: execution.id,
1088
+ status: 'running', attempts: 0, started: Date.now(), last_renew: Date.now() });
1089
+ record.current_task = { execution_id: execution.id, access_file: task.access_path, task_file: join(config.directory, `${task.id}.task.json`) };
1090
+ persist();
1091
+ return record.current_task;
1092
+ } finally { store.close(); }
1093
+ }
1094
+
1095
+ /** Return launch evidence and paths, never raw control or execution tokens. */
1096
+ async function launch_receipt(config, record, config_path) {
1097
+ const room = await api(config, '');
1098
+ const discussion = record.session_id ? await api(config, '/discussion') : null;
1099
+ return { entry: record.entry, room, url: `${config.site}/?room=${config.room}`, config_path,
1100
+ status: discussion?.session?.status || (record.session_id ? 'concluded' : 'free'), current_task: record.current_task || null };
1101
+ }
1102
+
1103
+ /** Read a saved launch without creating state during validation or preview. */
1104
+ function read_launch(directory) {
1105
+ const path = join(directory, 'launch.json');
1106
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
1107
+ }
1108
+
1109
+ /** Find the registered room only after proving the caller's original control credential. */
1110
+ async function room_config_context(home, raw, options = {}) {
1111
+ const input = launch_input({ ...raw, entry: 'web' });
1112
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('读取本场配置须提供房间编号和原控制凭据');
1113
+ const room = options.local_guard ? { id: input.room_id, archived: false }
1114
+ : await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
1115
+ const config = { site: input.site, room: input.room_id, token: input.token };
1116
+ if (!options.local_guard) await verify_control(config, room);
1117
+ const discussion = options.local_guard
1118
+ ? { session: { status: 'paused' }, revision: raw.session_revision ?? raw.runtime_revision }
1119
+ : await api(config, '/discussion');
1120
+ if (room.archived || discussion.session?.status !== 'paused') throw new Error('请先暂停本场讨论,再修正软件路径');
1121
+ const paths = read_registry(home).filter(path => {
1122
+ const identity = registered_identity(path);
1123
+ return identity?.site === input.site && identity.room === input.room_id;
1124
+ });
1125
+ if (paths.length !== 1) throw new Error('未找到本场唯一的已登记配置,请核对原接入服务');
1126
+ const path = paths[0];
1127
+ const journal = read_launch(dirname(path));
1128
+ if (journal && !journal.finished) throw new Error('本次启动尚未完成,请先核对原启动待办');
1129
+ return { input, room, discussion, path, ...configuration_snapshot(path) };
1130
+ }
1131
+
1132
+ /** Expose only editable connection paths, never credentials or unrelated room settings. */
1133
+ function connection_view(context) {
1134
+ return { room_id: context.room.id, session_revision: context.discussion.revision, runtime_revision: context.discussion.revision, version: context.version,
1135
+ participants: Object.fromEntries(Object.entries(context.config.participants).map(([name, profile]) =>
1136
+ [name, Object.fromEntries(['adapter', ...CONNECTION_FIELDS].filter(field => profile[field] !== undefined).map(field => [field, profile[field]]))])) };
1137
+ }
1138
+
1139
+ /** Switch only Codex's execution entrypoint, keeping its actual original conversation. */
1140
+ function switch_codex_adapter(context, name, profile, adapter) {
1141
+ if (!same_session_family(profile.adapter, adapter)) throw new Error('本场仅支持共用会话的 App / CLI 接入之间切换');
1142
+ const current = validate_config(context.config, dirname(context.path));
1143
+ let session = profile.session_id;
1144
+ if (existsSync(join(current.directory, 'runtime.sqlite3'))) {
1145
+ const store = new DiscussionStore(current.directory);
1146
+ try {
1147
+ const binding = store.binding(name).session_id;
1148
+ if (session && binding && session !== binding) throw new Error('原会话绑定不一致,请先核对原会话');
1149
+ session = binding || session;
1150
+ } finally { store.close(); }
1151
+ }
1152
+ if (!session) throw new Error('尚无实际 Codex 会话,不能切换;请先完成首次接入');
1153
+ profile.adapter = adapter;
1154
+ profile.session_id = session;
1155
+ }
1156
+
1157
+ /** Validate paused-room paths and Codex entrypoints without replacing the discussion or session. */
1158
+ function corrected_configuration(context) {
1159
+ const config = structuredClone(context.config);
1160
+ for (const [name, patch] of Object.entries(context.input.participants)) {
1161
+ const profile = config.participants[name];
1162
+ const field = adapter_profile(profile?.adapter)?.location || 'executable';
1163
+ const fields = ADAPTER_IDS.some(other => same_session_family(other, profile?.adapter)) ? [field, 'adapter'] : [field];
1164
+ if (!profile || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(key => !fields.includes(key))) throw new Error('本场修正仅支持软件路径、Sidecar 目录及 Codex App / CLI;参与者、项目及会话保持不变');
1165
+ validate_profile(name, patch);
1166
+ if (patch.adapter && patch.adapter !== profile.adapter) switch_codex_adapter(context, name, profile, patch.adapter);
1167
+ const path = patch[field] ?? profile[field];
1168
+ if (typeof path !== 'string' || !path || !existsSync(path)
1169
+ || (field === 'executable' ? !statSync(path).isFile() || (process.platform === 'win32' && !/\.exe$/i.test(path)) : !statSync(path).isDirectory())) throw new Error(`${name}:请提供有效的软件文件或连接目录`);
1170
+ profile[field] = path;
1171
+ }
1172
+ validate_config(config, dirname(context.path));
1173
+ return config;
1174
+ }
1175
+
1176
+ /** Wait for the resident worker to read this version before suggesting a manual restart. */
1177
+ async function configuration_applied(home, path, version) {
1178
+ for (let waited = 0; waited < CONFIG_APPLY_TIMEOUT_MS; waited += CONFIG_APPLY_POLL_MS) {
1179
+ const pid = service_pid(home);
1180
+ if (!pid) return false;
1181
+ const status_path = join(home, 'status.json');
1182
+ const status = existsSync(status_path) ? JSON.parse(readFileSync(status_path, 'utf8')) : {};
1183
+ const worker = status.rooms?.find(item => resolve(item.config) === resolve(path));
1184
+ if (status.pid === pid && worker?.config_version === version && worker.status !== 'error') return true;
1185
+ await sleep(CONFIG_APPLY_POLL_MS);
1186
+ }
1187
+ return false;
1188
+ }
1189
+
1190
+ /** Read or save a paused room's connection paths without changing its runtime or global defaults. */
1191
+ export async function room_configuration(home, raw, options = {}) {
1192
+ let context = await room_config_context(home, raw, options);
1193
+ if (raw.participants !== undefined && !options.local_guard) {
1194
+ const saved = await save_room_connection(raw);
1195
+ return { ...saved, applied: await configuration_applied(home, context.path, saved.version) };
1196
+ }
1197
+ if (raw.participants === undefined) return connection_view(context);
1198
+ if (!raw.participants || typeof raw.participants !== 'object' || Array.isArray(raw.participants)) throw new Error('participants 须为本场软件路径对象');
1199
+ const release = acquire_lock(join(dirname(context.path), 'configuration-edit'));
1200
+ try {
1201
+ context = await room_config_context(home, raw, options);
1202
+ const config = corrected_configuration(context);
1203
+ if (raw.version !== context.version || (raw.session_revision ?? raw.runtime_revision) !== context.discussion.revision) throw new Error('房间状态或配置已变化,请重新读取本场配置后核对');
1204
+ atomic_json(context.path, config);
1205
+ const saved = { ...context, ...configuration_snapshot(context.path) };
1206
+ // The local website holds its database write lock here; the worker can acknowledge only after that lock is released.
1207
+ return { ...connection_view(saved), applied: false };
1208
+ } finally { release(); }
1209
+ }
1210
+
1211
+ /** Serialize file replacement with runtime changes through the local website's database guard. */
1212
+ async function save_room_connection(raw) {
1213
+ const input = launch_input({ ...raw, entry: 'web' });
1214
+ const url = new URL(input.site);
1215
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('本场路径修正须通过已启用本机启动的回环网站');
1216
+ const response = await fetch(`${input.site}/api/local/room-config`, { method: 'POST',
1217
+ headers: { 'Content-Type': 'application/json', 'X-CoDiscuss-Local': '1' },
1218
+ body: JSON.stringify(input), redirect: 'error', signal: AbortSignal.timeout(15000) });
1219
+ const result = await response.json();
1220
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '本场配置保存失败'}`);
1221
+ return result;
1222
+ }
1223
+
1224
+ /** Prove dispatcher access before a protected room is started or a corrected credential is saved. */
1225
+ async function verify_control(config, room) {
1226
+ await api({ ...config, room: room.id }, '/inbox');
1227
+ }
1228
+
1229
+ /** Pin resolved defaults, while comparing retries against only the caller's explicit request. */
1230
+ async function prepare_launch(home, input, directory, options = {}) {
1231
+ const previous = read_launch(directory);
1232
+ if (options.browser_request && (typeof input.token !== 'string' || !input.token.trim())) throw new Error('网页启动须携带本次原始控制凭据,不能认领其他启动记录');
1233
+ const { token: ignored_token, access_file: ignored_access, ...request } = input;
1234
+ const request_signature = signature(request);
1235
+ if (previous?.request_signature && previous.request_signature !== request_signature) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
1236
+ const config_path = join(directory, 'config.json');
1237
+ const saved = previous?.config || (previous && existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : null);
1238
+ const token = input.token || previous?.token || randomUUID();
1239
+ const room_id = input.room_id || previous?.room_id || (saved?.room !== 'preview' ? saved?.room : null);
1240
+ if (options.browser_request && previous && token !== previous.token && !room_id) throw new Error('尚未确认原房间,不能替换本次控制凭据');
1241
+ const room = room_id ? await site_request(input.site, `/api/rooms/${room_id}`, token) : input.room;
1242
+ const config = previous?.request_signature ? { ...structuredClone(saved), token }
1243
+ : launch_config(home, input, room, directory, { token, defaults: saved?.participants, members: saved?.members });
1244
+ config.draft_directory = service_drafts(home, config.directory);
1245
+ if (previous?.request_signature && !previous.finished) validate_config(config, directory);
1246
+ const minutes = previous?.minutes ?? launch_minutes(input, room);
1247
+ const draft = signature({ entry: input.entry, room_id: input.room_id, room: input.room, participants: config.participants, cwd: input.cwd, discussion_project: input.discussion_project, minutes });
1248
+ if (room.id) await verify_control(config, room);
1249
+ return { input, config, room, minutes, draft, request_signature, directory, previous };
1250
+ }
1251
+
1252
+ /** Launch a room through either entry, using a durable journal for safe retries. */
1253
+ export async function launch_discussion(home, raw, options = {}) {
1254
+ assert_shared_home(home);
1255
+ const input = launch_input(raw);
1256
+ const key = createHash('sha256').update(`${input.site}/${input.room_id || input.room.submission_id}`).digest('hex');
1257
+ const directory = join(home, 'launches', key);
1258
+ let release;
1259
+ try {
1260
+ const preview = await prepare_launch(home, input, directory, options);
1261
+ if (options.dry_run) return { dry_run: true, entry: input.entry, site: input.site, room_id: input.room_id || null,
1262
+ host: preview.room.host, participants: preview.config.participants, cwd: preview.config.cwd || null,
1263
+ discussion_project: preview.config.discussion_project || null, minutes: preview.minutes };
1264
+ release = acquire_lock(directory);
1265
+ return await perform_launch(home, await prepare_launch(home, input, directory, options), options);
1266
+ } catch (error) {
1267
+ error.launch ||= { stage: existsSync(join(directory, 'launch.json')) ? 'unconfirmed' : 'not_started' };
1268
+ throw error;
1269
+ } finally { release?.(); }
1270
+ }
1271
+
1272
+ /** Persist preparation before writes, hand off the current host when needed, then register once. */
1273
+ async function perform_launch(home, context, options) {
1274
+ const { input, config, minutes, draft, request_signature, directory, previous } = context;
1275
+ // This is the creation input's start-discussion choice, not the persisted room model (always free).
1276
+ const discussion = previous?.discussion ?? (input.room ? input.room.mode !== 'free' : Boolean(context.room.session && context.room.session.status !== 'concluded'));
1277
+ const record = previous || { token: config.token, signature: draft, request_signature, config, minutes, entry: input.entry, discussion };
1278
+ record.discussion = discussion;
1279
+ const persist = () => atomic_json(join(directory, 'launch.json'), record);
1280
+ if (!previous) persist();
1281
+ const progress = { stage: 'unconfirmed' };
1282
+ try {
1283
+ let room = context.room;
1284
+ if (!input.room_id) room = await site_request(input.site, '/api/rooms', config.token, {
1285
+ ...input.room,
1286
+ members: [...Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
1287
+ token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
1288
+ // Manual members keep the credentials the browser generated; nothing local is configured for them.
1289
+ ...(input.room.members || []).filter(member => record.discussion ? member?.access === 'manual' : !woken_member(member))
1290
+ .map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' }))],
1291
+ });
1292
+ config.room = room.id;
1293
+ await verify_control(config, room);
1294
+ Object.assign(progress, { stage: 'created', room_id: room.id });
1295
+ Object.assign(record, { token: config.token, request_signature, config, minutes, room_id: room.id });
1296
+ persist();
1297
+ const config_path = join(directory, 'config.json');
1298
+ check_registration(home, config, config_path);
1299
+ if (record.finished) {
1300
+ const saved = existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : config;
1301
+ if (saved.token !== config.token || !existsSync(config_path)) atomic_json(config_path, { ...saved, token: config.token });
1302
+ return launch_receipt(config, record, config_path);
1303
+ }
1304
+ atomic_json(config_path, config);
1305
+ const service = await (options.start_service || start_service)(home);
1306
+ if (!service?.running) throw new Error('讨论服务未就绪,未开始本次讨论');
1307
+ if (record.discussion) await start_session(config, record, persist, room, minutes);
1308
+ progress.stage = 'started';
1309
+ if (record.discussion && input.entry === 'conversation') record.current_task = await current_host_task(config, record, persist, room);
1310
+ register_room(home, config_path);
1311
+ record.finished = true;
1312
+ persist();
1313
+ return launch_receipt(config, record, config_path);
1314
+ } catch (error) { error.launch = progress; throw error; }
1315
+ }
1316
+
1317
+ /** The workflow library: list, read, save, delete, preview an import, or read a room's member cards. */
1318
+ export function workflows_command(home, input = {}) {
1319
+ const action = input.action || 'list';
1320
+ if (action === 'list') return { workflows: list_workflows(home) };
1321
+ if (action === 'get') return get_workflow(home, input.id);
1322
+ if (action === 'save') return save_workflow(home, input.workflow, validate_preset);
1323
+ if (action === 'delete') return delete_workflow(home, input.id);
1324
+ if (action === 'preview') {
1325
+ const configured = launch_settings(home).participants;
1326
+ return preview_workflow(input, validate_preset, name => Boolean(configured[platform_of(name)]));
1327
+ }
1328
+ if (action === 'room-cards') return room_cards(home, input);
1329
+ throw new Error('action 须为 list、get、save、delete、preview 或 room-cards');
1330
+ }
1331
+
1332
+ /** Cards made from a registered room's members as configured here, prompts verbatim; no credential or path is returned. */
1333
+ async function room_cards(home, raw) {
1334
+ const input = launch_input({ ...raw, entry: 'web' });
1335
+ if (!input.room_id) throw new Error('读取成员卡片须提供 room_id');
1336
+ const path = registered_room_path(home, input.site, input.room_id);
1337
+ if (!path) throw new Error('本机没有登记这个房间');
1338
+ const config = configuration_snapshot(path).config;
1339
+ const room = await api(config, ''), taken = new Set();
1340
+ const members = new Set((room.free?.members || []).filter(member => member.status !== 'removed').map(member => member.name));
1341
+ return { cards: Object.entries(config.participants || {}).filter(([name]) => members.has(name)).map(([name, profile]) => {
1342
+ const suffix = name.split('-')[1] || platform_of(name).toLowerCase();
1343
+ let card = suffix.slice(0, 16);
1344
+ for (let number = 2; taken.has(card.toLowerCase()); number++) card = `${suffix.slice(0, 16 - String(number).length)}${number}`;
1345
+ taken.add(card.toLowerCase());
1346
+ return { card, name, label: room.labels?.[name] || '', description: room.descriptions?.[name] || '',
1347
+ ...Object.fromEntries(['system_prompt', 'project_access', 'model', 'effort']
1348
+ .filter(field => profile[field] && profile[field] !== 'own').map(field => [field, profile[field]])) };
1349
+ }) };
1350
+ }
1351
+
1352
+ /**
1353
+ * Bind or release a room's workflow. Members are invited or brought back by the usual member commands first;
1354
+ * enabling only records which card each member plays. Disabling sends those members away, except the host.
1355
+ */
1356
+ export async function room_workflow(home, raw) {
1357
+ const { input, path } = await member_room_context(home, raw);
1358
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
1359
+ try {
1360
+ const config = configuration_snapshot(path).config;
1361
+ if (input.action === 'enable') {
1362
+ get_workflow(home, input.workflow?.id);
1363
+ if (config.workflow?.enabled && config.workflow.id !== input.workflow.id) throw new Error('本房间已启用另一个工作流,请先停用');
1364
+ const next = bind_room_workflow(config, { ...input.workflow, enabled: true });
1365
+ validate_config({ ...next, api_home: home }, dirname(path));
1366
+ atomic_json(path, next);
1367
+ return { room_id: input.room_id, workflow: room_workflow_status(next, home) };
1368
+ }
1369
+ if (input.action !== 'disable') throw new Error('action 须为 enable 或 disable');
1370
+ if (!config.workflow?.enabled) return { room_id: input.room_id, workflow: room_workflow_status(config, home) };
1371
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
1372
+ const names = new Set(Object.values(config.workflow.members || {}));
1373
+ for (const member of room.free.members.filter(item => names.has(item.name) && item.status === 'active' && item.role !== 'host')) {
1374
+ await site_request(input.site, `/api/rooms/${input.room_id}/members/${member.id}`, input.token, { action: 'away' });
1375
+ }
1376
+ const next = { ...config, workflow: { ...config.workflow, enabled: false } };
1377
+ atomic_json(path, next);
1378
+ return { room_id: input.room_id, workflow: room_workflow_status(next, home) };
1379
+ } finally { release(); }
1380
+ }
1381
+
1382
+ /** CLI and local HTTP bridge use the same commands and configuration validation. */
1383
+ export async function launch_main(args = process.argv.slice(2), input_text) {
1384
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
1385
+ file: { type: 'string' }, 'dry-run': { type: 'boolean' }, browser: { type: 'boolean' },
1386
+ 'configuration-locked': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
1387
+ } });
1388
+ if (values.help || !positionals.length) return process.stdout.write(HELP);
1389
+ const input = input_text === undefined ? (values.file ? JSON.parse(readFileSync(values.file, 'utf8').replace(/^\uFEFF/, '')) : null) : JSON.parse(input_text);
1390
+ const home = service_home();
1391
+ assert_shared_home(home);
1392
+ let result;
1393
+ if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
1394
+ scheduler_providers: SCHEDULER_PROVIDERS, scheduler_modes: SCHEDULER_MODES, scheduler_default_mode: DEFAULT_SCHEDULER_MODE, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home), workflows: list_workflows(home) };
1395
+ else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
1396
+ else if (positionals[0] === 'presets') result = input ? save_expert_presets(home, input) : expert_presets(home);
1397
+ else if (positionals[0] === 'workflows') result = await workflows_command(home, input || {});
1398
+ else if (positionals[0] === 'room-workflow' && input) result = await room_workflow(home, input);
1399
+ else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
1400
+ else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
1401
+ else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
1402
+ else if (positionals[0] === 'tts-settings') result = input && Object.keys(input).length ? save_tts_settings(home, input) : tts_settings(home);
1403
+ else if (positionals[0] === 'tts' && input) result = await tts_speak(home, input);
1404
+ else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
1405
+ else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
1406
+ else if (positionals[0] === 'room-files' && input) result = await room_files(home, input);
1407
+ else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
1408
+ else if (positionals[0] === 'models' && input) {
1409
+ const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
1410
+ result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable,
1411
+ ...(input.model ? { model: input.model } : {}) }, { refresh: input.refresh === true });
1412
+ }
1413
+ else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
1414
+ else if (positionals[0] === 'room-member-access' && input) result = await room_member_access(home, input);
1415
+ else if (positionals[0] === 'room-member-allow' && input) result = await room_member_allow(home, input);
1416
+ else if (positionals[0] === 'room-member-adapter' && input) result = await room_member_adapter(home, input);
1417
+ else if (positionals[0] === 'room-member-model' && input) result = await room_member_model(home, input);
1418
+ else if (positionals[0] === 'room-profiles' && input) result = await room_profiles(home, input);
1419
+ else if (positionals[0] === 'room-restore' && input) result = await restore_room(home, input);
1420
+ else if (positionals[0] === 'room-fork' && input) result = await fork_room(home, input);
1421
+ else if (positionals[0] === 'pick-directory') result = await pick_directory();
1422
+ else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
1423
+ else if (positionals[0] === 'admin-account' && input) result = (await import('./admin_account.mjs')).save_admin_account(home, input);
1424
+ else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
1425
+ else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
1426
+ else throw new Error(HELP);
1427
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1428
+ }
1429
+
1430
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
1431
+ const args = process.argv.slice(2);
1432
+ const stdin = args.includes('--stdin') ? readFileSync(0, 'utf8') : undefined;
1433
+ launch_main(args.filter(arg => arg !== '--stdin'), stdin).catch(error => {
1434
+ const failure = args.includes('--browser') ? JSON.stringify({ error: error.message, launch: error.launch || { stage: 'not_started' } }) : error.message;
1435
+ process.stderr.write(`${failure}\n`); process.exitCode = 1;
1436
+ });
1437
+ }