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,394 +1,395 @@
1
- /** Resident local service that keeps every registered automatic room consumed; no model API orchestration. */
2
- import { spawn, spawnSync } from 'node:child_process';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
- import { dirname, join, resolve } from 'node:path';
7
- import { DatabaseSync } from 'node:sqlite';
8
- import { fileURLToPath, pathToFileURL } from 'node:url';
9
- import { parseArgs } from 'node:util';
10
- import { setTimeout as sleep } from 'node:timers/promises';
11
- import { atomic_json } from './discussion_adapters.mjs';
12
- import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, pause_if_running, service_drafts, validate_config } from './discussion_runner.mjs';
13
- import { FreeRoomRunner } from './free_runner.mjs';
14
-
15
- const POLL_MS = 2000;
16
- const START_WAIT_MS = 5000;
17
- const STOP_WAIT_MS = 20_000;
18
- const REGISTRY_WAIT_MS = 5000;
19
- const SCRIPT = fileURLToPath(import.meta.url);
20
- const REPO_ROOT = dirname(dirname(SCRIPT));
21
- const SHORTCUT_NAME = 'AI CoDiscuss 讨论服务.lnk';
22
- const TICK_STATES = { undefined: 'running', waiting: 'paused', false: 'idle' };
23
- const STATUS_LABELS = { running: '运行中', paused: '已暂停', idle: '等待启动', error: '异常' };
24
- const USAGE = '用法: discussion_service.mjs start|stop|status|open|run|list|add|remove|shortcut|autostart [--config 私有配置.json] [on|off]';
25
- export const CONNECTION_FIELDS = ['executable', 'ipc_directory'];
26
-
27
- /** Read one atomic configuration version for editing and worker acknowledgement. */
28
- export function configuration_snapshot(path) {
29
- const content = readFileSync(path, 'utf8');
30
- return { config: JSON.parse(content), version: createHash('sha256').update(content).digest('hex') };
31
- }
32
-
33
- /** Private per-user directory holding the registry, lock, status and log. */
34
- export function service_home() {
35
- if (process.env.DISCUSS_SERVICE_HOME) return resolve(process.env.DISCUSS_SERVICE_HOME);
36
- return join(process.env.LOCALAPPDATA || join(homedir(), '.local', 'state'), 'ai-codiscuss', 'service');
37
- }
38
-
39
- /** Reject an MSIX-private write view before reading shared service state or changing it. */
40
- export function assert_shared_home(home) {
41
- if (process.platform !== 'win32') return;
42
- let directory = resolve(home);
43
- while (!existsSync(directory)) {
44
- const parent = dirname(directory);
45
- if (parent === directory) throw new Error(`服务目录没有可访问的父目录:${home}`);
46
- directory = parent;
47
- }
48
- const probe = join(directory, `.codiscuss-path-${randomUUID()}`);
49
- closeSync(openSync(probe, 'wx', 0o600));
50
- try {
51
- const actual = realpathSync.native(probe);
52
- // Compare the container suffix so a short user-profile name (ADMINI~1) is not a redirect.
53
- const container = actual.match(/\\AppData\\Local\\Packages\\[^\\]+\\LocalCache\\(?:Local|Roaming)\\/i)?.[0];
54
- if (container && !probe.toLowerCase().includes(container.toLowerCase())) {
55
- throw new Error(`Windows MSIX 正在重定向服务目录 ${home},实际写入 ${dirname(actual)}。`
56
- + '请从应用外的普通终端执行 codiscuss local start/stop;'
57
- + '或让所有入口使用同一个 AppData 之外的 DISCUSS_SERVICE_HOME。原数据不会自动迁移。');
58
- }
59
- } finally { unlinkSync(probe); }
60
- }
61
-
62
- /** Read the registered private room configuration paths. */
63
- export function read_registry(home) {
64
- const path = join(home, 'rooms.json');
65
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).configs || [] : [];
66
- }
67
-
68
- /** Read room identity independently of software validity, isolating unreadable configuration files. */
69
- export function registered_identity(path) {
70
- try {
71
- const config = JSON.parse(readFileSync(path, 'utf8'));
72
- return { site: new URL(config.site).origin, room: config.room };
73
- } catch { return null; }
74
- }
75
-
76
- /** Serialize the entire registry read-modify-write across short-lived CLI processes. */
77
- function update_registry(home, update) {
78
- mkdirSync(home, { recursive: true });
79
- const lock = new DatabaseSync(join(home, 'registry-lock.sqlite3'));
80
- try {
81
- lock.exec(`PRAGMA busy_timeout=${REGISTRY_WAIT_MS}; BEGIN IMMEDIATE`);
82
- atomic_json(join(home, 'rooms.json'), { configs: update(read_registry(home)) });
83
- lock.exec('COMMIT');
84
- } finally {
85
- lock.close();
86
- }
87
- }
88
-
89
- /** Register a validated room once; the same site and room may not be served twice. */
90
- export function register_room(home, config_path) {
91
- const path = resolve(config_path);
92
- const config = load_config(path);
93
- update_registry(home, registered => {
94
- const configs = registered.filter(item => item !== path);
95
- for (const item of configs) {
96
- const other = registered_identity(item);
97
- if (other?.site === config.site && other.room === config.room) throw new Error(`房间 ${config.room} 已由 ${item} 登记`);
98
- }
99
- return [...configs, path];
100
- });
101
- return config;
102
- }
103
-
104
- /** Remove one configuration path from the registry. */
105
- export function unregister_room(home, config_path) {
106
- const path = resolve(config_path);
107
- update_registry(home, configs => {
108
- if (!configs.includes(path)) throw new Error('该配置未登记');
109
- return configs.filter(item => item !== path);
110
- });
111
- }
112
-
113
- /** One registered room: its own data-directory lock, store and dispatcher. */
114
- class RoomWorker {
115
- constructor(path, create_runner, log, home) {
116
- this.path = path;
117
- this.home = home;
118
- this.create_runner = create_runner;
119
- this.log = log;
120
- this.state = { config: path, status: 'starting', error: '' };
121
- }
122
- /** Open lazily so a busy lock or broken file is retried instead of stopping the service. */
123
- open() {
124
- const snapshot = configuration_snapshot(this.path);
125
- const config = validate_config(snapshot.config, dirname(this.path));
126
- config.draft_directory = service_drafts(this.home, config.directory);
127
- const release = acquire_lock(config.directory);
128
- try { this.store = new DiscussionStore(config.directory); }
129
- catch (error) { release(); throw error; }
130
- this.config = config;
131
- this.release = release;
132
- this.runner = this.create_runner(config, this.store);
133
- Object.assign(this.state, { site: config.site, room: config.room, config_version: snapshot.version });
134
- }
135
- /** Reload connection paths and credentials while retaining the exact conversation context. */
136
- async refresh_control() {
137
- const snapshot = configuration_snapshot(this.path);
138
- const updated = validate_config(snapshot.config, dirname(this.path));
139
- if (updated.site !== this.config.site || updated.room !== this.config.room || updated.directory !== this.config.directory) {
140
- throw new Error('已登记配置的房间或数据目录发生变化,请移除原登记后重新接入');
141
- }
142
- if (updated.mode === 'free') {
143
- // Members join and leave while a free room runs; the runner checks each member's context itself.
144
- this.runner.adopt({ ...updated, draft_directory: this.config.draft_directory });
145
- this.config = this.runner.config;
146
- this.state.config_version = snapshot.version;
147
- return;
148
- }
149
- try { this.store.context(updated); }
150
- catch (error) {
151
- const room = await api(this.config, '');
152
- if (room.runtime.status !== 'paused' || room.runtime.mode !== 'automatic' || room.archived || room.phase === 'concluded') throw error;
153
- this.runner.stop_children();
154
- if (this.runner.children?.size) throw new Error('正在等待原 CLI 进程退出;退出后采用新的接入方式');
155
- this.store.context(updated, true);
156
- }
157
- this.config.token = updated.token;
158
- for (const [name, profile] of Object.entries(this.config.participants)) {
159
- for (const field of [...CONNECTION_FIELDS, 'adapter', 'session_id']) {
160
- if (updated.participants[name][field] === undefined) delete profile[field];
161
- else profile[field] = updated.participants[name][field];
162
- }
163
- }
164
- this.state.config_version = snapshot.version;
165
- }
166
- /** Run one bounded dispatcher iteration and keep the last result for status. */
167
- async tick() {
168
- const previous_status = this.state.status;
169
- try {
170
- if (!this.runner) this.open();
171
- await this.refresh_control();
172
- const result = await this.runner.tick();
173
- Object.assign(this.state, { status: TICK_STATES[result], error: '' });
174
- } catch (error) {
175
- if (error.message !== this.state.error) this.log(`${this.state.room || this.path}: ${error.message}`);
176
- Object.assign(this.state, { status: 'error', error: error.message });
177
- }
178
- if (this.state.status !== previous_status) {
179
- this.log(`${this.state.site || ''}/${this.state.room || this.path}: ${STATUS_LABELS[this.state.status]}`);
180
- }
181
- this.state.checked_at = Math.floor(Date.now() / 1000);
182
- }
183
- /** Stopping the service pauses its runs, so the website does not wait for absent consumers. */
184
- async close(pause) {
185
- if (!this.runner) return;
186
- if (pause) {
187
- try { await pause_if_running(this.config); }
188
- catch (error) { this.log(`${this.config.room}: 停止回执未确认;租约到期后拒绝写入:${error.message}`); }
189
- }
190
- this.runner.stop_children();
191
- this.store.close();
192
- this.release();
193
- this.runner = null;
194
- }
195
- }
196
-
197
- /** Keep workers aligned with the registry and tick each one per poll. */
198
- export class DiscussionService {
199
- constructor(home, create_runner = (config, store) => config.mode === 'free' ? new FreeRoomRunner(config, store) : new DiscussionRunner(config, store), log = () => {}) {
200
- this.home = home;
201
- this.create_runner = create_runner;
202
- this.log = log;
203
- this.workers = new Map();
204
- }
205
- /** Reload the registry, close removed rooms, then tick every registered room. */
206
- async tick() {
207
- const configs = read_registry(this.home);
208
- for (const [path, worker] of this.workers) {
209
- if (!configs.includes(path)) { await worker.close(true); this.workers.delete(path); }
210
- }
211
- for (const path of configs) {
212
- if (!this.workers.has(path)) this.workers.set(path, new RoomWorker(path, this.create_runner, this.log, this.home));
213
- await this.workers.get(path).tick();
214
- }
215
- atomic_json(join(this.home, 'status.json'), { pid: process.pid, updated_at: Math.floor(Date.now() / 1000),
216
- rooms: [...this.workers.values()].map(worker => worker.state) });
217
- }
218
- /** Pause running rooms and release every lock and child process. */
219
- async close() {
220
- for (const worker of this.workers.values()) await worker.close(true);
221
- this.workers.clear();
222
- }
223
- }
224
-
225
- /** Return the live service PID, or 0 when no service owns the home lock. */
226
- export function service_pid(home) {
227
- const path = join(home, 'runner.lock');
228
- if (!existsSync(path)) return 0;
229
- const pid = Number(readFileSync(path, 'utf8'));
230
- try { process.kill(pid, 0); return pid; } catch (error) { return error.code === 'EPERM' ? pid : 0; }
231
- }
232
-
233
- /** Require a startup receipt from the current lock owner, not a previous process's status. */
234
- export function service_ready(home, pid = service_pid(home)) {
235
- if (!pid) return false;
236
- try {
237
- const status = JSON.parse(readFileSync(join(home, 'status.json'), 'utf8'));
238
- return status.pid === pid && Boolean(status.updated_at) && status.ready !== false;
239
- } catch { return false; }
240
- }
241
-
242
- /** Foreground service loop; `start` runs this detached in the background. */
243
- export async function run_service(home, output = () => {}) {
244
- assert_shared_home(home);
245
- const release = acquire_lock(home);
246
- const log_line = message => {
247
- const line = `${new Date().toISOString()} ${message}`;
248
- appendFileSync(join(home, 'service.log'), `${line}\n`);
249
- output(line);
250
- };
251
- const service = new DiscussionService(home, undefined, log_line);
252
- const stop_path = join(home, 'stop.request');
253
- if (existsSync(stop_path)) unlinkSync(stop_path);
254
- let stopped = false;
255
- const stop = () => { stopped = true; };
256
- process.once('SIGINT', stop);
257
- process.once('SIGTERM', stop);
258
- try {
259
- log_line(`讨论服务已启动 pid=${process.pid}`);
260
- atomic_json(join(home, 'status.json'), { pid: process.pid, ready: true,
261
- updated_at: Math.floor(Date.now() / 1000), rooms: [] });
262
- while (!stopped && !existsSync(stop_path)) {
263
- try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
264
- await sleep(POLL_MS);
265
- }
266
- } finally {
267
- await service.close();
268
- if (existsSync(stop_path)) unlinkSync(stop_path);
269
- log_line('讨论服务已停止');
270
- release();
271
- process.removeListener('SIGINT', stop);
272
- process.removeListener('SIGTERM', stop);
273
- }
274
- }
275
-
276
- /** Start the detached service once; an already running service is reused. */
277
- export async function start_service(home) {
278
- assert_shared_home(home);
279
- const running = service_pid(home);
280
- if (service_ready(home, running)) return { running: true, ready: true, pid: running, started: false };
281
- mkdirSync(home, { recursive: true });
282
- let child, spawn_error;
283
- if (!running) {
284
- const stop_path = join(home, 'stop.request');
285
- if (existsSync(stop_path)) unlinkSync(stop_path);
286
- const log = openSync(join(home, 'service.log'), 'a');
287
- child = spawn(process.execPath, [SCRIPT, 'run'], { detached: true, windowsHide: true,
288
- stdio: ['ignore', log, log], env: { ...process.env, DISCUSS_SERVICE_HOME: home } });
289
- closeSync(log);
290
- child.once('error', error => { spawn_error = error; });
291
- child.unref();
292
- }
293
- for (let waited = 0; waited < START_WAIT_MS; waited += 200) {
294
- const pid = service_pid(home);
295
- if (service_ready(home, pid)) {
296
- if (child && pid !== child.pid && child.exitCode === null) child.kill();
297
- return { running: true, ready: true, pid, started: pid === child?.pid };
298
- }
299
- if (spawn_error) throw spawn_error;
300
- await sleep(200);
301
- }
302
- throw new Error(`讨论服务尚未确认就绪,请查看 ${join(home, 'service.log')}`);
303
- }
304
-
305
- /** Ask the service to pause its runs and exit, then wait for its lock to be released. */
306
- async function stop_service(home) {
307
- if (!service_pid(home)) return { running: false };
308
- atomic_json(join(home, 'stop.request'), { requested_at: Math.floor(Date.now() / 1000) });
309
- for (let waited = 0; waited < STOP_WAIT_MS; waited += 500) {
310
- if (!service_pid(home)) return { running: false };
311
- await sleep(500);
312
- }
313
- throw new Error('讨论服务未在 20 秒内退出;请查看 service.log');
314
- }
315
-
316
- /** Report liveness with the last per-room result written by the service. */
317
- function service_status(home) {
318
- const pid = service_pid(home);
319
- const path = join(home, 'status.json');
320
- const saved = pid && existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
321
- const last = saved.pid === pid ? saved : {};
322
- return { running: Boolean(pid), ready: service_ready(home, pid), pid, home, configs: read_registry(home), updated_at: last.updated_at || 0, rooms: last.rooms || [] };
323
- }
324
-
325
- /** Open each registered site once in the default browser. */
326
- function open_sites(home) {
327
- const sites = new Set();
328
- for (const path of read_registry(home)) {
329
- try { sites.add(load_config(path).site); } catch { /* status reports broken configs. */ }
330
- }
331
- for (const site of sites) {
332
- const [command, args] = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', site]]
333
- : [process.platform === 'darwin' ? 'open' : 'xdg-open', [site]];
334
- spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
335
- }
336
- return [...sites];
337
- }
338
-
339
- /** Create a Windows shortcut through WScript.Shell; values pass via env to avoid quoting. */
340
- function write_shortcut(folder, action) {
341
- if (process.platform !== 'win32') throw new Error('快捷方式仅支持 Windows;其他系统请直接运行 start');
342
- const script = "$d=[Environment]::GetFolderPath($env:SC_FOLDER); $p=Join-Path $d $env:SC_NAME;"
343
- + " $s=(New-Object -ComObject WScript.Shell).CreateShortcut($p); $s.TargetPath=$env:SC_TARGET;"
344
- + " $s.Arguments=$env:SC_ARGS; $s.WorkingDirectory=$env:SC_CWD; $s.WindowStyle=7;"
345
- + " $s.Description='AI CoDiscuss 讨论服务'; $s.Save(); Write-Output $p";
346
- return powershell(script, { SC_FOLDER: folder, SC_NAME: SHORTCUT_NAME, SC_TARGET: process.execPath,
347
- SC_ARGS: `"${SCRIPT}" ${action}`, SC_CWD: REPO_ROOT });
348
- }
349
-
350
- /** Remove the login autostart shortcut when present. */
351
- function remove_shortcut(folder) {
352
- if (process.platform !== 'win32') throw new Error('开机自启仅支持 Windows');
353
- const script = "$p=Join-Path ([Environment]::GetFolderPath($env:SC_FOLDER)) $env:SC_NAME;"
354
- + " if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p }; Write-Output $p";
355
- return powershell(script, { SC_FOLDER: folder, SC_NAME: SHORTCUT_NAME });
356
- }
357
-
358
- /** Run one PowerShell snippet and return its single output line. */
359
- function powershell(script, env) {
360
- const utf8 = '[Console]::OutputEncoding=[Text.Encoding]::UTF8; ';
361
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', utf8 + script],
362
- { env: { ...process.env, ...env }, encoding: 'utf8', windowsHide: true });
363
- if (result.status !== 0) throw new Error(`PowerShell 执行失败:${result.stderr || result.error?.message}`);
364
- return result.stdout.trim();
365
- }
366
-
367
- /** Dispatch one service command; installing shortcuts or registering never starts a discussion. */
368
- export async function main(args = process.argv.slice(2), print = value => process.stdout.write(JSON.stringify(value) + '\n')) {
369
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { config: { type: 'string' } } });
370
- const [command, option] = positionals;
371
- const home = service_home();
372
- assert_shared_home(home);
373
- const require_config = () => { if (!values.config) throw new Error('请用 --config 指定私有房间配置'); return values.config; };
374
- switch (command) {
375
- case 'run': return run_service(home);
376
- case 'start': return print(await start_service(home));
377
- case 'stop': return print(await stop_service(home));
378
- case 'status': return print(service_status(home));
379
- case 'open': return print({ ...(await start_service(home)), opened: open_sites(home) });
380
- case 'list': return print({ home, configs: read_registry(home) });
381
- case 'add': { const config = register_room(home, require_config()); return print({ added: config.room, site: config.site }); }
382
- case 'remove': unregister_room(home, require_config()); return print({ removed: resolve(values.config) });
383
- case 'shortcut': return print({ shortcut: write_shortcut('Desktop', 'open') });
384
- case 'autostart':
385
- if (option === 'on') return print({ autostart: write_shortcut('Startup', 'start') });
386
- if (option === 'off') return print({ removed: remove_shortcut('Startup') });
387
- throw new Error('用法: autostart on|off');
388
- default: throw new Error(USAGE);
389
- }
390
- }
391
-
392
- if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
393
- main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
394
- }
1
+ /** Resident local service that dispatches registered rooms to software or configured API participants. */
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { parseArgs } from 'node:util';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
11
+ import { atomic_json } from './discussion_adapters.mjs';
12
+ import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, pause_if_running, service_drafts, validate_config } from './discussion_runner.mjs';
13
+ import { FreeRoomRunner } from './free_runner.mjs';
14
+
15
+ const POLL_MS = 2000;
16
+ const START_WAIT_MS = 5000;
17
+ const STOP_WAIT_MS = 20_000;
18
+ const REGISTRY_WAIT_MS = 5000;
19
+ const SCRIPT = fileURLToPath(import.meta.url);
20
+ const REPO_ROOT = dirname(dirname(SCRIPT));
21
+ const SHORTCUT_NAME = 'AI CoDiscuss 讨论服务.lnk';
22
+ const TICK_STATES = { undefined: 'running', waiting: 'paused', false: 'idle' };
23
+ const STATUS_LABELS = { running: '运行中', paused: '已暂停', idle: '等待启动', error: '异常' };
24
+ const USAGE = '用法: discussion_service.mjs start|stop|status|open|run|list|add|remove|shortcut|autostart [--config 私有配置.json] [on|off]';
25
+ export const CONNECTION_FIELDS = ['executable', 'ipc_directory'];
26
+
27
+ /** Read one atomic configuration version for editing and worker acknowledgement. */
28
+ export function configuration_snapshot(path) {
29
+ const content = readFileSync(path, 'utf8');
30
+ return { config: JSON.parse(content), version: createHash('sha256').update(content).digest('hex') };
31
+ }
32
+
33
+ /** Private per-user directory holding the registry, lock, status and log. */
34
+ export function service_home() {
35
+ if (process.env.DISCUSS_SERVICE_HOME) return resolve(process.env.DISCUSS_SERVICE_HOME);
36
+ return join(process.env.LOCALAPPDATA || join(homedir(), '.local', 'state'), 'ai-codiscuss', 'service');
37
+ }
38
+
39
+ /** Reject an MSIX-private write view before reading shared service state or changing it. */
40
+ export function assert_shared_home(home) {
41
+ if (process.platform !== 'win32') return;
42
+ let directory = resolve(home);
43
+ while (!existsSync(directory)) {
44
+ const parent = dirname(directory);
45
+ if (parent === directory) throw new Error(`服务目录没有可访问的父目录:${home}`);
46
+ directory = parent;
47
+ }
48
+ const probe = join(directory, `.codiscuss-path-${randomUUID()}`);
49
+ closeSync(openSync(probe, 'wx', 0o600));
50
+ try {
51
+ const actual = realpathSync.native(probe);
52
+ // Compare the container suffix so a short user-profile name (ADMINI~1) is not a redirect.
53
+ const container = actual.match(/\\AppData\\Local\\Packages\\[^\\]+\\LocalCache\\(?:Local|Roaming)\\/i)?.[0];
54
+ if (container && !probe.toLowerCase().includes(container.toLowerCase())) {
55
+ throw new Error(`Windows MSIX 正在重定向服务目录 ${home},实际写入 ${dirname(actual)}。`
56
+ + '请从应用外的普通终端执行 codiscuss local start/stop;'
57
+ + '或让所有入口使用同一个 AppData 之外的 DISCUSS_SERVICE_HOME。原数据不会自动迁移。');
58
+ }
59
+ } finally { unlinkSync(probe); }
60
+ }
61
+
62
+ /** Read the registered private room configuration paths. */
63
+ export function read_registry(home) {
64
+ const path = join(home, 'rooms.json');
65
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).configs || [] : [];
66
+ }
67
+
68
+ /** Read room identity independently of software validity, isolating unreadable configuration files. */
69
+ export function registered_identity(path) {
70
+ try {
71
+ const config = JSON.parse(readFileSync(path, 'utf8'));
72
+ return { site: new URL(config.site).origin, room: config.room };
73
+ } catch { return null; }
74
+ }
75
+
76
+ /** Serialize the entire registry read-modify-write across short-lived CLI processes. */
77
+ function update_registry(home, update) {
78
+ mkdirSync(home, { recursive: true });
79
+ const lock = new DatabaseSync(join(home, 'registry-lock.sqlite3'));
80
+ try {
81
+ lock.exec(`PRAGMA busy_timeout=${REGISTRY_WAIT_MS}; BEGIN IMMEDIATE`);
82
+ atomic_json(join(home, 'rooms.json'), { configs: update(read_registry(home)) });
83
+ lock.exec('COMMIT');
84
+ } finally {
85
+ lock.close();
86
+ }
87
+ }
88
+
89
+ /** Register a validated room once; the same site and room may not be served twice. */
90
+ export function register_room(home, config_path) {
91
+ const path = resolve(config_path);
92
+ const config = load_config(path);
93
+ update_registry(home, registered => {
94
+ const configs = registered.filter(item => item !== path);
95
+ for (const item of configs) {
96
+ const other = registered_identity(item);
97
+ if (other?.site === config.site && other.room === config.room) throw new Error(`房间 ${config.room} 已由 ${item} 登记`);
98
+ }
99
+ return [...configs, path];
100
+ });
101
+ return config;
102
+ }
103
+
104
+ /** Remove one configuration path from the registry. */
105
+ export function unregister_room(home, config_path) {
106
+ const path = resolve(config_path);
107
+ update_registry(home, configs => {
108
+ if (!configs.includes(path)) throw new Error('该配置未登记');
109
+ return configs.filter(item => item !== path);
110
+ });
111
+ }
112
+
113
+ /** One registered room: its own data-directory lock, store and dispatcher. */
114
+ class RoomWorker {
115
+ constructor(path, create_runner, log, home) {
116
+ this.path = path;
117
+ this.home = home;
118
+ this.create_runner = create_runner;
119
+ this.log = log;
120
+ this.state = { config: path, status: 'starting', error: '' };
121
+ }
122
+ /** Open lazily so a busy lock or broken file is retried instead of stopping the service. */
123
+ open() {
124
+ const snapshot = configuration_snapshot(this.path);
125
+ const config = validate_config(snapshot.config, dirname(this.path));
126
+ config.draft_directory = service_drafts(this.home, config.directory);
127
+ const release = acquire_lock(config.directory);
128
+ try { this.store = new DiscussionStore(config.directory); }
129
+ catch (error) { release(); throw error; }
130
+ this.config = config;
131
+ this.release = release;
132
+ this.runner = this.create_runner(config, this.store);
133
+ Object.assign(this.state, { site: config.site, room: config.room, config_version: snapshot.version });
134
+ }
135
+ /** Reload connection paths and credentials while retaining the exact conversation context. */
136
+ async refresh_control() {
137
+ const snapshot = configuration_snapshot(this.path);
138
+ const updated = validate_config(snapshot.config, dirname(this.path));
139
+ if (updated.site !== this.config.site || updated.room !== this.config.room || updated.directory !== this.config.directory) {
140
+ throw new Error('已登记配置的房间或数据目录发生变化,请移除原登记后重新接入');
141
+ }
142
+ if (updated.mode === 'free') {
143
+ // Members join and leave while a free room runs; the runner checks each member's context itself.
144
+ this.runner.adopt({ ...updated, draft_directory: this.config.draft_directory });
145
+ this.config = this.runner.config;
146
+ this.state.config_version = snapshot.version;
147
+ return;
148
+ }
149
+ try { this.store.context(updated); }
150
+ catch (error) {
151
+ const room = await api(this.config, '');
152
+ if (room.runtime.status !== 'paused' || room.runtime.mode !== 'automatic' || room.archived || room.phase === 'concluded') throw error;
153
+ this.runner.stop_children();
154
+ if (this.runner.children?.size) throw new Error('正在等待原 CLI 进程退出;退出后采用新的接入方式');
155
+ this.store.context(updated, true);
156
+ }
157
+ this.config.token = updated.token;
158
+ for (const [name, profile] of Object.entries(updated.participants)) this.config.participants[name] ??= profile;
159
+ for (const [name, profile] of Object.entries(this.config.participants)) {
160
+ for (const field of [...CONNECTION_FIELDS, 'adapter', 'session_id']) {
161
+ if (updated.participants[name][field] === undefined) delete profile[field];
162
+ else profile[field] = updated.participants[name][field];
163
+ }
164
+ }
165
+ this.state.config_version = snapshot.version;
166
+ }
167
+ /** Run one bounded dispatcher iteration and keep the last result for status. */
168
+ async tick() {
169
+ const previous_status = this.state.status;
170
+ try {
171
+ if (!this.runner) this.open();
172
+ await this.refresh_control();
173
+ const result = await this.runner.tick();
174
+ Object.assign(this.state, { status: TICK_STATES[result], error: '' });
175
+ } catch (error) {
176
+ if (error.message !== this.state.error) this.log(`${this.state.room || this.path}: ${error.message}`);
177
+ Object.assign(this.state, { status: 'error', error: error.message });
178
+ }
179
+ if (this.state.status !== previous_status) {
180
+ this.log(`${this.state.site || ''}/${this.state.room || this.path}: ${STATUS_LABELS[this.state.status]}`);
181
+ }
182
+ this.state.checked_at = Math.floor(Date.now() / 1000);
183
+ }
184
+ /** Stopping the service pauses its runs, so the website does not wait for absent consumers. */
185
+ async close(pause) {
186
+ if (!this.runner) return;
187
+ if (pause) {
188
+ try { await pause_if_running(this.config); }
189
+ catch (error) { this.log(`${this.config.room}: 停止回执未确认;租约到期后拒绝写入:${error.message}`); }
190
+ }
191
+ this.runner.stop_children();
192
+ this.store.close();
193
+ this.release();
194
+ this.runner = null;
195
+ }
196
+ }
197
+
198
+ /** Keep workers aligned with the registry and tick each one per poll. */
199
+ export class DiscussionService {
200
+ constructor(home, create_runner = (config, store) => config.mode === 'free' ? new FreeRoomRunner(config, store) : new DiscussionRunner(config, store), log = () => {}) {
201
+ this.home = home;
202
+ this.create_runner = create_runner;
203
+ this.log = log;
204
+ this.workers = new Map();
205
+ }
206
+ /** Reload the registry, close removed rooms, then tick every registered room. */
207
+ async tick() {
208
+ const configs = read_registry(this.home);
209
+ for (const [path, worker] of this.workers) {
210
+ if (!configs.includes(path)) { await worker.close(true); this.workers.delete(path); }
211
+ }
212
+ for (const path of configs) {
213
+ if (!this.workers.has(path)) this.workers.set(path, new RoomWorker(path, this.create_runner, this.log, this.home));
214
+ await this.workers.get(path).tick();
215
+ }
216
+ atomic_json(join(this.home, 'status.json'), { pid: process.pid, updated_at: Math.floor(Date.now() / 1000),
217
+ rooms: [...this.workers.values()].map(worker => worker.state) });
218
+ }
219
+ /** Pause running rooms and release every lock and child process. */
220
+ async close() {
221
+ for (const worker of this.workers.values()) await worker.close(true);
222
+ this.workers.clear();
223
+ }
224
+ }
225
+
226
+ /** Return the live service PID, or 0 when no service owns the home lock. */
227
+ export function service_pid(home) {
228
+ const path = join(home, 'runner.lock');
229
+ if (!existsSync(path)) return 0;
230
+ const pid = Number(readFileSync(path, 'utf8'));
231
+ try { process.kill(pid, 0); return pid; } catch (error) { return error.code === 'EPERM' ? pid : 0; }
232
+ }
233
+
234
+ /** Require a startup receipt from the current lock owner, not a previous process's status. */
235
+ export function service_ready(home, pid = service_pid(home)) {
236
+ if (!pid) return false;
237
+ try {
238
+ const status = JSON.parse(readFileSync(join(home, 'status.json'), 'utf8'));
239
+ return status.pid === pid && Boolean(status.updated_at) && status.ready !== false;
240
+ } catch { return false; }
241
+ }
242
+
243
+ /** Foreground service loop; `start` runs this detached in the background. */
244
+ export async function run_service(home, output = () => {}) {
245
+ assert_shared_home(home);
246
+ const release = acquire_lock(home);
247
+ const log_line = message => {
248
+ const line = `${new Date().toISOString()} ${message}`;
249
+ appendFileSync(join(home, 'service.log'), `${line}\n`);
250
+ output(line);
251
+ };
252
+ const service = new DiscussionService(home, undefined, log_line);
253
+ const stop_path = join(home, 'stop.request');
254
+ if (existsSync(stop_path)) unlinkSync(stop_path);
255
+ let stopped = false;
256
+ const stop = () => { stopped = true; };
257
+ process.once('SIGINT', stop);
258
+ process.once('SIGTERM', stop);
259
+ try {
260
+ log_line(`讨论服务已启动 pid=${process.pid}`);
261
+ atomic_json(join(home, 'status.json'), { pid: process.pid, ready: true,
262
+ updated_at: Math.floor(Date.now() / 1000), rooms: [] });
263
+ while (!stopped && !existsSync(stop_path)) {
264
+ try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
265
+ await sleep(POLL_MS);
266
+ }
267
+ } finally {
268
+ await service.close();
269
+ if (existsSync(stop_path)) unlinkSync(stop_path);
270
+ log_line('讨论服务已停止');
271
+ release();
272
+ process.removeListener('SIGINT', stop);
273
+ process.removeListener('SIGTERM', stop);
274
+ }
275
+ }
276
+
277
+ /** Start the detached service once; an already running service is reused. */
278
+ export async function start_service(home) {
279
+ assert_shared_home(home);
280
+ const running = service_pid(home);
281
+ if (service_ready(home, running)) return { running: true, ready: true, pid: running, started: false };
282
+ mkdirSync(home, { recursive: true });
283
+ let child, spawn_error;
284
+ if (!running) {
285
+ const stop_path = join(home, 'stop.request');
286
+ if (existsSync(stop_path)) unlinkSync(stop_path);
287
+ const log = openSync(join(home, 'service.log'), 'a');
288
+ child = spawn(process.execPath, [SCRIPT, 'run'], { detached: true, windowsHide: true,
289
+ stdio: ['ignore', log, log], env: { ...process.env, DISCUSS_SERVICE_HOME: home } });
290
+ closeSync(log);
291
+ child.once('error', error => { spawn_error = error; });
292
+ child.unref();
293
+ }
294
+ for (let waited = 0; waited < START_WAIT_MS; waited += 200) {
295
+ const pid = service_pid(home);
296
+ if (service_ready(home, pid)) {
297
+ if (child && pid !== child.pid && child.exitCode === null) child.kill();
298
+ return { running: true, ready: true, pid, started: pid === child?.pid };
299
+ }
300
+ if (spawn_error) throw spawn_error;
301
+ await sleep(200);
302
+ }
303
+ throw new Error(`讨论服务尚未确认就绪,请查看 ${join(home, 'service.log')}`);
304
+ }
305
+
306
+ /** Ask the service to pause its runs and exit, then wait for its lock to be released. */
307
+ async function stop_service(home) {
308
+ if (!service_pid(home)) return { running: false };
309
+ atomic_json(join(home, 'stop.request'), { requested_at: Math.floor(Date.now() / 1000) });
310
+ for (let waited = 0; waited < STOP_WAIT_MS; waited += 500) {
311
+ if (!service_pid(home)) return { running: false };
312
+ await sleep(500);
313
+ }
314
+ throw new Error('讨论服务未在 20 秒内退出;请查看 service.log');
315
+ }
316
+
317
+ /** Report liveness with the last per-room result written by the service. */
318
+ function service_status(home) {
319
+ const pid = service_pid(home);
320
+ const path = join(home, 'status.json');
321
+ const saved = pid && existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
322
+ const last = saved.pid === pid ? saved : {};
323
+ return { running: Boolean(pid), ready: service_ready(home, pid), pid, home, configs: read_registry(home), updated_at: last.updated_at || 0, rooms: last.rooms || [] };
324
+ }
325
+
326
+ /** Open each registered site once in the default browser. */
327
+ function open_sites(home) {
328
+ const sites = new Set();
329
+ for (const path of read_registry(home)) {
330
+ try { sites.add(load_config(path).site); } catch { /* status reports broken configs. */ }
331
+ }
332
+ for (const site of sites) {
333
+ const [command, args] = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', site]]
334
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [site]];
335
+ spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
336
+ }
337
+ return [...sites];
338
+ }
339
+
340
+ /** Create a Windows shortcut through WScript.Shell; values pass via env to avoid quoting. */
341
+ function write_shortcut(folder, action) {
342
+ if (process.platform !== 'win32') throw new Error('快捷方式仅支持 Windows;其他系统请直接运行 start');
343
+ const script = "$d=[Environment]::GetFolderPath($env:SC_FOLDER); $p=Join-Path $d $env:SC_NAME;"
344
+ + " $s=(New-Object -ComObject WScript.Shell).CreateShortcut($p); $s.TargetPath=$env:SC_TARGET;"
345
+ + " $s.Arguments=$env:SC_ARGS; $s.WorkingDirectory=$env:SC_CWD; $s.WindowStyle=7;"
346
+ + " $s.Description='AI CoDiscuss 讨论服务'; $s.Save(); Write-Output $p";
347
+ return powershell(script, { SC_FOLDER: folder, SC_NAME: SHORTCUT_NAME, SC_TARGET: process.execPath,
348
+ SC_ARGS: `"${SCRIPT}" ${action}`, SC_CWD: REPO_ROOT });
349
+ }
350
+
351
+ /** Remove the login autostart shortcut when present. */
352
+ function remove_shortcut(folder) {
353
+ if (process.platform !== 'win32') throw new Error('开机自启仅支持 Windows');
354
+ const script = "$p=Join-Path ([Environment]::GetFolderPath($env:SC_FOLDER)) $env:SC_NAME;"
355
+ + " if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p }; Write-Output $p";
356
+ return powershell(script, { SC_FOLDER: folder, SC_NAME: SHORTCUT_NAME });
357
+ }
358
+
359
+ /** Run one PowerShell snippet and return its single output line. */
360
+ function powershell(script, env) {
361
+ const utf8 = '[Console]::OutputEncoding=[Text.Encoding]::UTF8; ';
362
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', utf8 + script],
363
+ { env: { ...process.env, ...env }, encoding: 'utf8', windowsHide: true });
364
+ if (result.status !== 0) throw new Error(`PowerShell 执行失败:${result.stderr || result.error?.message}`);
365
+ return result.stdout.trim();
366
+ }
367
+
368
+ /** Dispatch one service command; installing shortcuts or registering never starts a discussion. */
369
+ export async function main(args = process.argv.slice(2), print = value => process.stdout.write(JSON.stringify(value) + '\n')) {
370
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { config: { type: 'string' } } });
371
+ const [command, option] = positionals;
372
+ const home = service_home();
373
+ assert_shared_home(home);
374
+ const require_config = () => { if (!values.config) throw new Error('请用 --config 指定私有房间配置'); return values.config; };
375
+ switch (command) {
376
+ case 'run': return run_service(home);
377
+ case 'start': return print(await start_service(home));
378
+ case 'stop': return print(await stop_service(home));
379
+ case 'status': return print(service_status(home));
380
+ case 'open': return print({ ...(await start_service(home)), opened: open_sites(home) });
381
+ case 'list': return print({ home, configs: read_registry(home) });
382
+ case 'add': { const config = register_room(home, require_config()); return print({ added: config.room, site: config.site }); }
383
+ case 'remove': unregister_room(home, require_config()); return print({ removed: resolve(values.config) });
384
+ case 'shortcut': return print({ shortcut: write_shortcut('Desktop', 'open') });
385
+ case 'autostart':
386
+ if (option === 'on') return print({ autostart: write_shortcut('Startup', 'start') });
387
+ if (option === 'off') return print({ removed: remove_shortcut('Startup') });
388
+ throw new Error('用法: autostart on|off');
389
+ default: throw new Error(USAGE);
390
+ }
391
+ }
392
+
393
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
394
+ main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
395
+ }