ai_co_discuss 0.2.13 → 0.2.15

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,664 +1,728 @@
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, readFileSync, statSync } from 'node:fs';
5
- import { dirname, isAbsolute, join, resolve } from 'node:path';
6
- import { setTimeout as sleep } from 'node:timers/promises';
7
- import { pathToFileURL } from 'node:url';
8
- import { parseArgs } from 'node:util';
9
-
10
- import { atomic_json } from './discussion_adapters.mjs';
11
- import { software_models } from './discussion_models.mjs';
12
- import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
13
- import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
14
-
15
- const CLIENTS = { Codex: ['codex_cli', 'codex'], Claude: ['claude'], Gemini: ['antigravity_cli', 'antigravity'] };
16
- // A platform is a type; `Claude-评审` is another Claude instance with its own session, model and effort.
17
- const INSTANCE_PATTERN = /^(Codex|Claude|Gemini|DeepSeek|Qwen)(?:-[0-9A-Za-z_一-鿿]{1,16})?$/;
18
-
19
- /** The platform of an AI instance name, or null. */
20
- export function platform_of(name) { return typeof name === 'string' ? INSTANCE_PATTERN.exec(name)?.[1] ?? null : null; }
21
-
22
- /** The first unused instance name of a platform: the platform itself, then platform-2, platform-3, …. */
23
- export function next_instance(platform, taken) {
24
- if (!taken.has(platform)) return platform;
25
- for (let number = 2; ; number++) if (!taken.has(`${platform}-${number}`)) return `${platform}-${number}`;
26
- }
27
-
28
- /** Supported local clients of an instance's platform. */
29
- function clients_of(name) { return CLIENTS[platform_of(name)]; }
30
- // Reasoning effort is a separate CLI argument only for Codex and Claude; agy encodes it in the model name.
31
- const EFFORT_LEVELS = { codex_cli: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], claude: ['low', 'medium', 'high', 'xhigh', 'max'] };
32
- const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id'];
33
- const MAX_MINUTES = 1440;
34
- const CONFIG_APPLY_TIMEOUT_MS = 6000;
35
- const CONFIG_APPLY_POLL_MS = 100;
36
- const HELP = `用法:codiscuss launch settings [--file FILE]
37
- codiscuss launch start --file FILE [--dry-run]
38
- codiscuss launch room-config --file FILE
39
- codiscuss launch room-models --file FILE
40
- codiscuss launch models --file FILE
41
- codiscuss launch room-member --file FILE
42
- codiscuss launch pick-directory
43
-
44
- settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
45
- start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
46
- room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
47
- room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论。
48
- models 按 adapter、executable 读取本机 CLI 的模型目录,不启动讨论或生成回答。
49
- pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
50
- room-member 为运行中的自由房间邀请或请回一位成员;须提供 site、room_id、token、action(add/rejoin)、name。
51
- JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
52
- 或 room_id(已有房间)、participants(本次软件覆盖配置)。
53
- conversation 还须 current_session_id,当前会话接手首个主持任务。
54
- 已有受保护房间使用 access_file。路径、凭据只保存在本机。
55
- 详见 docs/runner.md 的“两种讨论入口与启动配置”。
56
- `;
57
-
58
- // Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
59
- // shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
60
- const WINDOWS_PICKER = [
61
- 'Add-Type -AssemblyName System.Windows.Forms',
62
- '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
63
- '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
64
- '$owner.Show(); $owner.Activate()',
65
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
66
- 'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
67
- '$owner.Close()',
68
- ].join('; ');
69
- const PICKERS = {
70
- win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
71
- darwin: ['osascript', ['-e', 'POSIX path of (choose folder with prompt "选择项目目录")']],
72
- linux: ['zenity', ['--file-selection', '--directory', '--title=选择项目目录']],
73
- };
74
-
75
- /** Show the platform's own folder dialog on this computer; cancelling resolves without a path. */
76
- export function pick_directory({ platform = process.platform, spawn_process = spawn } = {}) {
77
- const picker = PICKERS[platform];
78
- if (!picker) return Promise.reject(new Error('此系统不支持目录选择窗口,请直接输入完整路径'));
79
- return new Promise((resolve_result, reject) => {
80
- const child = spawn_process(picker[0], picker[1], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
81
- let output = '';
82
- child.stdout.setEncoding('utf8');
83
- child.stdout.on('data', chunk => { output += chunk; });
84
- child.once('error', () => reject(new Error('无法打开目录选择窗口,请直接输入完整路径')));
85
- child.once('close', code => {
86
- const path = output.trim();
87
- resolve_result(code === 0 && isAbsolute(path) ? { path } : { cancelled: true });
88
- });
89
- });
90
- }
91
-
92
- /** Some CLIs take seconds to list models; reuse a good list for a day unless the executable changes or the user retries. */
93
- const MODEL_CACHE_MS = 24 * 60 * 60 * 1000;
94
-
95
- /** Return the software catalog, served from the private service home when still valid. */
96
- export async function cached_software_models(home, profile, { refresh = false, query = software_models } = {}) {
97
- const path = join(home, 'model-catalogs.json');
98
- let stamp = '';
99
- try { const file = statSync(profile.executable); stamp = `${file.size}:${file.mtimeMs}`; } catch {}
100
- const key = JSON.stringify([profile.adapter, profile.executable || '', stamp]);
101
- let cache = {};
102
- try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
103
- const hit = cache[key];
104
- if (!refresh && hit && Date.now() - hit.at < MODEL_CACHE_MS) return hit.result;
105
- const result = await query(profile);
106
- if (!result.error && result.models.length) {
107
- const prefix = JSON.stringify([profile.adapter, profile.executable || '']).slice(0, -1);
108
- for (const old of Object.keys(cache)) if (old.startsWith(prefix)) delete cache[old];
109
- atomic_json(path, { ...cache, [key]: { at: Date.now(), result } });
110
- }
111
- return result;
112
- }
113
-
114
- /** Read private defaults without selecting a global room or identity. */
115
- export function launch_settings(home) {
116
- const path = join(home, 'launch-settings.json');
117
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { participants: {} };
118
- }
119
-
120
- /** Read only the registered room's model choices, never machine defaults or connection secrets. */
121
- export function room_models(home, raw) {
122
- const input = launch_input({ ...raw, entry: 'web' });
123
- if (!input.room_id) throw new Error('读取本场模型须提供 room_id');
124
- const paths = read_registry(home).filter(path => {
125
- const identity = registered_identity(path);
126
- return identity?.site === input.site && identity.room === input.room_id;
127
- });
128
- if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
129
- const profiles = paths.length ? configuration_snapshot(paths[0]).config.participants : {};
130
- const participants = Object.fromEntries(Object.entries(profiles).map(([name, profile]) => {
131
- const cli = ['codex_cli', 'claude', 'antigravity_cli'].includes(profile.adapter);
132
- return [name, { adapter: profile.adapter, model: cli ? profile.model || '' : '',
133
- effort: EFFORT_LEVELS[profile.adapter] ? profile.effort || '' : '' }];
134
- }));
135
- return { room_id: input.room_id, participants };
136
- }
137
-
138
- /** Save only reusable client settings; room credentials and session bindings are never defaults. */
139
- export function save_launch_settings(home, input) {
140
- const participants = {};
141
- for (const [name, profile] of Object.entries(input.participants || {})) {
142
- if (platform_of(name) !== name) throw new Error(`本机默认配置按平台保存;${name} 的模型与强度只用于本场讨论`);
143
- validate_profile(name, profile);
144
- participants[name] = profile;
145
- }
146
- const settings = { participants };
147
- mkdirSync(home, { recursive: true, mode: 0o700 });
148
- atomic_json(join(home, 'launch-settings.json'), settings);
149
- return settings;
150
- }
151
-
152
- /** Reject malformed client fields before saving defaults or creating any discussion. */
153
- function validate_profile(name, profile, binding = false) {
154
- if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
155
- if (profile.adapter && !clients_of(name).includes(profile.adapter)) throw new Error(`${name} 的客户端不匹配`);
156
- // Session bindings and project write access apply to one launch only, never to machine defaults.
157
- const fields = binding ? [...PROFILE_FIELDS, 'session_id', 'project_access'] : PROFILE_FIELDS;
158
- if (Object.keys(profile).some(key => !fields.includes(key))) throw new Error('软件配置包含不支持的字段;默认配置不能保存房间或会话绑定');
159
- for (const [key, value] of Object.entries(profile)) {
160
- if (key === 'allowed_tools') {
161
- if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${name}.allowed_tools 必须为工具名称数组`);
162
- } else if (typeof value !== 'string') throw new Error(`${name}.${key} 必须为文本`);
163
- if (['executable', 'cwd', 'ipc_directory'].includes(key) && value && !isAbsolute(value)) throw new Error(`${name}.${key} 必须为完整绝对路径`);
164
- // The model is passed as one CLI argument, so it must not look like a flag or contain spaces.
165
- 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`);
166
- }
167
- if (profile.effort && !EFFORT_LEVELS[profile.adapter]?.includes(profile.effort)) {
168
- throw new Error(EFFORT_LEVELS[profile.adapter] ? `${name}.effort 只能是 ${EFFORT_LEVELS[profile.adapter].join(' / ')}`
169
- : `${name} 的思考档位不能单独设置;agy 请选择带档位的模型名,App 在软件内选择`);
170
- }
171
- }
172
-
173
- /** Send one request without following redirects that could disclose the room credential. */
174
- async function site_request(site, path, token, body) {
175
- const response = await fetch(`${site}${path}`, { method: body === undefined ? 'GET' : 'POST',
176
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
177
- body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
178
- const result = await response.json();
179
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
180
- return result;
181
- }
182
-
183
- /** Require an explicit website, launch entry and stable creation identity. */
184
- function launch_input(input) {
185
- const value = structuredClone(input);
186
- const url = new URL(value.site);
187
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('site 必须为 HTTP(S) 根地址');
188
- value.site = url.origin;
189
- if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
190
- if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
191
- if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
192
- if (value.project_access && !['read', 'write'].includes(value.project_access)) throw new Error('project_access 只能为 read 或 write');
193
- if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
194
- if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
195
- if (value.room?.mode === 'free') {
196
- // P1: a free room is launched once, at creation, from the web page.
197
- if (value.entry !== 'web' || value.room_id) throw new Error('自由协作房间目前只能在网页创建时启动');
198
- const members = value.room.members;
199
- const names = members?.map(member => typeof member === 'string' ? member : member.name);
200
- if (!Array.isArray(members) || !members.length || new Set(names).size !== names.length || names.some(name => !clients_of(name))) {
201
- throw new Error('自由协作房间须选择至少 1 位支持自动接入的 AI 成员,且不能重复');
202
- }
203
- }
204
- if (value.access_file) {
205
- const access = JSON.parse(readFileSync(resolve(value.access_file), 'utf8'));
206
- if (access.site?.replace(/\/$/, '') !== value.site || access.room !== value.room_id) throw new Error('access_file 与本次站点或房间不符');
207
- value.token = access.token;
208
- }
209
- return value;
210
- }
211
-
212
- /** Merge machine defaults and explicit room overrides, preserving the selected participants. */
213
- function launch_config(home, input, room, directory, options) {
214
- if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
215
- if (!clients_of(room.host)) throw new Error('请选择支持全自动讨论的 AI 主持人');
216
- if (!room.experts || room.experts[room.host] !== 'cli') throw new Error('请将 AI 主持人的参与方式设为本地读写');
217
- const online = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
218
- if (online.length) throw new Error(`全自动无法接入在线专家:${online.join('、')}。请调整参与方式或改用手动推进`);
219
- const defaults = options.defaults || launch_settings(home).participants;
220
- const participants = {};
221
- for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
222
- participants[name] = participant_profile(name, defaults, input, name === room.host);
223
- }
224
- const members = launch_members(input, Object.keys(participants), options);
225
- return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members,
226
- cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
227
- data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
228
- }
229
-
230
- /** Free rooms add one private credential per member, kept with the room configuration for retries. */
231
- function free_launch_config(home, input, room, directory, options) {
232
- const defaults = options.defaults || launch_settings(home).participants;
233
- const names = room.members.map(member => typeof member === 'string' ? member : member.name);
234
- const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
235
- const members = launch_members(input, names, options);
236
- return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members,
237
- cwd: input.cwd, project_access: input.project_access || undefined,
238
- data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
239
- }
240
-
241
- /** Preserve caller-generated member credentials; journal newly generated ones before creation. */
242
- function launch_members(input, names, options) {
243
- return Object.fromEntries(names.map(name => {
244
- const supplied = input.room?.members?.find(member => member?.name === name);
245
- return [name, { token: supplied?.token || options.members?.[name]?.token || `${randomUUID()}${randomUUID()}` }];
246
- }));
247
- }
248
-
249
- /** Merge one participant's machine defaults with this launch's overrides and check its software. */
250
- function participant_profile(name, defaults, input, current_host) {
251
- const platform = platform_of(name);
252
- // An extra instance starts from its platform's settings but never from another instance's bound session.
253
- const { session_id: ignored_session, ...inherited } = name === platform ? {} : input.participants?.[platform] || {};
254
- const profile = { ...defaults[platform], ...(name === platform ? {} : defaults[name]), ...inherited, ...input.participants?.[name] };
255
- for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
256
- validate_profile(name, profile, true);
257
- if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
258
- if (profile.adapter !== 'antigravity' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
259
- || (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
260
- const cwd = profile.cwd || input.cwd;
261
- if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
262
- if (input.entry === 'conversation' && current_host) profile.session_id = input.current_session_id;
263
- // agy runs only in sessions this tool creates, so its native discussion project always applies.
264
- if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name}:agy 不能绑定已有会话或从当前 agy 对话主持全自动讨论;请从网页或其他软件发起,agy 会由讨论服务新建会话`);
265
- return profile;
266
- }
267
-
268
- /** Find the one registered configuration of a free room after proving the owner's credential. */
269
- async function free_room_context(home, raw) {
270
- const input = launch_input({ ...raw, entry: 'web' });
271
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('管理成员须提供房间编号和房间控制凭据');
272
- await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
273
- const paths = read_registry(home).filter(path => {
274
- const identity = registered_identity(path);
275
- return identity?.site === input.site && identity.room === input.room_id;
276
- });
277
- if (paths.length !== 1) throw new Error('未找到本房间唯一的已登记配置,请核对本机接入服务');
278
- const config = configuration_snapshot(paths[0]).config;
279
- if (config.mode !== 'free') throw new Error('只有自由协作房间可以在运行中增减成员');
280
- return { input, path: paths[0], config };
281
- }
282
-
283
- /** Write a manual member's private credential next to the room configuration, never into a receipt. */
284
- function manual_access(path, input, name, token) {
285
- const access_path = join(dirname(path), `manual-${name}.access.json`);
286
- atomic_json(access_path, { site: input.site, room: input.room_id, token });
287
- return access_path;
288
- }
289
-
290
- /**
291
- * Invite (add) or bring back (rejoin) one member of a running free room.
292
- * The local configuration is written before the website call, so a retry reuses the same credential.
293
- */
294
- export async function room_member(home, raw, locked = false) {
295
- const { input, path } = await free_room_context(home, raw);
296
- const { action, name } = input;
297
- const access = input.access || 'runner';
298
- if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !['runner', 'manual'].includes(access)) {
299
- throw new Error('action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual');
300
- }
301
- const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
302
- try {
303
- const config = configuration_snapshot(path).config;
304
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
305
- if (action === 'add' && input.new_instance) {
306
- // A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
307
- const taken = new Set([...room.free.members.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
308
- return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true);
309
- }
310
- const current = room.free.members.find(member => member.name === name);
311
- 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 字符');
312
- if (current?.status === 'active' && input.member_token && config.members?.[name]?.token === input.member_token) {
313
- const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token });
314
- return { room_id: input.room_id, member: name, access: current.access, free: view, access_file: null };
315
- }
316
- if (action === 'add' && !current && config.participants[name] && !locked) throw new Error(`${name} 曾在本房间使用过并绑定了会话;请换一个名称`);
317
- if (action === 'rejoin' && current?.status !== 'away') throw new Error(`${name} 不是离开状态,无需请回`);
318
- if (action === 'add' && current) throw new Error(`${name} 已是本房间成员;离开的成员请用请回`);
319
- const manual = action === 'rejoin' ? current.access === 'manual' : access === 'manual';
320
- const pending = input.retry_token && config.members?.[name]?.pending === input.retry_token;
321
- const token = input.member_token || (pending ? config.members[name].token : `${randomUUID()}${randomUUID()}`);
322
- let profile = config.participants[name];
323
- if (!manual) {
324
- if (action === 'add') profile = participant_profile(name, launch_settings(home).participants, { ...input, cwd: input.cwd || config.cwd }, false);
325
- if (!profile) throw new Error(`${name} 没有本机接入配置;请移除后重新邀请`);
326
- const next = { ...config, participants: { ...config.participants, [name]: profile },
327
- members: { ...config.members, [name]: { token, pending: input.retry_token || '' } } };
328
- validate_config(next, dirname(path));
329
- atomic_json(path, next);
330
- }
331
- const access_file = manual ? manual_access(path, input, name, token) : null;
332
- const body = action === 'add'
333
- ? { name, adapter: manual ? '' : profile.adapter, token, access, session_mode: profile?.session_id ? 'borrowed' : 'new' }
334
- : { action: 'active', token };
335
- const view = await site_request(input.site, action === 'add' ? `/api/rooms/${input.room_id}/members` : `/api/rooms/${input.room_id}/members/${current.id}`,
336
- input.token, body);
337
- return { room_id: input.room_id, member: name, access: manual ? 'manual' : 'runner', free: view, access_file };
338
- } finally { release(); }
339
- }
340
-
341
- /** Stable signatures make changed retry requests explicit without putting credentials in receipts. */
342
- function signature(value) {
343
- if (Array.isArray(value)) return `[${value.map(signature).join(',')}]`;
344
- if (value && typeof value === 'object') return `{${Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${signature(value[key])}`).join(',')}}`;
345
- return JSON.stringify(value);
346
- }
347
-
348
- /** Inherit the topic duration unless overridden; zero leaves the run without a time limit. */
349
- function launch_minutes(input, room) {
350
- const minutes = Number(input.minutes ?? room.limits?.max_minutes ?? 0);
351
- if (!Number.isFinite(minutes) || minutes < 0 || minutes > MAX_MINUTES) throw new Error('讨论总时长或 minutes 须在 [0,1440],0 表示不限时长');
352
- return minutes;
353
- }
354
-
355
- /** Refuse a second registry owner before changing the website runtime. */
356
- function check_registration(home, config, path) {
357
- for (const registered of read_registry(home)) {
358
- if (resolve(registered) === path || !existsSync(registered)) continue;
359
- const other = registered_identity(registered);
360
- if (other?.site === config.site && other.room === config.room) throw new Error('房间已使用另一份接入配置登记;请沿用原配置,不重复接入');
361
- }
362
- }
363
-
364
- /** Start once; a lost start response is reconciled from the persisted expected generation. */
365
- async function enable_run(config, record, persist, minutes) {
366
- let runtime = await api(config, '/runtime');
367
- if (!record.generation && record.expected_generation === runtime.generation && runtime.revision === record.start_revision && runtime.status === 'running') {
368
- record.generation = runtime.generation;
369
- persist();
370
- }
371
- if (record.generation) {
372
- if (runtime.generation !== record.generation || runtime.status !== 'running' || runtime.expired) throw new Error('这次启动已暂停、停止或过期;请在原房间核对并明确继续');
373
- return runtime;
374
- }
375
- if (runtime.status !== 'ready' || runtime.expired) throw new Error('房间已运行、暂停、停止或过期;请在原房间核对,不会重新启动');
376
- if (!record.start_revision) { record.start_revision = runtime.revision + 1; persist(); }
377
- if (runtime.revision + 1 !== record.start_revision) throw new Error('启动期间房间状态已变化,请核对原房间');
378
- record.expected_generation = runtime.generation + 1;
379
- persist();
380
- try {
381
- runtime = await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: runtime.revision,
382
- deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
383
- } catch (error) {
384
- const latest = await api(config, '/runtime');
385
- if (latest.generation !== record.expected_generation || latest.status !== 'running') throw error;
386
- runtime = latest;
387
- }
388
- record.generation = runtime.generation;
389
- persist();
390
- return runtime;
391
- }
392
-
393
- /** Reserve the first host task for the calling conversation without sending it a duplicate prompt. */
394
- async function current_host_task(config, record, persist, room) {
395
- const page = await api(config, '/events?after=0');
396
- const event = record.event_id ? page.events.find(item => item.id === record.event_id)
397
- : page.events.filter(item => item.generation === record.generation && item.role === 'host' && item.participant === room.host && item.status === 'pending').at(-1);
398
- if (!event) throw new Error('没有可由当前会话接手的主持任务;请核对原房间');
399
- record.event_id = event.id;
400
- record.execution_token ||= randomUUID();
401
- persist();
402
- if (event.status === 'pending') await api(config, `/events/${event.id}`, { action: 'reserve', execution_token: record.execution_token });
403
- else await api(config, `/events/${event.id}`, { action: 'view' }, record.execution_token);
404
- const store = new DiscussionStore(config.directory);
405
- try {
406
- store.context(config);
407
- const runtime = await api(config, '/runtime');
408
- store.scope(config, runtime.run_id);
409
- store.receive(page);
410
- const session_id = config.participants[room.host].session_id;
411
- store.bind(room.host, session_id);
412
- const task = task_files(config, event, record.execution_token, { session_id });
413
- store.record(event.id, { status: 'accepted', token: record.execution_token, sent_at: Date.now() });
414
- return { event_id: event.id, access_file: task.access_path, task_file: join(config.directory, `${task.id}.task.json`) };
415
- } finally { store.close(); }
416
- }
417
-
418
- /** Return launch evidence and paths, never raw control or execution tokens. */
419
- async function launch_receipt(config, record, config_path) {
420
- const room = await api(config, '');
421
- return { entry: record.entry, room, url: `${config.site}/?room=${config.room}`, config_path,
422
- status: room.mode === 'free' ? 'free' : room.runtime.status, current_task: record.current_task || null };
423
- }
424
-
425
- /** Read a saved launch without creating state during validation or preview. */
426
- function read_launch(directory) {
427
- const path = join(directory, 'launch.json');
428
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
429
- }
430
-
431
- /** Find the registered room only after proving the caller's original control credential. */
432
- async function room_config_context(home, raw) {
433
- const input = launch_input({ ...raw, entry: 'web' });
434
- if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('读取本场配置须提供房间编号和原控制凭据');
435
- const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
436
- if (!room.runtime?.protected) throw new Error('本场配置修正仅支持具有原控制凭据的受保护房间');
437
- await verify_control({ site: input.site, room: input.room_id, token: input.token }, room);
438
- if (room.archived || room.runtime.mode !== 'automatic' || room.runtime.status !== 'paused' || room.phase === 'concluded') throw new Error('请先暂停本场讨论,再修正软件路径');
439
- const paths = read_registry(home).filter(path => {
440
- const identity = registered_identity(path);
441
- return identity?.site === input.site && identity.room === input.room_id;
442
- });
443
- if (paths.length !== 1) throw new Error('未找到本场唯一的已登记配置,请核对原接入服务');
444
- const path = paths[0];
445
- const journal = read_launch(dirname(path));
446
- if (journal && !journal.finished) throw new Error('本次启动尚未完成,请先核对原启动待办');
447
- return { input, room, path, ...configuration_snapshot(path) };
448
- }
449
-
450
- /** Expose only editable connection paths, never credentials or unrelated room settings. */
451
- function connection_view(context) {
452
- return { room_id: context.room.id, runtime_revision: context.room.runtime.revision, version: context.version,
453
- participants: Object.fromEntries(Object.entries(context.config.participants).map(([name, profile]) =>
454
- [name, Object.fromEntries(['adapter', ...CONNECTION_FIELDS].filter(field => profile[field] !== undefined).map(field => [field, profile[field]]))])) };
455
- }
456
-
457
- /** Switch only Codex's execution entrypoint, keeping its actual original conversation. */
458
- function switch_codex_adapter(context, name, profile, adapter) {
459
- if (!CODEX_ADAPTERS.includes(profile.adapter) || !CODEX_ADAPTERS.includes(adapter)) throw new Error('本场仅支持 Codex App / CLI 切换');
460
- const current = validate_config(context.config, dirname(context.path));
461
- let session = profile.session_id;
462
- if (existsSync(join(current.directory, 'runtime.sqlite3'))) {
463
- const store = new DiscussionStore(current.directory);
464
- try {
465
- const binding = store.binding(name).session_id;
466
- if (session && binding && session !== binding) throw new Error('原会话绑定不一致,请先核对原会话');
467
- session = binding || session;
468
- } finally { store.close(); }
469
- }
470
- if (!session) throw new Error('尚无实际 Codex 会话,不能切换;请先完成首次接入');
471
- profile.adapter = adapter;
472
- profile.session_id = session;
473
- }
474
-
475
- /** Validate paused-room paths and Codex entrypoints without replacing the discussion or session. */
476
- function corrected_configuration(context) {
477
- const config = structuredClone(context.config);
478
- for (const [name, patch] of Object.entries(context.input.participants)) {
479
- const profile = config.participants[name];
480
- const field = profile?.adapter === 'antigravity' ? 'ipc_directory' : 'executable';
481
- const fields = CODEX_ADAPTERS.includes(profile?.adapter) ? [field, 'adapter'] : [field];
482
- if (!profile || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(key => !fields.includes(key))) throw new Error('本场修正仅支持软件路径、Sidecar 目录及 Codex App / CLI;参与者、项目及会话保持不变');
483
- validate_profile(name, patch);
484
- if (patch.adapter && patch.adapter !== profile.adapter) switch_codex_adapter(context, name, profile, patch.adapter);
485
- const path = patch[field] ?? profile[field];
486
- if (typeof path !== 'string' || !path || !existsSync(path)
487
- || (field === 'executable' ? !statSync(path).isFile() || (process.platform === 'win32' && !/\.exe$/i.test(path)) : !statSync(path).isDirectory())) throw new Error(`${name}:请提供有效的软件文件或连接目录`);
488
- profile[field] = path;
489
- }
490
- validate_config(config, dirname(context.path));
491
- return config;
492
- }
493
-
494
- /** Wait for the resident worker to read this version before suggesting a manual restart. */
495
- async function configuration_applied(home, path, version) {
496
- for (let waited = 0; waited < CONFIG_APPLY_TIMEOUT_MS; waited += CONFIG_APPLY_POLL_MS) {
497
- const pid = service_pid(home);
498
- if (!pid) return false;
499
- const status_path = join(home, 'status.json');
500
- const status = existsSync(status_path) ? JSON.parse(readFileSync(status_path, 'utf8')) : {};
501
- const worker = status.rooms?.find(item => resolve(item.config) === resolve(path));
502
- if (status.pid === pid && worker?.config_version === version && worker.status === 'paused') return true;
503
- await sleep(CONFIG_APPLY_POLL_MS);
504
- }
505
- return false;
506
- }
507
-
508
- /** Read or save a paused room's connection paths without changing its runtime or global defaults. */
509
- export async function room_configuration(home, raw, options = {}) {
510
- if (raw.participants !== undefined && !options.local_guard) return save_room_connection(raw);
511
- let context = await room_config_context(home, raw);
512
- if (raw.participants === undefined) return connection_view(context);
513
- if (!raw.participants || typeof raw.participants !== 'object' || Array.isArray(raw.participants)) throw new Error('participants 须为本场软件路径对象');
514
- const release = acquire_lock(join(dirname(context.path), 'configuration-edit'));
515
- try {
516
- context = await room_config_context(home, raw);
517
- const config = corrected_configuration(context);
518
- if (raw.version !== context.version || raw.runtime_revision !== context.room.runtime.revision) throw new Error('房间状态或配置已变化,请重新读取本场配置后核对');
519
- atomic_json(context.path, config);
520
- const saved = { ...context, ...configuration_snapshot(context.path) };
521
- return { ...connection_view(saved), applied: await configuration_applied(home, context.path, saved.version) };
522
- } finally { release(); }
523
- }
524
-
525
- /** Serialize file replacement with runtime changes through the local website's database guard. */
526
- async function save_room_connection(raw) {
527
- const input = launch_input({ ...raw, entry: 'web' });
528
- const url = new URL(input.site);
529
- if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('本场路径修正须通过已启用本机启动的回环网站');
530
- const response = await fetch(`${input.site}/api/local/room-config`, { method: 'POST',
531
- headers: { 'Content-Type': 'application/json', 'X-CoDiscuss-Local': '1' },
532
- body: JSON.stringify(input), redirect: 'error', signal: AbortSignal.timeout(15000) });
533
- const result = await response.json();
534
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '本场配置保存失败'}`);
535
- return result;
536
- }
537
-
538
- /** Prove dispatcher access before a protected room is started or a corrected credential is saved. */
539
- async function verify_control(config, room) {
540
- if (room.runtime?.protected) await api({ ...config, room: room.id }, '/events?after=0');
541
- }
542
-
543
- /** Pin resolved defaults, while comparing retries against only the caller's explicit request. */
544
- async function prepare_launch(home, input, directory, options = {}) {
545
- const previous = read_launch(directory);
546
- if (options.browser_request && (typeof input.token !== 'string' || !input.token.trim())) throw new Error('网页启动须携带本次原始控制凭据,不能认领其他启动记录');
547
- const { token: ignored_token, access_file: ignored_access, ...request } = input;
548
- const request_signature = signature(request);
549
- if (previous?.request_signature && previous.request_signature !== request_signature) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
550
- const legacy = previous && !previous.request_signature ? JSON.parse(previous.signature) : null;
551
- const config_path = join(directory, 'config.json');
552
- const saved = previous?.config || (previous && existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : null);
553
- const token = input.token || previous?.token || randomUUID();
554
- const room_id = input.room_id || previous?.room_id || (saved?.room !== 'preview' ? saved?.room : null);
555
- if (options.browser_request && previous && token !== previous.token && !room_id) throw new Error('尚未确认原房间,不能替换本次控制凭据');
556
- const room = room_id ? await site_request(input.site, `/api/rooms/${room_id}`, token) : input.room;
557
- if (room.runtime?.protected && !input.token && !previous) throw new Error('已有房间需要原接入配置的 access_file');
558
- if (!previous && room.runtime && room.runtime.status !== 'ready') throw new Error('房间已启动过;请沿用原配置核对或从原进度看板明确继续');
559
- const config = previous?.request_signature ? { ...structuredClone(saved), token }
560
- : launch_config(home, input, room, directory, { token, defaults: saved?.participants || legacy?.participants, members: saved?.members });
561
- config.draft_directory = service_drafts(home, config.directory);
562
- if (previous?.request_signature && !previous.finished) validate_config(config, directory);
563
- const minutes = previous?.minutes ?? legacy?.minutes ?? launch_minutes(input, room);
564
- 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 });
565
- if (legacy && previous.signature !== draft) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
566
- await verify_control(config, room);
567
- return { input, config, room, minutes, draft, request_signature, directory, previous };
568
- }
569
-
570
- /** Launch a room through either entry, using a durable journal for safe retries. */
571
- export async function launch_discussion(home, raw, options = {}) {
572
- assert_shared_home(home);
573
- const input = launch_input(raw);
574
- const key = createHash('sha256').update(`${input.site}/${input.room_id || input.room.submission_id}`).digest('hex');
575
- const directory = join(home, 'launches', key);
576
- let release;
577
- try {
578
- const preview = await prepare_launch(home, input, directory, options);
579
- if (options.dry_run) return { dry_run: true, entry: input.entry, site: input.site, room_id: input.room_id || null,
580
- host: preview.room.host, participants: preview.config.participants, cwd: preview.config.cwd || null,
581
- discussion_project: preview.config.discussion_project || null, minutes: preview.minutes };
582
- release = acquire_lock(directory);
583
- return await perform_launch(home, await prepare_launch(home, input, directory, options), options);
584
- } catch (error) {
585
- error.launch ||= { stage: existsSync(join(directory, 'launch.json')) ? 'unconfirmed' : 'not_started' };
586
- throw error;
587
- } finally { release?.(); }
588
- }
589
-
590
- /** Persist preparation before writes, hand off the current host when needed, then register once. */
591
- async function perform_launch(home, context, options) {
592
- const { input, config, minutes, draft, request_signature, directory, previous } = context;
593
- const record = previous || { token: config.token, signature: draft, request_signature, config, minutes, entry: input.entry };
594
- const persist = () => atomic_json(join(directory, 'launch.json'), record);
595
- if (!previous) persist();
596
- const progress = { stage: 'unconfirmed' };
597
- try {
598
- let room = context.room;
599
- const free = config.mode === 'free';
600
- if (!input.room_id) room = await site_request(input.site, '/api/rooms', config.token, {
601
- ...input.room, ...(free ? {} : { runtime_mode: 'automatic' }),
602
- members: Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
603
- token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
604
- });
605
- config.room = room.id;
606
- await verify_control(config, room);
607
- Object.assign(progress, { stage: 'created', room_id: room.id });
608
- Object.assign(record, { token: config.token, request_signature, config, minutes, room_id: room.id });
609
- if (!record.start_revision && room.runtime.status === 'ready') record.start_revision = room.runtime.revision + 1;
610
- persist();
611
- const config_path = join(directory, 'config.json');
612
- check_registration(home, config, config_path);
613
- if (record.finished) {
614
- const saved = existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : config;
615
- if (saved.token !== config.token || !existsSync(config_path)) atomic_json(config_path, { ...saved, token: config.token });
616
- return launch_receipt(config, record, config_path);
617
- }
618
- atomic_json(config_path, config);
619
- const service = await (options.start_service || start_service)(home);
620
- if (!service?.running) throw new Error('讨论服务未就绪,未开始本次讨论');
621
- // Free rooms have no discussion run: registering the room is what lets the service deliver requests.
622
- if (!free) await enable_run(config, record, persist, minutes);
623
- progress.stage = 'started';
624
- if (input.entry === 'conversation') record.current_task = await current_host_task(config, record, persist, room);
625
- register_room(home, config_path);
626
- record.finished = true;
627
- persist();
628
- return launch_receipt(config, record, config_path);
629
- } catch (error) { error.launch = progress; throw error; }
630
- }
631
-
632
- /** CLI and local HTTP bridge use the same commands and configuration validation. */
633
- export async function launch_main(args = process.argv.slice(2), input_text) {
634
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
635
- file: { type: 'string' }, 'dry-run': { type: 'boolean' }, browser: { type: 'boolean' },
636
- 'configuration-locked': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
637
- } });
638
- if (values.help || !positionals.length) return process.stdout.write(HELP);
639
- const input = input_text === undefined ? (values.file ? JSON.parse(readFileSync(values.file, 'utf8').replace(/^\uFEFF/, '')) : null) : JSON.parse(input_text);
640
- const home = service_home();
641
- assert_shared_home(home);
642
- let result;
643
- if (positionals[0] === 'settings') result = input ? save_launch_settings(home, input) : launch_settings(home);
644
- else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
645
- else if (positionals[0] === 'models' && input) {
646
- const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
647
- result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable }, { refresh: input.refresh === true });
648
- }
649
- else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
650
- else if (positionals[0] === 'pick-directory') result = await pick_directory();
651
- else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
652
- else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
653
- else throw new Error(HELP);
654
- process.stdout.write(`${JSON.stringify(result)}\n`);
655
- }
656
-
657
- if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
658
- const args = process.argv.slice(2);
659
- const stdin = args.includes('--stdin') ? readFileSync(0, 'utf8') : undefined;
660
- launch_main(args.filter(arg => arg !== '--stdin'), stdin).catch(error => {
661
- const failure = args.includes('--browser') ? JSON.stringify({ error: error.message, launch: error.launch || { stage: 'not_started' } }) : error.message;
662
- process.stderr.write(`${failure}\n`); process.exitCode = 1;
663
- });
664
- }
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, resolve } from 'node:path';
7
+ import { setTimeout as sleep } from 'node:timers/promises';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { parseArgs } from 'node:util';
10
+
11
+ import { atomic_json } from './discussion_adapters.mjs';
12
+ import { software_models } from './discussion_models.mjs';
13
+ import { API_PROVIDERS, api_models, save_api_key, validate_api_profile } from './discussion_api.mjs';
14
+ import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
15
+ import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
16
+
17
+ const CLIENTS = { Codex: ['codex_cli', 'codex', 'api'], Claude: ['claude', 'api'], Gemini: ['antigravity_cli', 'antigravity', 'api'], DeepSeek: ['api'], Qwen: ['api'], Grok: ['api'] };
18
+ // A platform is a type; `Claude-评审` is another Claude instance with its own session, model and effort.
19
+ const INSTANCE_PATTERN = /^(Codex|Claude|Gemini|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_一-鿿]{1,16})?$/;
20
+
21
+ /** The platform of an AI instance name, or null. */
22
+ export function platform_of(name) { return typeof name === 'string' ? INSTANCE_PATTERN.exec(name)?.[1] ?? null : null; }
23
+
24
+ /** The first unused instance name of a platform: the platform itself, then platform-2, platform-3, …. */
25
+ export function next_instance(platform, taken) {
26
+ if (!taken.has(platform)) return platform;
27
+ for (let number = 2; ; number++) if (!taken.has(`${platform}-${number}`)) return `${platform}-${number}`;
28
+ }
29
+
30
+ /** Supported local clients of an instance's platform. */
31
+ function clients_of(name) { return CLIENTS[platform_of(name)]; }
32
+ // Reasoning effort is a separate CLI argument only for Codex and Claude; agy encodes it in the model name.
33
+ const EFFORT_LEVELS = { codex_cli: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], claude: ['low', 'medium', 'high', 'xhigh', 'max'] };
34
+ 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'];
35
+ const MAX_MINUTES = 1440;
36
+ const SESSION_ID_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
37
+ // Reading events after this id proves the owner credential without transferring any.
38
+ const MAX_EVENT_ID = 2147483647;
39
+ const CONFIG_APPLY_TIMEOUT_MS = 6000;
40
+ const CONFIG_APPLY_POLL_MS = 100;
41
+ const HELP = `用法:codiscuss launch settings [--file FILE]
42
+ codiscuss launch start --file FILE [--dry-run]
43
+ codiscuss launch room-config --file FILE
44
+ codiscuss launch room-models --file FILE
45
+ codiscuss launch models --file FILE
46
+ codiscuss launch api-key --file FILE
47
+ codiscuss launch room-member --file FILE
48
+ codiscuss launch pick-directory
49
+
50
+ settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
51
+ start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
52
+ room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
53
+ room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论。
54
+ models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
55
+ api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
56
+ pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
57
+ room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
58
+ JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
59
+ 或 room_id(已有房间)、participants(本次软件覆盖配置)。
60
+ conversation 还须 current_session_id,当前会话接手首个主持任务。
61
+ 已有受保护房间使用 access_file。路径、凭据只保存在本机。
62
+ 详见 docs/runner.md 的“两种讨论入口与启动配置”。
63
+ `;
64
+
65
+ // Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
66
+ // shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
67
+ const WINDOWS_PICKER = [
68
+ 'Add-Type -AssemblyName System.Windows.Forms',
69
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
70
+ '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
71
+ '$owner.Show(); $owner.Activate()',
72
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
73
+ 'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
74
+ '$owner.Close()',
75
+ ].join('; ');
76
+ const PICKERS = {
77
+ win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
78
+ darwin: ['osascript', ['-e', 'POSIX path of (choose folder with prompt "选择项目目录")']],
79
+ linux: ['zenity', ['--file-selection', '--directory', '--title=选择项目目录']],
80
+ };
81
+
82
+ /** Show the platform's own folder dialog on this computer; cancelling resolves without a path. */
83
+ export function pick_directory({ platform = process.platform, spawn_process = spawn } = {}) {
84
+ const picker = PICKERS[platform];
85
+ if (!picker) return Promise.reject(new Error('此系统不支持目录选择窗口,请直接输入完整路径'));
86
+ return new Promise((resolve_result, reject) => {
87
+ const child = spawn_process(picker[0], picker[1], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
88
+ let output = '';
89
+ child.stdout.setEncoding('utf8');
90
+ child.stdout.on('data', chunk => { output += chunk; });
91
+ child.once('error', () => reject(new Error('无法打开目录选择窗口,请直接输入完整路径')));
92
+ child.once('close', code => {
93
+ const path = output.trim();
94
+ resolve_result(code === 0 && isAbsolute(path) ? { path } : { cancelled: true });
95
+ });
96
+ });
97
+ }
98
+
99
+ /** Some CLIs take seconds to list models; reuse a good list for a day unless the executable changes or the user retries. */
100
+ const MODEL_CACHE_MS = 24 * 60 * 60 * 1000;
101
+
102
+ /** Return the software catalog, served from the private service home when still valid. */
103
+ export async function cached_software_models(home, profile, { refresh = false, query = software_models } = {}) {
104
+ const path = join(home, 'model-catalogs.json');
105
+ let stamp = '';
106
+ try { const file = statSync(profile.executable); stamp = `${file.size}:${file.mtimeMs}`; } catch {}
107
+ const key = JSON.stringify([profile.adapter, profile.executable || '', stamp]);
108
+ let cache = {};
109
+ try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch {}
110
+ const hit = cache[key];
111
+ if (!refresh && hit && Date.now() - hit.at < MODEL_CACHE_MS) return hit.result;
112
+ const result = await query(profile);
113
+ if (!result.error && result.models.length) {
114
+ const prefix = JSON.stringify([profile.adapter, profile.executable || '']).slice(0, -1);
115
+ for (const old of Object.keys(cache)) if (old.startsWith(prefix)) delete cache[old];
116
+ atomic_json(path, { ...cache, [key]: { at: Date.now(), result } });
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /** Read private defaults without selecting a global room or identity. */
122
+ export function launch_settings(home) {
123
+ const path = join(home, 'launch-settings.json');
124
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { participants: {} };
125
+ }
126
+
127
+ /** Read only the registered room's model choices, never machine defaults or connection secrets. */
128
+ export function room_models(home, raw) {
129
+ const input = launch_input({ ...raw, entry: 'web' });
130
+ if (!input.room_id) throw new Error('读取本场模型须提供 room_id');
131
+ const paths = read_registry(home).filter(path => {
132
+ const identity = registered_identity(path);
133
+ return identity?.site === input.site && identity.room === input.room_id;
134
+ });
135
+ if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
136
+ const profiles = paths.length ? configuration_snapshot(paths[0]).config.participants : {};
137
+ const participants = Object.fromEntries(Object.entries(profiles).map(([name, profile]) => {
138
+ const cli = ['codex_cli', 'claude', 'antigravity_cli', 'api'].includes(profile.adapter);
139
+ return [name, { adapter: profile.adapter, model: cli ? profile.model || '' : '',
140
+ effort: EFFORT_LEVELS[profile.adapter] ? profile.effort || '' : '' }];
141
+ }));
142
+ return { room_id: input.room_id, participants };
143
+ }
144
+
145
+ /** Save only reusable client settings; room credentials and session bindings are never defaults. */
146
+ export function save_launch_settings(home, input) {
147
+ const participants = {};
148
+ for (const [name, profile] of Object.entries(input.participants || {})) {
149
+ if (platform_of(name) !== name) throw new Error(`本机默认配置按平台保存;${name} 的模型与强度只用于本场讨论`);
150
+ validate_profile(name, profile);
151
+ participants[name] = profile;
152
+ }
153
+ const settings = { participants };
154
+ mkdirSync(home, { recursive: true, mode: 0o700 });
155
+ atomic_json(join(home, 'launch-settings.json'), settings);
156
+ return settings;
157
+ }
158
+
159
+ /** Reject malformed client fields before saving defaults or creating any discussion. */
160
+ function validate_profile(name, profile, binding = false) {
161
+ if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
162
+ if (profile.adapter && !clients_of(name).includes(profile.adapter)) throw new Error(`${name} 的客户端不匹配`);
163
+ // Session bindings and project write access apply to one launch only, never to machine defaults.
164
+ const fields = binding ? [...PROFILE_FIELDS, 'session_id', 'project_access'] : PROFILE_FIELDS;
165
+ if (Object.keys(profile).some(key => !fields.includes(key))) throw new Error('软件配置包含不支持的字段;默认配置不能保存房间或会话绑定');
166
+ for (const [key, value] of Object.entries(profile)) {
167
+ if (key === 'allowed_tools') {
168
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${name}.allowed_tools 必须为工具名称数组`);
169
+ } else if (typeof value !== 'string') throw new Error(`${name}.${key} 必须为文本`);
170
+ if (['executable', 'cwd', 'ipc_directory'].includes(key) && value && !isAbsolute(value)) throw new Error(`${name}.${key} 必须为完整绝对路径`);
171
+ // The model is passed as one CLI argument, so it must not look like a flag or contain spaces.
172
+ 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`);
173
+ }
174
+ if (profile.adapter === 'api') validate_api_profile(profile);
175
+ if (profile.effort && profile.adapter !== 'api' && !EFFORT_LEVELS[profile.adapter]?.includes(profile.effort)) {
176
+ throw new Error(EFFORT_LEVELS[profile.adapter] ? `${name}.effort 只能是 ${EFFORT_LEVELS[profile.adapter].join(' / ')}`
177
+ : `${name} 的思考档位不能单独设置;agy 请选择带档位的模型名,App 在软件内选择`);
178
+ }
179
+ }
180
+
181
+ /** Send one request without following redirects that could disclose the room credential. */
182
+ async function site_request(site, path, token, body) {
183
+ const response = await fetch(`${site}${path}`, { method: body === undefined ? 'GET' : 'POST',
184
+ headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
185
+ body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
186
+ const result = await response.json();
187
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
188
+ return result;
189
+ }
190
+
191
+ /** Require an explicit website, launch entry and stable creation identity. */
192
+ function launch_input(input) {
193
+ const value = structuredClone(input);
194
+ const url = new URL(value.site);
195
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('site 必须为 HTTP(S) 根地址');
196
+ value.site = url.origin;
197
+ if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
198
+ if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
199
+ if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
200
+ if (value.project_access && !['read', 'write'].includes(value.project_access)) throw new Error('project_access 只能为 read 或 write');
201
+ if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
202
+ if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
203
+ if (value.room?.mode === 'free') {
204
+ // P1: a free room is launched once, at creation, from the web page.
205
+ if (value.entry !== 'web' || value.room_id) throw new Error('自由协作房间目前只能在网页创建时启动');
206
+ const members = value.room.members;
207
+ const names = members?.map(member => typeof member === 'string' ? member : member.name);
208
+ const woken = Array.isArray(members) ? members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name) : [];
209
+ if (!woken.length || new Set(names).size !== names.length || woken.some(name => !clients_of(name))) {
210
+ throw new Error('自由协作房间须选择至少 1 位支持自动接入的 AI 成员,且不能重复');
211
+ }
212
+ }
213
+ if (value.access_file) {
214
+ const access = JSON.parse(readFileSync(resolve(value.access_file), 'utf8'));
215
+ if (access.site?.replace(/\/$/, '') !== value.site || access.room !== value.room_id) throw new Error('access_file 与本次站点或房间不符');
216
+ value.token = access.token;
217
+ }
218
+ return value;
219
+ }
220
+
221
+ /** Merge machine defaults and explicit room overrides, preserving the selected participants. */
222
+ function launch_config(home, input, room, directory, options) {
223
+ if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
224
+ if (!clients_of(room.host)) throw new Error('请选择支持全自动讨论的 AI 主持人');
225
+ if (!room.experts || room.experts[room.host] !== 'cli') throw new Error('请将 AI 主持人的参与方式设为本地读写');
226
+ const online = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
227
+ if (online.length) throw new Error(`全自动无法接入在线专家:${online.join('、')}。请调整参与方式或改用手动推进`);
228
+ const defaults = options.defaults || launch_settings(home).participants;
229
+ const participants = {};
230
+ for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
231
+ participants[name] = participant_profile(name, defaults, input, name === room.host);
232
+ }
233
+ const members = launch_members(input, Object.keys(participants), options);
234
+ return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members, api_home: home,
235
+ cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
236
+ data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
237
+ }
238
+
239
+ /** A free-room member the local service wakes; manual and online members join without local configuration. */
240
+ function woken_member(member) { return typeof member === 'string' || !member.access || member.access === 'runner'; }
241
+
242
+ /** Free rooms add one private credential per woken member, kept with the room configuration for retries. */
243
+ function free_launch_config(home, input, room, directory, options) {
244
+ const defaults = options.defaults || launch_settings(home).participants;
245
+ const names = room.members.filter(woken_member).map(member => typeof member === 'string' ? member : member.name);
246
+ const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
247
+ const members = launch_members(input, names, options);
248
+ return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
249
+ cwd: input.cwd, project_access: input.project_access || undefined,
250
+ data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
251
+ }
252
+
253
+ /** Preserve caller-generated member credentials; journal newly generated ones before creation. */
254
+ function launch_members(input, names, options) {
255
+ return Object.fromEntries(names.map(name => {
256
+ const supplied = input.room?.members?.find(member => member?.name === name);
257
+ return [name, { token: supplied?.token || options.members?.[name]?.token || `${randomUUID()}${randomUUID()}` }];
258
+ }));
259
+ }
260
+
261
+ /** Merge one participant's machine defaults with this launch's overrides and check its software. */
262
+ function participant_profile(name, defaults, input, current_host) {
263
+ const platform = platform_of(name);
264
+ // An extra instance starts from its platform's settings but never from another instance's bound session.
265
+ const { session_id: ignored_session, ...inherited } = name === platform ? {} : input.participants?.[platform] || {};
266
+ const profile = { ...defaults[platform], ...(name === platform ? {} : defaults[name]), ...inherited, ...input.participants?.[name] };
267
+ for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
268
+ validate_profile(name, profile, true);
269
+ if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
270
+ if (profile.adapter === 'api') {
271
+ if (input.entry === 'conversation' && current_host) throw new Error('API 主持人请从网页启动;当前软件会话主持须选择对应 CLI / App');
272
+ return profile;
273
+ }
274
+ if (profile.adapter !== 'antigravity' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
275
+ || (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
276
+ const cwd = profile.cwd || input.cwd;
277
+ if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
278
+ if (input.entry === 'conversation' && current_host) profile.session_id = input.current_session_id;
279
+ // agy runs only in sessions this tool creates, so its native discussion project always applies.
280
+ if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name}:agy 不能绑定已有会话或从当前 agy 对话主持全自动讨论;请从网页或其他软件发起,agy 会由讨论服务新建会话`);
281
+ return profile;
282
+ }
283
+
284
+ /** List every file below a directory, or nothing when the software has never stored a session there. */
285
+ function session_files(directory) {
286
+ return existsSync(directory) ? readdirSync(directory, { recursive: true }).map(path => join(directory, String(path))) : [];
287
+ }
288
+
289
+ /** Compare directories as the operating system resolves them (Windows short names and case). */
290
+ function same_directory(left, right) {
291
+ const real = path => { try { return realpathSync.native(path); } catch { return resolve(path); } };
292
+ const [a, b] = [real(left), real(right)];
293
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
294
+ }
295
+
296
+ /**
297
+ * Prove an invited existing conversation is on this machine before it can hold a turn, so a mistyped ID
298
+ * fails here instead of pausing the discussion later. Claude stores sessions per directory and resumes only
299
+ * from the original one, so its cwd follows the session; a different room project is refused.
300
+ */
301
+ export function borrowed_session(name, profile, room_cwd, env = process.env) {
302
+ const id = profile.session_id;
303
+ if (!SESSION_ID_PATTERN.test(id)) throw new Error(`${name}:会话 ID 格式不正确`);
304
+ if (CODEX_ADAPTERS.includes(profile.adapter)) {
305
+ const sessions = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
306
+ if (!session_files(sessions).some(path => path.endsWith(`-${id}.jsonl`))) throw new Error(`${name}:本机找不到 Codex 会话 ${id},请核对会话 ID`);
307
+ return profile;
308
+ }
309
+ if (profile.adapter !== 'claude') throw new Error(`${name}:只有 Codex 与 Claude 可以接入已有会话`);
310
+ const projects = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
311
+ const file = session_files(projects).find(path => path.endsWith(`${id}.jsonl`) && dirname(dirname(path)) === projects);
312
+ if (!file) throw new Error(`${name}:本机找不到 Claude 会话 ${id},请核对会话 ID`);
313
+ const cwd = readFileSync(file, 'utf8').split('\n').map(line => { try { return JSON.parse(line).cwd; } catch { return undefined; } }).find(Boolean);
314
+ if (!cwd) throw new Error(`${name}:Claude 会话 ${id} 没有记录原工作目录,无法续接`);
315
+ const wanted = profile.cwd || room_cwd;
316
+ if (wanted && !same_directory(wanted, cwd)) throw new Error(`${name}:这个 Claude 会话属于 ${cwd},只能在原目录续接;请让项目目录与它一致,或留空会话 ID 新建会话`);
317
+ return { ...profile, cwd: wanted || cwd };
318
+ }
319
+
320
+ /** Find the one registered configuration of a room after proving the owner's credential. */
321
+ async function member_room_context(home, raw) {
322
+ const input = launch_input({ ...raw, entry: 'web' });
323
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('管理成员须提供房间编号和房间控制凭据');
324
+ const paths = read_registry(home).filter(path => {
325
+ const identity = registered_identity(path);
326
+ return identity?.site === input.site && identity.room === input.room_id;
327
+ });
328
+ if (paths.length !== 1) throw new Error('未找到本房间唯一的已登记配置,请核对本机接入服务');
329
+ const config = configuration_snapshot(paths[0]).config;
330
+ // Both reads need the owner credential; a discussion reads past its last event so nothing is transferred.
331
+ await site_request(input.site, `/api/rooms/${input.room_id}/${config.mode === 'free' ? 'inbox' : `events?after=${MAX_EVENT_ID}`}`, input.token);
332
+ return { input, path: paths[0], config };
333
+ }
334
+
335
+ /** Write a manual member's private credential next to the room configuration, never into a receipt. */
336
+ function manual_access(path, input, name, token) {
337
+ const access_path = join(dirname(path), `manual-${name}.access.json`);
338
+ atomic_json(access_path, { site: input.site, room: input.room_id, token });
339
+ return access_path;
340
+ }
341
+
342
+ /**
343
+ * Invite (add) or bring back (rejoin) one member of a running free room or discussion.
344
+ * The local configuration is written before the website call, so a retry reuses the same credential.
345
+ * Discussions only admit members the local service wakes.
346
+ */
347
+ export async function room_member(home, raw, locked = false) {
348
+ const { input, path, config: registered } = await member_room_context(home, raw);
349
+ const { action, name } = input;
350
+ const access = input.access || 'runner';
351
+ const free = registered.mode === 'free';
352
+ if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !(free ? ['runner', 'manual'] : ['runner']).includes(access)) {
353
+ throw new Error(free ? 'action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual' : '讨论房间只接受本机唤醒的成员;action 须为 add/rejoin');
354
+ }
355
+ const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
356
+ try {
357
+ const config = configuration_snapshot(path).config;
358
+ config.api_home = home;
359
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
360
+ const roster = free ? room.free.members : room.members;
361
+ if (action === 'add' && input.new_instance) {
362
+ // A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
363
+ const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
364
+ return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true);
365
+ }
366
+ const current = roster.find(member => member.name === name);
367
+ 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 字符');
368
+ if (current?.status === 'active' && input.member_token && config.members?.[name]?.token === input.member_token) {
369
+ const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token });
370
+ return { room_id: input.room_id, member: name, access: current.access, [free ? 'free' : 'room']: view, access_file: null };
371
+ }
372
+ if (action === 'add' && !current && config.participants[name] && !locked) throw new Error(`${name} 曾在本房间使用过并绑定了会话;请换一个名称`);
373
+ if (action === 'rejoin' && current?.status !== 'away') throw new Error(`${name} 不是离开状态,无需请回`);
374
+ if (action === 'add' && current) throw new Error(`${name} 已是本房间成员;离开的成员请用请回`);
375
+ const manual = action === 'rejoin' ? current.access === 'manual' : access === 'manual';
376
+ const pending = input.retry_token && config.members?.[name]?.pending === input.retry_token;
377
+ const token = input.member_token || (pending ? config.members[name].token : `${randomUUID()}${randomUUID()}`);
378
+ let profile = config.participants[name];
379
+ if (!manual) {
380
+ if (action === 'add') {
381
+ profile = participant_profile(name, launch_settings(home).participants, { ...input, cwd: input.cwd || config.cwd }, false);
382
+ if (profile.session_id) profile = borrowed_session(name, profile, input.cwd || config.cwd);
383
+ }
384
+ if (!profile) throw new Error(`${name} 没有本机接入配置;请移除后重新邀请`);
385
+ const next = { ...config, participants: { ...config.participants, [name]: profile },
386
+ members: { ...config.members, [name]: { token, pending: input.retry_token || '' } } };
387
+ validate_config(next, dirname(path));
388
+ atomic_json(path, next);
389
+ }
390
+ const access_file = manual ? manual_access(path, input, name, token) : null;
391
+ const body = action === 'add'
392
+ ? { name, adapter: manual ? '' : profile.adapter, token, access, session_mode: profile?.session_id ? 'borrowed' : 'new' }
393
+ : { action: 'active', token };
394
+ const view = await site_request(input.site, action === 'add' ? `/api/rooms/${input.room_id}/members` : `/api/rooms/${input.room_id}/members/${current.id}`,
395
+ input.token, body);
396
+ return { room_id: input.room_id, member: name, access: manual ? 'manual' : 'runner', [free ? 'free' : 'room']: view, access_file };
397
+ } finally { release(); }
398
+ }
399
+
400
+ /** Stable signatures make changed retry requests explicit without putting credentials in receipts. */
401
+ function signature(value) {
402
+ if (Array.isArray(value)) return `[${value.map(signature).join(',')}]`;
403
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${signature(value[key])}`).join(',')}}`;
404
+ return JSON.stringify(value);
405
+ }
406
+
407
+ /** Inherit the topic duration unless overridden; zero leaves the run without a time limit. */
408
+ function launch_minutes(input, room) {
409
+ const minutes = Number(input.minutes ?? room.limits?.max_minutes ?? 0);
410
+ if (!Number.isFinite(minutes) || minutes < 0 || minutes > MAX_MINUTES) throw new Error('讨论总时长或 minutes 须在 [0,1440],0 表示不限时长');
411
+ return minutes;
412
+ }
413
+
414
+ /** Refuse a second registry owner before changing the website runtime. */
415
+ function check_registration(home, config, path) {
416
+ for (const registered of read_registry(home)) {
417
+ if (resolve(registered) === path || !existsSync(registered)) continue;
418
+ const other = registered_identity(registered);
419
+ if (other?.site === config.site && other.room === config.room) throw new Error('房间已使用另一份接入配置登记;请沿用原配置,不重复接入');
420
+ }
421
+ }
422
+
423
+ /** Start once; a lost start response is reconciled from the persisted expected generation. */
424
+ async function enable_run(config, record, persist, minutes) {
425
+ let runtime = await api(config, '/runtime');
426
+ if (!record.generation && record.expected_generation === runtime.generation && runtime.revision === record.start_revision && runtime.status === 'running') {
427
+ record.generation = runtime.generation;
428
+ persist();
429
+ }
430
+ if (record.generation) {
431
+ if (runtime.generation !== record.generation || runtime.status !== 'running' || runtime.expired) throw new Error('这次启动已暂停、停止或过期;请在原房间核对并明确继续');
432
+ return runtime;
433
+ }
434
+ if (runtime.status !== 'ready' || runtime.expired) throw new Error('房间已运行、暂停、停止或过期;请在原房间核对,不会重新启动');
435
+ if (!record.start_revision) { record.start_revision = runtime.revision + 1; persist(); }
436
+ if (runtime.revision + 1 !== record.start_revision) throw new Error('启动期间房间状态已变化,请核对原房间');
437
+ record.expected_generation = runtime.generation + 1;
438
+ persist();
439
+ try {
440
+ runtime = await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: runtime.revision,
441
+ deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
442
+ } catch (error) {
443
+ const latest = await api(config, '/runtime');
444
+ if (latest.generation !== record.expected_generation || latest.status !== 'running') throw error;
445
+ runtime = latest;
446
+ }
447
+ record.generation = runtime.generation;
448
+ persist();
449
+ return runtime;
450
+ }
451
+
452
+ /** Reserve the first host task for the calling conversation without sending it a duplicate prompt. */
453
+ async function current_host_task(config, record, persist, room) {
454
+ const page = await api(config, '/events?after=0');
455
+ const event = record.event_id ? page.events.find(item => item.id === record.event_id)
456
+ : page.events.filter(item => item.generation === record.generation && item.role === 'host' && item.participant === room.host && item.status === 'pending').at(-1);
457
+ if (!event) throw new Error('没有可由当前会话接手的主持任务;请核对原房间');
458
+ record.event_id = event.id;
459
+ record.execution_token ||= randomUUID();
460
+ persist();
461
+ if (event.status === 'pending') await api(config, `/events/${event.id}`, { action: 'reserve', execution_token: record.execution_token });
462
+ else await api(config, `/events/${event.id}`, { action: 'view' }, record.execution_token);
463
+ const store = new DiscussionStore(config.directory);
464
+ try {
465
+ store.context(config);
466
+ const runtime = await api(config, '/runtime');
467
+ store.scope(config, runtime.run_id);
468
+ store.receive(page);
469
+ const session_id = config.participants[room.host].session_id;
470
+ store.bind(room.host, session_id);
471
+ const task = task_files(config, event, record.execution_token, { session_id });
472
+ store.record(event.id, { status: 'accepted', token: record.execution_token, sent_at: Date.now() });
473
+ return { event_id: event.id, access_file: task.access_path, task_file: join(config.directory, `${task.id}.task.json`) };
474
+ } finally { store.close(); }
475
+ }
476
+
477
+ /** Return launch evidence and paths, never raw control or execution tokens. */
478
+ async function launch_receipt(config, record, config_path) {
479
+ const room = await api(config, '');
480
+ return { entry: record.entry, room, url: `${config.site}/?room=${config.room}`, config_path,
481
+ status: room.mode === 'free' ? 'free' : room.runtime.status, current_task: record.current_task || null };
482
+ }
483
+
484
+ /** Read a saved launch without creating state during validation or preview. */
485
+ function read_launch(directory) {
486
+ const path = join(directory, 'launch.json');
487
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
488
+ }
489
+
490
+ /** Find the registered room only after proving the caller's original control credential. */
491
+ async function room_config_context(home, raw) {
492
+ const input = launch_input({ ...raw, entry: 'web' });
493
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim()) throw new Error('读取本场配置须提供房间编号和原控制凭据');
494
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
495
+ if (!room.runtime?.protected) throw new Error('本场配置修正仅支持具有原控制凭据的受保护房间');
496
+ await verify_control({ site: input.site, room: input.room_id, token: input.token }, room);
497
+ if (room.archived || room.runtime.mode !== 'automatic' || room.runtime.status !== 'paused' || room.phase === 'concluded') throw new Error('请先暂停本场讨论,再修正软件路径');
498
+ const paths = read_registry(home).filter(path => {
499
+ const identity = registered_identity(path);
500
+ return identity?.site === input.site && identity.room === input.room_id;
501
+ });
502
+ if (paths.length !== 1) throw new Error('未找到本场唯一的已登记配置,请核对原接入服务');
503
+ const path = paths[0];
504
+ const journal = read_launch(dirname(path));
505
+ if (journal && !journal.finished) throw new Error('本次启动尚未完成,请先核对原启动待办');
506
+ return { input, room, path, ...configuration_snapshot(path) };
507
+ }
508
+
509
+ /** Expose only editable connection paths, never credentials or unrelated room settings. */
510
+ function connection_view(context) {
511
+ return { room_id: context.room.id, runtime_revision: context.room.runtime.revision, version: context.version,
512
+ participants: Object.fromEntries(Object.entries(context.config.participants).map(([name, profile]) =>
513
+ [name, Object.fromEntries(['adapter', ...CONNECTION_FIELDS].filter(field => profile[field] !== undefined).map(field => [field, profile[field]]))])) };
514
+ }
515
+
516
+ /** Switch only Codex's execution entrypoint, keeping its actual original conversation. */
517
+ function switch_codex_adapter(context, name, profile, adapter) {
518
+ if (!CODEX_ADAPTERS.includes(profile.adapter) || !CODEX_ADAPTERS.includes(adapter)) throw new Error('本场仅支持 Codex App / CLI 切换');
519
+ const current = validate_config(context.config, dirname(context.path));
520
+ let session = profile.session_id;
521
+ if (existsSync(join(current.directory, 'runtime.sqlite3'))) {
522
+ const store = new DiscussionStore(current.directory);
523
+ try {
524
+ const binding = store.binding(name).session_id;
525
+ if (session && binding && session !== binding) throw new Error('原会话绑定不一致,请先核对原会话');
526
+ session = binding || session;
527
+ } finally { store.close(); }
528
+ }
529
+ if (!session) throw new Error('尚无实际 Codex 会话,不能切换;请先完成首次接入');
530
+ profile.adapter = adapter;
531
+ profile.session_id = session;
532
+ }
533
+
534
+ /** Validate paused-room paths and Codex entrypoints without replacing the discussion or session. */
535
+ function corrected_configuration(context) {
536
+ const config = structuredClone(context.config);
537
+ for (const [name, patch] of Object.entries(context.input.participants)) {
538
+ const profile = config.participants[name];
539
+ const field = profile?.adapter === 'antigravity' ? 'ipc_directory' : 'executable';
540
+ const fields = CODEX_ADAPTERS.includes(profile?.adapter) ? [field, 'adapter'] : [field];
541
+ if (!profile || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(key => !fields.includes(key))) throw new Error('本场修正仅支持软件路径、Sidecar 目录及 Codex App / CLI;参与者、项目及会话保持不变');
542
+ validate_profile(name, patch);
543
+ if (patch.adapter && patch.adapter !== profile.adapter) switch_codex_adapter(context, name, profile, patch.adapter);
544
+ const path = patch[field] ?? profile[field];
545
+ if (typeof path !== 'string' || !path || !existsSync(path)
546
+ || (field === 'executable' ? !statSync(path).isFile() || (process.platform === 'win32' && !/\.exe$/i.test(path)) : !statSync(path).isDirectory())) throw new Error(`${name}:请提供有效的软件文件或连接目录`);
547
+ profile[field] = path;
548
+ }
549
+ validate_config(config, dirname(context.path));
550
+ return config;
551
+ }
552
+
553
+ /** Wait for the resident worker to read this version before suggesting a manual restart. */
554
+ async function configuration_applied(home, path, version) {
555
+ for (let waited = 0; waited < CONFIG_APPLY_TIMEOUT_MS; waited += CONFIG_APPLY_POLL_MS) {
556
+ const pid = service_pid(home);
557
+ if (!pid) return false;
558
+ const status_path = join(home, 'status.json');
559
+ const status = existsSync(status_path) ? JSON.parse(readFileSync(status_path, 'utf8')) : {};
560
+ const worker = status.rooms?.find(item => resolve(item.config) === resolve(path));
561
+ if (status.pid === pid && worker?.config_version === version && worker.status === 'paused') return true;
562
+ await sleep(CONFIG_APPLY_POLL_MS);
563
+ }
564
+ return false;
565
+ }
566
+
567
+ /** Read or save a paused room's connection paths without changing its runtime or global defaults. */
568
+ export async function room_configuration(home, raw, options = {}) {
569
+ if (raw.participants !== undefined && !options.local_guard) return save_room_connection(raw);
570
+ let context = await room_config_context(home, raw);
571
+ if (raw.participants === undefined) return connection_view(context);
572
+ if (!raw.participants || typeof raw.participants !== 'object' || Array.isArray(raw.participants)) throw new Error('participants 须为本场软件路径对象');
573
+ const release = acquire_lock(join(dirname(context.path), 'configuration-edit'));
574
+ try {
575
+ context = await room_config_context(home, raw);
576
+ const config = corrected_configuration(context);
577
+ if (raw.version !== context.version || raw.runtime_revision !== context.room.runtime.revision) throw new Error('房间状态或配置已变化,请重新读取本场配置后核对');
578
+ atomic_json(context.path, config);
579
+ const saved = { ...context, ...configuration_snapshot(context.path) };
580
+ return { ...connection_view(saved), applied: await configuration_applied(home, context.path, saved.version) };
581
+ } finally { release(); }
582
+ }
583
+
584
+ /** Serialize file replacement with runtime changes through the local website's database guard. */
585
+ async function save_room_connection(raw) {
586
+ const input = launch_input({ ...raw, entry: 'web' });
587
+ const url = new URL(input.site);
588
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('本场路径修正须通过已启用本机启动的回环网站');
589
+ const response = await fetch(`${input.site}/api/local/room-config`, { method: 'POST',
590
+ headers: { 'Content-Type': 'application/json', 'X-CoDiscuss-Local': '1' },
591
+ body: JSON.stringify(input), redirect: 'error', signal: AbortSignal.timeout(15000) });
592
+ const result = await response.json();
593
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '本场配置保存失败'}`);
594
+ return result;
595
+ }
596
+
597
+ /** Prove dispatcher access before a protected room is started or a corrected credential is saved. */
598
+ async function verify_control(config, room) {
599
+ if (room.runtime?.protected) await api({ ...config, room: room.id }, '/events?after=0');
600
+ }
601
+
602
+ /** Pin resolved defaults, while comparing retries against only the caller's explicit request. */
603
+ async function prepare_launch(home, input, directory, options = {}) {
604
+ const previous = read_launch(directory);
605
+ if (options.browser_request && (typeof input.token !== 'string' || !input.token.trim())) throw new Error('网页启动须携带本次原始控制凭据,不能认领其他启动记录');
606
+ const { token: ignored_token, access_file: ignored_access, ...request } = input;
607
+ const request_signature = signature(request);
608
+ if (previous?.request_signature && previous.request_signature !== request_signature) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
609
+ const legacy = previous && !previous.request_signature ? JSON.parse(previous.signature) : null;
610
+ const config_path = join(directory, 'config.json');
611
+ const saved = previous?.config || (previous && existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : null);
612
+ const token = input.token || previous?.token || randomUUID();
613
+ const room_id = input.room_id || previous?.room_id || (saved?.room !== 'preview' ? saved?.room : null);
614
+ if (options.browser_request && previous && token !== previous.token && !room_id) throw new Error('尚未确认原房间,不能替换本次控制凭据');
615
+ const room = room_id ? await site_request(input.site, `/api/rooms/${room_id}`, token) : input.room;
616
+ if (room.runtime?.protected && !input.token && !previous) throw new Error('已有房间需要原接入配置的 access_file');
617
+ if (!previous && room.runtime && room.runtime.status !== 'ready') throw new Error('房间已启动过;请沿用原配置核对或从原进度看板明确继续');
618
+ const config = previous?.request_signature ? { ...structuredClone(saved), token }
619
+ : launch_config(home, input, room, directory, { token, defaults: saved?.participants || legacy?.participants, members: saved?.members });
620
+ config.draft_directory = service_drafts(home, config.directory);
621
+ if (previous?.request_signature && !previous.finished) validate_config(config, directory);
622
+ const minutes = previous?.minutes ?? legacy?.minutes ?? launch_minutes(input, room);
623
+ 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 });
624
+ if (legacy && previous.signature !== draft) throw new Error('启动配置与原请求不同;同一次重试须沿用原配置,新讨论请使用新提交编号');
625
+ await verify_control(config, room);
626
+ return { input, config, room, minutes, draft, request_signature, directory, previous };
627
+ }
628
+
629
+ /** Launch a room through either entry, using a durable journal for safe retries. */
630
+ export async function launch_discussion(home, raw, options = {}) {
631
+ assert_shared_home(home);
632
+ const input = launch_input(raw);
633
+ const key = createHash('sha256').update(`${input.site}/${input.room_id || input.room.submission_id}`).digest('hex');
634
+ const directory = join(home, 'launches', key);
635
+ let release;
636
+ try {
637
+ const preview = await prepare_launch(home, input, directory, options);
638
+ if (options.dry_run) return { dry_run: true, entry: input.entry, site: input.site, room_id: input.room_id || null,
639
+ host: preview.room.host, participants: preview.config.participants, cwd: preview.config.cwd || null,
640
+ discussion_project: preview.config.discussion_project || null, minutes: preview.minutes };
641
+ release = acquire_lock(directory);
642
+ return await perform_launch(home, await prepare_launch(home, input, directory, options), options);
643
+ } catch (error) {
644
+ error.launch ||= { stage: existsSync(join(directory, 'launch.json')) ? 'unconfirmed' : 'not_started' };
645
+ throw error;
646
+ } finally { release?.(); }
647
+ }
648
+
649
+ /** Persist preparation before writes, hand off the current host when needed, then register once. */
650
+ async function perform_launch(home, context, options) {
651
+ const { input, config, minutes, draft, request_signature, directory, previous } = context;
652
+ const record = previous || { token: config.token, signature: draft, request_signature, config, minutes, entry: input.entry };
653
+ const persist = () => atomic_json(join(directory, 'launch.json'), record);
654
+ if (!previous) persist();
655
+ const progress = { stage: 'unconfirmed' };
656
+ try {
657
+ let room = context.room;
658
+ const free = config.mode === 'free';
659
+ if (!input.room_id) room = await site_request(input.site, '/api/rooms', config.token, {
660
+ ...input.room, ...(free ? {} : { runtime_mode: 'automatic' }),
661
+ members: [...Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
662
+ token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
663
+ // Manual and online members keep the credentials the browser generated; nothing local is configured for them.
664
+ ...(free ? input.room.members.filter(member => !woken_member(member))
665
+ .map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' })) : [])],
666
+ });
667
+ config.room = room.id;
668
+ await verify_control(config, room);
669
+ Object.assign(progress, { stage: 'created', room_id: room.id });
670
+ Object.assign(record, { token: config.token, request_signature, config, minutes, room_id: room.id });
671
+ if (!record.start_revision && room.runtime.status === 'ready') record.start_revision = room.runtime.revision + 1;
672
+ persist();
673
+ const config_path = join(directory, 'config.json');
674
+ check_registration(home, config, config_path);
675
+ if (record.finished) {
676
+ const saved = existsSync(config_path) ? JSON.parse(readFileSync(config_path, 'utf8')) : config;
677
+ if (saved.token !== config.token || !existsSync(config_path)) atomic_json(config_path, { ...saved, token: config.token });
678
+ return launch_receipt(config, record, config_path);
679
+ }
680
+ atomic_json(config_path, config);
681
+ const service = await (options.start_service || start_service)(home);
682
+ if (!service?.running) throw new Error('讨论服务未就绪,未开始本次讨论');
683
+ // Free rooms have no discussion run: registering the room is what lets the service deliver requests.
684
+ if (!free) await enable_run(config, record, persist, minutes);
685
+ progress.stage = 'started';
686
+ if (input.entry === 'conversation') record.current_task = await current_host_task(config, record, persist, room);
687
+ register_room(home, config_path);
688
+ record.finished = true;
689
+ persist();
690
+ return launch_receipt(config, record, config_path);
691
+ } catch (error) { error.launch = progress; throw error; }
692
+ }
693
+
694
+ /** CLI and local HTTP bridge use the same commands and configuration validation. */
695
+ export async function launch_main(args = process.argv.slice(2), input_text) {
696
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
697
+ file: { type: 'string' }, 'dry-run': { type: 'boolean' }, browser: { type: 'boolean' },
698
+ 'configuration-locked': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
699
+ } });
700
+ if (values.help || !positionals.length) return process.stdout.write(HELP);
701
+ const input = input_text === undefined ? (values.file ? JSON.parse(readFileSync(values.file, 'utf8').replace(/^\uFEFF/, '')) : null) : JSON.parse(input_text);
702
+ const home = service_home();
703
+ assert_shared_home(home);
704
+ let result;
705
+ if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS };
706
+ else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
707
+ else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
708
+ else if (positionals[0] === 'models' && input?.adapter === 'api') result = await api_models(home, input);
709
+ else if (positionals[0] === 'models' && input) {
710
+ const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
711
+ result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable }, { refresh: input.refresh === true });
712
+ }
713
+ else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
714
+ else if (positionals[0] === 'pick-directory') result = await pick_directory();
715
+ else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
716
+ else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
717
+ else throw new Error(HELP);
718
+ process.stdout.write(`${JSON.stringify(result)}\n`);
719
+ }
720
+
721
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
722
+ const args = process.argv.slice(2);
723
+ const stdin = args.includes('--stdin') ? readFileSync(0, 'utf8') : undefined;
724
+ launch_main(args.filter(arg => arg !== '--stdin'), stdin).catch(error => {
725
+ const failure = args.includes('--browser') ? JSON.stringify({ error: error.message, launch: error.launch || { stage: 'not_started' } }) : error.message;
726
+ process.stderr.write(`${failure}\n`); process.exitCode = 1;
727
+ });
728
+ }